diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index f821095f..1608ecbd 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -199,6 +199,10 @@ is a fixed-size digest of the canonical payload, keeping it under wallet | `phase-144` | `NEXT_PUBLIC_FEATURE_PHASE_144` / `FEATURE_PHASE_144` | ve-PHASELQ time-locked governance escrow with pro-rata treasury fee-share distribution | off | Unset var, restart — governance ve-lock route returns 404, SQLite ve rows remain inert | | `phase-145` | `NEXT_PUBLIC_FEATURE_PHASE_145` / `FEATURE_PHASE_145` | Gauge voting for quest emissions with ve-weight directing collection quest budgets | off | Unset var, restart — gauge route returns 404, SQLite vote rows remain inert | | `phase-154` | `NEXT_PUBLIC_FEATURE_PHASE_154` / `FEATURE_PHASE_154` | Outbound webhook subscriptions: admin-managed subscriber endpoints with per-endpoint HMAC secrets and signed, retried event delivery (`settle.verified`, `market.offer_accepted`, `faucet.claim_completed`). Partial slice of spike 664 (issue #527) — no production event wiring, SSRF fencing, or redelivery UI yet | off | Unset var, restart — admin routes return 404; `webhook-subscriptions.json` rows remain inert, no migration to undo | +| `phase-158` | `NEXT_PUBLIC_FEATURE_PHASE_158` / `FEATURE_PHASE_158` | Soroban read coalescing (`lib/soroban-read-coalescer.ts`): process-local sharing of in-flight identical reads plus a short TTL cache with stale-on-error. Partial slice of spike 655 (issue #517) — no JSON-RPC array batching, no cross-process cache | off | Unset var, restart — every read hits `simulateTransaction` uncoalesced (pre-phase-158 behavior); only in-memory entries dropped | +| `phase-159` | `NEXT_PUBLIC_FEATURE_PHASE_159` / `FEATURE_PHASE_159` | Batched owner-key loader (`lib/owner-batch-loader.ts`): DataLoader-style per-tick coalescing, key dedupe, chunking and memoization to remove N+1 owner reads. Partial slice of spike 656 (issue #518) — route wiring, index pushdown, and sidecar `mget` are follow-ups | off | Unset var, restart — callers use their unbatched per-token path (pre-phase-159 behavior); only in-memory cache dropped | +| `phase-160` | `NEXT_PUBLIC_FEATURE_PHASE_160` / `FEATURE_PHASE_160` | Web Vitals budgets (`lib/web-vitals-budgets.ts`): per-route LCP/INP/CLS budget table plus pass/fail evaluation and worst-per-vital summary. Partial slice of spike 657 (issue #520) — no live collector, RUM transport, or PR-blocking gate yet | off | Unset var, restart — no vitals evaluated or reported; the budget table is inert (no persisted state) | +| `phase-161` | `NEXT_PUBLIC_FEATURE_PHASE_161` / `FEATURE_PHASE_161` | Typed contract-error taxonomy (`lib/phase-error-taxonomy.ts`): code-stable `PhaseError` descriptors 1..39 with `retryable` hints and `userMessageKey`s, mapped from raw host errors. Partial slice of spike 660 (issue #523) — Horizon/RPC transport taxonomy and retry policy are follow-ups | off | Unset var, restart — callers keep using the string helpers in `lib/phase-host-error.ts` (pre-phase-161 behavior); module stays inert | Flags are read via `lib/feature-flags.ts:isFeatureEnabled`. Client flags use `NEXT_PUBLIC_*`, server also accepts `FEATURE_*`. Zero regression when off. diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index 3a5005fd..5f0732b0 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -414,6 +414,11 @@ NEXT_PUBLIC_FEATURE_PHASE_146=1 NEXT_PUBLIC_FEATURE_PHASE_147=1 NEXT_PUBLIC_FEATURE_PHASE_148=1 NEXT_PUBLIC_FEATURE_PHASE_149=1 +# Spikes 655/656/657/660 (issues #517/#518/#520/#523) +NEXT_PUBLIC_FEATURE_PHASE_158=1 +NEXT_PUBLIC_FEATURE_PHASE_159=1 +NEXT_PUBLIC_FEATURE_PHASE_160=1 +NEXT_PUBLIC_FEATURE_PHASE_161=1 # Server-only aliases also accepted: FEATURE_PHASE_104, etc. ``` diff --git a/lib/__tests__/owner-batch-loader.test.ts b/lib/__tests__/owner-batch-loader.test.ts new file mode 100644 index 00000000..e1df1c84 --- /dev/null +++ b/lib/__tests__/owner-batch-loader.test.ts @@ -0,0 +1,95 @@ +import { describe, it, beforeEach } from "vitest" +import * as assert from "node:assert/strict" +import { + createBatchLoader, + isOwnerBatchLoaderEnabled, + ownerBatchLoaderRollbackNote, +} from "@/lib/owner-batch-loader" + +function enable(on: boolean) { + if (on) { + process.env.NEXT_PUBLIC_FEATURE_PHASE_159 = "1" + process.env.FEATURE_PHASE_159 = "1" + } else { + delete process.env.NEXT_PUBLIC_FEATURE_PHASE_159 + delete process.env.FEATURE_PHASE_159 + } +} + +describe("phase-159 owner batch loader", () => { + beforeEach(() => enable(true)) + + it("coalesces same-tick loads into a single batch call", async () => { + const batches: number[][] = [] + const loader = createBatchLoader(async (keys) => { + batches.push([...keys]) + return keys.map((k) => `owner-${k}`) + }) + + const results = await Promise.all([loader.load(1), loader.load(2), loader.load(3)]) + assert.deepEqual(results, ["owner-1", "owner-2", "owner-3"]) + assert.equal(batches.length, 1) + assert.deepEqual(batches[0], [1, 2, 3]) + }) + + it("deduplicates repeated keys within a tick", async () => { + const batches: number[][] = [] + const loader = createBatchLoader(async (keys) => { + batches.push([...keys]) + return keys.map((k) => `owner-${k}`) + }) + + const [a, b] = await Promise.all([loader.load(7), loader.load(7)]) + assert.equal(a, "owner-7") + assert.equal(b, "owner-7") + assert.equal(batches.length, 1) + assert.deepEqual(batches[0], [7]) + }) + + it("chunks by maxBatchSize", async () => { + const batches: number[][] = [] + const loader = createBatchLoader( + async (keys) => { + batches.push([...keys]) + return keys.map((k) => k * 10) + }, + { maxBatchSize: 2 }, + ) + + const results = await loader.loadMany([1, 2, 3, 4, 5]) + assert.deepEqual(results, [10, 20, 30, 40, 50]) + assert.equal(batches.length, 3) + }) + + it("memoizes resolved values across ticks", async () => { + let calls = 0 + const loader = createBatchLoader(async (keys) => { + calls += 1 + return keys.map((k) => k) + }) + await loader.load(1) + await loader.load(1) + assert.equal(calls, 1) + + loader.clearAll() + await loader.load(1) + assert.equal(calls, 2) + }) + + it("rejects a key when batchFn returns an Error at its position", async () => { + const loader = createBatchLoader(async (keys) => + keys.map((k) => (k === 2 ? new Error("missing") : `owner-${k}`)), + ) + const results = await loader.loadMany([1, 2, 3]) + assert.equal(results[0], "owner-1") + assert.ok(results[1] instanceof Error) + assert.equal(results[2], "owner-3") + }) + + it("reflects the phase-159 flag and exposes a rollback note", () => { + assert.equal(isOwnerBatchLoaderEnabled(), true) + enable(false) + assert.equal(isOwnerBatchLoaderEnabled(), false) + assert.match(ownerBatchLoaderRollbackNote(), /phase-159/) + }) +}) diff --git a/lib/__tests__/phase-error-taxonomy.test.ts b/lib/__tests__/phase-error-taxonomy.test.ts new file mode 100644 index 00000000..97376b57 --- /dev/null +++ b/lib/__tests__/phase-error-taxonomy.test.ts @@ -0,0 +1,64 @@ +import { describe, it, beforeEach } from "vitest" +import * as assert from "node:assert/strict" +import { + PHASE_ERROR_CODES, + PHASE_ERROR_TAXONOMY, + describePhaseErrorCode, + isPhaseErrorTaxonomyEnabled, + mapPhaseContractError, + phaseErrorTaxonomyRollbackNote, +} from "@/lib/phase-error-taxonomy" + +function enable(on: boolean) { + if (on) { + process.env.NEXT_PUBLIC_FEATURE_PHASE_161 = "1" + process.env.FEATURE_PHASE_161 = "1" + } else { + delete process.env.NEXT_PUBLIC_FEATURE_PHASE_161 + delete process.env.FEATURE_PHASE_161 + } +} + +describe("phase-161 phase error taxonomy", () => { + beforeEach(() => enable(true)) + + it("mirrors the contract discriminants 1..39", () => { + assert.equal(PHASE_ERROR_CODES.length, 39) + assert.equal(PHASE_ERROR_TAXONOMY[13].discriminant, "SelfTransfer") + assert.equal(PHASE_ERROR_TAXONOMY[39].discriminant, "InvalidSchedule") + }) + + it("derives a stable snake_case user message key", () => { + assert.equal(PHASE_ERROR_TAXONOMY[11].userMessageKey, "phase.error.nft_not_found") + assert.equal(PHASE_ERROR_TAXONOMY[4].userMessageKey, "phase.error.unauthorized_token") + }) + + it("marks only transient failures retryable", () => { + assert.equal(PHASE_ERROR_TAXONOMY[3].retryable, true) + assert.equal(PHASE_ERROR_TAXONOMY[5].retryable, true) + assert.equal(PHASE_ERROR_TAXONOMY[12].retryable, false) + }) + + it("describes a known code and returns null otherwise", () => { + assert.equal(describePhaseErrorCode(2)!.discriminant, "InsufficientBalance") + assert.equal(describePhaseErrorCode(999), null) + }) + + it("maps a raw host error carrying a contract code", () => { + const descriptor = mapPhaseContractError(new Error("host invocation failed: Error(Contract, #11)")) + assert.ok(descriptor) + assert.equal(descriptor!.discriminant, "NftNotFound") + }) + + it("returns null for an unrecognized error shape", () => { + assert.equal(mapPhaseContractError(new Error("network timeout")), null) + assert.equal(mapPhaseContractError(null), null) + }) + + it("reflects the phase-161 flag and exposes a rollback note", () => { + assert.equal(isPhaseErrorTaxonomyEnabled(), true) + enable(false) + assert.equal(isPhaseErrorTaxonomyEnabled(), false) + assert.match(phaseErrorTaxonomyRollbackNote(), /phase-161/) + }) +}) diff --git a/lib/__tests__/soroban-read-coalescer.test.ts b/lib/__tests__/soroban-read-coalescer.test.ts new file mode 100644 index 00000000..75d464de --- /dev/null +++ b/lib/__tests__/soroban-read-coalescer.test.ts @@ -0,0 +1,98 @@ +import { describe, it, beforeEach } from "vitest" +import * as assert from "node:assert/strict" +import { + buildReadKey, + coalesceSorobanRead, + getReadCoalescerMetrics, + resetReadCoalescer, + readCoalescerRollbackNote, +} from "@/lib/soroban-read-coalescer" + +const CONTRACT = "CAZKRXQWXKM4UNDB5FY4XVMWDKKZJ2EFKMNFCFH3WC7SHE7RCO7HOR6L" + +function enable(on: boolean) { + if (on) { + process.env.NEXT_PUBLIC_FEATURE_PHASE_158 = "1" + process.env.FEATURE_PHASE_158 = "1" + } else { + delete process.env.NEXT_PUBLIC_FEATURE_PHASE_158 + delete process.env.FEATURE_PHASE_158 + } +} + +describe("phase-158 soroban read coalescer", () => { + beforeEach(() => { + resetReadCoalescer() + enable(true) + }) + + it("builds a stable key from contract, method, and args", () => { + const a = buildReadKey(CONTRACT, "get_config", [1, "x"]) + const b = buildReadKey(CONTRACT, "get_config", [1, "x"]) + const c = buildReadKey(CONTRACT, "get_config", [2, "x"]) + assert.equal(a, b) + assert.notEqual(a, c) + }) + + it("passes through the loader when the flag is off", async () => { + enable(false) + let calls = 0 + const loader = async () => { + calls += 1 + return 42 + } + await coalesceSorobanRead(CONTRACT, "balance", [], loader) + await coalesceSorobanRead(CONTRACT, "balance", [], loader) + assert.equal(calls, 2) + }) + + it("serves a fresh cached value without re-invoking the loader", async () => { + let calls = 0 + const loader = async () => { + calls += 1 + return calls + } + const first = await coalesceSorobanRead(CONTRACT, "get_config", [], loader) + const second = await coalesceSorobanRead(CONTRACT, "get_config", [], loader) + assert.equal(first, 1) + assert.equal(second, 1) + assert.equal(calls, 1) + assert.equal(getReadCoalescerMetrics().hits, 1) + }) + + it("coalesces concurrent identical reads into one loader call", async () => { + let calls = 0 + const loader = () => + new Promise((resolve) => { + calls += 1 + setTimeout(() => resolve(7), 10) + }) + const [a, b] = await Promise.all([ + coalesceSorobanRead(CONTRACT, "owner_of", [5], loader), + coalesceSorobanRead(CONTRACT, "owner_of", [5], loader), + ]) + assert.equal(a, 7) + assert.equal(b, 7) + assert.equal(calls, 1) + assert.equal(getReadCoalescerMetrics().coalesced, 1) + }) + + it("serves stale-on-error when enabled and a prior value exists", async () => { + await coalesceSorobanRead(CONTRACT, "balance", ["G1"], async () => 99, { ttlMs: -1 }) + const recovered = await coalesceSorobanRead( + CONTRACT, + "balance", + ["G1"], + async () => { + throw new Error("rpc down") + }, + { staleOnError: true }, + ) + assert.equal(recovered, 99) + assert.equal(getReadCoalescerMetrics().stale_served, 1) + }) + + it("exposes a rollback note", () => { + assert.match(readCoalescerRollbackNote(), /phase-158/) + }) +}) diff --git a/lib/__tests__/web-vitals-budgets.test.ts b/lib/__tests__/web-vitals-budgets.test.ts new file mode 100644 index 00000000..f3a6e99a --- /dev/null +++ b/lib/__tests__/web-vitals-budgets.test.ts @@ -0,0 +1,72 @@ +import { describe, it, beforeEach } from "vitest" +import * as assert from "node:assert/strict" +import { + DEFAULT_VITAL_BUDGETS, + evaluateVital, + getVitalsBudget, + isWebVitalsBudgetsEnabled, + summarizeVitals, + webVitalsBudgetsRollbackNote, +} from "@/lib/web-vitals-budgets" + +function enable(on: boolean) { + if (on) { + process.env.NEXT_PUBLIC_FEATURE_PHASE_160 = "1" + process.env.FEATURE_PHASE_160 = "1" + } else { + delete process.env.NEXT_PUBLIC_FEATURE_PHASE_160 + delete process.env.FEATURE_PHASE_160 + } +} + +describe("phase-160 web vitals budgets", () => { + beforeEach(() => enable(true)) + + it("resolves exact, prefixed, and default budgets", () => { + assert.equal(getVitalsBudget("/explore").CLS, 0.05) + assert.deepEqual(getVitalsBudget("/world/123"), getVitalsBudget("/world/[collection_id]")) + assert.deepEqual(getVitalsBudget("/unknown"), DEFAULT_VITAL_BUDGETS) + }) + + it("prefers the longest matching prefix", () => { + assert.equal(getVitalsBudget("/explore/feed").LCP, 2500) + }) + + it("rates a sample as good, needs-improvement, or poor", () => { + assert.equal(evaluateVital("/explore", "LCP", 2000).rating, "good") + assert.equal(evaluateVital("/explore", "LCP", 4000).rating, "needs-improvement") + assert.equal(evaluateVital("/explore", "LCP", 9000).rating, "poor") + }) + + it("flags a budget breach", () => { + const pass = evaluateVital("/explore", "CLS", 0.04) + const fail = evaluateVital("/explore", "CLS", 0.2) + assert.equal(pass.withinBudget, true) + assert.equal(fail.withinBudget, false) + }) + + it("summarizes the worst sample per vital and lists failures", () => { + const summary = summarizeVitals("/explore", [ + { name: "LCP", value: 1800 }, + { name: "LCP", value: 5200 }, + { name: "CLS", value: 0.02 }, + { name: "INP", value: 150 }, + ]) + assert.equal(summary.worst.LCP!.value, 5200) + assert.deepEqual(summary.failures, ["LCP"]) + assert.equal(summary.withinBudget, false) + }) + + it("treats an empty sample set as within budget", () => { + const summary = summarizeVitals("/forge", []) + assert.equal(summary.withinBudget, true) + assert.deepEqual(summary.failures, []) + }) + + it("reflects the phase-160 flag and exposes a rollback note", () => { + assert.equal(isWebVitalsBudgetsEnabled(), true) + enable(false) + assert.equal(isWebVitalsBudgetsEnabled(), false) + assert.match(webVitalsBudgetsRollbackNote(), /phase-160/) + }) +}) diff --git a/lib/feature-flags.ts b/lib/feature-flags.ts index c53e3df3..7dee6af8 100644 --- a/lib/feature-flags.ts +++ b/lib/feature-flags.ts @@ -43,6 +43,15 @@ * - phase-145: gauge voting for emissions with ve-weight directing quest budgets * - phase-154: outbound webhook subscriptions — signed event delivery to * third-party endpoints (partial slice of spike 664 / issue #527) + * - phase-158: Soroban read coalescing — in-flight dedupe + TTL cache for + * repeated simulations (partial slice of spike 655 / issue #517) + * - phase-159: batched owner-key loader — DataLoader-style per-tick coalescing + * to remove N+1 owner reads (partial slice of spike 656 / issue #518) + * - phase-160: Web Vitals budgets — per-route LCP/INP/CLS budgets with + * pass/fail evaluation (partial slice of spike 657 / issue #520) + * - phase-161: typed contract-error taxonomy — code-stable PhaseError + * descriptors with retryable hints and message keys (partial slice of spike + * 660 / issue #523) */ export type PhaseFeatureFlag = @@ -105,7 +114,11 @@ export type PhaseFeatureFlag = | "phase-143" | "phase-144" | "phase-145" - | "phase-154"; + | "phase-154" + | "phase-158" + | "phase-159" + | "phase-160" + | "phase-161"; const FLAG_ENV_MAP: Record = { "phase-66": ["NEXT_PUBLIC_FEATURE_PHASE_66", "FEATURE_PHASE_66"], @@ -167,6 +180,10 @@ const FLAG_ENV_MAP: Record = { "phase-144": ["NEXT_PUBLIC_FEATURE_PHASE_144", "FEATURE_PHASE_144"], "phase-145": ["NEXT_PUBLIC_FEATURE_PHASE_145", "FEATURE_PHASE_145"], "phase-154": ["NEXT_PUBLIC_FEATURE_PHASE_154", "FEATURE_PHASE_154"], + "phase-158": ["NEXT_PUBLIC_FEATURE_PHASE_158", "FEATURE_PHASE_158"], + "phase-159": ["NEXT_PUBLIC_FEATURE_PHASE_159", "FEATURE_PHASE_159"], + "phase-160": ["NEXT_PUBLIC_FEATURE_PHASE_160", "FEATURE_PHASE_160"], + "phase-161": ["NEXT_PUBLIC_FEATURE_PHASE_161", "FEATURE_PHASE_161"], }; function isTruthy(v: string | undefined): boolean { @@ -261,6 +278,10 @@ export function getEnabledFeatureFlags(): PhaseFeatureFlag[] { "phase-144", "phase-145", "phase-154", + "phase-158", + "phase-159", + "phase-160", + "phase-161", ]; // Filtering is evaluated at call time, allowing runtime test overrides. return all.filter(isFeatureEnabled) diff --git a/lib/owner-batch-loader.ts b/lib/owner-batch-loader.ts new file mode 100644 index 00000000..43c3ad33 --- /dev/null +++ b/lib/owner-batch-loader.ts @@ -0,0 +1,141 @@ +/** + * Batched key loader — partial slice of spike 656 (issue #518). + * + * Explore/world/profile reads issue one `owner_of(id)` simulation per token + * (N+1). This is a minimal DataLoader-style primitive: callers enqueue keys + * during a tick and the loader coalesces them into a single `batchFn` call, + * deduplicates repeated keys, caches resolved values, and chunks by + * `maxBatchSize`. Wiring a route's existing per-token loop through this loader + * replaces N round-trips with ceil(N / maxBatchSize). + * + * Scope: the coalescing primitive + one batch owner loader. It does not yet + * push collection filters into an index or add sidecar-level `mget` APIs; those + * remain spike 656 follow-ups. + * + * Feature flag: phase-159. Rollback: unset → callers use their unbatched path; + * only in-memory caches are dropped (no persisted state). + */ + +import { isFeatureEnabled } from "@/lib/feature-flags" + +export function isOwnerBatchLoaderEnabled(): boolean { + return isFeatureEnabled("phase-159") +} + +type Pending = { + promise: Promise + resolve: (value: V) => void + reject: (error: unknown) => void +} + +export type BatchLoader = { + load: (key: K) => Promise + loadMany: (keys: readonly K[]) => Promise> + clear: (key: K) => void + clearAll: () => void +} + +export type BatchLoaderOptions = { + /** Maximum keys per `batchFn` invocation. Default: 100. */ + maxBatchSize?: number + /** Memoize resolved values across ticks. Default: true. */ + cache?: boolean +} + +/** + * Create a per-tick coalescing loader over `batchFn`, which receives a batch of + * keys and must return a result array aligned to the input order (an `Error` + * at a position rejects that key's promise). + */ +export function createBatchLoader( + batchFn: (keys: readonly K[]) => Promise>, + options: BatchLoaderOptions = {}, +): BatchLoader { + const maxBatchSize = Math.max(1, options.maxBatchSize ?? 100) + const useCache = options.cache ?? true + + const cache = new Map() + const pending = new Map>() + let queue: K[] = [] + let scheduled = false + + function enqueueDispatch(): void { + if (scheduled) return + scheduled = true + void Promise.resolve().then(dispatch) + } + + async function runBatch(chunk: K[]): Promise { + try { + const results = await batchFn(chunk) + chunk.forEach((key, index) => { + const entry = pending.get(key) + pending.delete(key) + if (!entry) return + const result = results[index] + if (result instanceof Error) { + entry.reject(result) + } else { + if (useCache) cache.set(key, result as V) + entry.resolve(result as V) + } + }) + } catch (error) { + for (const key of chunk) { + const entry = pending.get(key) + pending.delete(key) + entry?.reject(error) + } + } + } + + async function dispatch(): Promise { + scheduled = false + const keys = queue + queue = [] + for (let i = 0; i < keys.length; i += maxBatchSize) { + await runBatch(keys.slice(i, i + maxBatchSize)) + } + } + + function load(key: K): Promise { + if (useCache && cache.has(key)) { + return Promise.resolve(cache.get(key) as V) + } + const existing = pending.get(key) + if (existing) return existing.promise + + let resolve!: (value: V) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + pending.set(key, { promise, resolve, reject }) + queue.push(key) + enqueueDispatch() + return promise + } + + async function loadMany(keys: readonly K[]): Promise> { + const settled = await Promise.allSettled(keys.map((key) => load(key))) + return settled.map((result) => + result.status === "fulfilled" ? result.value : (result.reason as Error), + ) + } + + return { + load, + loadMany, + clear: (key: K) => { + cache.delete(key) + }, + clearAll: () => { + cache.clear() + }, + } +} + +export function ownerBatchLoaderRollbackNote(): string { + return "Rollback phase-159: unset NEXT_PUBLIC_FEATURE_PHASE_159 / FEATURE_PHASE_159 or set 0/false and restart. No persisted state." +} diff --git a/lib/phase-error-taxonomy.ts b/lib/phase-error-taxonomy.ts new file mode 100644 index 00000000..7b0909e7 --- /dev/null +++ b/lib/phase-error-taxonomy.ts @@ -0,0 +1,119 @@ +/** + * Typed contract-error taxonomy — partial slice of spike 660 (issue #523). + * + * The SDK-facing error surface is currently the free-form + * `parsePhaseContractErrorCode` / `humanizePhaseHostErrorMessage` pair. This + * adds a typed, code-stable descriptor layer that mirrors the contract's + * `PhaseError` discriminants 1:1 (with a stable `userMessageKey` and a + * `retryable` hint), so a public consumer can branch on an enum instead of + * matching strings. + * + * Scope: the contract `PhaseError` taxonomy + typed lookup. Horizon result + * codes, JSON-RPC transport errors, retry/circuit policy, and dual-CJS/ESM + * packaging remain spike 660 follow-ups. + * + * Feature flag: phase-161. Rollback: unset → callers keep using the existing + * string helpers; this module stays inert (no persisted state). + */ + +import { isFeatureEnabled } from "@/lib/feature-flags" +import { parsePhaseContractErrorCode } from "@/lib/phase-host-error" + +export function isPhaseErrorTaxonomyEnabled(): boolean { + return isFeatureEnabled("phase-161") +} + +export type PhaseErrorDescriptor = { + code: number + discriminant: string + /** Transient failures a caller may safely retry. */ + retryable: boolean + /** Stable key into `lib/phase-copy.ts` for the user-facing message. */ + userMessageKey: string +} + +const DISCRIMINANTS: Record = { + 1: "AlreadyPhased", + 2: "InsufficientBalance", + 3: "TransferFailed", + 4: "UnauthorizedToken", + 5: "SettlementFailed", + 6: "InvalidInvoice", + 7: "InvalidCollection", + 8: "CreatorAlreadyHasCollection", + 9: "AmountBelowCollectionPrice", + 10: "InvalidCollectionMetadata", + 11: "NftNotFound", + 12: "NotNftOwner", + 13: "SelfTransfer", + 14: "UnauthorizedApprover", + 15: "NotApproved", + 16: "InvalidApproval", + 17: "InvalidOwnerTokenList", + 18: "CoinAlreadyRegistered", + 19: "CoinNotRegistered", + 20: "CoinNotVerified", + 21: "RewardAlreadyClaimed", + 22: "InvalidSplit", + 23: "TipBelowMinimum", + 24: "SelfDeal", + 25: "TipReplay", + 26: "EndowmentNotFound", + 27: "NothingToClaim", + 28: "EndowmentLocked", + 29: "EndowmentClosed", + 30: "DisputeOpen", + 31: "BondNotFound", + 32: "BondNotActive", + 33: "BondChallengeOpen", + 34: "BondLocked", + 35: "NotAuthorized", + 36: "InvalidAmount", + 37: "BondAlreadyPosted", + 38: "NoOpenChallenge", + 39: "InvalidSchedule", +} + +/** Discriminants that are transient (retry-safe) rather than caller errors. */ +const RETRYABLE_CODES = new Set([3, 5]) + +function userMessageKey(discriminant: string): string { + const snake = discriminant.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase() + return `phase.error.${snake}` +} + +export const PHASE_ERROR_TAXONOMY: Record = Object.fromEntries( + Object.entries(DISCRIMINANTS).map(([code, discriminant]) => { + const numeric = Number(code) + return [ + numeric, + { + code: numeric, + discriminant, + retryable: RETRYABLE_CODES.has(numeric), + userMessageKey: userMessageKey(discriminant), + } satisfies PhaseErrorDescriptor, + ] + }), +) + +export function describePhaseErrorCode(code: number): PhaseErrorDescriptor | null { + return PHASE_ERROR_TAXONOMY[code] ?? null +} + +/** + * Parse a raw host/RPC error into a typed descriptor. Returns `null` when the + * input carries no recognizable PHASE contract code, so callers fall back to + * their generic error path. + */ +export function mapPhaseContractError(raw: unknown): PhaseErrorDescriptor | null { + const code = parsePhaseContractErrorCode(raw) + if (code == null) return null + return describePhaseErrorCode(code) +} + +export const PHASE_ERROR_CODES: readonly number[] = Object.keys(PHASE_ERROR_TAXONOMY).map(Number) + +export function phaseErrorTaxonomyRollbackNote(): string { + return "Rollback phase-161: unset NEXT_PUBLIC_FEATURE_PHASE_161 / FEATURE_PHASE_161 or set 0/false and restart. No persisted state." +} diff --git a/lib/soroban-read-coalescer.ts b/lib/soroban-read-coalescer.ts new file mode 100644 index 00000000..18930eb4 --- /dev/null +++ b/lib/soroban-read-coalescer.ts @@ -0,0 +1,147 @@ +/** + * Soroban read coalescing — partial slice of spike 655 (issue #517). + * + * `lib/phase-protocol.ts` builds a fresh `simulateTransaction` per logical + * read, so a page that asks for the same `get_config` / `balance` / `owner_of` + * (or the same value twice within one request) produces duplicate JSON-RPC + * round-trips and multiplies retry storms on 429/502/503/504. This module adds + * a process-local coalescer that (a) shares one in-flight promise for identical + * reads and (b) memoizes the result for a short TTL. + * + * Scope: read-shape de-duplication only. It does not batch distinct reads into + * one JSON-RPC array (Soroban has no server-side multi-simulate) and does not + * touch write preparation, whose footprint must stay ledger-fresh. + * + * Feature flag: phase-158. Rollback: unset → callers fall back to uncoalesced + * reads; only in-memory entries are dropped (no persisted state). + */ + +import { isFeatureEnabled } from "@/lib/feature-flags" + +export type SorobanReadArgs = readonly unknown[] + +export type ReadCoalescerMetrics = { + hits: number + misses: number + coalesced: number + stale_served: number +} + +const DEFAULT_TTL_MS = 5_000 +const MAX_ENTRIES = 500 + +type Entry = { value: unknown; expiresAt: number } + +const cache = new Map() +const inflight = new Map>() +const metrics: ReadCoalescerMetrics = { hits: 0, misses: 0, coalesced: 0, stale_served: 0 } + +export function isReadCoalescingEnabled(): boolean { + return isFeatureEnabled("phase-158") +} + +/** Stable key for a read, sharing one entry across identical (method, args). */ +export function buildReadKey(contractId: string, method: string, args: SorobanReadArgs): string { + return `${contractId}::${method}::${JSON.stringify(args)}` +} + +function getFresh(key: string, now: number): Entry | null { + const entry = cache.get(key) + if (!entry) return null + if (entry.expiresAt <= now) return null + return entry +} + +function getStale(key: string): unknown | undefined { + const entry = cache.get(key) + return entry ? entry.value : undefined +} + +function put(key: string, value: unknown, ttlMs: number, now: number): void { + cache.delete(key) + cache.set(key, { value, expiresAt: now + ttlMs }) + while (cache.size > MAX_ENTRIES) { + const oldest = cache.keys().next().value + if (oldest === undefined) break + cache.delete(oldest) + } +} + +export function getReadCoalescerMetrics(): ReadCoalescerMetrics { + return { ...metrics } +} + +export function resetReadCoalescer(): void { + cache.clear() + inflight.clear() + metrics.hits = 0 + metrics.misses = 0 + metrics.coalesced = 0 + metrics.stale_served = 0 +} + +export type CoalesceOptions = { + ttlMs?: number + /** Serve the last cached value if `loader` rejects (stale-on-error). */ + staleOnError?: boolean +} + +/** + * Resolve a Soroban read through the coalescer. Concurrent callers with the + * same key await a single `loader` invocation; a fresh cached value short- + * circuits the loader entirely. When disabled, every call hits `loader`. + */ +export async function coalesceSorobanRead( + contractId: string, + method: string, + args: SorobanReadArgs, + loader: () => Promise, + options: CoalesceOptions = {}, +): Promise { + if (!isReadCoalescingEnabled()) { + return loader() + } + + const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS + const key = buildReadKey(contractId, method, args) + const now = Date.now() + + const fresh = getFresh(key, now) + if (fresh) { + metrics.hits += 1 + return fresh.value as T + } + + const pending = inflight.get(key) + if (pending) { + metrics.coalesced += 1 + return pending as Promise + } + + metrics.misses += 1 + const promise = (async () => { + try { + const value = await loader() + put(key, value, ttlMs, Date.now()) + return value + } catch (error) { + if (options.staleOnError) { + const stale = getStale(key) + if (stale !== undefined) { + metrics.stale_served += 1 + return stale as T + } + } + throw error + } finally { + inflight.delete(key) + } + })() + + inflight.set(key, promise) + return promise +} + +export function readCoalescerRollbackNote(): string { + return "Rollback phase-158: unset NEXT_PUBLIC_FEATURE_PHASE_158 / FEATURE_PHASE_158 or set 0/false and restart. No persisted state." +} diff --git a/lib/web-vitals-budgets.ts b/lib/web-vitals-budgets.ts new file mode 100644 index 00000000..2d5be21e --- /dev/null +++ b/lib/web-vitals-budgets.ts @@ -0,0 +1,124 @@ +/** + * Web Vitals budgets — partial slice of spike 657 (issue #520). + * + * The layout ships a page-view beacon only; there is no per-route budget and no + * assertion that a route regressed. This module defines the client-side budget + * table and the pure evaluation used to rate a sample and decide pass/fail, so + * a `web-vitals` reporter (or a CI Lighthouse run) has one source of truth. + * + * Scope: the budget table + evaluation + rating. Wiring a live `onCLS/onINP/onLCP` + * collector, RUM transport, CSP allow-listing, and PR-blocking gates are spike + * 657 follow-ups. + * + * Feature flag: phase-160. Rollback: unset → no vitals are evaluated/reported; + * the table stays inert (no persisted state). + */ + +import { isFeatureEnabled } from "@/lib/feature-flags" + +export type VitalName = "LCP" | "INP" | "CLS" +export type VitalRating = "good" | "needs-improvement" | "poor" + +export type VitalBudgetMap = Record + +/** Budgets mirror the Core Web Vitals "good" thresholds, tightened per route. */ +export const DEFAULT_VITAL_BUDGETS: VitalBudgetMap = { LCP: 2500, INP: 200, CLS: 0.1 } + +export const WEB_VITALS_ROUTE_BUDGETS: Record = { + "/": { LCP: 2500, INP: 200, CLS: 0.1 }, + "/explore": { LCP: 2500, INP: 200, CLS: 0.05 }, + "/forge": { LCP: 3000, INP: 300, CLS: 0.1 }, + "/chamber": { LCP: 3000, INP: 300, CLS: 0.1 }, + "/world/[collection_id]": { LCP: 3000, INP: 300, CLS: 0.1 }, + "/profile/[wallet]": { LCP: 3000, INP: 300, CLS: 0.1 }, +} + +export function isWebVitalsBudgetsEnabled(): boolean { + return isFeatureEnabled("phase-160") +} + +/** + * Segment-wise prefix match where a `[param]` pattern segment matches any + * single path segment (e.g. `/world/[collection_id]` matches `/world/123`). + */ +function matchesRoute(pattern: string, path: string): boolean { + const patternSegments = pattern.split("/").filter(Boolean) + const pathSegments = path.split("/").filter(Boolean) + if (patternSegments.length > pathSegments.length) return false + return patternSegments.every((segment, index) => { + const isDynamic = segment.startsWith("[") && segment.endsWith("]") + return isDynamic || segment === pathSegments[index] + }) +} + +/** Resolve a route's budget, preferring the most specific matching prefix. */ +export function getVitalsBudget(route: string): VitalBudgetMap { + if (WEB_VITALS_ROUTE_BUDGETS[route]) return WEB_VITALS_ROUTE_BUDGETS[route] + let best: string | null = null + for (const candidate of Object.keys(WEB_VITALS_ROUTE_BUDGETS)) { + if (matchesRoute(candidate, route) && (best === null || candidate.length > best.length)) { + best = candidate + } + } + return best ? WEB_VITALS_ROUTE_BUDGETS[best] : DEFAULT_VITAL_BUDGETS +} + +export type VitalEvaluation = { + route: string + name: VitalName + value: number + budget: number + withinBudget: boolean + rating: VitalRating +} + +/** Rate a single vital sample against its route budget (good ≤ budget ≤ poor). */ +export function evaluateVital(route: string, name: VitalName, value: number): VitalEvaluation { + const budget = getVitalsBudget(route)[name] + const withinBudget = value <= budget + let rating: VitalRating + if (withinBudget) rating = "good" + else if (value <= budget * 2) rating = "needs-improvement" + else rating = "poor" + return { route, name, value, budget, withinBudget, rating } +} + +export type VitalsSummary = { + route: string + withinBudget: boolean + worst: Record + failures: VitalName[] +} + +/** + * Summarize a batch of samples for one route, keeping the worst reading per + * vital (the value a budget is actually judged against) and listing failures. + */ +export function summarizeVitals( + route: string, + samples: ReadonlyArray<{ name: VitalName; value: number }>, +): VitalsSummary { + const worst: Record = { LCP: null, INP: null, CLS: null } + for (const sample of samples) { + const evaluation = evaluateVital(route, sample.name, sample.value) + const current = worst[sample.name] + if (!current || evaluation.value > current.value) { + worst[sample.name] = evaluation + } + } + const failures = (Object.keys(worst) as VitalName[]).filter( + (name) => worst[name] !== null && !worst[name]!.withinBudget, + ) + return { route, withinBudget: failures.length === 0, worst, failures } +} + +/** Closed event names for whatever reporter consumes these evaluations (no PII). */ +export const WEB_VITALS_EVENT_NAMES = [ + "web_vitals.sample", + "web_vitals.budget_exceeded", +] as const +export type WebVitalsEventName = (typeof WEB_VITALS_EVENT_NAMES)[number] + +export function webVitalsBudgetsRollbackNote(): string { + return "Rollback phase-160: unset NEXT_PUBLIC_FEATURE_PHASE_160 / FEATURE_PHASE_160 or set 0/false and restart. No persisted state." +}