diff --git a/apps/alerting/src/worker.ts b/apps/alerting/src/worker.ts
index 03cecea26..6d81cee9c 100644
--- a/apps/alerting/src/worker.ts
+++ b/apps/alerting/src/worker.ts
@@ -1,5 +1,5 @@
import {
- ANTICIPATED_ERROR_TAGS,
+ ANTICIPATED_ERROR_IDENTIFIERS,
AlertsService,
AnomalyDetectionService,
BucketCacheService,
@@ -38,7 +38,7 @@ const telemetry = MapleCloudflareSDK.make({
serviceName: "alerting",
serviceNamespace: "backend",
repositoryUrl: "https://github.com/Makisuo/maple",
- anticipatedErrorTags: [...ANTICIPATED_ERROR_TAGS],
+ anticipatedErrorIdentifiers: [...ANTICIPATED_ERROR_IDENTIFIERS],
})
const buildLayer = (_env: Record) => {
diff --git a/apps/api/src/alerting.ts b/apps/api/src/alerting.ts
index 3f7fe7102..dc89cd89b 100644
--- a/apps/api/src/alerting.ts
+++ b/apps/api/src/alerting.ts
@@ -1,4 +1,4 @@
-export { ANTICIPATED_ERROR_TAGS } from "@maple/domain/anticipated-errors"
+export { ANTICIPATED_ERROR_IDENTIFIERS } from "@maple/domain/anticipated-errors"
export { AlertRuntime, AlertsService } from "./services/AlertsService"
export { AnomalyDetectionService } from "./services/AnomalyDetectionService"
export { BucketCacheService } from "@maple/query-engine/caching"
diff --git a/apps/api/src/internal-rpc.test.ts b/apps/api/src/internal-rpc.test.ts
index 180cf49bf..37d894f71 100644
--- a/apps/api/src/internal-rpc.test.ts
+++ b/apps/api/src/internal-rpc.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from "vitest"
+import { describe, expect, it } from "@effect/vitest"
import { Effect } from "effect"
import type { InternalRpcInvalidInputError } from "@maple/domain/internal-rpc"
import { callMcpToolRpc, submitDiagnosisRpc } from "./internal-rpc"
@@ -31,60 +31,60 @@ const unusedInvestigationService: InvestigationServiceShape = {
}
describe("internal RPC boundary", () => {
- it("rejects invalid org IDs before MCP dispatch", async () => {
- const error = await Effect.runPromise(
- Effect.flip(
+ it.effect("rejects invalid org IDs before MCP dispatch", () =>
+ Effect.gen(function* () {
+ const error = yield* Effect.flip(
callMcpToolRpc({ orgId: " ", name: "inspect_trace", input: {} }) as Effect.Effect<
never,
InternalRpcInvalidInputError,
never
>,
- ),
- )
- expect(error._tag).toBe("@maple/internal-rpc/InvalidInputError")
- expect(error.method).toBe("callMcpTool")
- })
+ )
+ expect(error._tag).toBe("@maple/internal-rpc/InvalidInputError")
+ expect(error.method).toBe("callMcpTool")
+ }),
+ )
- it("rejects invalid investigation IDs and model-produced reports", async () => {
- for (const input of [
- { orgId: "org_1", investigationId: "not-a-uuid", report },
- { orgId: "org_1", investigationId, report: { summary: "incomplete" } },
- ]) {
- const error = await Effect.runPromise(
- Effect.flip(
+ it.effect("rejects invalid investigation IDs and model-produced reports", () =>
+ Effect.gen(function* () {
+ for (const input of [
+ { orgId: "org_1", investigationId: "not-a-uuid", report },
+ { orgId: "org_1", investigationId, report: { summary: "incomplete" } },
+ ]) {
+ const error = yield* Effect.flip(
submitDiagnosisRpc(input).pipe(
Effect.provideService(InvestigationService, unusedInvestigationService),
),
- ),
- )
- expect(error._tag).toBe("@maple/internal-rpc/InvalidInputError")
- if (error._tag !== "@maple/internal-rpc/InvalidInputError") {
- throw new Error(`Expected invalid input, received ${error._tag}`)
+ )
+ expect(error._tag).toBe("@maple/internal-rpc/InvalidInputError")
+ if (error._tag !== "@maple/internal-rpc/InvalidInputError") {
+ throw new Error(`Expected invalid input, received ${error._tag}`)
+ }
+ expect(error.method).toBe("submitDiagnosis")
}
- expect(error.method).toBe("submitDiagnosis")
- }
- })
+ }),
+ )
- it("submits a decoded diagnosis to the org-scoped service", async () => {
- const calls: Array<{ orgId: string; investigationId: string; summary: string }> = []
- const expected = { id: investigationId, status: "diagnosed" } as never
- const service: InvestigationServiceShape = {
- ...unusedInvestigationService,
- submitDiagnosis: (orgId, id, request) =>
- Effect.sync(() => {
- calls.push({ orgId, investigationId: id, summary: request.report.summary })
- return expected
- }),
- }
+ it.effect("submits a decoded diagnosis to the org-scoped service", () =>
+ Effect.gen(function* () {
+ const calls: Array<{ orgId: string; investigationId: string; summary: string }> = []
+ const expected = { id: investigationId, status: "diagnosed" } as never
+ const service: InvestigationServiceShape = {
+ ...unusedInvestigationService,
+ submitDiagnosis: (orgId, id, request) =>
+ Effect.sync(() => {
+ calls.push({ orgId, investigationId: id, summary: request.report.summary })
+ return expected
+ }),
+ }
- const result = await Effect.runPromise(
- submitDiagnosisRpc({ orgId: "org_1", investigationId, report }).pipe(
+ const result = yield* submitDiagnosisRpc({ orgId: "org_1", investigationId, report }).pipe(
Effect.provideService(InvestigationService, service),
- ),
- )
- expect(result).toBe(expected)
- expect(calls).toEqual([
- { orgId: "org_1", investigationId, summary: "Checkout latency doubled after deploy." },
- ])
- })
+ )
+ expect(result).toBe(expected)
+ expect(calls).toEqual([
+ { orgId: "org_1", investigationId, summary: "Checkout latency doubled after deploy." },
+ ])
+ }),
+ )
})
diff --git a/apps/api/src/mcp/dispatcher.test.ts b/apps/api/src/mcp/dispatcher.test.ts
index 4d63ba995..e7eaad0b7 100644
--- a/apps/api/src/mcp/dispatcher.test.ts
+++ b/apps/api/src/mcp/dispatcher.test.ts
@@ -1,48 +1,50 @@
-import { describe, expect, it } from "vitest"
+import { describe, expect, it } from "@effect/vitest"
import { Effect } from "effect"
import type { InternalRpcToolNotFoundError } from "@maple/domain/internal-rpc"
import { callMcpTool, listMcpTools } from "./dispatcher"
import { mapleToolDefinitions, toInputSchema } from "./tools/registry"
describe("MCP dispatcher", () => {
- it("publishes the same names, descriptions, and schemas used by HTTP MCP", async () => {
- const descriptors = await Effect.runPromise(listMcpTools)
- expect(descriptors).toEqual(
- mapleToolDefinitions.map((definition) => ({
- name: definition.name,
- description: definition.description,
- inputSchema: toInputSchema(definition.schema),
- })),
- )
- })
+ it.effect("publishes the same names, descriptions, and schemas used by HTTP MCP", () =>
+ Effect.gen(function* () {
+ const descriptors = yield* listMcpTools
+ expect(descriptors).toEqual(
+ mapleToolDefinitions.map((definition) => ({
+ name: definition.name,
+ description: definition.description,
+ inputSchema: toInputSchema(definition.schema),
+ })),
+ )
+ }),
+ )
- it("returns MCP validation feedback for invalid model tool input", async () => {
- const result = await Effect.runPromise(
- callMcpTool("inspect_trace", {}) as unknown as Effect.Effect<
+ it.effect("returns MCP validation feedback for invalid model tool input", () =>
+ Effect.gen(function* () {
+ const result = yield* callMcpTool("inspect_trace", {}) as unknown as Effect.Effect<
{
readonly content: ReadonlyArray<{ readonly type: "text"; readonly text: string }>
readonly isError?: boolean
},
never,
never
- >,
- )
- expect(result.isError).toBe(true)
- expect(result.content[0]?.text).toContain("Invalid parameters")
- expect(result.content[0]?.text).toContain("inspect_trace")
- })
+ >
+ expect(result.isError).toBe(true)
+ expect(result.content[0]?.text).toContain("Invalid parameters")
+ expect(result.content[0]?.text).toContain("inspect_trace")
+ }),
+ )
- it("fails unknown RPC tool names with a typed error", async () => {
- const error = await Effect.runPromise(
- Effect.flip(
+ it.effect("fails unknown RPC tool names with a typed error", () =>
+ Effect.gen(function* () {
+ const error = yield* Effect.flip(
callMcpTool("not_a_maple_tool", {}) as Effect.Effect<
never,
InternalRpcToolNotFoundError,
never
>,
- ),
- )
- expect(error._tag).toBe("@maple/internal-rpc/ToolNotFoundError")
- expect(error.name).toBe("not_a_maple_tool")
- })
+ )
+ expect(error._tag).toBe("@maple/internal-rpc/ToolNotFoundError")
+ expect(error.name).toBe("not_a_maple_tool")
+ }),
+ )
})
diff --git a/apps/api/src/routes/scraper-internal.http.ts b/apps/api/src/routes/scraper-internal.http.ts
index 6240be1eb..7c43c1042 100644
--- a/apps/api/src/routes/scraper-internal.http.ts
+++ b/apps/api/src/routes/scraper-internal.http.ts
@@ -117,7 +117,7 @@ export const ScraperInternalRouter = HttpRouter.use((router) =>
const denied = unauthorized(req)
if (denied) return denied
- const rows = yield* service.listAllEnabled().pipe(Effect.catch(() => Effect.succeed([])))
+ const rows = yield* service.listAllEnabled()
// One public ingest key per org (lazily created on first use, like
// onboarding does). The scraper ingests with this key so scraped
@@ -128,12 +128,9 @@ export const ScraperInternalRouter = HttpRouter.use((router) =>
Effect.gen(function* () {
const cached = keyByOrg.get(orgId)
if (cached !== undefined) return cached
- const key: string | null = yield* ingestKeys
+ const key: string = yield* ingestKeys
.getOrCreate(decodeOrgIdSync(orgId), SCRAPER_SYSTEM_USER)
- .pipe(
- Effect.map((keys): string | null => keys.publicKey),
- Effect.catch(() => Effect.succeed(null)),
- )
+ .pipe(Effect.map((keys) => keys.publicKey))
keyByOrg.set(orgId, key)
return key
})
@@ -171,7 +168,9 @@ export const ScraperInternalRouter = HttpRouter.use((router) =>
if (Option.isSome(target)) {
targets.push(target.value)
} else {
- yield* Effect.logWarning("Skipping scrape sub-target (invalid row)").pipe(
+ yield* Effect.logWarning(
+ "Skipping scrape sub-target (invalid row)",
+ ).pipe(
Effect.annotateLogs({
scrapeTargetId: row.id,
orgId: row.orgId,
@@ -196,7 +195,15 @@ export const ScraperInternalRouter = HttpRouter.use((router) =>
)
return yield* HttpServerResponse.json(targets)
- }).pipe(Effect.withSpan("ScraperInternal.listTargets"))
+ }).pipe(
+ Effect.catch((error) =>
+ Effect.logError("Failed to build scraper target list").pipe(
+ Effect.annotateLogs({ error: error.message }),
+ Effect.as(errorText("Scraper target list unavailable", 503)),
+ ),
+ ),
+ Effect.withSpan("ScraperInternal.listTargets"),
+ )
const recordResults = (req: HttpServerRequest.HttpServerRequest) =>
Effect.gen(function* () {
@@ -209,18 +216,18 @@ export const ScraperInternalRouter = HttpRouter.use((router) =>
const results = yield* decodeScrapeResultsEffect(body.value).pipe(Effect.option)
if (Option.isNone(results)) return errorText("Invalid scrape results payload", 400)
- yield* service
- .recordScrapeResults(results.value)
- .pipe(
- Effect.catch((error) =>
- Effect.logWarning("Failed to persist scrape results").pipe(
- Effect.annotateLogs({ error: error.message }),
- ),
- ),
- )
+ yield* service.recordScrapeResults(results.value)
return yield* HttpServerResponse.json({ recorded: results.value.length })
- }).pipe(Effect.withSpan("ScraperInternal.recordResults"))
+ }).pipe(
+ Effect.catch((error) =>
+ Effect.logError("Failed to persist scrape results").pipe(
+ Effect.annotateLogs({ error: error.message }),
+ Effect.as(errorText("Scrape result persistence unavailable", 503)),
+ ),
+ ),
+ Effect.withSpan("ScraperInternal.recordResults"),
+ )
yield* router.add("GET", "/api/internal/scrape-targets", listTargets)
yield* router.add("POST", "/api/internal/scrape-results", recordResults)
diff --git a/apps/api/src/services/AlertDeliveryDispatch.test.ts b/apps/api/src/services/AlertDeliveryDispatch.test.ts
index e74de1403..69bfed125 100644
--- a/apps/api/src/services/AlertDeliveryDispatch.test.ts
+++ b/apps/api/src/services/AlertDeliveryDispatch.test.ts
@@ -1,7 +1,7 @@
import type { AlertDestinationRow } from "@maple/db"
-import { AlertDeliveryError } from "@maple/domain/http"
+import { AlertDeliveryError, AlertDestinationId } from "@maple/domain/http"
import { assert, describe, it } from "@effect/vitest"
-import { Effect } from "effect"
+import { Effect, Schema } from "effect"
import {
buildAlertChatUrl,
buildDiscordEmbedsFromTemplate,
@@ -37,6 +37,7 @@ const baseContext: TemplateRenderContext = {
const LINK = "https://web.localhost/alerts"
const CHAT = "https://web.localhost/chat?mode=alert"
+const DESTINATION_ID = Schema.decodeUnknownSync(AlertDestinationId)("7c6b5a49-3821-4e0f-9d8c-7b6a59483726")
/** Dispatch deps for non-email destinations — email sends must not happen. */
const noEmailDeps: DispatchDeps = {
@@ -138,7 +139,7 @@ describe("buildDiscordEmbedsFromTemplate", () => {
describe("dispatchDelivery", () => {
const destinationRow: AlertDestinationRow = {
- id: "dest_1" as AlertDestinationRow["id"],
+ id: DESTINATION_ID,
orgId: "org_1" as AlertDestinationRow["orgId"],
name: "PagerDuty",
type: "pagerduty",
diff --git a/apps/api/src/services/AlertsService.ts b/apps/api/src/services/AlertsService.ts
index 13ec465cd..255f1af07 100644
--- a/apps/api/src/services/AlertsService.ts
+++ b/apps/api/src/services/AlertsService.ts
@@ -27,7 +27,6 @@ import {
AlertCheckDocument,
AlertChecksListResponse,
AlertCheckStatus as AlertCheckStatusSchema,
- AlertEvaluationStatus as AlertEvaluationStatusSchema,
AlertIncidentDocument,
AlertIncidentsListResponse,
AlertIncidentStatus,
@@ -243,6 +242,8 @@ interface DeliveryAttemptFailure {
}
const MAX_DELIVERY_ATTEMPTS = 5
+const ALERT_TEST_DELIVERY_CONCURRENCY = 5
+const ALERT_CHECK_INGEST_CONCURRENCY = 4
// Storm fuse: cap issue-hub upserts per scheduler tick so a pathological
// group-by rule opening hundreds of incidents can't stall the per-minute tick.
const ISSUE_UPSERTS_PER_TICK = 50
@@ -300,7 +301,6 @@ const decodeAlertDestinationTypeSync = Schema.decodeUnknownSync(AlertDestination
const decodeAlertSeveritySync = Schema.decodeUnknownSync(AlertSeveritySchema)
const decodeAlertSignalTypeSync = Schema.decodeUnknownSync(AlertSignalTypeSchema)
const decodeAlertComparatorSync = Schema.decodeUnknownSync(AlertComparatorSchema)
-const decodeAlertEvaluationStatusSync = Schema.decodeUnknownSync(AlertEvaluationStatusSchema)
const decodeAlertCheckStatusSync = Schema.decodeUnknownSync(AlertCheckStatusSchema)
const decodeAlertIncidentTransitionSync = Schema.decodeUnknownSync(AlertIncidentTransitionSchema)
const decodeAlertMetricTypeSync = Schema.decodeUnknownSync(AlertMetricTypeSchema)
@@ -2826,7 +2826,7 @@ export class AlertsService extends Context.Service Effect.succeed("failed" as const),
}),
),
- { concurrency: "unbounded" },
+ { concurrency: NOTIFICATION_DELIVERY_CONCURRENCY },
)
return {
diff --git a/apps/api/src/services/OrgClickHouseSettingsService.test.ts b/apps/api/src/services/OrgClickHouseSettingsService.test.ts
index c7838250e..ba1f1c94e 100644
--- a/apps/api/src/services/OrgClickHouseSettingsService.test.ts
+++ b/apps/api/src/services/OrgClickHouseSettingsService.test.ts
@@ -151,7 +151,7 @@ describe("isRetryableUpstream", () => {
})
describe("execClickHouse", () => {
- it.live("uses manual redirects and rejects every 3xx without following it", () =>
+ it.effect("uses manual redirects and rejects every 3xx without following it", () =>
Effect.gen(function* () {
let redirectMode: RequestRedirect | undefined
let calls = 0
@@ -206,7 +206,7 @@ describe("execClickHouse", () => {
}),
)
- it.live("does NOT retry a 4xx rejection", () =>
+ it.effect("does NOT retry a 4xx rejection", () =>
Effect.gen(function* () {
const { state, fetchImpl } = makeFetch(() => Promise.resolve(mockResponse("Syntax error", 400)))
@@ -218,7 +218,7 @@ describe("execClickHouse", () => {
}),
)
- it.live("does NOT retry a ClickHouse 500 SQL error (carries the DB::Exception text)", () =>
+ it.effect("does NOT retry a ClickHouse 500 SQL error (carries the DB::Exception text)", () =>
Effect.gen(function* () {
const { state, fetchImpl } = makeFetch(() =>
Promise.resolve(mockResponse("Code: 60. DB::Exception: UNKNOWN_TABLE", 500)),
diff --git a/apps/api/src/services/vcs/__tests__/VcsCommitService.test.ts b/apps/api/src/services/vcs/__tests__/VcsCommitService.test.ts
index 038eeb3e0..3f2f6afbe 100644
--- a/apps/api/src/services/vcs/__tests__/VcsCommitService.test.ts
+++ b/apps/api/src/services/vcs/__tests__/VcsCommitService.test.ts
@@ -23,6 +23,7 @@ import {
testEnv,
type VcsRepo,
} from "./harness"
+import { decodeGitCommitSha } from "./fixtures"
const trackedDbs: TestDb[] = []
afterEach(() => cleanupTestDbs(trackedDbs))
@@ -195,7 +196,7 @@ describe("VcsCommitService.resolveCommitDetail", () => {
// It was persisted: now stored, no further provider calls.
const before = calls.commitGets
- const stored = expectSome(yield* repo.findCommitBySha(orgId, SHA as never))
+ const stored = expectSome(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA)))
assert.strictEqual(
stored.repositoryId,
expectSome(yield* repo.resolveRepository(orgId, "github", "7")).id,
diff --git a/apps/api/src/services/vcs/__tests__/fixtures.ts b/apps/api/src/services/vcs/__tests__/fixtures.ts
new file mode 100644
index 000000000..c23c4212e
--- /dev/null
+++ b/apps/api/src/services/vcs/__tests__/fixtures.ts
@@ -0,0 +1,5 @@
+import { GitCommitSha } from "@maple/domain/http"
+import { Schema } from "effect"
+
+/** Decode commit SHA fixtures through the same branded schema used in production. */
+export const decodeGitCommitSha = Schema.decodeUnknownSync(GitCommitSha)
diff --git a/apps/api/src/services/vcs/__tests__/vcs.test.ts b/apps/api/src/services/vcs/__tests__/vcs.test.ts
index 1493e6565..eee71bef9 100644
--- a/apps/api/src/services/vcs/__tests__/vcs.test.ts
+++ b/apps/api/src/services/vcs/__tests__/vcs.test.ts
@@ -1,7 +1,6 @@
import { afterEach, assert, describe, it } from "@effect/vitest"
import { randomUUID } from "node:crypto"
import {
- GitCommitSha,
VcsInstallation,
VcsInstallationGoneError,
VcsProviderError,
@@ -45,6 +44,7 @@ import {
type VcsRepo,
WEBHOOK_SECRET,
} from "./harness"
+import { decodeGitCommitSha } from "./fixtures"
const trackedDbs: TestDb[] = []
afterEach(() => cleanupTestDbs(trackedDbs))
@@ -203,10 +203,7 @@ describe("GithubProvider.webhookToJobs", () => {
// Push payloads carry no avatar URL — the provider derives one from the
// committer login against the commit's own host (here github.com), so the
// dashboard never has to patch a null avatar.
- assert.strictEqual(
- job.commits[0]!.authorAvatarUrl,
- "https://github.com/octocat.png?size=64",
- )
+ assert.strictEqual(job.commits[0]!.authorAvatarUrl, "https://github.com/octocat.png?size=64")
}).pipe(Effect.provide(providerLayer())),
)
@@ -233,10 +230,7 @@ describe("GithubProvider.webhookToJobs", () => {
})
const job = jobs[0]!
if (job.kind !== "push") return assert.fail("expected a push job")
- assert.strictEqual(
- job.commits[0]!.authorAvatarUrl,
- "https://github.acme.com/octocat.png?size=64",
- )
+ assert.strictEqual(job.commits[0]!.authorAvatarUrl, "https://github.acme.com/octocat.png?size=64")
}).pipe(Effect.provide(providerLayer())),
)
@@ -734,14 +728,14 @@ describe("VcsRepository", () => {
assert.ok(branches.filter((b) => b.name !== "main").every((b) => !b.isDefault))
yield* repo.upsertCommits(r, [mk(SHA_X, 100), mk(SHA_Y, 200)])
- assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, SHA_X as never)))
+ assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_X))))
// Changing the tracked branch wipes the repo's stored (old-branch) commits.
yield* repo.changeTrackedBranch(orgId, r.id, "feature")
r = yield* repoFor(repo, orgId, "7")
assert.strictEqual(r.trackedBranch, "feature")
- assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, SHA_X as never)))
- assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, SHA_Y as never)))
+ assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_X))))
+ assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_Y))))
// Reconcile: remote lacks "stale" → deleted; its name is returned.
const deleted = yield* repo.reconcileBranchDeletions(r.id, new Set(["main", "feature"]), {
@@ -824,7 +818,7 @@ describe("VcsRepository", () => {
])
assert.strictEqual(count, 1)
- const commit = yield* repo.findCommitBySha(orgId, SHA as never)
+ const commit = yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA))
assert.ok(Option.isSome(commit))
assert.strictEqual(commit.value.authorLogin, "octocat")
assert.strictEqual(commit.value.repositoryId, repoRow.value.id)
@@ -846,18 +840,7 @@ describe("VcsRepository", () => {
(id, org_id, provider, external_installation_id, account_login, account_type,
external_account_id, repository_selection, status, installed_by_user_id, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10, now(), now())`,
- [
- randomUUID(),
- "org_x",
- "github",
- "55",
- "octo",
- "team",
- "1",
- "all",
- "active",
- "user_1",
- ],
+ [randomUUID(), "org_x", "github", "55", "octo", "team", "1", "all", "active", "user_1"],
),
)
const exit = yield* repo.resolveInstallation("github", "55").pipe(Effect.exit)
@@ -926,12 +909,12 @@ describe("VcsRepository", () => {
assert.ok(Option.isNone(yield* repo.resolveInstallation("github", "42")))
assert.strictEqual((yield* reposOfInstallation(repo, "42", "all")).length, 0)
- assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, SHA_42 as never)))
+ assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_42))))
// Installation 99 is untouched (delete was scoped to 42's repo ids).
assert.ok(Option.isSome(yield* repo.resolveInstallation("github", "99")))
assert.strictEqual((yield* reposOfInstallation(repo, "99", "all")).length, 1)
- assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, SHA_99 as never)))
+ assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_99))))
// Idempotent: purging again is a no-op, not an error.
yield* purgeInstallationFor(repo, orgId, "42")
@@ -1023,8 +1006,8 @@ describe("VcsRepository", () => {
const repoA = yield* repoFor(repo, orgA, "7")
const repoB = yield* repoFor(repo, orgB, "8")
- const foundA = yield* repo.findCommitBySha(orgA, SHARED as never)
- const foundB = yield* repo.findCommitBySha(orgB, SHARED as never)
+ const foundA = yield* repo.findCommitBySha(orgA, decodeGitCommitSha(SHARED))
+ const foundB = yield* repo.findCommitBySha(orgB, decodeGitCommitSha(SHARED))
assert.strictEqual(expectSome(foundA).repositoryId, repoA.id)
assert.strictEqual(expectSome(foundB).repositoryId, repoB.id)
// And never the other org's row.
@@ -1064,7 +1047,7 @@ describe("VcsRepository", () => {
const after = yield* repoFor(repo, orgId, "7")
assert.strictEqual(after.trackedBranch, "main") // untouched
- assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, SHA_X as never))) // not wiped
+ assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_X)))) // not wiped
}).pipe(Effect.provide(repoLayer(testDb)))
})
@@ -1119,7 +1102,7 @@ describe("VcsRepository", () => {
},
])
// Stored lowercased → found by the lowercase form.
- assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, "a".repeat(40) as never)))
+ assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, decodeGitCommitSha("a".repeat(40)))))
}).pipe(Effect.provide(repoLayer(testDb)))
})
@@ -1412,7 +1395,7 @@ describe("VcsSyncService orchestrator", () => {
}),
)
assert.ok(!(yield* repo.listBranchesByRepository(r.id)).some((b) => b.name === "feature/x"))
- assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, SHA_A as never)))
+ assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_A))))
assert.strictEqual(sent.length, 0) // branch events make no GitHub/queue calls
}).pipe(Effect.provide(orchestratorLayer(testDb, { sent, repos: oneRepo })))
})
@@ -1433,7 +1416,7 @@ describe("VcsSyncService orchestrator", () => {
])
yield* repo.changeTrackedBranch(orgId, r.id, "release")
yield* repo.upsertCommits(r, [commit(SHA_A, 1)])
- assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, SHA_A as never)))
+ assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_A))))
yield* svc.processMessage(
Schema.encodeSync(VcsSyncJob)({
@@ -1450,7 +1433,7 @@ describe("VcsSyncService orchestrator", () => {
// backfill of the default enqueued.
const updated = yield* repoFor(repo, orgId, "7")
assert.strictEqual(updated.trackedBranch, "main")
- assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, SHA_A as never)))
+ assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_A))))
const backfills = sent.filter((j) => j.kind === "sync-commits")
assert.strictEqual(backfills.length, 1)
assert.strictEqual(backfills[0]!.kind === "sync-commits" ? backfills[0]!.branch : "", "main")
@@ -1489,13 +1472,17 @@ describe("VcsSyncService orchestrator", () => {
// retarget to "main": commits wiped, exactly one backfill, for "main".
const updated = yield* repoFor(repo, orgId, "7")
assert.strictEqual(updated.trackedBranch, "main")
- assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, SHA_A as never)))
+ assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_A))))
const backfills = sent.filter((j) => j.kind === "sync-commits")
assert.strictEqual(backfills.length, 1)
assert.strictEqual(backfills[0]!.kind === "sync-commits" ? backfills[0]!.branch : "", "main")
}).pipe(
Effect.provide(
- orchestratorLayer(testDb, { sent, repos: oneRepo, branches: [{ name: "main", headSha: null }] }),
+ orchestratorLayer(testDb, {
+ sent,
+ repos: oneRepo,
+ branches: [{ name: "main", headSha: null }],
+ }),
),
)
})
@@ -1527,7 +1514,7 @@ describe("VcsSyncService orchestrator", () => {
"main",
)
// Forced ⇒ the payload is discarded (we re-walk instead), so SHA_A is not stored.
- assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, SHA_A as never)))
+ assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_A))))
}).pipe(Effect.provide(orchestratorLayer(testDb, { sent, repos: oneRepo })))
})
@@ -1547,7 +1534,7 @@ describe("VcsSyncService orchestrator", () => {
commits: [commit(SHA_A, 1)],
}
yield* svc.processMessage(Schema.encodeSync(VcsSyncJob)(job)) // must not fail
- const found = yield* repo.findCommitBySha(orgId, SHA_A as never)
+ const found = yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_A))
assert.ok(Option.isNone(found))
assert.strictEqual(sent.length, 0)
}).pipe(Effect.provide(orchestratorLayer(testDb, { sent })))
@@ -1577,7 +1564,7 @@ describe("VcsSyncService orchestrator", () => {
const branches = yield* repo.listBranchesByRepository(r.id)
assert.ok(branches.some((b) => b.name === "feature/x"))
// …and its commits are NOT stored — only the tracked branch's commits are.
- assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, SHA_A as never)))
+ assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_A))))
}).pipe(Effect.provide(orchestratorLayer(testDb, { sent })))
})
@@ -1603,7 +1590,7 @@ describe("VcsSyncService orchestrator", () => {
commits: [commit(SHA_A, 1)],
}),
)
- assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, SHA_A as never)))
+ assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_A))))
}).pipe(Effect.provide(orchestratorLayer(testDb, { sent })))
})
@@ -1629,7 +1616,7 @@ describe("VcsSyncService orchestrator", () => {
commits: [commit(SHA_A, 1)],
}),
)
- assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, SHA_A as never))) // push landed…
+ assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_A)))) // push landed…
// …yet the sync status stays exactly as the backfill left it (untouched here).
const stored = yield* reposOfInstallation(repo, "42", "all")
assert.strictEqual(stored[0]!.syncStatus, "pending")
@@ -1734,7 +1721,7 @@ describe("VcsSyncService orchestrator", () => {
// The prior installation, its repos, and its commits are all hard-deleted…
assert.ok(Option.isNone(yield* repo.resolveInstallation("github", "11")))
assert.strictEqual((yield* reposOfInstallation(repo, "11", "all")).length, 0)
- assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, STALE_SHA as never)))
+ assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(STALE_SHA))))
// …leaving exactly the new installation, which synced its own repo.
const remaining = (yield* repo.listInstallationsByOrg(orgId)).filter(
@@ -1780,8 +1767,8 @@ describe("VcsSyncService orchestrator", () => {
sinceMs: 0,
}
yield* svc.processMessage(Schema.encodeSync(VcsSyncJob)(job))
- const a = yield* repo.findCommitBySha(orgId, SHA_A as never)
- const b = yield* repo.findCommitBySha(orgId, SHA_B as never)
+ const a = yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_A))
+ const b = yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_B))
assert.ok(Option.isSome(a) && Option.isSome(b))
const stored = yield* reposOfInstallation(repo, "42", "all")
assert.strictEqual(stored[0]!.syncStatus, "ready")
@@ -1908,7 +1895,7 @@ describe("VcsSyncService orchestrator", () => {
commits: [commit(SHA_A, 1)],
}
yield* svc.processMessage(Schema.encodeSync(VcsSyncJob)(job)) // gated → must not fail
- const a = yield* repo.findCommitBySha(orgId, SHA_A as never)
+ const a = yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_A))
assert.ok(Option.isNone(a)) // gate short-circuits before the upsert
const inst = yield* repo.resolveInstallation("github", "42")
assert.ok(Option.isSome(inst))
@@ -1968,7 +1955,7 @@ describe("VcsSyncService orchestrator", () => {
yield* seedRepo(repo)
yield* svc.processMessage(Schema.encodeSync(VcsSyncJob)(backfillJob)) // must not fail
// The fetched commit was persisted…
- assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, SHA_A as never)))
+ assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_A))))
// …the repo is marked backfilling (in progress, not ready)…
const stored = yield* reposOfInstallation(repo, "42", "all")
assert.strictEqual(stored[0]!.syncStatus, "backfilling")
@@ -2005,7 +1992,7 @@ describe("VcsSyncService orchestrator", () => {
yield* seedRepo(repo)
yield* svc.processMessage(Schema.encodeSync(VcsSyncJob)(backfillJob)) // must not fail
// The fetched page was persisted and the repo marked backfilling…
- assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, SHA_A as never)))
+ assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_A))))
const stored = yield* reposOfInstallation(repo, "42", "all")
assert.strictEqual(stored[0]!.syncStatus, "backfilling")
// …and a continuation was requeued from the watermark with NO delay…
@@ -2127,7 +2114,7 @@ describe("VcsSyncService orchestrator", () => {
assert.strictEqual(all.length, 1)
assert.strictEqual(all[0]!.status, "removed")
// Its commits are retained (soft delete, not a purge).
- assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, SHA_A as never)))
+ assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_A))))
}).pipe(Effect.provide(orchestratorLayer(testDb, { sent, repos: [] })))
})
@@ -2192,7 +2179,7 @@ describe("VcsSyncService orchestrator", () => {
commits: [commit(SHA_A, 1)],
}
yield* svc.processMessage(Schema.encodeSync(VcsSyncJob)(job)) // must not fail
- assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, SHA_A as never)))
+ assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_A))))
}).pipe(Effect.provide(orchestratorLayer(testDb, { sent })))
})
@@ -2228,7 +2215,9 @@ describe("VcsSyncService orchestrator", () => {
// …and reaches back exactly one window from the enqueue moment.
assert.ok(backfill.sinceMs >= before - BACKFILL_WINDOW_MS)
assert.ok(backfill.sinceMs <= after - BACKFILL_WINDOW_MS)
- }).pipe(Effect.provide(orchestratorLayer(testDb, { sent, branches: [{ name: "main", headSha: null }] })))
+ }).pipe(
+ Effect.provide(orchestratorLayer(testDb, { sent, branches: [{ name: "main", headSha: null }] })),
+ )
})
// A continuation must carry the original walk's `sinceMs` (and branch) unchanged —
@@ -2485,7 +2474,7 @@ describe("VcsSyncService orchestrator", () => {
}),
)
assert.ok((yield* repo.listBranchesByRepository(r.id)).some((b) => b.name === "main"))
- assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, SHA_A as never)))
+ assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_A))))
}).pipe(Effect.provide(orchestratorLayer(testDb, { sent })))
})
@@ -2639,7 +2628,7 @@ describe("VcsSyncService orchestrator", () => {
// validation fires at both the webhook decode boundary and on persistence.
describe("git SHA validation (branded type)", () => {
it("GitCommitSha accepts mixed-case input and normalizes it to lowercase", () => {
- const decode = Schema.decodeUnknownSync(GitCommitSha)
+ const decode = decodeGitCommitSha
// All-uppercase 40-hex is accepted and lowercased.
assert.strictEqual(decode("A".repeat(40)), "a".repeat(40))
// Mixed case round-trips to its lowercase form (so case never splits a row).
diff --git a/apps/api/src/services/vcs/vendor/github/__tests__/GithubConnectService.test.ts b/apps/api/src/services/vcs/vendor/github/__tests__/GithubConnectService.test.ts
index 4d2eb9371..dec7c3a4d 100644
--- a/apps/api/src/services/vcs/vendor/github/__tests__/GithubConnectService.test.ts
+++ b/apps/api/src/services/vcs/vendor/github/__tests__/GithubConnectService.test.ts
@@ -25,6 +25,7 @@ import {
upsertCommitsFor,
upsertReposFor,
} from "../../../__tests__/harness"
+import { decodeGitCommitSha } from "../../../__tests__/fixtures"
const trackedDbs: TestDb[] = []
afterEach(() => cleanupTestDbs(trackedDbs))
@@ -325,7 +326,7 @@ describe("GithubConnectService", () => {
assert.strictEqual(result.disconnected, true)
assert.ok(Option.isNone(yield* repo.resolveInstallation("github", "42")))
assert.strictEqual((yield* reposOfInstallation(repo, "42", "all")).length, 0)
- assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, SHA as never)))
+ assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA))))
// A user-initiated disconnect fully removes the row, so status reverts to the
// pristine "never connected" state; a second disconnect is a no-op.
@@ -504,9 +505,9 @@ describe("GithubConnectService", () => {
assert.strictEqual(result.deleted, true)
assert.ok(Option.isNone(yield* repo.resolveRepository(orgId, "github", "7")))
- assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, SHA_7 as never)))
+ assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_7))))
assert.ok(Option.isSome(yield* repo.resolveRepository(orgId, "github", "8")))
- assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, SHA_8 as never)))
+ assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA_8))))
// Deleting the same (now-absent) id again is a no-op (idempotent).
const again = yield* svc.deleteRepository(orgId, repo7Id)
@@ -563,7 +564,7 @@ describe("GithubConnectService", () => {
const exit = yield* svc.deleteRepository(orgId, repo7.value.id).pipe(Effect.exit)
assert.ok(findError(exit) instanceof IntegrationsValidationError)
assert.ok(Option.isSome(yield* repo.resolveRepository(orgId, "github", "7")))
- assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, SHA as never)))
+ assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA))))
}).pipe(Effect.provide(connectLayer(testDb, http, sent)))
})
@@ -620,13 +621,13 @@ describe("GithubConnectService", () => {
// An unknown branch is rejected; nothing changes.
const bad = yield* svc.setTrackedBranch(orgId, r.id, "nope").pipe(Effect.exit)
assert.ok(findError(bad) instanceof IntegrationsValidationError)
- assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, SHA as never)))
+ assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA))))
// Selecting the current branch is a no-op: no wipe, no backfill enqueued.
sent.length = 0
const noop = yield* svc.setTrackedBranch(orgId, r.id, "main")
assert.strictEqual(noop.backfillQueued, false)
- assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, SHA as never)))
+ assert.ok(Option.isSome(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA))))
assert.strictEqual(sent.filter((j) => j.kind === "sync-commits").length, 0)
// Changing the branch wipes commits and enqueues a backfill.
@@ -634,7 +635,7 @@ describe("GithubConnectService", () => {
const changed = yield* svc.setTrackedBranch(orgId, r.id, "release")
assert.strictEqual(changed.trackedBranch, "release")
assert.ok(changed.backfillQueued)
- assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, SHA as never)))
+ assert.ok(Option.isNone(yield* repo.findCommitBySha(orgId, decodeGitCommitSha(SHA))))
assert.strictEqual((yield* repoFor(repo, orgId, "7")).trackedBranch, "release")
const backfills = sent.filter((j) => j.kind === "sync-commits")
assert.strictEqual(backfills.length, 1)
diff --git a/apps/api/src/vcs-sync-runtime.ts b/apps/api/src/vcs-sync-runtime.ts
index 1a2a504fc..fea7253df 100644
--- a/apps/api/src/vcs-sync-runtime.ts
+++ b/apps/api/src/vcs-sync-runtime.ts
@@ -1,6 +1,6 @@
import type { MessageBatch } from "@cloudflare/workers-types"
import * as MapleCloudflareSDK from "@maple-dev/effect-sdk/cloudflare"
-import { ANTICIPATED_ERROR_TAGS } from "@maple/domain/anticipated-errors"
+import { ANTICIPATED_ERROR_IDENTIFIERS } from "@maple/domain/anticipated-errors"
import { WorkerConfigProviderLayer, WorkerEnvironment } from "@maple/effect-cloudflare"
import { Cause, Effect, Layer, Option } from "effect"
import { layerPg } from "./lib/DatabasePgLive"
@@ -24,7 +24,7 @@ const telemetry = MapleCloudflareSDK.make({
serviceName: "maple-api",
serviceNamespace: "backend",
repositoryUrl: "https://github.com/Makisuo/maple",
- anticipatedErrorTags: [...ANTICIPATED_ERROR_TAGS],
+ anticipatedErrorIdentifiers: [...ANTICIPATED_ERROR_IDENTIFIERS],
})
export const buildVcsSyncLayer = (_env: Record) => {
diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts
index 1b0b07353..fdbac0fa8 100644
--- a/apps/api/src/worker.ts
+++ b/apps/api/src/worker.ts
@@ -1,6 +1,6 @@
import type { MessageBatch, ScheduledController } from "@cloudflare/workers-types"
import * as MapleCloudflareSDK from "@maple-dev/effect-sdk/cloudflare"
-import { ANTICIPATED_ERROR_TAGS } from "@maple/domain/anticipated-errors"
+import { ANTICIPATED_ERROR_IDENTIFIERS } from "@maple/domain/anticipated-errors"
import {
layerFromEnvRecord,
runScheduledEffect,
@@ -53,7 +53,7 @@ const telemetry = MapleCloudflareSDK.make({
dropSpanNames: ["McpServer/Notifications."],
// Expected 4xx outcomes (validation, not-found, unauthorized, …) record as
// Ok spans instead of errors — see @maple/domain/anticipated-errors.
- anticipatedErrorTags: [...ANTICIPATED_ERROR_TAGS],
+ anticipatedErrorIdentifiers: [...ANTICIPATED_ERROR_IDENTIFIERS],
})
// `HttpMiddleware.tracer` ends the root server span on a deferred macrotask
@@ -280,13 +280,23 @@ const handle = async (
return response
} catch (err) {
console.error("[worker] handler failed:", err)
+ const message = err instanceof Error ? err.message : String(err)
+ Effect.runFork(
+ Effect.logError("API worker handler failed").pipe(
+ Effect.annotateLogs({
+ error: message,
+ method: request.method,
+ url: request.url,
+ }),
+ Effect.provide(telemetry.layer),
+ ),
+ )
if (isMcp && mcpFrame) {
console.error(
`[mcp-err] method=${mcpFrame.method} id=${mcpFrame.id}` + ` dur=${Date.now() - startedAt}ms`,
)
}
ctx.waitUntil(flushTelemetry(env))
- const message = err instanceof Error ? err.message : String(err)
return new Response(`worker handler error: ${message}`, { status: 504 })
}
}
diff --git a/apps/api/src/workflows/AiTriageWorkflow.run.ts b/apps/api/src/workflows/AiTriageWorkflow.run.ts
index 754d19928..8bb72dc54 100644
--- a/apps/api/src/workflows/AiTriageWorkflow.run.ts
+++ b/apps/api/src/workflows/AiTriageWorkflow.run.ts
@@ -22,7 +22,7 @@ import { createFlueClient } from "@flue/sdk"
import * as MapleCloudflareSDK from "@maple-dev/effect-sdk/cloudflare"
import { aiTriageRuns, anomalyIncidents, errorIssueEvents } from "@maple/db"
import { createMaplePgClient, type MaplePgClient } from "@maple/db/client"
-import { ANTICIPATED_ERROR_TAGS } from "@maple/domain/anticipated-errors"
+import { ANTICIPATED_ERROR_IDENTIFIERS } from "@maple/domain/anticipated-errors"
import { AiTriageResult } from "@maple/domain/http"
import {
AiTriageRunId,
@@ -177,7 +177,7 @@ const triageTelemetry = MapleCloudflareSDK.make({
serviceName: "maple-api",
serviceNamespace: "backend",
repositoryUrl: "https://github.com/Makisuo/maple",
- anticipatedErrorTags: [...ANTICIPATED_ERROR_TAGS],
+ anticipatedErrorIdentifiers: [...ANTICIPATED_ERROR_IDENTIFIERS],
})
const GATE_STEP = { retries: { limit: 3, delay: "2 seconds", backoff: "exponential" } }
diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts
index 833d39f1d..b9da65a45 100644
--- a/apps/cli/src/bin.ts
+++ b/apps/cli/src/bin.ts
@@ -3,6 +3,7 @@ import { BunRuntime } from "@effect/platform-bun"
import * as BunServices from "@effect/platform-bun/BunServices"
import { Effect, Layer } from "effect"
import * as Command from "effect/unstable/cli/Command"
+import { FetchHttpClient } from "effect/unstable/http"
import { cli } from "./cli"
import { MapleConfig } from "./core/config"
import { Mode } from "./core/mode"
@@ -20,6 +21,7 @@ const MainLayer = WarehouseExecutorFromMode.pipe(
Layer.provideMerge(Mode.layer),
Layer.provideMerge(MapleConfig.layer),
Layer.provideMerge(BunServices.layer),
+ Layer.provideMerge(FetchHttpClient.layer),
)
// Throttled, non-blocking "update available" notice before dispatching the
diff --git a/apps/cli/src/commands/auth.ts b/apps/cli/src/commands/auth.ts
index c42df0918..9e7bf7231 100644
--- a/apps/cli/src/commands/auth.ts
+++ b/apps/cli/src/commands/auth.ts
@@ -96,7 +96,7 @@ export const whoami = Command.make("whoami", {}).pipe(
Effect.fnUntraced(function* () {
const config = yield* MapleConfig
const mode = yield* Mode
- const defaultMode = config.defaultMode ?? "auto"
+ const defaultMode = Option.getOrElse(config.defaultMode, () => "auto" as const)
const resolved = yield* mode.resolve.pipe(
Effect.map((m) => ({ ok: true as const, m })),
Effect.catch((e) => Effect.succeed({ ok: false as const, message: e.message })),
diff --git a/apps/cli/src/core/config.ts b/apps/cli/src/core/config.ts
index edb676e75..b1c3f5b61 100644
--- a/apps/cli/src/core/config.ts
+++ b/apps/cli/src/core/config.ts
@@ -1,4 +1,4 @@
-import { Clock, Context, Effect, Layer, type PlatformError, Schema } from "effect"
+import { Clock, Context, Effect, Layer, Option, Redacted, type PlatformError, Schema } from "effect"
import { FileSystem } from "effect/FileSystem"
import * as os from "node:os"
import * as path from "node:path"
@@ -64,19 +64,19 @@ const writeMerged = (
export interface MapleConfigShape {
/** Remote API base URL (env `MAPLE_API_URL` overrides the stored value). */
- readonly apiUrl: string | undefined
+ readonly apiUrl: Option.Option
/** Remote bearer token (env `MAPLE_API_TOKEN` overrides the stored value). */
- readonly token: string | undefined
- readonly orgId: string | undefined
+ readonly token: Option.Option>
+ readonly orgId: Option.Option
/** Local binary base URL (env `MAPLE_LOCAL_URL`, else the default). */
readonly localUrl: string
- readonly defaultMode: "local" | "remote" | undefined
+ readonly defaultMode: Option.Option<"local" | "remote">
/** API URL to use for `maple login` when none is passed. */
readonly defaultApiUrl: string
- /** ISO timestamp of the last startup update check (undefined = never checked). */
- readonly lastUpdateCheck: string | undefined
- /** Latest release tag seen by the last update check, or undefined. */
- readonly latestKnownVersion: string | undefined
+ /** ISO timestamp of the last startup update check (`None` = never checked). */
+ readonly lastUpdateCheck: Option.Option
+ /** Latest release tag seen by the last update check, or `None`. */
+ readonly latestKnownVersion: Option.Option
/** Persist config fields (merged with existing). */
readonly write: (next: StoredConfig) => Effect.Effect
/** Remove the stored token (used by `maple logout`). */
@@ -96,14 +96,14 @@ export class MapleConfig extends Context.Service(
const stored = yield* readStored(fs)
const env = process.env
return {
- apiUrl: env.MAPLE_API_URL ?? stored.apiUrl,
- token: env.MAPLE_API_TOKEN ?? stored.token,
- orgId: env.MAPLE_ORG_ID ?? stored.orgId,
+ apiUrl: Option.fromNullishOr(env.MAPLE_API_URL ?? stored.apiUrl),
+ token: Option.map(Option.fromNullishOr(env.MAPLE_API_TOKEN ?? stored.token), Redacted.make),
+ orgId: Option.fromNullishOr(env.MAPLE_ORG_ID ?? stored.orgId),
localUrl: env.MAPLE_LOCAL_URL ?? defaultLocalUrl(env.MAPLE_LOCAL_BIND_HOST),
- defaultMode: stored.defaultMode,
+ defaultMode: Option.fromNullishOr(stored.defaultMode),
defaultApiUrl: env.MAPLE_API_URL ?? DEFAULT_API_URL,
- lastUpdateCheck: stored.lastUpdateCheck,
- latestKnownVersion: stored.latestKnownVersion,
+ lastUpdateCheck: Option.fromNullishOr(stored.lastUpdateCheck),
+ latestKnownVersion: Option.fromNullishOr(stored.latestKnownVersion),
write: (next) => writeMerged(fs, (cur) => ({ ...cur, ...next })),
clearToken: () =>
writeMerged(fs, (cur) => {
diff --git a/apps/cli/src/core/mode.ts b/apps/cli/src/core/mode.ts
index 5aa1ef019..8355b90d3 100644
--- a/apps/cli/src/core/mode.ts
+++ b/apps/cli/src/core/mode.ts
@@ -1,4 +1,5 @@
-import { Context, Effect, Layer, Schema } from "effect"
+import { Context, Duration, Effect, Layer, Option, Redacted, Schema } from "effect"
+import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { MapleConfig } from "./config"
/**
@@ -28,17 +29,14 @@ const hasFlag = (name: string): boolean =>
typeof process !== "undefined" && Array.isArray(process.argv) && process.argv.includes(name)
/** Fast, non-fatal liveness probe of the local binary's `/health` route. */
-const probeLocal = (baseUrl: string): Effect.Effect =>
- Effect.tryPromise(async () => {
- const controller = new AbortController()
- const timer = setTimeout(() => controller.abort(), 400)
- try {
- const res = await fetch(`${baseUrl.replace(/\/$/, "")}/health`, { signal: controller.signal })
- return res.ok
- } finally {
- clearTimeout(timer)
- }
- }).pipe(Effect.orElseSucceed(() => false))
+const probeLocal = (client: HttpClient.HttpClient, baseUrl: string): Effect.Effect => {
+ const request = HttpClientRequest.get(`${baseUrl.replace(/\/$/, "")}/health`)
+ return client.execute(request).pipe(
+ Effect.map((response) => response.status >= 200 && response.status < 300),
+ Effect.timeoutOrElse({ duration: Duration.millis(400), orElse: () => Effect.succeed(false) }),
+ Effect.orElseSucceed(() => false),
+ )
+}
export interface ModeShape {
/** Resolve the active backend. Fails with `ModeError` if none is available. */
@@ -48,14 +46,18 @@ export interface ModeShape {
export class Mode extends Context.Service()("@maple/cli/Mode", {
make: Effect.gen(function* () {
const config = yield* MapleConfig
+ const client = yield* HttpClient.HttpClient
- const hasRemoteCreds = !!config.token && !!config.apiUrl
- const remote = (): ResolvedMode => ({
- _tag: "remote",
- apiUrl: config.apiUrl!,
- token: config.token!,
- orgId: config.orgId,
- })
+ const remoteConfig = Option.map(
+ Option.all({ apiUrl: config.apiUrl, token: config.token }),
+ ({ apiUrl, token }): ResolvedMode => ({
+ _tag: "remote",
+ apiUrl,
+ token: Redacted.value(token),
+ orgId: Option.getOrUndefined(config.orgId),
+ }),
+ )
+ const remote = Option.getOrUndefined(remoteConfig)
const local = (): ResolvedMode => ({ _tag: "local", baseUrl: config.localUrl })
const resolve: Effect.Effect = Effect.gen(function* () {
@@ -66,24 +68,24 @@ export class Mode extends Context.Service()("@maple/cli/Mode",
return yield* new ModeError({ message: "Cannot use --remote and --local together." })
}
if (forceRemote) {
- if (!hasRemoteCreds) {
+ if (remote === undefined) {
return yield* new ModeError({
message:
"Remote mode needs a workspace. Run `maple login`, or set MAPLE_API_URL + MAPLE_API_TOKEN.",
})
}
- return remote()
+ return remote
}
if (forceLocal) return local()
// Stored preference.
- if (config.defaultMode === "remote" && hasRemoteCreds) return remote()
- if (config.defaultMode === "local") return local()
+ if (Option.contains(config.defaultMode, "remote") && remote !== undefined) return remote
+ if (Option.contains(config.defaultMode, "local")) return local()
// Auto-detect: a configured token implies remote; otherwise probe for a
// running local binary.
- if (hasRemoteCreds) return remote()
- if (yield* probeLocal(config.localUrl)) return local()
+ if (remote !== undefined) return remote
+ if (yield* probeLocal(client, config.localUrl)) return local()
return yield* new ModeError({
message:
diff --git a/apps/cli/src/core/remote-executor.test.ts b/apps/cli/src/core/remote-executor.test.ts
index 7f36dcaad..a95b4329b 100644
--- a/apps/cli/src/core/remote-executor.test.ts
+++ b/apps/cli/src/core/remote-executor.test.ts
@@ -1,15 +1,10 @@
-import { afterEach, describe, it } from "@effect/vitest"
+import { describe, it } from "@effect/vitest"
import { strict as assert } from "node:assert"
import { Effect, Tracer } from "effect"
+import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { WarehouseUpstreamError } from "@maple/domain/http/warehouse-errors"
import { makeRemoteWarehouseExecutorShape } from "./remote-executor"
-const realFetch = globalThis.fetch
-
-afterEach(() => {
- globalThis.fetch = realFetch
-})
-
const makeRecordingTracer = () => {
const spans: Array = []
const tracer = Tracer.make({
@@ -30,14 +25,24 @@ describe("remote warehouse executor", () => {
pipeName: "list_services",
upstreamStatus: 503,
})
- globalThis.fetch = (async () =>
+ const fetchStub = (async () =>
new Response(JSON.stringify(upstream), {
status: 503,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch
- const shape = makeRemoteWarehouseExecutorShape("https://api.maple.dev", "test-token", "org_test")
-
- const error = yield* Effect.flip(shape.query("get_service_usage", {}))
+ const error = yield* Effect.gen(function* () {
+ const client = yield* HttpClient.HttpClient
+ const shape = makeRemoteWarehouseExecutorShape(
+ client,
+ "https://api.maple.dev",
+ "test-token",
+ "org_test",
+ )
+ return yield* Effect.flip(shape.query("get_service_usage", {}))
+ }).pipe(
+ Effect.provide(FetchHttpClient.layer),
+ Effect.provideService(FetchHttpClient.Fetch, fetchStub),
+ )
assert.ok(error instanceof WarehouseUpstreamError)
assert.strictEqual(error.upstreamStatus, 503)
@@ -46,17 +51,27 @@ describe("remote warehouse executor", () => {
it.effect("attributes the HTTP peer without inventing a database system", () =>
Effect.gen(function* () {
- globalThis.fetch = (async () =>
+ const fetchStub = (async () =>
new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch
const { spans, tracer } = makeRecordingTracer()
- const shape = makeRemoteWarehouseExecutorShape("https://api.maple.dev", "test-token", "org_test")
-
- yield* shape
- .query("get_service_usage", {}, { context: "remoteServices", profile: "list" })
- .pipe(Effect.withTracer(tracer))
+ yield* Effect.gen(function* () {
+ const client = yield* HttpClient.HttpClient
+ const shape = makeRemoteWarehouseExecutorShape(
+ client,
+ "https://api.maple.dev",
+ "test-token",
+ "org_test",
+ )
+ yield* shape
+ .query("get_service_usage", {}, { context: "remoteServices", profile: "list" })
+ .pipe(Effect.withTracer(tracer))
+ }).pipe(
+ Effect.provide(FetchHttpClient.layer),
+ Effect.provideService(FetchHttpClient.Fetch, fetchStub),
+ )
const span = spans.find((candidate) => candidate.name === "warehouse.query")
assert.ok(span)
diff --git a/apps/cli/src/core/remote-executor.ts b/apps/cli/src/core/remote-executor.ts
index 227c4bc25..a8b3b6188 100644
--- a/apps/cli/src/core/remote-executor.ts
+++ b/apps/cli/src/core/remote-executor.ts
@@ -1,4 +1,5 @@
-import { Effect, Option, Schema, type Option as EffectOption } from "effect"
+import { Duration, Effect, Option, Schema, type Option as EffectOption } from "effect"
+import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { type WarehouseExecutorShape, type SqlQueryOptions } from "@maple/query-engine/observability"
import {
type WarehouseError,
@@ -45,6 +46,7 @@ const unsupported = (pipeName: string): Effect.Effect (pipe: string, params: Record, options?: SqlQueryOptions) =>
- Effect.tryPromise({
- try: async (): Promise<{ data: ReadonlyArray }> => {
- const started = performance.now()
- try {
- const res = await fetch(endpoint, {
- method: "POST",
- headers: {
- "content-type": "application/json",
- authorization: `Bearer ${token}`,
- },
- body: JSON.stringify({ pipe, params }),
- })
- const text = await res.text()
- if (!res.ok) {
- const decoded = decodeRemoteWarehouseError(text)
- if (decoded !== undefined) throw decoded
- throw new Error(`HTTP ${res.status}${text ? `: ${text}` : ""}`)
- }
- const json = decodeRemoteQueryResponseJson(text)
- return { data: (json.data ?? []) as ReadonlyArray }
- } finally {
- // Server-side SQL isn't returned; log the pipe + params instead.
- debugLog(
- `${pipe} · ${Math.round(performance.now() - started)}ms`,
- JSON.stringify(params),
+ Effect.gen(function* () {
+ const started = performance.now()
+ const request = HttpClientRequest.post(endpoint, {
+ headers: { authorization: `Bearer ${token}` },
+ }).pipe(HttpClientRequest.bodyText(JSON.stringify({ pipe, params }), "application/json"))
+ return yield* Effect.gen(function* () {
+ const response = yield* client
+ .execute(request)
+ .pipe(
+ Effect.mapError(
+ (error) =>
+ new WarehouseQueryError({
+ message: error.message,
+ pipeName: pipe,
+ cause: error,
+ }),
+ ),
)
+ const text = yield* response.text.pipe(
+ Effect.mapError(
+ (error) =>
+ new WarehouseQueryError({
+ message: error.message,
+ pipeName: pipe,
+ cause: error,
+ }),
+ ),
+ )
+ if (response.status < 200 || response.status >= 300) {
+ const decoded = decodeRemoteWarehouseError(text)
+ if (decoded !== undefined) return yield* decoded
+ return yield* new WarehouseQueryError({
+ message: `HTTP ${response.status}${text ? `: ${text}` : ""}`,
+ pipeName: pipe,
+ cause: text,
+ })
}
- },
- catch: (error) => {
- const decoded = Schema.decodeUnknownOption(WarehouseErrorSchema)(error)
- return Option.isSome(decoded)
- ? decoded.value
- : new WarehouseQueryError({
+ const json = yield* Effect.try({
+ try: () => decodeRemoteQueryResponseJson(text),
+ catch: (error) =>
+ new WarehouseQueryError({
message: error instanceof Error ? error.message : String(error),
pipeName: pipe,
cause: error,
- })
- },
+ }),
+ })
+ return { data: (json.data ?? []) as ReadonlyArray }
+ }).pipe(
+ Effect.timeoutOrElse({
+ duration: Duration.minutes(2),
+ orElse: () =>
+ Effect.fail(
+ new WarehouseQueryError({
+ message: "Remote warehouse query timed out after 2 minutes",
+ pipeName: pipe,
+ cause: "timeout",
+ }),
+ ),
+ }),
+ Effect.ensuring(
+ Effect.sync(() =>
+ debugLog(
+ `${pipe} · ${Math.round(performance.now() - started)}ms`,
+ JSON.stringify(params),
+ ),
+ ),
+ ),
+ )
}).pipe(
Effect.tap((result) => Effect.annotateCurrentSpan({ "result.rowCount": result.data.length })),
Effect.withSpan("warehouse.query", {
diff --git a/apps/cli/src/core/update.ts b/apps/cli/src/core/update.ts
index 77e1a4364..d20ca086e 100644
--- a/apps/cli/src/core/update.ts
+++ b/apps/cli/src/core/update.ts
@@ -13,7 +13,8 @@
// rename swaps the directory entry, so the running process keeps its old inode
// while new invocations pick up the new binary. Keep the triple/URL logic here
// in sync with install.sh.
-import { Clock, Effect, Option, Schema } from "effect"
+import { Clock, Duration, Effect, Option, Schema } from "effect"
+import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { realpathSync } from "node:fs"
import { chmod, mkdir, rename, rm } from "node:fs/promises"
import { dirname, join } from "node:path"
@@ -31,6 +32,8 @@ const REPO = "Makisuo/maple"
const LATEST_API = `https://api.github.com/repos/${REPO}/releases/latest`
/** Throttle window for the startup check — hit GitHub at most once per day. */
export const CHECK_TTL_MS = 24 * 60 * 60 * 1000
+const CHECKSUM_TIMEOUT = Duration.seconds(30)
+const DOWNLOAD_TIMEOUT = Duration.minutes(2)
// --- pure helpers (unit-tested) ----------------------------------------------
@@ -102,33 +105,51 @@ const resolveTarget: Effect.Effect = Effect.suspend(() => {
)
})
-/** Fetch the latest release tag from the GitHub API (keeps the leading "v" for
- * URL building). Bounded by `timeoutMs` via AbortController. */
-export const fetchLatestTag = (timeoutMs = 5000): Effect.Effect =>
- Effect.tryPromise({
- try: async () => {
- const controller = new AbortController()
- const timer = setTimeout(() => controller.abort(), timeoutMs)
- try {
- // GitHub rejects requests without a User-Agent; Accept pins the API version.
- const res = await fetch(LATEST_API, {
- headers: { "User-Agent": "maple-cli", Accept: "application/vnd.github+json" },
- signal: controller.signal,
- })
- if (!res.ok) throw new Error(`GitHub API returned ${res.status} ${res.statusText}`)
- const body = (await res.json()) as { tag_name?: string }
- if (!body.tag_name) throw new Error("no published release found")
- return body.tag_name
- } finally {
- clearTimeout(timer)
- }
- },
- catch: (e) =>
- new UpdateError({
- message: `could not check for updates: ${e instanceof Error ? e.message : String(e)}`,
- }),
+const toUpdateError = (prefix: string, error: unknown): UpdateError =>
+ new UpdateError({
+ message: `${prefix}: ${error instanceof Error ? error.message : String(error)}`,
})
+/** Fetch the latest release tag from the GitHub API (keeps the leading "v" for URL building). */
+export const fetchLatestTag = (timeoutMs = 5000): Effect.Effect =>
+ Effect.gen(function* () {
+ const client = yield* HttpClient.HttpClient
+ const request = HttpClientRequest.get(LATEST_API, {
+ headers: { "User-Agent": "maple-cli", Accept: "application/vnd.github+json" },
+ })
+ const response = yield* client
+ .execute(request)
+ .pipe(Effect.mapError((error) => toUpdateError("could not check for updates", error)))
+ if (response.status < 200 || response.status >= 300) {
+ return yield* new UpdateError({
+ message: `could not check for updates: GitHub API returned ${response.status}`,
+ })
+ }
+ const text = yield* response.text.pipe(
+ Effect.mapError((error) => toUpdateError("could not read GitHub release response", error)),
+ )
+ const body = yield* Effect.try({
+ try: () => JSON.parse(text) as { tag_name?: string },
+ catch: (error) => toUpdateError("could not decode GitHub release response", error),
+ })
+ if (!body.tag_name) {
+ return yield* new UpdateError({
+ message: "could not check for updates: no published release found",
+ })
+ }
+ return body.tag_name
+ }).pipe(
+ Effect.timeoutOrElse({
+ duration: Duration.millis(timeoutMs),
+ orElse: () =>
+ Effect.fail(
+ new UpdateError({
+ message: `could not check for updates: timed out after ${timeoutMs}ms`,
+ }),
+ ),
+ }),
+ )
+
/** Directory holding the running `maple` binary and its sibling `libchdb.so`
* (the symlink on PATH resolves here). */
const resolveInstallDir = (): string => dirname(realpathSync(process.execPath))
@@ -143,29 +164,62 @@ const mapFsError = (e: unknown, installDir: string): UpdateError => {
return new UpdateError({ message: e instanceof Error ? e.message : String(e) })
}
-const downloadTo = (url: string, dest: string): Effect.Effect =>
- Effect.tryPromise({
- try: async () => {
- const res = await fetch(url, { headers: { "User-Agent": "maple-cli" } })
- if (!res.ok) throw new Error(`download failed (${res.status} ${res.statusText}) for ${url}`)
- // NB: `Bun.write(dest, res)` (writing the Response directly) hangs
- // indefinitely on GitHub's redirect-backed release-asset streams — it
- // never resolves. Buffer the body first, then write the bytes.
- const body = await res.arrayBuffer()
- await Bun.write(dest, body)
- },
- catch: (e) => new UpdateError({ message: e instanceof Error ? e.message : String(e) }),
- })
+const downloadTo = (
+ url: string,
+ dest: string,
+ timeout: Duration.Input = DOWNLOAD_TIMEOUT,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const client = yield* HttpClient.HttpClient
+ const response = yield* client
+ .execute(HttpClientRequest.get(url, { headers: { "User-Agent": "maple-cli" } }))
+ .pipe(Effect.mapError((error) => toUpdateError("release download failed", error)))
+ if (response.status < 200 || response.status >= 300) {
+ return yield* new UpdateError({
+ message: `download failed (${response.status}) for ${url}`,
+ })
+ }
+ // Buffer the redirect-backed release asset before Bun.write; passing the
+ // response stream directly can hang indefinitely.
+ const body = yield* response.arrayBuffer.pipe(
+ Effect.mapError((error) => toUpdateError("release download body failed", error)),
+ )
+ yield* Effect.tryPromise({
+ try: () => Bun.write(dest, body).then(() => undefined),
+ catch: (error) => toUpdateError("release download write failed", error),
+ })
+ }).pipe(
+ Effect.timeoutOrElse({
+ duration: timeout,
+ orElse: () =>
+ Effect.fail(new UpdateError({ message: "release download timed out after 2 minutes" })),
+ }),
+ )
-const fetchText = (url: string): Effect.Effect =>
- Effect.tryPromise({
- try: async () => {
- const res = await fetch(url, { headers: { "User-Agent": "maple-cli" } })
- if (!res.ok) throw new Error(`could not fetch ${url} (${res.status} ${res.statusText})`)
- return await res.text()
- },
- catch: (e) => new UpdateError({ message: e instanceof Error ? e.message : String(e) }),
- })
+const fetchText = (
+ url: string,
+ timeout: Duration.Input = CHECKSUM_TIMEOUT,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const client = yield* HttpClient.HttpClient
+ const response = yield* client
+ .execute(HttpClientRequest.get(url, { headers: { "User-Agent": "maple-cli" } }))
+ .pipe(Effect.mapError((error) => toUpdateError("checksum request failed", error)))
+ if (response.status < 200 || response.status >= 300) {
+ return yield* new UpdateError({ message: `could not fetch ${url} (${response.status})` })
+ }
+ return yield* response.text.pipe(
+ Effect.mapError((error) => toUpdateError("checksum response read failed", error)),
+ )
+ }).pipe(
+ Effect.timeoutOrElse({
+ duration: timeout,
+ orElse: () =>
+ Effect.fail(new UpdateError({ message: "checksum request timed out after 30 seconds" })),
+ }),
+ )
+
+export const __testables = { downloadTo, fetchText }
const sha256File = (path: string): Effect.Effect =>
Effect.tryPromise({
@@ -218,7 +272,9 @@ export interface UpdateResult {
}
/** Download, verify, and atomically install a release bundle in place. */
-export const performUpdate = (opts: { tag?: string } = {}): Effect.Effect =>
+export const performUpdate = (
+ opts: { tag?: string } = {},
+): Effect.Effect =>
Effect.gen(function* () {
const target = yield* resolveTarget
const tagRaw = opts.tag ?? (yield* fetchLatestTag(10_000))
@@ -314,28 +370,30 @@ const printNotice = (current: string, latest: string): void => {
* notice to stderr when a newer release exists. Never blocks fatally and never
* fails the command — every error path collapses to a no-op.
*/
-export const maybeNotifyUpdate: Effect.Effect = Effect.gen(function* () {
- if (!shouldRunNotify(process.argv)) return
- const config = yield* MapleConfig
- const now = yield* Clock.currentTimeMillis
- let latest = config.latestKnownVersion
-
- if (shouldCheck(config.lastUpdateCheck, now)) {
- const fetched = yield* fetchLatestTag(1500).pipe(
- Effect.map((tag) => Option.some(tag)),
- Effect.catch(() => Effect.succeed(Option.none())),
- )
- if (Option.isSome(fetched)) {
- latest = fetched.value
- yield* config.recordUpdateCheck(fetched.value).pipe(Effect.ignore)
- } else {
- // Record the timestamp even on failure so an offline machine backs off
- // for the TTL instead of re-probing GitHub on every command.
- yield* config.recordUpdateCheck().pipe(Effect.ignore)
+export const maybeNotifyUpdate: Effect.Effect = Effect.gen(
+ function* () {
+ if (!shouldRunNotify(process.argv)) return
+ const config = yield* MapleConfig
+ const now = yield* Clock.currentTimeMillis
+ let latest = Option.getOrUndefined(config.latestKnownVersion)
+
+ if (shouldCheck(Option.getOrUndefined(config.lastUpdateCheck), now)) {
+ const fetched = yield* fetchLatestTag(1500).pipe(
+ Effect.map((tag) => Option.some(tag)),
+ Effect.catch(() => Effect.succeed(Option.none())),
+ )
+ if (Option.isSome(fetched)) {
+ latest = fetched.value
+ yield* config.recordUpdateCheck(fetched.value).pipe(Effect.ignore)
+ } else {
+ // Record the timestamp even on failure so an offline machine backs off
+ // for the TTL instead of re-probing GitHub on every command.
+ yield* config.recordUpdateCheck().pipe(Effect.ignore)
+ }
}
- }
- if (latest && isNewer(MAPLE_VERSION, latest)) {
- yield* Effect.sync(() => printNotice(MAPLE_VERSION, stripV(latest)))
- }
-})
+ if (latest && isNewer(MAPLE_VERSION, latest)) {
+ yield* Effect.sync(() => printNotice(MAPLE_VERSION, stripV(latest)))
+ }
+ },
+)
diff --git a/apps/cli/src/core/warehouse.ts b/apps/cli/src/core/warehouse.ts
index 679746134..0227f3291 100644
--- a/apps/cli/src/core/warehouse.ts
+++ b/apps/cli/src/core/warehouse.ts
@@ -1,4 +1,5 @@
import { Effect, Layer } from "effect"
+import { HttpClient } from "effect/unstable/http"
import {
WarehouseExecutor,
type WarehouseExecutorShape,
@@ -29,13 +30,14 @@ export const WarehouseExecutorFromMode = Layer.effect(
WarehouseExecutor,
Effect.gen(function* () {
const mode = yield* Mode
+ const client = yield* HttpClient.HttpClient
const getShape = yield* Effect.cached(
mode.resolve.pipe(
Effect.map(
(m): WarehouseExecutorShape =>
m._tag === "local"
? makeLocalWarehouseExecutorShape(m.baseUrl)
- : makeRemoteWarehouseExecutorShape(m.apiUrl, m.token, m.orgId ?? ""),
+ : makeRemoteWarehouseExecutorShape(client, m.apiUrl, m.token, m.orgId ?? ""),
),
Effect.mapError((e) => new WarehouseConfigError({ message: e.message, pipeName: "mode" })),
),
diff --git a/apps/cli/src/server/checkpoints.ts b/apps/cli/src/server/checkpoints.ts
index 43894bed2..1610946e7 100644
--- a/apps/cli/src/server/checkpoints.ts
+++ b/apps/cli/src/server/checkpoints.ts
@@ -5,6 +5,7 @@ import { cp, lstat, mkdir, readFile, readdir, rm, stat } from "node:fs/promises"
import { tmpdir } from "node:os"
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
import { Effect, Schema } from "effect"
+import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { CHDB_VERSION, MAPLE_VERSION } from "../version"
import { serverUrl } from "../lib/local-address"
import { Chdb } from "./chdb"
@@ -419,29 +420,32 @@ const localQueryError = (status: number, detail: string, cause = detail): LocalQ
export const checkpointQueryUrl = (host: string, port: number): string =>
`${serverUrl(host, port)}/local/query`
-const postLocalQuery = (host: string, port: number, sql: string): Effect.Effect => {
+const postLocalQuery = (
+ host: string,
+ port: number,
+ sql: string,
+): Effect.Effect => {
const url = checkpointQueryUrl(host, port)
return Effect.gen(function* () {
- const response = yield* Effect.tryPromise({
- try: (signal) =>
- fetch(url, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ sql }),
- signal,
- }),
- catch: (error) => localQueryError(0, errorMessage(error), errorCause(error)),
- })
+ const client = yield* HttpClient.HttpClient
+ const request = HttpClientRequest.post(url).pipe(
+ HttpClientRequest.bodyText(JSON.stringify({ sql }), "application/json"),
+ )
+ const response = yield* client
+ .execute(request)
+ .pipe(Effect.mapError((error) => localQueryError(0, errorMessage(error), errorCause(error))))
yield* Effect.annotateCurrentSpan("http.response.status_code", response.status)
- if (!response.ok) {
- const detail = yield* Effect.tryPromise({
- try: () => response.text(),
- catch: (error) => localQueryError(response.status, errorMessage(error), errorCause(error)),
- })
+ const text = yield* response.text.pipe(
+ Effect.mapError((error) =>
+ localQueryError(response.status, errorMessage(error), errorCause(error)),
+ ),
+ )
+ if (response.status < 200 || response.status >= 300) {
+ const detail = text
return yield* localQueryError(response.status, detail)
}
- return yield* Effect.tryPromise({
- try: () => response.json() as Promise,
+ return yield* Effect.try({
+ try: () => JSON.parse(text) as unknown,
catch: (error) => localQueryError(response.status, errorMessage(error), errorCause(error)),
})
}).pipe(
@@ -949,12 +953,12 @@ const acquireMaintenance = async (
}
}
-const withMaintenance = (
+const withMaintenance = (
dataDir: string,
operationId: CheckpointOperationId,
onAcquireError: (error: unknown) => E,
- use: () => Effect.Effect ,
-): Effect.Effect =>
+ use: () => Effect.Effect ,
+): Effect.Effect =>
Effect.acquireRelease(
Effect.tryPromise({
try: () => acquireMaintenance(dataDir, operationId),
diff --git a/apps/cli/test/update.test.ts b/apps/cli/test/update.test.ts
index 52cae23e2..594755418 100644
--- a/apps/cli/test/update.test.ts
+++ b/apps/cli/test/update.test.ts
@@ -1,6 +1,25 @@
import { describe, it } from "@effect/vitest"
-import { strictEqual } from "node:assert"
-import { CHECK_TTL_MS, isNewer, shouldCheck, stripV, targetTripleFor } from "../src/core/update"
+import { ok, strictEqual } from "node:assert"
+import { mkdtemp, readFile, rm } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { Duration, Effect } from "effect"
+import { FetchHttpClient } from "effect/unstable/http"
+import {
+ __testables,
+ CHECK_TTL_MS,
+ fetchLatestTag,
+ isNewer,
+ shouldCheck,
+ stripV,
+ targetTripleFor,
+} from "../src/core/update"
+
+const withFetch = (effect: Effect.Effect , fetchStub: typeof fetch) =>
+ effect.pipe(
+ Effect.provide(FetchHttpClient.layer),
+ Effect.provideService(FetchHttpClient.Fetch, fetchStub),
+ )
describe("stripV", () => {
it("drops a leading v", () => {
@@ -79,3 +98,87 @@ describe("shouldCheck", () => {
strictEqual(shouldCheck(exactlyTtl, now), true)
})
})
+
+describe("update HTTP", () => {
+ it.effect("decodes the latest release tag without mutating global fetch", () =>
+ Effect.gen(function* () {
+ const fetchStub = (async () => Response.json({ tag_name: "v1.2.3" })) as typeof fetch
+ const tag = yield* withFetch(fetchLatestTag(), fetchStub)
+ strictEqual(tag, "v1.2.3")
+ }),
+ )
+
+ it.effect("maps non-2xx and decode failures to UpdateError", () =>
+ Effect.gen(function* () {
+ const statusError = yield* Effect.flip(
+ withFetch(
+ fetchLatestTag(),
+ (async () => new Response("unavailable", { status: 503 })) as typeof fetch,
+ ),
+ )
+ strictEqual(statusError._tag, "@maple/cli/UpdateError")
+ ok(statusError.message.includes("503"))
+
+ const decodeError = yield* Effect.flip(
+ withFetch(fetchLatestTag(), (async () => new Response("not-json")) as typeof fetch),
+ )
+ strictEqual(decodeError._tag, "@maple/cli/UpdateError")
+ ok(decodeError.message.includes("decode"))
+ }),
+ )
+
+ it("aborts a tag request at its configured deadline", async () => {
+ let aborted = false
+ const fetchStub = ((input: string | URL | Request, init?: RequestInit) => {
+ const signal = input instanceof Request ? input.signal : init?.signal
+ return new Promise((_resolve, reject) => {
+ signal?.addEventListener("abort", () => {
+ aborted = true
+ reject(new DOMException("aborted", "AbortError"))
+ })
+ })
+ }) as typeof fetch
+ const error = await Effect.runPromise(Effect.flip(withFetch(fetchLatestTag(5), fetchStub)))
+ ok(error.message.includes("timed out"))
+ strictEqual(aborted, true)
+ })
+
+ it("downloads release bytes and reads checksums through the shared client", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "maple-update-http-"))
+ const destination = join(dir, "release.tar.gz")
+ const fetchStub = (async (input: string | URL | Request) => {
+ const url = input instanceof Request ? input.url : String(input)
+ return url.endsWith(".sha256")
+ ? new Response("abc123 release.tar.gz\n")
+ : new Response("release-bytes")
+ }) as typeof fetch
+
+ try {
+ await Effect.runPromise(
+ withFetch(
+ __testables.downloadTo(
+ "https://release.test/release.tar.gz",
+ destination,
+ Duration.seconds(1),
+ ),
+ fetchStub,
+ ),
+ )
+ strictEqual(await readFile(destination, "utf8"), "release-bytes")
+ strictEqual(
+ await Effect.runPromise(
+ withFetch(
+ __testables.fetchText(
+ "https://release.test/release.tar.gz.sha256",
+ Duration.seconds(1),
+ ),
+ fetchStub,
+ ),
+ ),
+ "abc123 release.tar.gz\n",
+ )
+ } finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+})
diff --git a/apps/electric-sync/src/worker.ts b/apps/electric-sync/src/worker.ts
index c27702c21..3bbcdb16a 100644
--- a/apps/electric-sync/src/worker.ts
+++ b/apps/electric-sync/src/worker.ts
@@ -1,5 +1,5 @@
import * as MapleCloudflareSDK from "@maple-dev/effect-sdk/cloudflare"
-import { ANTICIPATED_ERROR_TAGS } from "@maple/domain/anticipated-errors"
+import { ANTICIPATED_ERROR_IDENTIFIERS } from "@maple/domain/anticipated-errors"
import { WorkerConfigProviderLayer } from "@maple/effect-cloudflare"
import { Context, Effect, FileSystem, Layer, Path } from "effect"
import { HttpMiddleware, HttpRouter } from "effect/unstable/http"
@@ -42,7 +42,7 @@ const telemetry = MapleCloudflareSDK.make({
serviceName: "electric-sync",
serviceNamespace: "backend",
repositoryUrl: "https://github.com/Makisuo/maple",
- anticipatedErrorTags: [...ANTICIPATED_ERROR_TAGS],
+ anticipatedErrorIdentifiers: [...ANTICIPATED_ERROR_IDENTIFIERS],
})
// `HttpMiddleware.tracer` ends the root server span on a deferred macrotask, but
diff --git a/apps/scraper/src/ApiClient.test.ts b/apps/scraper/src/ApiClient.test.ts
index 4da5a84cc..597872e90 100644
--- a/apps/scraper/src/ApiClient.test.ts
+++ b/apps/scraper/src/ApiClient.test.ts
@@ -121,7 +121,10 @@ describe("ApiClient", () => {
),
)
- assert.strictEqual(recorded[0]?.url, "http://api.test/api/internal/prometheus-scrape?targetId=target-1")
+ assert.strictEqual(
+ recorded[0]?.url,
+ "http://api.test/api/internal/prometheus-scrape?targetId=target-1",
+ )
assert.strictEqual(recorded[0]?.headers.authorization, "Bearer internal-token")
assert.strictEqual(response.status, 200)
assert.include(response.body, "up 1")
@@ -169,4 +172,24 @@ describe("ApiClient", () => {
])
}).pipe(Effect.provide(TestLayer)),
)
+
+ it.effect("surfaces a 503 result-persistence response so the scheduler can re-buffer", () =>
+ Effect.gen(function* () {
+ const client = yield* ApiClient
+ const error = yield* client
+ .reportResults([
+ { targetId: VALID_TARGET.id, scrapedAt: 1750000000000, error: null } as never,
+ ])
+ .pipe(
+ Effect.provideService(
+ FetchHttpClient.Fetch,
+ stubFetch([], () => new Response("persistence unavailable", { status: 503 })),
+ ),
+ Effect.flip,
+ )
+
+ assert.strictEqual(error._tag, "@maple/scraper/ApiRequestError")
+ assert.strictEqual(error.status, 503)
+ }).pipe(Effect.provide(TestLayer)),
+ )
})
diff --git a/apps/web/src/components/chat/chat-apply-payload.test.ts b/apps/web/src/components/chat/chat-apply-payload.test.ts
new file mode 100644
index 000000000..edd7c6170
--- /dev/null
+++ b/apps/web/src/components/chat/chat-apply-payload.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from "vitest"
+import { Schema } from "effect"
+import { ChatApplyRequest } from "@maple/domain/http"
+import { makeChatApplyPayload } from "./chat-apply-payload"
+
+describe("makeChatApplyPayload", () => {
+ it("constructs an encodable ChatApplyRequest", () => {
+ const payload = makeChatApplyPayload("update_dashboard_widget", { title: "Latency" })
+
+ expect(payload).toBeInstanceOf(ChatApplyRequest)
+ expect(() => Schema.encodeUnknownSync(ChatApplyRequest)(payload)).not.toThrow()
+ })
+})
diff --git a/apps/web/src/components/chat/chat-apply-payload.ts b/apps/web/src/components/chat/chat-apply-payload.ts
new file mode 100644
index 000000000..1735d8066
--- /dev/null
+++ b/apps/web/src/components/chat/chat-apply-payload.ts
@@ -0,0 +1,4 @@
+import { ChatApplyRequest } from "@maple/domain/http"
+
+export const makeChatApplyPayload = (tool: string, input: unknown): ChatApplyRequest =>
+ new ChatApplyRequest({ tool, input })
diff --git a/apps/web/src/components/chat/chat-conversation.tsx b/apps/web/src/components/chat/chat-conversation.tsx
index 56fad82e4..f3e60c04b 100644
--- a/apps/web/src/components/chat/chat-conversation.tsx
+++ b/apps/web/src/components/chat/chat-conversation.tsx
@@ -46,6 +46,7 @@ import { ThinkingIndicator } from "@/components/ai-elements/thinking-indicator"
import { Tool, ToolRow, toolLabel } from "@/components/ai-elements/tool"
import { ToolGroup } from "@/components/ai-elements/tool-group"
import { ApprovalCard } from "./approval-card"
+import { makeChatApplyPayload } from "./chat-apply-payload"
import type { UIMessage } from "@/components/ai-elements/types"
type ToolPart = {
@@ -177,7 +178,7 @@ export function ChatConversation({
return next
})
const handleApprove = async (toolCallId: string, tool: string, input: unknown) => {
- const exit = await applyProposal({ payload: { tool, input } })
+ const exit = await applyProposal({ payload: makeChatApplyPayload(tool, input) })
if (Exit.isSuccess(exit)) {
if (exit.value.isError) {
toast.error(exit.value.content || `Couldn't apply ${tool}`)
@@ -262,8 +263,9 @@ export function ChatConversation({
Ready to investigate
- The {investigationNoun(investigationContext!.kind)} above is attached to every message
- in this thread. Start with a suggestion or ask your own question.
+ The {investigationNoun(investigationContext!.kind)} above is attached to
+ every message in this thread. Start with a suggestion or ask your own
+ question.
) : isWidgetFixMode ? (
diff --git a/apps/web/src/components/integrations/hazel-integration-card.tsx b/apps/web/src/components/integrations/hazel-integration-card.tsx
index 16cc68a48..96b85d9d5 100644
--- a/apps/web/src/components/integrations/hazel-integration-card.tsx
+++ b/apps/web/src/components/integrations/hazel-integration-card.tsx
@@ -1,22 +1,25 @@
-import { useEffect, useState } from "react"
-import { Exit } from "effect"
+import { useState } from "react"
+import { Exit, Option } from "effect"
import { HazelStartConnectRequest } from "@maple/domain/http"
import { Badge } from "@maple/ui/components/ui/badge"
import { Button } from "@maple/ui/components/ui/button"
+import { Skeleton } from "@maple/ui/components/ui/skeleton"
import { toast } from "sonner"
+import { ErrorState } from "@/components/common/error-state"
import { BellIcon, ConnectionIcon, HazelIcon, LoaderIcon, ShieldIcon } from "@/components/icons"
-import { Result, useAtomSet, useAtomValue } from "@/lib/effect-atom"
+import { useMountEffect } from "@/hooks/use-mount-effect"
+import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom"
import { MapleApiAtomClient } from "@/lib/services/common/atom-client"
import { HAZEL_ACCENT, IntegrationIconPlate } from "./integration-catalog"
import { IntegrationEmptyState } from "./integration-empty-state"
export function HazelIntegrationCard() {
- const statusResult = useAtomValue(
- MapleApiAtomClient.query("integrations", "hazelStatus", {
- reactivityKeys: ["hazelIntegrationStatus"],
- }),
- )
+ const statusAtom = MapleApiAtomClient.query("integrations", "hazelStatus", {
+ reactivityKeys: ["hazelIntegrationStatus"],
+ })
+ const statusResult = useAtomValue(statusAtom)
+ const refreshStatus = useAtomRefresh(statusAtom)
const startConnect = useAtomSet(MapleApiAtomClient.mutation("integrations", "hazelStart"), {
mode: "promiseExit",
@@ -27,7 +30,7 @@ export function HazelIntegrationCard() {
const [busy, setBusy] = useState<"connect" | "disconnect" | null>(null)
- useEffect(() => {
+ useMountEffect(() => {
function onMessage(event: MessageEvent) {
if (event.data?.type === "maple:integration:hazel") {
if (event.data.status === "success") {
@@ -39,11 +42,17 @@ export function HazelIntegrationCard() {
}
window.addEventListener("message", onMessage)
return () => window.removeEventListener("message", onMessage)
- }, [])
+ })
const status = Result.builder(statusResult)
.onSuccess((s) => s)
- .orElse(() => null)
+ .orElse(() =>
+ Result.isFailure(statusResult)
+ ? Option.getOrNull(Option.map(statusResult.previousSuccess, (previous) => previous.value))
+ : null,
+ )
+ const isLoading = Result.isInitial(statusResult) && status === null
+ const loadFailed = Result.isFailure(statusResult) && status === null
async function handleConnect() {
const popup = window.open("", "maple-hazel-connect", "popup,width=520,height=640")
@@ -77,6 +86,18 @@ export function HazelIntegrationCard() {
}
const isConnected = status?.connected === true
+ if (isLoading) {
+ return
+ }
+ if (loadFailed) {
+ return (
+
+ )
+ }
if (!isConnected) {
return (
@@ -127,8 +148,8 @@ export function HazelIntegrationCard() {
Connected
- Forward Maple alerts into a Hazel workspace via OAuth. Once connected, create a
- Hazel destination to pick which workspace receives notifications.
+ Forward Maple alerts into a Hazel workspace via OAuth. Once connected, create a Hazel
+ destination to pick which workspace receives notifications.
@@ -152,9 +173,7 @@ export function HazelIntegrationCard() {
Reconnect
- {busy === "disconnect" ? (
-
- ) : null}
+ {busy === "disconnect" ? : null}
Disconnect
diff --git a/apps/web/src/lib/services/common/otel-layer.ts b/apps/web/src/lib/services/common/otel-layer.ts
index 1cc677bd7..526d432d9 100644
--- a/apps/web/src/lib/services/common/otel-layer.ts
+++ b/apps/web/src/lib/services/common/otel-layer.ts
@@ -1,5 +1,5 @@
import { MapleFlush } from "@maple-dev/effect-sdk/client"
-import { ANTICIPATED_ERROR_TAGS } from "@maple/domain/anticipated-errors"
+import { ANTICIPATED_ERROR_IDENTIFIERS } from "@maple/domain/anticipated-errors"
import { ingestUrl } from "./ingest-url"
// Buffer-backed client telemetry with flush-on-unload. `Maple.layer`
@@ -27,7 +27,7 @@ const telemetry = MapleFlush.make({
},
// Expected 4xx API responses (the maple-web → maple-api edge surfaces these
// as client-span failures) record as Ok instead of errors.
- anticipatedErrorTags: [...ANTICIPATED_ERROR_TAGS],
+ anticipatedErrorIdentifiers: [...ANTICIPATED_ERROR_IDENTIFIERS],
// Maple's dashboard prioritizes operator responsiveness over self-replay.
// Keep lightweight session metadata and trace/log correlation, but do not
// run rrweb DOM capture, compression, and upload loops in every open tab.
diff --git a/apps/web/src/lib/services/common/v2-pagination.test.ts b/apps/web/src/lib/services/common/v2-pagination.test.ts
index 53ebbb9ce..8d970687a 100644
--- a/apps/web/src/lib/services/common/v2-pagination.test.ts
+++ b/apps/web/src/lib/services/common/v2-pagination.test.ts
@@ -3,12 +3,12 @@ import { Effect } from "effect"
import { collectV2Pages } from "./v2-pagination"
describe("collectV2Pages", () => {
- it("collects every page without an item cap", async () => {
- const itemCount = 2_005
- const pageSize = 100
+ it.effect("collects every page without an item cap", () =>
+ Effect.gen(function* () {
+ const itemCount = 2_005
+ const pageSize = 100
- const result = await Effect.runPromise(
- collectV2Pages((cursor) => {
+ const result = yield* collectV2Pages((cursor) => {
const offset = cursor === undefined ? 0 : Number(cursor)
const data = Array.from(
{ length: Math.min(pageSize, itemCount - offset) },
@@ -20,22 +20,22 @@ describe("collectV2Pages", () => {
has_more: nextOffset < itemCount,
next_cursor: nextOffset < itemCount ? String(nextOffset) : null,
})
- }),
- )
+ })
- expect(result).toHaveLength(itemCount)
- expect(result[0]).toBe(0)
- expect(result.at(-1)).toBe(itemCount - 1)
- })
+ expect(result).toHaveLength(itemCount)
+ expect(result[0]).toBe(0)
+ expect(result.at(-1)).toBe(itemCount - 1)
+ }),
+ )
- it("fails with a specific tag when a cursor repeats", async () => {
- const error = await Effect.runPromise(
- Effect.flip(
+ it.effect("fails with a specific tag when a cursor repeats", () =>
+ Effect.gen(function* () {
+ const error = yield* Effect.flip(
collectV2Pages(() => Effect.succeed({ data: [1], has_more: true, next_cursor: "off_1" })),
- ),
- )
+ )
- expect(error._tag).toBe("@maple/web/services/V2PaginationCursorLoopError")
- expect(error.cursor).toBe("off_1")
- })
+ expect(error._tag).toBe("@maple/web/services/V2PaginationCursorLoopError")
+ expect(error.cursor).toBe("off_1")
+ }),
+ )
})
diff --git a/apps/web/src/lib/services/common/v2-pagination.ts b/apps/web/src/lib/services/common/v2-pagination.ts
index 3beb89894..6ec83d8a0 100644
--- a/apps/web/src/lib/services/common/v2-pagination.ts
+++ b/apps/web/src/lib/services/common/v2-pagination.ts
@@ -10,8 +10,16 @@ export class V2PaginationCursorLoopError extends Schema.TaggedErrorClass(
@@ -28,7 +36,7 @@ export const collectV2Pages = (
if (!response.has_more || response.next_cursor === null) return data
if (seenCursors.has(response.next_cursor)) {
- return yield* new V2PaginationCursorLoopError({ cursor: response.next_cursor })
+ return yield* V2PaginationCursorLoopError.repeated(response.next_cursor)
}
seenCursors.add(response.next_cursor)
diff --git a/apps/web/src/routes/errors/issues/$issueId.tsx b/apps/web/src/routes/errors/issues/$issueId.tsx
index 57e36b769..fe4017444 100644
--- a/apps/web/src/routes/errors/issues/$issueId.tsx
+++ b/apps/web/src/routes/errors/issues/$issueId.tsx
@@ -35,6 +35,12 @@ import {
type IssueSeverity,
type WorkflowState,
} from "@maple/domain/http"
+import {
+ makeIssueClaimPayload,
+ makeIssueCommentPayload,
+ makeIssueReleasePayload,
+ makeIssueTransitionPayload,
+} from "./-issue-mutation-payloads"
const decodeIssueId = Schema.decodeSync(ErrorIssueId)
@@ -95,7 +101,7 @@ function IssueDetailPage() {
setBusy("state")
const result = await transitionIssue({
params: { issueId },
- payload: { toState: next },
+ payload: makeIssueTransitionPayload(next),
reactivityKeys: invalidateKeys,
})
setBusy(null)
@@ -107,7 +113,7 @@ function IssueDetailPage() {
setBusy("claim")
const result = await claimIssue({
params: { issueId },
- payload: {},
+ payload: makeIssueClaimPayload(),
reactivityKeys: invalidateKeys,
})
setBusy(null)
@@ -130,7 +136,7 @@ function IssueDetailPage() {
setBusy("release")
const result = await releaseIssue({
params: { issueId },
- payload: {},
+ payload: makeIssueReleasePayload(),
reactivityKeys: invalidateKeys,
})
setBusy(null)
@@ -159,7 +165,7 @@ function IssueDetailPage() {
setBusy("comment")
const result = await commentOnIssue({
params: { issueId },
- payload: { body },
+ payload: makeIssueCommentPayload(body),
reactivityKeys: invalidateKeys,
})
setBusy(null)
@@ -225,7 +231,9 @@ function IssueDetailPage() {
void navigator.clipboard
.writeText(agentPromptFromIssue(issue))
.then(() =>
- toast.success("Copied agent prompt — paste it into your MCP agent"),
+ toast.success(
+ "Copied agent prompt — paste it into your MCP agent",
+ ),
)
.catch(() => toast.error("Copy failed"))
}}
@@ -241,7 +249,13 @@ function IssueDetailPage() {
}
>
diff --git a/apps/web/src/routes/errors/issues/-issue-mutation-payloads.test.ts b/apps/web/src/routes/errors/issues/-issue-mutation-payloads.test.ts
new file mode 100644
index 000000000..3e9721ebb
--- /dev/null
+++ b/apps/web/src/routes/errors/issues/-issue-mutation-payloads.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest"
+import { Schema } from "effect"
+import {
+ ErrorIssueClaimRequest,
+ ErrorIssueCommentRequest,
+ ErrorIssueReleaseRequest,
+ ErrorIssueTransitionRequest,
+} from "@maple/domain/http"
+import {
+ makeIssueClaimPayload,
+ makeIssueCommentPayload,
+ makeIssueReleasePayload,
+ makeIssueTransitionPayload,
+} from "./-issue-mutation-payloads"
+
+describe("error issue mutation payloads", () => {
+ it.each([
+ [ErrorIssueTransitionRequest, makeIssueTransitionPayload("done")],
+ [ErrorIssueClaimRequest, makeIssueClaimPayload()],
+ [ErrorIssueReleaseRequest, makeIssueReleasePayload()],
+ [ErrorIssueCommentRequest, makeIssueCommentPayload("Investigating the regression")],
+ ] as const)("constructs an encodable %s", (Request, payload) => {
+ expect(payload).toBeInstanceOf(Request)
+ expect(() => Schema.encodeUnknownSync(Request)(payload)).not.toThrow()
+ })
+})
diff --git a/apps/web/src/routes/errors/issues/-issue-mutation-payloads.ts b/apps/web/src/routes/errors/issues/-issue-mutation-payloads.ts
new file mode 100644
index 000000000..00fd14948
--- /dev/null
+++ b/apps/web/src/routes/errors/issues/-issue-mutation-payloads.ts
@@ -0,0 +1,17 @@
+import {
+ ErrorIssueClaimRequest,
+ ErrorIssueCommentRequest,
+ ErrorIssueReleaseRequest,
+ ErrorIssueTransitionRequest,
+ type WorkflowState,
+} from "@maple/domain/http"
+
+export const makeIssueTransitionPayload = (toState: WorkflowState): ErrorIssueTransitionRequest =>
+ new ErrorIssueTransitionRequest({ toState })
+
+export const makeIssueClaimPayload = (): ErrorIssueClaimRequest => new ErrorIssueClaimRequest({})
+
+export const makeIssueReleasePayload = (): ErrorIssueReleaseRequest => new ErrorIssueReleaseRequest({})
+
+export const makeIssueCommentPayload = (body: string): ErrorIssueCommentRequest =>
+ new ErrorIssueCommentRequest({ body })
diff --git a/apps/web/src/routes/infra/kubernetes/pods/$podName.tsx b/apps/web/src/routes/infra/kubernetes/pods/$podName.tsx
index 6bdd3b79b..b6835b755 100644
--- a/apps/web/src/routes/infra/kubernetes/pods/$podName.tsx
+++ b/apps/web/src/routes/infra/kubernetes/pods/$podName.tsx
@@ -1,12 +1,14 @@
import { useState } from "react"
import { Navigate, createFileRoute } from "@tanstack/react-router"
-import { Result, useAtomValue } from "@/lib/effect-atom"
+import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom"
import { Schema } from "effect"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select"
import { Card, CardContent, CardHeader, CardTitle } from "@maple/ui/components/ui/card"
import { cn } from "@maple/ui/lib/utils"
+import { Skeleton } from "@maple/ui/components/ui/skeleton"
+import { QueryErrorState } from "@/components/common/query-error-state"
import { DashboardLayout } from "@/components/layout/dashboard-layout"
import { FolderIcon } from "@/components/icons"
import { useInfraEnabled } from "@/hooks/use-infra-enabled"
@@ -52,11 +54,11 @@ function PodDetailContent() {
const { startTime, endTime } = useEffectiveTimeRange(undefined, undefined, preset)
const bucketSeconds = bucketSecondsFor(preset)
- const summaryResult = useAtomValue(
- podDetailSummaryResultAtom({
- data: { podName, namespace, startTime, endTime },
- }),
- )
+ const summaryAtom = podDetailSummaryResultAtom({
+ data: { podName, namespace, startTime, endTime },
+ })
+ const summaryResult = useAtomValue(summaryAtom)
+ const refreshSummary = useAtomRefresh(summaryAtom)
const summary = Result.builder(summaryResult)
.onSuccess((r) => r.data)
@@ -123,7 +125,15 @@ function PodDetailContent() {
}
/>
- {summary ? (
+ {Result.isInitial(summaryResult) ? (
+
+ ) : Result.isFailure(summaryResult) ? (
+
+ ) : summary ? (
r.data)
@@ -159,7 +160,15 @@ function WorkloadDetailContent() {
}
/>
- {summary ? (
+ {Result.isInitial(summaryResult) ? (
+
+ ) : Result.isFailure(summaryResult) ? (
+
+ ) : summary ? (
Pods
{Result.builder(podsResult)
+ .onInitial(() => )
+ .onError((error) => (
+
+ ))
.onSuccess((r) => {
const pods = r.data
if (pods.length === 0) {
@@ -247,7 +264,7 @@ function WorkloadDetailContent() {
}
return
})
- .orElse(() => null)}
+ .render()}
diff --git a/apps/web/src/routes/service-map.tsx b/apps/web/src/routes/service-map.tsx
index 2ce054a09..c70dcf47a 100644
--- a/apps/web/src/routes/service-map.tsx
+++ b/apps/web/src/routes/service-map.tsx
@@ -2,7 +2,7 @@ import { useNavigate, createFileRoute } from "@tanstack/react-router"
import { Schema } from "effect"
import { useMemo } from "react"
-import { Result } from "@/lib/effect-atom"
+import { Result, useAtomRefresh } from "@/lib/effect-atom"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select"
import { useRetainedRefreshableResultValue } from "@/hooks/use-retained-refreshable-result-value"
import { getServicesFacetsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms"
@@ -13,6 +13,7 @@ import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range"
import { applyTimeRangeSearch } from "@/components/time-range-picker/search"
import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context"
import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls"
+import { QueryErrorState } from "@/components/common/query-error-state"
// `__all__` is the sentinel for the "All Environments" option. Storing it in the
// URL (rather than clearing the param) keeps an explicit all-environments choice
@@ -63,12 +64,14 @@ function ServiceMapContent() {
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000)
return { startTime: fmt(start), endTime: fmt(end) }
}, [])
- const facetsResult = useRetainedRefreshableResultValue(getServicesFacetsResultAtom({ data: facetsRange }))
+ const facetsAtom = getServicesFacetsResultAtom({ data: facetsRange })
+ const facetsResult = useRetainedRefreshableResultValue(facetsAtom)
+ const refreshFacets = useAtomRefresh(facetsAtom)
const environments = Result.builder(facetsResult)
.onSuccess((response) => response.data.environments)
.orElse(() => [])
- const facetsReady = !Result.isInitial(facetsResult)
+ const facetsReady = Result.isSuccess(facetsResult)
const hasProduction = environments.some((e) => e.name === "production")
// Default to production. Before facets resolve, optimistically assume it exists
@@ -159,15 +162,23 @@ function ServiceMapContent() {
}
>
-
-
-
+ ) : (
+
+
+
+ )}
)
}
diff --git a/apps/web/src/routes/services/$serviceName.tsx b/apps/web/src/routes/services/$serviceName.tsx
index 4788795a5..407c4bd1b 100644
--- a/apps/web/src/routes/services/$serviceName.tsx
+++ b/apps/web/src/routes/services/$serviceName.tsx
@@ -1,16 +1,14 @@
import { Link, useNavigate, createFileRoute } from "@tanstack/react-router"
import { useCallback, useMemo } from "react"
-import { Result, useAtomValue } from "@/lib/effect-atom"
+import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom"
import { Option, Schema } from "effect"
import { DashboardLayout } from "@/components/layout/dashboard-layout"
+import { QueryErrorState } from "@/components/common/query-error-state"
import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range"
import { useRetainedRefreshableResultValue } from "@/hooks/use-retained-refreshable-result-value"
import { MetricsGrid } from "@/components/dashboard/metrics-grid"
-import type {
- ChartLegendMode,
- ChartTooltipMode,
-} from "@maple/ui/components/charts/_shared/chart-types"
+import type { ChartLegendMode, ChartTooltipMode } from "@maple/ui/components/charts/_shared/chart-types"
import { Tabs, TabsList, TabsTrigger } from "@maple/ui/components/ui/tabs"
import {
getServiceDetailOverviewResultAtom,
@@ -308,16 +306,16 @@ function OverviewTab({
// One fetch for the whole Overview tab — the primary chart and the environment
// switcher's options (the switcher reads this same atom key, so it shares this
// round-trip instead of issuing its own overview query).
- const overviewResult = useRetainedRefreshableResultValue(
- getServiceDetailOverviewResultAtom({
- data: {
- serviceName,
- startTime: effectiveStartTime,
- endTime: effectiveEndTime,
- environments,
- },
- }),
- )
+ const overviewAtom = getServiceDetailOverviewResultAtom({
+ data: {
+ serviceName,
+ startTime: effectiveStartTime,
+ endTime: effectiveEndTime,
+ environments,
+ },
+ })
+ const overviewResult = useRetainedRefreshableResultValue(overviewAtom)
+ const refreshOverview = useAtomRefresh(overviewAtom)
// Sampling verdict from the already-loaded primary chart. Drives a separate,
// non-blocking fetch of the exact pre-sampling throughput (SpanMetrics `calls`)
@@ -409,6 +407,16 @@ function OverviewTab({
[detailPoints, isDetailLoading],
)
+ if (Result.isFailure(overviewResult)) {
+ return (
+
+ )
+ }
+
return (
Loading…
)}
- {!loading && todos.length === 0 && (
+ {Result.isFailure(listResult) && (
+
+ Failed to load todos.
+
+ Try again
+
+
+ )}
+ {Result.isSuccess(listResult) && todos.length === 0 && (
Nothing yet — add one above.
diff --git a/lib/effect-sdk/README.md b/lib/effect-sdk/README.md
index 2b37c4a4c..c5b710e20 100644
--- a/lib/effect-sdk/README.md
+++ b/lib/effect-sdk/README.md
@@ -48,6 +48,8 @@ const telemetry = MapleCloudflareSDK.make({
serviceName: "my-worker",
// Optional: drop noisy spans before they hit OTLP (prefix match).
// dropSpanNames: ["McpServer/Notifications."],
+ // Optional: classify expected 4xx failures as successful spans.
+ // anticipatedErrorIdentifiers: ["@my-app/http/NotFoundError"],
})
const handler = HttpRouter.toWebHandler(Routes.pipe(Layer.provideMerge(telemetry.layer)))
@@ -63,16 +65,17 @@ export default {
`telemetry.layer` MUST live in the same runtime as your routes — provide it to the layer composition you hand to `HttpRouter.toWebHandler`, not a separate per-request runtime, or your spans won't pick up the Tracer reference.
-When `MAPLE_INGEST_KEY` is unset, the SDK runs in no-op mode: buffers are drained so they don't grow across the isolate's lifetime, but no requests are made. After a flush failure, each signal sleeps 60s before retrying so a broken collector doesn't get hammered.
+When `MAPLE_INGEST_KEY` is unset, the SDK runs in no-op mode: buffers are drained so they don't grow across the isolate's lifetime, but no requests are made. After a flush failure, the failed batch is restored ahead of newer telemetry and that signal sleeps 60s before retrying. Trace and log cooldowns are independent.
### Cloudflare-specific options
-| Option | Description |
-| ----------------- | ----------------------------------------------------------------------------------------------------------- |
-| `dropSpanNames` | Span names whose prefix matches an entry are dropped before OTLP export (e.g. `"McpServer/Notifications."`) |
-| `excludeLogSpans` | Skip Effect log spans in OTLP log attributes. Default `false` |
-| `tracesPath` | OTLP traces path appended to `endpoint`. Default `/v1/traces` |
-| `logsPath` | OTLP logs path appended to `endpoint`. Default `/v1/logs` |
+| Option | Description |
+| ----------------------------- | ----------------------------------------------------------------------------------------------------------- |
+| `anticipatedErrorIdentifiers` | Stable `_tag` / `Error.name` identifiers for expected 4xx failures; exported as `Ok` without an exception |
+| `dropSpanNames` | Span names whose prefix matches an entry are dropped before OTLP export (e.g. `"McpServer/Notifications."`) |
+| `excludeLogSpans` | Skip Effect log spans in OTLP log attributes. Default `false` |
+| `tracesPath` | OTLP traces path appended to `endpoint`. Default `/v1/traces` |
+| `logsPath` | OTLP logs path appended to `endpoint`. Default `/v1/logs` |
The same `MAPLE_ENDPOINT` / `MAPLE_INGEST_KEY` / `MAPLE_ENVIRONMENT` env vars apply, read from the Workers `env` binding.
diff --git a/lib/effect-sdk/src/client/flushable.ts b/lib/effect-sdk/src/client/flushable.ts
index 80e098de6..3c64a5cd4 100644
--- a/lib/effect-sdk/src/client/flushable.ts
+++ b/lib/effect-sdk/src/client/flushable.ts
@@ -30,6 +30,7 @@ import { Layer, Redacted } from "effect"
import {
buildResolved,
type FlushTransport,
+ makeSerializedFlush,
type Resolved,
type ResourceInput,
runFlush,
@@ -61,10 +62,12 @@ export interface MapleClientFlushableConfig {
/** Span name prefixes to drop before OTLP export. */
readonly dropSpanNames?: ReadonlyArray | undefined
/**
- * `_tag`s of *anticipated* failures (expected 4xx business errors). Spans
+ * Stable `_tag` / `Error.name` identifiers of anticipated 4xx failures. Spans
* failing entirely with these export as status `Ok` (no `exception` event),
* so they stay visible but never count as errors.
*/
+ readonly anticipatedErrorIdentifiers?: ReadonlyArray | undefined
+ /** @deprecated Use `anticipatedErrorIdentifiers`. */
readonly anticipatedErrorTags?: ReadonlyArray | undefined
/** OTLP traces path appended to `endpoint`. Default `/v1/traces`. */
readonly tracesPath?: string | undefined
@@ -154,10 +157,12 @@ export const make = (config: MapleClientFlushableConfig): FlushableTelemetry =>
dropPrefixes !== undefined && dropPrefixes.length > 0
? (name: string) => dropPrefixes.some((prefix) => name.startsWith(prefix))
: undefined
- const anticipatedErrorTags =
- config.anticipatedErrorTags !== undefined && config.anticipatedErrorTags.length > 0
- ? new Set(config.anticipatedErrorTags)
- : undefined
+ const anticipatedErrorIdentifiers = [
+ ...(config.anticipatedErrorIdentifiers ?? []),
+ ...(config.anticipatedErrorTags ?? []),
+ ]
+ const anticipatedIdentifiers =
+ anticipatedErrorIdentifiers.length > 0 ? new Set(anticipatedErrorIdentifiers) : undefined
startClientSession({
endpoint: config.endpoint,
ingestKey: config.ingestKey,
@@ -168,7 +173,10 @@ export const make = (config: MapleClientFlushableConfig): FlushableTelemetry =>
emitSessionMeta: config.emitSessionMeta,
})
- const spans: SpanBuffer = makeSpanBuffer({ dropSpan, anticipatedErrorTags })
+ const spans: SpanBuffer = makeSpanBuffer({
+ dropSpan,
+ anticipatedErrorIdentifiers: anticipatedIdentifiers,
+ })
const logs: LogBuffer = makeLogBuffer({ excludeLogSpans: config.excludeLogSpans })
// `withSessionLink` overrides only the Tracer reference, keeping the logger.
const layer = withSessionLink(Layer.mergeAll(spans.tracerLayer, logs.loggerLayer))
@@ -195,7 +203,7 @@ export const make = (config: MapleClientFlushableConfig): FlushableTelemetry =>
const logsState: SignalState = { disabledUntil: 0 }
let noOpLogged = false
- const flush = async (): Promise => {
+ const flush = makeSerializedFlush(async (): Promise => {
await runFlush({
resolved,
spans,
@@ -213,7 +221,7 @@ export const make = (config: MapleClientFlushableConfig): FlushableTelemetry =>
}
},
})
- }
+ })
const intervalMs =
config.autoFlushInterval === undefined
diff --git a/lib/effect-sdk/src/cloudflare/index.ts b/lib/effect-sdk/src/cloudflare/index.ts
index 090806cc2..ba41a6b8e 100644
--- a/lib/effect-sdk/src/cloudflare/index.ts
+++ b/lib/effect-sdk/src/cloudflare/index.ts
@@ -35,6 +35,7 @@ import { Layer } from "effect"
import {
buildResolved,
fetchTransport,
+ makeSerializedFlush,
type Resolved,
runFlush,
type SignalState,
@@ -84,11 +85,13 @@ export interface Config {
*/
readonly dropSpanNames?: ReadonlyArray | undefined
/**
- * `_tag`s of *anticipated* failures (expected 4xx business errors). A span
+ * Stable `_tag` / `Error.name` identifiers of anticipated 4xx failures. A span
* whose failure is caused entirely by these is exported with status `Ok` and
* no `exception` event, so it stays visible as a trace but never counts as an
- * error. See `SpanBufferOptions.anticipatedErrorTags`.
+ * error.
*/
+ readonly anticipatedErrorIdentifiers?: ReadonlyArray | undefined
+ /** @deprecated Use `anticipatedErrorIdentifiers`. */
readonly anticipatedErrorTags?: ReadonlyArray | undefined
/** OTLP traces path appended to `endpoint`. Default `/v1/traces`. */
readonly tracesPath?: string | undefined
@@ -133,11 +136,16 @@ export const make = (config: Config = {}): Telemetry => {
dropPrefixes !== undefined && dropPrefixes.length > 0
? (name: string) => dropPrefixes.some((prefix) => name.startsWith(prefix))
: undefined
- const anticipatedErrorTags =
- config.anticipatedErrorTags !== undefined && config.anticipatedErrorTags.length > 0
- ? new Set(config.anticipatedErrorTags)
- : undefined
- const spans: SpanBuffer = makeSpanBuffer({ dropSpan, anticipatedErrorTags })
+ const anticipatedErrorIdentifiers = [
+ ...(config.anticipatedErrorIdentifiers ?? []),
+ ...(config.anticipatedErrorTags ?? []),
+ ]
+ const anticipatedIdentifiers =
+ anticipatedErrorIdentifiers.length > 0 ? new Set(anticipatedErrorIdentifiers) : undefined
+ const spans: SpanBuffer = makeSpanBuffer({
+ dropSpan,
+ anticipatedErrorIdentifiers: anticipatedIdentifiers,
+ })
const logs: LogBuffer = makeLogBuffer({ excludeLogSpans: config.excludeLogSpans })
let resolved: Resolved | undefined = undefined
@@ -147,7 +155,7 @@ export const make = (config: Config = {}): Telemetry => {
const layer = Layer.mergeAll(spans.tracerLayer, logs.loggerLayer)
- const flush = async (env: Record): Promise => {
+ const flush = makeSerializedFlush(async (env: Record): Promise => {
if (resolved === undefined) {
resolved = resolveOnce(env, config)
}
@@ -169,7 +177,7 @@ export const make = (config: Config = {}): Telemetry => {
}
},
})
- }
+ })
return { layer, flush }
}
diff --git a/lib/effect-sdk/src/server/flushable.ts b/lib/effect-sdk/src/server/flushable.ts
index 8d6933f0d..25ca7ad49 100644
--- a/lib/effect-sdk/src/server/flushable.ts
+++ b/lib/effect-sdk/src/server/flushable.ts
@@ -21,6 +21,7 @@ import { Effect, Layer } from "effect"
import {
buildResolved,
fetchTransport,
+ makeSerializedFlush,
type Resolved,
runFlush,
type SignalState,
@@ -61,10 +62,12 @@ export interface MapleFlushableConfig {
/** Span name prefixes to drop before OTLP export. */
readonly dropSpanNames?: ReadonlyArray | undefined
/**
- * `_tag`s of *anticipated* failures (expected 4xx business errors). Spans
+ * Stable `_tag` / `Error.name` identifiers of anticipated 4xx failures. Spans
* failing entirely with these export as status `Ok` (no `exception` event),
* so they stay visible but never count as errors.
*/
+ readonly anticipatedErrorIdentifiers?: ReadonlyArray | undefined
+ /** @deprecated Use `anticipatedErrorIdentifiers`. */
readonly anticipatedErrorTags?: ReadonlyArray | undefined
/** OTLP traces path appended to `endpoint`. Default `/v1/traces`. */
readonly tracesPath?: string | undefined
@@ -98,11 +101,16 @@ export const make = (config: MapleFlushableConfig = {}): FlushableTelemetry => {
dropPrefixes !== undefined && dropPrefixes.length > 0
? (name: string) => dropPrefixes.some((prefix) => name.startsWith(prefix))
: undefined
- const anticipatedErrorTags =
- config.anticipatedErrorTags !== undefined && config.anticipatedErrorTags.length > 0
- ? new Set(config.anticipatedErrorTags)
- : undefined
- const spans: SpanBuffer = makeSpanBuffer({ dropSpan, anticipatedErrorTags })
+ const anticipatedErrorIdentifiers = [
+ ...(config.anticipatedErrorIdentifiers ?? []),
+ ...(config.anticipatedErrorTags ?? []),
+ ]
+ const anticipatedIdentifiers =
+ anticipatedErrorIdentifiers.length > 0 ? new Set(anticipatedErrorIdentifiers) : undefined
+ const spans: SpanBuffer = makeSpanBuffer({
+ dropSpan,
+ anticipatedErrorIdentifiers: anticipatedIdentifiers,
+ })
const logs: LogBuffer = makeLogBuffer({ excludeLogSpans: config.excludeLogSpans })
const layer = Layer.mergeAll(spans.tracerLayer, logs.loggerLayer)
@@ -129,7 +137,7 @@ export const make = (config: MapleFlushableConfig = {}): FlushableTelemetry => {
return resolvedPromise
}
- const flush = async (): Promise => {
+ const flush = makeSerializedFlush(async (): Promise => {
const resolved = await ensureResolved()
await runFlush({
resolved,
@@ -148,7 +156,7 @@ export const make = (config: MapleFlushableConfig = {}): FlushableTelemetry => {
}
},
})
- }
+ })
const intervalMs =
config.autoFlushInterval === undefined
diff --git a/lib/effect-sdk/src/shared/flush-core.test.ts b/lib/effect-sdk/src/shared/flush-core.test.ts
new file mode 100644
index 000000000..87f323fdb
--- /dev/null
+++ b/lib/effect-sdk/src/shared/flush-core.test.ts
@@ -0,0 +1,113 @@
+import { afterEach, describe, expect, it, vi } from "vitest"
+import { Effect, Redacted } from "effect"
+import {
+ buildResolved,
+ makeSerializedFlush,
+ runFlush,
+ type FlushTransport,
+ type SignalState,
+} from "./flush-core.js"
+import { makeLogBuffer } from "./flushable-logger.js"
+import { makeSpanBuffer } from "./flushable-tracer.js"
+
+const resolved = buildResolved(
+ {
+ endpoint: "https://collector.test",
+ ingestKey: Redacted.make("test-key"),
+ resource: { serviceName: "test", serviceVersion: undefined, attributes: {} },
+ },
+ { userAgent: "test" },
+)
+
+const recordSpan = (spans: ReturnType, name: string) =>
+ Effect.runPromise(Effect.succeed(undefined).pipe(Effect.withSpan(name), Effect.provide(spans.tracerLayer)))
+
+const recordLog = (logs: ReturnType, message: string) =>
+ Effect.runPromise(Effect.logInfo(message).pipe(Effect.provide(logs.loggerLayer)))
+
+describe("runFlush", () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it("retains failed and cooldown batches while allowing the other signal to succeed", async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date("2026-01-01T00:00:00Z"))
+ const spans = makeSpanBuffer()
+ const logs = makeLogBuffer()
+ const tracesState: SignalState = { disabledUntil: 0 }
+ const logsState: SignalState = { disabledUntil: 0 }
+ const traceBodies: unknown[] = []
+ const logBodies: unknown[] = []
+ let failTraces = true
+ const transport: FlushTransport = {
+ post: async (url, _headers, body) => {
+ if (url.endsWith("/v1/traces")) {
+ if (failTraces) throw new Error("collector unavailable")
+ traceBodies.push(body)
+ } else {
+ logBodies.push(body)
+ }
+ },
+ }
+ const flush = () =>
+ runFlush({
+ resolved,
+ spans,
+ logs,
+ tracesState,
+ logsState,
+ transport,
+ logPrefix: "[test]",
+ onNoOp: () => undefined,
+ })
+
+ await recordSpan(spans, "first")
+ await recordLog(logs, "first-log")
+ await flush()
+ expect(spans.size()).toBe(1)
+ expect(logs.size()).toBe(0)
+ expect(logBodies).toHaveLength(1)
+
+ await recordSpan(spans, "second")
+ await flush()
+ expect(spans.size()).toBe(2)
+ expect(traceBodies).toHaveLength(0)
+
+ failTraces = false
+ vi.advanceTimersByTime(60_000)
+ await flush()
+ expect(spans.size()).toBe(0)
+ expect(traceBodies).toHaveLength(1)
+ const body = traceBodies[0] as {
+ resourceSpans: Array<{ scopeSpans: Array<{ spans: Array<{ name: string }> }> }>
+ }
+ expect(body.resourceSpans[0]!.scopeSpans[0]!.spans.map((span) => span.name)).toEqual([
+ "first",
+ "second",
+ ])
+ })
+
+ it("serializes overlapping flush calls", async () => {
+ let active = 0
+ let peak = 0
+ let release: (() => void) | undefined
+ const gate = new Promise((resolve) => {
+ release = resolve
+ })
+ const run = makeSerializedFlush(async () => {
+ active += 1
+ peak = Math.max(peak, active)
+ await gate
+ active -= 1
+ })
+
+ const first = run()
+ const second = run()
+ await Promise.resolve()
+ expect(peak).toBe(1)
+ release?.()
+ await Promise.all([first, second])
+ expect(peak).toBe(1)
+ })
+})
diff --git a/lib/effect-sdk/src/shared/flush-core.ts b/lib/effect-sdk/src/shared/flush-core.ts
index dfaed0809..06f86f300 100644
--- a/lib/effect-sdk/src/shared/flush-core.ts
+++ b/lib/effect-sdk/src/shared/flush-core.ts
@@ -136,17 +136,17 @@ const post = async (url: string, headers: Record, body: unknown)
/** Default transport: plain `fetch` (server + Cloudflare). */
export const fetchTransport: FlushTransport = { post }
-const flushSignal = async (
- url: string,
- headers: Record,
- body: () => unknown,
- state: SignalState,
- count: number,
- signal: string,
- transport: FlushTransport,
- logPrefix: string,
-): Promise => {
- if (count === 0) return
+const flushSignal = async (args: {
+ readonly url: string
+ readonly headers: Record
+ readonly buffer: { readonly drain: () => Array; readonly restore: (items: ReadonlyArray ) => void }
+ readonly body: (items: ReadonlyArray ) => unknown
+ readonly state: SignalState
+ readonly signal: string
+ readonly transport: FlushTransport
+ readonly logPrefix: string
+}): Promise => {
+ const { url, headers, buffer, body, state, signal, transport, logPrefix } = args
if (state.disabledUntil && Date.now() < state.disabledUntil) {
console.warn(
`${logPrefix} ${signal} flush skipped (cooldown ${state.disabledUntil - Date.now()}ms remaining)`,
@@ -154,14 +154,32 @@ const flushSignal = async (
return
}
state.disabledUntil = 0
+ const batch = buffer.drain()
+ if (batch.length === 0) return
try {
- await transport.post(url, headers, body())
+ await transport.post(url, headers, body(batch))
} catch (err) {
+ buffer.restore(batch)
state.disabledUntil = Date.now() + COOLDOWN_MS
console.error(`${logPrefix} ${signal} flush failed; cooldown 60s:`, err)
}
}
+/** Serialize flush calls so concurrent timers/manual hooks cannot drain overlapping batches. */
+export const makeSerializedFlush = >(
+ run: (...args: Args) => Promise,
+): ((...args: Args) => Promise) => {
+ let tail: Promise = Promise.resolve()
+ return (...args) => {
+ const next = tail.then(
+ () => run(...args),
+ () => run(...args),
+ )
+ tail = next.catch(() => undefined)
+ return next
+ }
+}
+
/**
* Drain the span + log buffers and POST them. Errors are swallowed per signal
* (see {@link flushSignal}); the returned promise never rejects.
@@ -189,31 +207,27 @@ export const runFlush = async (args: {
return
}
- const spanBatch = spans.drain()
- const logBatch = logs.drain()
- if (spanBatch.length === 0 && logBatch.length === 0) return
-
await Promise.all([
- flushSignal(
- r.tracesUrl,
- r.headers,
- () => makeTracesBody(spanBatch, r),
- tracesState,
- spanBatch.length,
- "traces",
+ flushSignal({
+ url: r.tracesUrl,
+ headers: r.headers,
+ buffer: spans,
+ body: (items) => makeTracesBody(items, r),
+ state: tracesState,
+ signal: "traces",
transport,
logPrefix,
- ),
- flushSignal(
- r.logsUrl,
- r.headers,
- () => makeLogsBody(logBatch, r),
- logsState,
- logBatch.length,
- "logs",
+ }),
+ flushSignal({
+ url: r.logsUrl,
+ headers: r.headers,
+ buffer: logs,
+ body: (items) => makeLogsBody(items, r),
+ state: logsState,
+ signal: "logs",
transport,
logPrefix,
- ),
+ }),
])
}
diff --git a/lib/effect-sdk/src/shared/flushable-logger.ts b/lib/effect-sdk/src/shared/flushable-logger.ts
index e50274d40..26e31d045 100644
--- a/lib/effect-sdk/src/shared/flushable-logger.ts
+++ b/lib/effect-sdk/src/shared/flushable-logger.ts
@@ -11,6 +11,7 @@ import * as OtlpResource from "effect/unstable/observability/OtlpResource"
export interface LogBuffer {
readonly loggerLayer: Layer.Layer
readonly drain: () => Array
+ readonly restore: (items: ReadonlyArray) => void
readonly setDisabled: (value: boolean) => void
readonly size: () => number
}
@@ -35,6 +36,10 @@ export const makeLogBuffer = (options: { readonly excludeLogSpans?: boolean } =
buffer = []
return items
},
+ restore: (items) => {
+ if (disabled || items.length === 0) return
+ buffer = [...items, ...buffer].slice(0, MAX_BUFFER)
+ },
setDisabled: (value) => {
disabled = value
if (value) buffer = []
diff --git a/lib/effect-sdk/src/shared/flushable-tracer.test.ts b/lib/effect-sdk/src/shared/flushable-tracer.test.ts
index be2e4aa48..0e543ff5d 100644
--- a/lib/effect-sdk/src/shared/flushable-tracer.test.ts
+++ b/lib/effect-sdk/src/shared/flushable-tracer.test.ts
@@ -1,5 +1,5 @@
import { assert, describe, it } from "@effect/vitest"
-import { Data, Effect } from "effect"
+import { Data, Effect, Schema } from "effect"
import * as ErrorReporter from "effect/ErrorReporter"
import * as HttpServerError from "effect/unstable/http/HttpServerError"
import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"
@@ -14,6 +14,9 @@ class ReportableError extends Data.TaggedError("ReportableError")<{}> {}
// An anticipated 4xx business error (e.g. unauthorized / not-found).
class UnauthorizedError extends Data.TaggedError("UnauthorizedError")<{}> {}
+class V2InvalidRequestError extends Schema.ErrorClass(
+ "@maple/http/v2/InvalidRequestError",
+)({ error: Schema.Struct({ type: Schema.Literal("invalid_request_error") }) }) {}
const runSpan = (buffer: ReturnType, effect: Effect.Effect) =>
effect.pipe(Effect.withSpan("http.server GET"), Effect.provide(buffer.tracerLayer), Effect.exit)
@@ -43,6 +46,14 @@ describe("makeSpanBuffer ignored-failure drop", () => {
}),
)
+ it.effect("keeps a mixed ignored failure and defect", () =>
+ Effect.gen(function* () {
+ const buffer = makeSpanBuffer()
+ yield* runSpan(buffer, Effect.fail(new BenignError()).pipe(Effect.ensuring(Effect.die("boom"))))
+ assert.strictEqual(buffer.size(), 1)
+ }),
+ )
+
// Pins the upstream contract: the actual error HttpRouter raises for an
// unmatched route must stay [ErrorReporter.ignore]-flagged, so the drop holds.
it.effect("drops the real HttpServerError/RouteNotFound", () =>
@@ -75,6 +86,25 @@ describe("makeSpanBuffer anticipated-error classification", () => {
}),
)
+ it.effect("classifies Schema.ErrorClass failures by Error.name", () =>
+ Effect.gen(function* () {
+ const buffer = makeSpanBuffer({
+ anticipatedErrorIdentifiers: new Set(["@maple/http/v2/InvalidRequestError"]),
+ })
+ yield* runSpan(
+ buffer,
+ Effect.fail(new V2InvalidRequestError({ error: { type: "invalid_request_error" } })),
+ )
+ const [span] = buffer.drain()
+ assert.isDefined(span)
+ assert.strictEqual(span!.status.code, 1 /* Ok */)
+ assert.strictEqual(
+ span!.events.some((event) => event.name === "exception"),
+ false,
+ )
+ }),
+ )
+
it.effect("still marks an unclassified failure as an Error span with an exception event", () =>
Effect.gen(function* () {
const buffer = makeSpanBuffer({ anticipatedErrorTags: tags })
@@ -132,3 +162,26 @@ describe("makeSpanBuffer anticipated-error classification", () => {
}),
)
})
+
+describe("makeSpanBuffer restore", () => {
+ it.effect("keeps older failed telemetry and discards newest overflow", () =>
+ Effect.gen(function* () {
+ const buffer = makeSpanBuffer()
+ yield* runSpan(buffer, Effect.succeed("old"))
+ const [oldest] = buffer.drain()
+ yield* Effect.succeed(undefined).pipe(
+ Effect.withSpan("newest"),
+ Effect.provide(buffer.tracerLayer),
+ )
+ const [newest] = buffer.drain()
+ assert.isDefined(oldest)
+ assert.isDefined(newest)
+
+ buffer.restore([oldest!, ...Array.from({ length: 10_000 }, () => newest!)])
+ const restored = buffer.drain()
+ assert.strictEqual(restored.length, 10_000)
+ assert.strictEqual(restored[0]?.name, "http.server GET")
+ assert.strictEqual(restored.at(-1)?.name, "newest")
+ }),
+ )
+})
diff --git a/lib/effect-sdk/src/shared/flushable-tracer.ts b/lib/effect-sdk/src/shared/flushable-tracer.ts
index 21ebb7515..2a5e6b6c6 100644
--- a/lib/effect-sdk/src/shared/flushable-tracer.ts
+++ b/lib/effect-sdk/src/shared/flushable-tracer.ts
@@ -14,6 +14,7 @@ import type { ExtractTag } from "effect/Types"
export interface SpanBuffer {
readonly tracerLayer: Layer.Layer
readonly drain: () => Array
+ readonly restore: (items: ReadonlyArray) => void
readonly setDisabled: (value: boolean) => void
readonly size: () => number
}
@@ -28,9 +29,10 @@ export interface SpanBufferOptions {
*/
readonly dropSpan?: ((name: string) => boolean) | undefined
/**
- * `_tag`s of failures that represent *anticipated* outcomes (expected 4xx
- * business errors: validation, not-found, unauthorized, …). When a span's
- * failure is caused *entirely* by errors with one of these tags, the span is
+ * Stable `_tag` or `Error.name` identifiers for failures that represent
+ * anticipated outcomes (expected 4xx business errors: validation, not-found,
+ * unauthorized, …). When a span's failure is caused *entirely* by errors with
+ * one of these identifiers, the span is
* still exported (so latency / `http.response.status_code` stay visible) but
* with OTLP status `Ok` and **no** `exception` event — so it never lands in
* error tracking (`error_events_mv` keys off `StatusCode='Error'`). Mirrors
@@ -39,6 +41,8 @@ export interface SpanBufferOptions {
* Distinct from Effect's `ErrorReporter.ignore` flag, which *drops* the span
* entirely (used for benign routing 404s).
*/
+ readonly anticipatedErrorIdentifiers?: ReadonlySet | undefined
+ /** @deprecated Use `anticipatedErrorIdentifiers`. */
readonly anticipatedErrorTags?: ReadonlySet | undefined
}
@@ -56,15 +60,16 @@ const isIgnoredSpan = (span: SpanImpl): boolean => {
if (status._tag !== "Ended") return false
const exit = status.exit
if (exit._tag !== "Failure") return false
- // Cause is flat in effect v4: walk `reasons`, pick Fail reasons, inspect their error.
- return exit.cause.reasons.some((reason) => Cause.isFailReason(reason) && isIgnoredFailure(reason.error))
+ if (exit.cause.reasons.some(Cause.isDieReason)) return false
+ const failures = exit.cause.reasons.filter(Cause.isFailReason)
+ return failures.length > 0 && failures.every((reason) => isIgnoredFailure(reason.error))
}
export const makeSpanBuffer = (options: SpanBufferOptions = {}): SpanBuffer => {
let buffer: Array = []
let disabled = false
const dropSpan = options.dropSpan
- const anticipatedErrorTags = options.anticipatedErrorTags
+ const anticipatedErrorIdentifiers = options.anticipatedErrorIdentifiers ?? options.anticipatedErrorTags
const exportFn = (span: SpanImpl) => {
if (disabled) return
@@ -72,7 +77,7 @@ export const makeSpanBuffer = (options: SpanBufferOptions = {}): SpanBuffer => {
if (dropSpan !== undefined && dropSpan(span.name)) return
if (isIgnoredSpan(span)) return
if (buffer.length >= MAX_BUFFER) return
- buffer.push(makeOtlpSpan(span, anticipatedErrorTags))
+ buffer.push(makeOtlpSpan(span, anticipatedErrorIdentifiers))
}
const tracer = Tracer.make({
@@ -93,6 +98,10 @@ export const makeSpanBuffer = (options: SpanBufferOptions = {}): SpanBuffer => {
buffer = []
return items
},
+ restore: (items) => {
+ if (disabled || items.length === 0) return
+ buffer = [...items, ...buffer].slice(0, MAX_BUFFER)
+ },
setDisabled: (value) => {
disabled = value
if (value) buffer = []
@@ -163,20 +172,28 @@ const generateId = (len: number): string => {
// A failure is "anticipated" when its `_tag` is in the configured set. A span
// whose failure is caused *entirely* by anticipated errors (no defects/Die)
// records OTLP status `Ok` and emits no `exception` event.
-const isAnticipatedFailure = (error: unknown, tags: ReadonlySet): boolean =>
- Predicate.hasProperty(error, "_tag") && typeof error._tag === "string" && tags.has(error._tag)
+const failureIdentifier = (error: unknown): string | undefined => {
+ if (Predicate.hasProperty(error, "_tag") && typeof error._tag === "string") return error._tag
+ if (Predicate.hasProperty(error, "name") && typeof error.name === "string") return error.name
+ return undefined
+}
+
+const isAnticipatedFailure = (error: unknown, identifiers: ReadonlySet): boolean => {
+ const identifier = failureIdentifier(error)
+ return identifier !== undefined && identifiers.has(identifier)
+}
const isFullyAnticipated = (
cause: Cause.Cause,
- tags: ReadonlySet | undefined,
+ identifiers: ReadonlySet | undefined,
): boolean => {
- if (tags === undefined || tags.size === 0) return false
+ if (identifiers === undefined || identifiers.size === 0) return false
if (cause.reasons.some(Cause.isDieReason)) return false
const failErrors = cause.reasons.filter(Cause.isFailReason).map((reason) => reason.error)
- return failErrors.length > 0 && failErrors.every((error) => isAnticipatedFailure(error, tags))
+ return failErrors.length > 0 && failErrors.every((error) => isAnticipatedFailure(error, identifiers))
}
-const makeOtlpSpan = (self: SpanImpl, anticipatedErrorTags?: ReadonlySet): OtlpSpan => {
+const makeOtlpSpan = (self: SpanImpl, anticipatedErrorIdentifiers?: ReadonlySet): OtlpSpan => {
const status = self.status as ExtractTag
const attributes = OtlpResource.entriesToAttributes(self.attributes.entries())
const events = self.events.map(([name, startTime, attrs]) => ({
@@ -195,7 +212,7 @@ const makeOtlpSpan = (self: SpanImpl, anticipatedErrorTags?: ReadonlySet
{ key: "span.label", value: { stringValue: "⚠︎ Interrupted" } },
{ key: "status.interrupted", value: { boolValue: true } },
)
- } else if (isFullyAnticipated(status.exit.cause, anticipatedErrorTags)) {
+ } else if (isFullyAnticipated(status.exit.cause, anticipatedErrorIdentifiers)) {
// Expected business outcome (4xx). Keep the span (latency / status code
// stay visible) but don't flag it as an error or fingerprint it.
otelStatus = constOtelStatusSuccess
diff --git a/packages/domain/src/anticipated-errors.test.ts b/packages/domain/src/anticipated-errors.test.ts
index 7b10c7cb3..6b2fd100f 100644
--- a/packages/domain/src/anticipated-errors.test.ts
+++ b/packages/domain/src/anticipated-errors.test.ts
@@ -1,28 +1,33 @@
import { describe, expect, it } from "vitest"
-import { ANTICIPATED_ERROR_TAGS, isAnticipatedErrorTag } from "./anticipated-errors"
+import { ANTICIPATED_ERROR_IDENTIFIERS, isAnticipatedErrorIdentifier } from "./anticipated-errors"
-describe("ANTICIPATED_ERROR_TAGS", () => {
- it("includes the observed 4xx business-error tags", () => {
- for (const tag of [
+describe("ANTICIPATED_ERROR_IDENTIFIERS", () => {
+ it("includes legacy tags and v2 ErrorClass names for 4xx business errors", () => {
+ for (const identifier of [
"@maple/http/errors/UnauthorizedError",
"@maple/http/errors/RawSqlValidationError",
"@maple/http/ai-triage/AiTriageNotFoundError",
"@maple/http/errors/IntegrationsNotConnectedError",
+ "@maple/http/v2/InvalidRequestError",
+ "@maple/http/v2/AuthenticationError",
+ "@maple/http/v2/RateLimitError",
]) {
- expect(isAnticipatedErrorTag(tag), tag).toBe(true)
+ expect(isAnticipatedErrorIdentifier(identifier), identifier).toBe(true)
}
})
it("excludes 5xx persistence / upstream failures", () => {
- for (const tag of [
+ for (const identifier of [
"@maple/http/errors/WarehouseQueryError",
"@maple/http/errors/QueryEngineTimeoutError",
+ "@maple/http/v2/ApiError",
+ "@maple/http/v2/ServiceUnavailableError",
]) {
- expect(isAnticipatedErrorTag(tag), tag).toBe(false)
+ expect(isAnticipatedErrorIdentifier(identifier), identifier).toBe(false)
}
})
it("derives a non-trivial set (reflection still works)", () => {
- expect(ANTICIPATED_ERROR_TAGS.size).toBeGreaterThan(20)
+ expect(ANTICIPATED_ERROR_IDENTIFIERS.size).toBeGreaterThan(25)
})
})
diff --git a/packages/domain/src/anticipated-errors.ts b/packages/domain/src/anticipated-errors.ts
index 5995533c0..1675ba22c 100644
--- a/packages/domain/src/anticipated-errors.ts
+++ b/packages/domain/src/anticipated-errors.ts
@@ -1,8 +1,10 @@
// ---------------------------------------------------------------------------
-// Anticipated error tags
+// Anticipated error identifiers
//
-// The set of domain HTTP error `_tag`s that represent *expected* client-facing
-// outcomes (4xx): validation, not-found, unauthorized, forbidden, conflict, …
+// The set of stable domain HTTP error identifiers that represent *expected*
+// client-facing outcomes (4xx): validation, not-found, unauthorized, forbidden,
+// conflict, … Tagged errors contribute `_tag`; v2 ErrorClass values contribute
+// their class identifier / `Error.name`.
//
// These are not bugs — they're normal business results. The telemetry SDK uses
// this set to record spans that fail *entirely* with one of these errors as
@@ -12,12 +14,13 @@
// rule (4xx → Ok, 5xx → Error).
//
// Derived (not hand-maintained) from the error classes themselves: every
-// `Schema.TaggedErrorClass` carries its `_tag` literal and an `httpApiStatus`
-// annotation on its AST, so a new 4xx error is picked up automatically. A 5xx
-// error (persistence/upstream failures) is intentionally excluded and keeps
-// tracing.
+// Both `Schema.TaggedErrorClass` and `Schema.ErrorClass` carry a stable
+// identifier plus an `httpApiStatus` annotation, so a new 4xx error is picked
+// up automatically. A 5xx error (persistence/upstream failures) is intentionally
+// excluded and keeps tracing.
// ---------------------------------------------------------------------------
import * as Http from "./http/index"
+import * as HttpV2 from "./http/v2/index"
/** Read `obj[key]` when `obj` is an object/function that has it; `undefined` otherwise. */
const prop = (obj: unknown, key: string): unknown =>
@@ -25,10 +28,12 @@ const prop = (obj: unknown, key: string): unknown =>
? (obj as Record)[key]
: undefined
-/** The `_tag` literal of a `Schema.TaggedErrorClass` (`ast.fields._tag.schema.literal`). */
-const readTag = (value: unknown): string | undefined => {
+/** Stable runtime identifier: tagged errors use `_tag`; ErrorClass uses its class identifier/name. */
+const readIdentifier = (value: unknown): string | undefined => {
const literal = prop(prop(prop(prop(value, "fields"), "_tag"), "schema"), "literal")
- return typeof literal === "string" ? literal : undefined
+ if (typeof literal === "string") return literal
+ const identifier = prop(value, "identifier")
+ return typeof identifier === "string" ? identifier : undefined
}
/** The `httpApiStatus` annotation on a schema's AST, when present. */
@@ -37,24 +42,30 @@ const readHttpStatus = (value: unknown): number | undefined => {
return typeof status === "number" ? status : undefined
}
-const deriveAnticipatedTags = (): ReadonlySet => {
- const tags = new Set()
- for (const value of Object.values(Http)) {
+const deriveAnticipatedIdentifiers = (): ReadonlySet => {
+ const identifiers = new Set()
+ for (const value of [...Object.values(Http), ...Object.values(HttpV2)]) {
if (typeof value !== "function") continue
- const tag = readTag(value)
- if (tag === undefined) continue
+ const identifier = readIdentifier(value)
+ if (identifier === undefined) continue
const status = readHttpStatus(value)
if (status === undefined) continue
- if (status >= 400 && status < 500) tags.add(tag)
+ if (status >= 400 && status < 500) identifiers.add(identifier)
}
- return tags
+ return identifiers
}
/**
- * `_tag`s of all domain HTTP errors annotated with a 4xx `httpApiStatus`.
- * Pass `[...ANTICIPATED_ERROR_TAGS]` to the telemetry SDK's `anticipatedErrorTags`.
+ * Stable identifiers of all domain HTTP errors annotated with a 4xx `httpApiStatus`.
+ * Tagged errors contribute `_tag`; v2 ErrorClass values contribute `Error.name`.
*/
-export const ANTICIPATED_ERROR_TAGS: ReadonlySet = deriveAnticipatedTags()
+export const ANTICIPATED_ERROR_IDENTIFIERS: ReadonlySet = deriveAnticipatedIdentifiers()
-/** True when `tag` is a known anticipated (4xx) domain error tag. */
-export const isAnticipatedErrorTag = (tag: string): boolean => ANTICIPATED_ERROR_TAGS.has(tag)
+export const isAnticipatedErrorIdentifier = (identifier: string): boolean =>
+ ANTICIPATED_ERROR_IDENTIFIERS.has(identifier)
+
+/** @deprecated Use `ANTICIPATED_ERROR_IDENTIFIERS`. */
+export const ANTICIPATED_ERROR_TAGS = ANTICIPATED_ERROR_IDENTIFIERS
+
+/** @deprecated Use `isAnticipatedErrorIdentifier`. */
+export const isAnticipatedErrorTag = isAnticipatedErrorIdentifier
diff --git a/packages/domain/src/http/v2/v2-contract.test.ts b/packages/domain/src/http/v2/v2-contract.test.ts
index 5f769755c..ee5d8bba0 100644
--- a/packages/domain/src/http/v2/v2-contract.test.ts
+++ b/packages/domain/src/http/v2/v2-contract.test.ts
@@ -480,10 +480,7 @@ describe("scopes", () => {
describe("telemetry contracts", () => {
it("round-trips the synthetic composite log ID", () => {
- const internal = JSON.stringify([
- "2026-07-15 12:00:00.123",
- "00112233445566778899AABBCCDDEEFF",
- ])
+ const internal = JSON.stringify(["2026-07-15 12:00:00.123", "00112233445566778899AABBCCDDEEFF"])
const wire = Schema.encodeSync(LogPublicId)(internal)
expect(wire.startsWith("log_")).toBe(true)
expect(Schema.decodeSync(LogPublicId)(wire)).toBe(internal)
@@ -532,20 +529,22 @@ describe("telemetry contracts", () => {
describe("list pagination", () => {
const items = Array.from({ length: 45 }, (_, index) => index)
- it("paginates with default limit and opaque cursors", () => {
- const first = Effect.runSync(paginateArray(items, {}))
- expect(first.data).toHaveLength(20)
- expect(first.has_more).toBe(true)
- expect(first.next_cursor).not.toBeNull()
+ it.effect("paginates with default limit and opaque cursors", () =>
+ Effect.gen(function* () {
+ const first = yield* paginateArray(items, {})
+ expect(first.data).toHaveLength(20)
+ expect(first.has_more).toBe(true)
+ expect(first.next_cursor).not.toBeNull()
- const second = Effect.runSync(paginateArray(items, { cursor: first.next_cursor! }))
- expect(second.data[0]).toBe(20)
+ const second = yield* paginateArray(items, { cursor: first.next_cursor! })
+ expect(second.data[0]).toBe(20)
- const third = Effect.runSync(paginateArray(items, { cursor: second.next_cursor!, limit: 20 }))
- expect(third.data).toHaveLength(5)
- expect(third.has_more).toBe(false)
- expect(third.next_cursor).toBeNull()
- })
+ const third = yield* paginateArray(items, { cursor: second.next_cursor!, limit: 20 })
+ expect(third.data).toHaveLength(5)
+ expect(third.has_more).toBe(false)
+ expect(third.next_cursor).toBeNull()
+ }),
+ )
it("cursor round-trips and rejects garbage", () => {
expect(decodeOffsetCursor(encodeOffsetCursor(1234))).toBe(1234)
@@ -553,29 +552,33 @@ describe("list pagination", () => {
expect(decodeOffsetCursor("off_-1")).toBeNull()
})
- it("fails invalid cursors instead of silently restarting at page one", () => {
- const result = Effect.runSync(Effect.result(paginateArray(items, { cursor: "garbage" })))
- expect(Result.isFailure(result)).toBe(true)
- if (Result.isFailure(result)) {
- expect(result.failure.error.code).toBe("parameter_invalid")
- expect(result.failure.error.param).toBe("cursor")
- }
- })
-
- it("uses storage lookahead and returns each row exactly once", () => {
- const calls: Array<{ limit: number; offset: number }> = []
- const fetch = ({ limit, offset }: { limit: number; offset: number }) => {
- calls.push({ limit, offset })
- return Effect.succeed(items.slice(offset, offset + limit))
- }
- const first = Effect.runSync(paginateOffsetQuery({ limit: 10 }, fetch))
- const second = Effect.runSync(paginateOffsetQuery({ limit: 10, cursor: first.next_cursor! }, fetch))
- expect(calls).toEqual([
- { limit: 11, offset: 0 },
- { limit: 11, offset: 10 },
- ])
- expect([...first.data, ...second.data]).toEqual(items.slice(0, 20))
- })
+ it.effect("fails invalid cursors instead of silently restarting at page one", () =>
+ Effect.gen(function* () {
+ const result = yield* Effect.result(paginateArray(items, { cursor: "garbage" }))
+ expect(Result.isFailure(result)).toBe(true)
+ if (Result.isFailure(result)) {
+ expect(result.failure.error.code).toBe("parameter_invalid")
+ expect(result.failure.error.param).toBe("cursor")
+ }
+ }),
+ )
+
+ it.effect("uses storage lookahead and returns each row exactly once", () =>
+ Effect.gen(function* () {
+ const calls: Array<{ limit: number; offset: number }> = []
+ const fetch = ({ limit, offset }: { limit: number; offset: number }) => {
+ calls.push({ limit, offset })
+ return Effect.succeed(items.slice(offset, offset + limit))
+ }
+ const first = yield* paginateOffsetQuery({ limit: 10 }, fetch)
+ const second = yield* paginateOffsetQuery({ limit: 10, cursor: first.next_cursor! }, fetch)
+ expect(calls).toEqual([
+ { limit: 11, offset: 0 },
+ { limit: 11, offset: 10 },
+ ])
+ expect([...first.data, ...second.data]).toEqual(items.slice(0, 20))
+ }),
+ )
it("enforces the shared limit range", () => {
const decode = Schema.decodeUnknownSync(ListQuery)
diff --git a/packages/domain/src/tinybird/project-sync.test.ts b/packages/domain/src/tinybird/project-sync.test.ts
index 19a052995..64bda1103 100644
--- a/packages/domain/src/tinybird/project-sync.test.ts
+++ b/packages/domain/src/tinybird/project-sync.test.ts
@@ -349,6 +349,49 @@ describe("Tinybird project sync", () => {
expect(calls.filter((c) => c.method === "DELETE")).toHaveLength(0)
})
+
+ it("bounds stale-deployment deletion concurrency and attempts every item", async () => {
+ const staleIds = Array.from({ length: 7 }, (_, index) => `failed-${index + 1}`)
+ const attempted: string[] = []
+ let active = 0
+ let peak = 0
+ let releaseGate: (() => void) | undefined
+ const gate = new Promise((resolve) => {
+ releaseGate = resolve
+ })
+
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url
+ const method = init?.method ?? "GET"
+ if (method === "GET") {
+ return jsonResponse({
+ deployments: staleIds.map((id) => ({ id, status: "failed", live: false })),
+ })
+ }
+
+ const id = url.match(/\/v1\/deployments\/([^?]+)/)?.[1]
+ if (id === undefined) throw new Error(`Unexpected deletion URL: ${url}`)
+ attempted.push(id)
+ active += 1
+ peak = Math.max(peak, active)
+ await gate
+ active -= 1
+ return new Response("", { status: 200 })
+ }) as unknown as typeof fetch
+
+ const cleanup = cleanupStaleTinybirdDeployments({
+ baseUrl: "https://customer.tinybird.co",
+ token: "token",
+ })
+ await vi.waitFor(() => expect(attempted).toHaveLength(3))
+ expect(peak).toBe(3)
+ releaseGate?.()
+ await cleanup
+
+ expect(attempted.sort()).toEqual(staleIds)
+ expect(peak).toBe(3)
+ })
})
describe("cleanupOwnedTinybirdDeployment", () => {
diff --git a/packages/domain/src/tinybird/project-sync.ts b/packages/domain/src/tinybird/project-sync.ts
index 94134fc37..3d0670408 100644
--- a/packages/domain/src/tinybird/project-sync.ts
+++ b/packages/domain/src/tinybird/project-sync.ts
@@ -9,6 +9,7 @@ import {
} from "./ttl-override"
const REQUEST_TIMEOUT = Duration.seconds(30)
+const STALE_DEPLOYMENT_DELETE_CONCURRENCY = 3
const FeedbackEntrySchema = Schema.Struct({
resource: Schema.NullOr(Schema.String),
@@ -445,7 +446,7 @@ export class TinybirdProjectSync extends Context.Service
Layer.succeed(EdgeCacheService, makeEdgeCacheService(backend, readTimeoutMs))
describe("EdgeCacheService.getOrCompute (no schema)", () => {
- it.effect("fails open to computation when a backend read exceeds its deadline", () => {
+ it.live("fails open to computation when a backend read exceeds its deadline", () => {
let computeCalls = 0
const backend: EdgeCacheBackend = {
get: async () => await new Promise(() => {}),
@@ -57,7 +57,7 @@ describe("EdgeCacheService.getOrCompute (no schema)", () => {
}).pipe(Effect.provide(makeLayer(backend, 10)), Effect.timeout(200))
})
- it.effect("shares the complete slow read-or-compute operation across concurrent callers", () => {
+ it.live("shares the complete slow read-or-compute operation across concurrent callers", () => {
let getCalls = 0
let computeCalls = 0
const backend: EdgeCacheBackend = {
@@ -140,7 +140,7 @@ describe("EdgeCacheService.getOrCompute (no schema)", () => {
})
describe("EdgeCacheService.rawGet", () => {
- it.effect("reports hit, miss, and timeout outcomes without collapsing them", () => {
+ it.live("reports hit, miss, and timeout outcomes without collapsing them", () => {
const backend: EdgeCacheBackend = {
get: async (bucket) => {
if (bucket === "hit") return { value: 42 }
@@ -166,7 +166,7 @@ describe("EdgeCacheService.rawGet", () => {
}).pipe(Effect.provide(makeLayer(backend, 10)), Effect.timeout(200))
})
- it.effect("treats a backend read timeout as a cache miss", () => {
+ it.live("treats a backend read timeout as a cache miss", () => {
const backend: EdgeCacheBackend = {
get: async () => await new Promise(() => {}),
put: async () => {},
diff --git a/packages/query-engine/src/observability/session-events.ts b/packages/query-engine/src/observability/session-events.ts
index 22c67ff9f..66c012ef3 100644
--- a/packages/query-engine/src/observability/session-events.ts
+++ b/packages/query-engine/src/observability/session-events.ts
@@ -68,7 +68,7 @@ export const searchSessions = Effect.fn("Observability.searchSessions")(function
}),
{ orgId: executor.orgId, startTime: input.startTime, endTime: input.endTime },
)
- return yield* executor.compiledQuery(compiled, { profile: "list" })
+ return yield* executor.compiledQuery(compiled, { profile: "list", context: "searchSessions" })
})
/**
@@ -103,5 +103,5 @@ export const getSessionTranscript = Effect.fn("Observability.getSessionTranscrip
sessionId: input.sessionId,
},
)
- return yield* executor.compiledQuery(compiled, { profile: "list" })
+ return yield* executor.compiledQuery(compiled, { profile: "list", context: "sessionTranscript" })
})
diff --git a/packages/query-engine/src/observability/session-replays.ts b/packages/query-engine/src/observability/session-replays.ts
index b71126786..11c602265 100644
--- a/packages/query-engine/src/observability/session-replays.ts
+++ b/packages/query-engine/src/observability/session-replays.ts
@@ -75,8 +75,14 @@ export const getSessionTraces = Effect.fn("Observability.getSessionTraces")(func
})
const [maybeSession, maybeActivity] = yield* Effect.all(
[
- executor.compiledQueryFirst(detailCompiled, { profile: "discovery" }),
- executor.compiledQueryFirst(activityCompiled, { profile: "discovery" }),
+ executor.compiledQueryFirst(detailCompiled, {
+ profile: "discovery",
+ context: "sessionReplayDetail",
+ }),
+ executor.compiledQueryFirst(activityCompiled, {
+ profile: "discovery",
+ context: "sessionActivity",
+ }),
],
{ concurrency: 2 },
)
@@ -106,7 +112,10 @@ export const getSessionTraces = Effect.fn("Observability.getSessionTraces")(func
const summariesCompiled = CH.compile(CH.sessionTraceSummariesQuery({ traceIds }), {
orgId: executor.orgId,
})
- const traces = yield* executor.compiledQuery(summariesCompiled, { profile: "list" })
+ const traces = yield* executor.compiledQuery(summariesCompiled, {
+ profile: "list",
+ context: "sessionTraceSummaries",
+ })
return { session, traces, totalTraceCount } satisfies SessionTracesOutput
})
diff --git a/packages/query-engine/src/observability/span-detail.ts b/packages/query-engine/src/observability/span-detail.ts
index 6f53693e7..773af0a1f 100644
--- a/packages/query-engine/src/observability/span-detail.ts
+++ b/packages/query-engine/src/observability/span-detail.ts
@@ -70,7 +70,10 @@ export const spanDetail = Effect.fn("Observability.spanDetail")(function* (input
? { orgId: executor.orgId, startTime: range.startTime, endTime: range.endTime }
: { orgId: executor.orgId },
)
- const maybeRow = yield* executor.compiledQueryFirst(compiled, { profile: "discovery" })
+ const maybeRow = yield* executor.compiledQueryFirst(compiled, {
+ profile: "discovery",
+ context: "spanDetail",
+ })
if (Option.isNone(maybeRow)) {
return {
diff --git a/packages/query-engine/src/runtime/raw-sql.test.ts b/packages/query-engine/src/runtime/raw-sql.test.ts
index 6b286279b..9fbdc01f7 100644
--- a/packages/query-engine/src/runtime/raw-sql.test.ts
+++ b/packages/query-engine/src/runtime/raw-sql.test.ts
@@ -189,6 +189,8 @@ describe("makeExecuteRawSql", () => {
const cellError = yield* Effect.flip(
executeRows([{ value: "x".repeat(MAX_RAW_SQL_CELL_LENGTH + 1) }]),
)
+ assert.strictEqual(cellError._tag, "@maple/http/errors/RawSqlValidationError")
+ assert.strictEqual(cellError.code, "ResourceLimit")
assert.include(cellError.message, "cells")
const bytesError = yield* Effect.flip(
@@ -199,9 +201,13 @@ describe("makeExecuteRawSql", () => {
})),
),
)
+ assert.strictEqual(bytesError._tag, "@maple/http/errors/RawSqlValidationError")
+ assert.strictEqual(bytesError.code, "ResourceLimit")
assert.include(bytesError.message, "bytes")
const jsonError = yield* Effect.flip(executeRows([{ value: 1n }]))
+ assert.strictEqual(jsonError._tag, "@maple/http/errors/RawSqlValidationError")
+ assert.strictEqual(jsonError.code, "ResourceLimit")
assert.include(jsonError.message, "JSON serializable")
}),
)