Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions PROJECT_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions docs/TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
```

Expand Down
95 changes: 95 additions & 0 deletions lib/__tests__/owner-batch-loader.test.ts
Original file line number Diff line number Diff line change
@@ -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<number, string>(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<number, string>(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<number, number>(
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<number, number>(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<number, string>(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/)
})
})
64 changes: 64 additions & 0 deletions lib/__tests__/phase-error-taxonomy.test.ts
Original file line number Diff line number Diff line change
@@ -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/)
})
})
98 changes: 98 additions & 0 deletions lib/__tests__/soroban-read-coalescer.test.ts
Original file line number Diff line number Diff line change
@@ -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<number>((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/)
})
})
72 changes: 72 additions & 0 deletions lib/__tests__/web-vitals-budgets.test.ts
Original file line number Diff line number Diff line change
@@ -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/)
})
})
Loading