diff --git a/AGENTS.md b/AGENTS.md index cd81fe173..28cba219d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,9 +40,19 @@ packages/synapse-sdk/src/ **Data flow**: Client signs for FWSS → Curio HTTP API → PDPVerifier contract → FWSS callback → Payments contract. +## Monorepo Package Structure + +**synapse-core** (`packages/synapse-core/` `@filoz/synapse-core`): Stateless, low-level library of pure functions. Includes utilities, types, chain definitions, and blockchain interactions using viem. Shared foundation for both synapse-react and synapse-sdk. + +**synapse-react** (`packages/synapse-react/`): React hooks wrapping synapse-core for React apps. Uses wagmi + @tanstack/react-query. Does NOT depend on synapse-sdk. + +**synapse-sdk** (`packages/synapse-sdk/` `@filoz/synapse-sdk`): Stateful service classes. DX-focused golden path (_don't make me care about your junk_). + +**synapse-playground** (`apps/synapse-playground/`): Vite-based React demo app using synapse-react hooks. Dev server runs at localhost:5173. + ## Development -**Monorepo**: pnpm workspace, packages in `packages/*`, examples in `examples/*` +**Monorepo**: pnpm workspace, packages in `packages/*`, apps in `apps/*`, examples in `examples/*` **Commands**: @@ -53,6 +63,47 @@ packages/synapse-sdk/src/ **Tests**: Mocha + Chai, `src/test/`, run with `pnpm test` +When working on a single package, run the tests for that package only. For +example: + +```bash +pnpm -F ./packages/synapse-core test +``` + +**Test assertions**: Use `assert.deepStrictEqual` for comparing objects instead of multiple `assert.equal` calls: + +```typescript +// Bad +assert.equal(result.cdnEgressQuota, 1000000n) +assert.equal(result.cacheMissEgressQuota, 500000n) + +// Good +assert.deepStrictEqual(result, { cdnEgressQuota: 1000000n, cacheMissEgressQuota: 500000n }) +``` + +**Parameterized tests**: When testing multiple similar cases, use a single loop with descriptive names instead of multiple `it()` blocks with repeated assertions. One assertion per test, with names describing the specific input condition: + +```typescript +// Bad - multiple assertions in one test +it('should throw error when input is invalid', () => { + assert.throws(() => validate(null), isError) + assert.throws(() => validate('string'), isError) + assert.throws(() => validate(123), isError) +}) + +// Good - parameterized tests with descriptive names +const invalidInputCases: Record = { + 'input is null': null, + 'input is a string': 'string', + 'input is a number': 123, +} +for (const [name, input] of Object.entries(invalidInputCases)) { + it(`should throw error when ${name}`, () => { + assert.throws(() => validate(input), isError) + }) +} +``` + ## Biome Linting (Critical) **NO** `!` operator → use `?.` or explicit checks diff --git a/apps/synapse-playground/src/components/warm-storage/cdn-details.tsx b/apps/synapse-playground/src/components/warm-storage/cdn-details.tsx new file mode 100644 index 000000000..d0199d82c --- /dev/null +++ b/apps/synapse-playground/src/components/warm-storage/cdn-details.tsx @@ -0,0 +1,27 @@ +import { useEgressQuota } from '@filoz/synapse-react' +import { Globe } from 'lucide-react' +import { formatBytes } from '@/lib/utils.ts' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip.tsx' + +export function CdnDetails({ dataSetId }: { dataSetId: bigint }) { + const { data: egressQuota } = useEgressQuota({ dataSetId }) + + return ( + + + + + + +

This data set is using CDN

+
+
+ {egressQuota && ( + <> + Egress remaining: {formatBytes(egressQuota.cdnEgressQuota)} delivery{' · '} + {formatBytes(egressQuota.cacheMissEgressQuota)} cache-miss + + )} +
+ ) +} diff --git a/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx b/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx index 8354bb2f3..23fd67a74 100644 --- a/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx +++ b/apps/synapse-playground/src/components/warm-storage/data-sets-section.tsx @@ -1,6 +1,6 @@ import type { DataSetWithPieces, UseProvidersResult } from '@filoz/synapse-react' import { useDeletePiece } from '@filoz/synapse-react' -import { CloudDownload, FileAudio, FileCode, FilePlay, FileText, Globe, Info, Trash } from 'lucide-react' +import { CloudDownload, FileAudio, FileCode, FilePlay, FileText, Info, Trash } from 'lucide-react' import { useState } from 'react' import { toast } from 'sonner' import { toastError } from '@/lib/utils.ts' @@ -12,6 +12,7 @@ import { Button } from '../ui/button.tsx' import { Item, ItemActions, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '../ui/item.tsx' import { Skeleton } from '../ui/skeleton.tsx' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip.tsx' +import { CdnDetails } from './cdn-details.tsx' import { CreateDataSetDialog } from './create-data-set.tsx' export function DataSetsSection({ @@ -109,16 +110,7 @@ export function DataSetsSection({ ))} - {dataSet.cdn && ( - - - - - -

This data set is using CDN

-
-
- )} + {dataSet.cdn && }

{dataSet.pieces.map((piece) => ( diff --git a/apps/synapse-playground/src/lib/utils.ts b/apps/synapse-playground/src/lib/utils.ts index 30c807742..cb47e1616 100644 --- a/apps/synapse-playground/src/lib/utils.ts +++ b/apps/synapse-playground/src/lib/utils.ts @@ -41,3 +41,38 @@ export function toastError(error: Error, id: string, title?: string) { id, }) } + +const UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'] + +/** + * Formats a byte value into a human-readable string using binary units (KiB, MiB, etc.). + * Supports both `number` and `bigint` inputs and preserves the sign for negative values. + * + * @param bytes - The number of bytes to format. Can be a `number` or `bigint`, positive or negative. + * @returns A human-readable string representation of the byte value (e.g., `"1.23 MiB"`). + * @example + * ```ts twoslash + * import { formatBytes } from 'filsnap/utils' + * formatBytes(1024) // "1 KiB" + * formatBytes(1048576) // "1 MiB" + * ``` + */ +export function formatBytes(bytes: bigint | number): string { + const isNegative = bytes < 0 + let num = isNegative ? -BigInt(bytes) : BigInt(bytes) + if (num === 0n) return '0 B' + + const kBig = 1024n + let i = 0 + while (num >= kBig && i < UNITS.length - 1) { + num /= kBig + i++ + } + + const sign = isNegative ? '-' : '' + const value = Number(num) + .toFixed(2) + .replace(/\.?0+$/, '') + const unit = UNITS[i] + return `${sign}${value} ${unit}` +} diff --git a/packages/synapse-core/package.json b/packages/synapse-core/package.json index 942ba57ca..a85d6e597 100644 --- a/packages/synapse-core/package.json +++ b/packages/synapse-core/package.json @@ -81,6 +81,10 @@ "types": "./dist/src/errors/index.d.ts", "default": "./dist/src/errors/index.js" }, + "./filbeam": { + "types": "./dist/src/filbeam/index.d.ts", + "default": "./dist/src/filbeam/index.js" + }, "./piece": { "types": "./dist/src/piece.d.ts", "default": "./dist/src/piece.js" @@ -136,6 +140,9 @@ "errors": [ "./dist/src/errors/index" ], + "filbeam": [ + "./dist/src/filbeam/index" + ], "piece": [ "./dist/src/piece" ], diff --git a/packages/synapse-core/src/chains.ts b/packages/synapse-core/src/chains.ts index a52fd6c85..574c468e2 100644 --- a/packages/synapse-core/src/chains.ts +++ b/packages/synapse-core/src/chains.ts @@ -61,6 +61,7 @@ export interface Chain extends ViemChain { } filbeam: { retrievalDomain: string + statsBaseUrl: string } | null } @@ -142,6 +143,7 @@ export const mainnet: Chain = { }, filbeam: { retrievalDomain: 'filbeam.io', + statsBaseUrl: 'https://stats.filbeam.com', }, /** * Filecoin Mainnet genesis: August 24, 2020 22:00:00 UTC @@ -227,6 +229,7 @@ export const calibration: Chain = { }, filbeam: { retrievalDomain: 'calibration.filbeam.io', + statsBaseUrl: 'https://calibration.stats.filbeam.com', }, testnet: true, /** diff --git a/packages/synapse-core/src/errors/base.ts b/packages/synapse-core/src/errors/base.ts index b4f512195..869ffd85e 100644 --- a/packages/synapse-core/src/errors/base.ts +++ b/packages/synapse-core/src/errors/base.ts @@ -24,6 +24,7 @@ export class SynapseError extends Error { shortMessage: string constructor(message: string, options?: SynapseErrorOptions) { + // Use explicit details if provided, otherwise fall back to cause.message const details = options?.details ? options.details : options?.cause instanceof Error diff --git a/packages/synapse-core/src/errors/filbeam.ts b/packages/synapse-core/src/errors/filbeam.ts new file mode 100644 index 000000000..68b96b298 --- /dev/null +++ b/packages/synapse-core/src/errors/filbeam.ts @@ -0,0 +1,9 @@ +import { isSynapseError, SynapseError } from './base.ts' + +export class GetDataSetStatsError extends SynapseError { + override name: 'GetDataSetStatsError' = 'GetDataSetStatsError' + + static override is(value: unknown): value is GetDataSetStatsError { + return isSynapseError(value) && value.name === 'GetDataSetStatsError' + } +} diff --git a/packages/synapse-core/src/errors/index.ts b/packages/synapse-core/src/errors/index.ts index 0fc26f89e..8c7239154 100644 --- a/packages/synapse-core/src/errors/index.ts +++ b/packages/synapse-core/src/errors/index.ts @@ -11,5 +11,6 @@ export * from './base.ts' export * from './chains.ts' export * from './erc20.ts' +export * from './filbeam.ts' export * from './pay.ts' export * from './pdp.ts' diff --git a/packages/synapse-core/src/filbeam/index.ts b/packages/synapse-core/src/filbeam/index.ts new file mode 100644 index 000000000..9e2963c47 --- /dev/null +++ b/packages/synapse-core/src/filbeam/index.ts @@ -0,0 +1,11 @@ +/** + * FilBeam API + * + * @example + * ```ts + * import * as FilBeam from '@filoz/synapse-core/filbeam' + * ``` + * + * @module filbeam + */ +export * from './stats.ts' diff --git a/packages/synapse-core/src/filbeam/stats.ts b/packages/synapse-core/src/filbeam/stats.ts new file mode 100644 index 000000000..2764e0c57 --- /dev/null +++ b/packages/synapse-core/src/filbeam/stats.ts @@ -0,0 +1,134 @@ +/** + * FilBeam Stats API + * + * @example + * ```ts + * import { getDataSetStats } from '@filoz/synapse-core/filbeam' + * ``` + * + * @module filbeam + */ + +import { HttpError, request } from 'iso-web/http' +import type { Chain } from '../chains.ts' +import { GetDataSetStatsError } from '../errors/filbeam.ts' + +/** + * Data set statistics from FilBeam. + * + * These quotas represent the remaining pay-per-byte allocation available for data retrieval + * through FilBeam's trusted measurement layer. The values decrease as data is served and + * represent how many bytes can still be retrieved before needing to add more credits. + */ +export interface DataSetStats { + /** The remaining quota for all requests served by FilBeam (both cache-hit and cache-miss) in bytes */ + cdnEgressQuota: bigint + /** The remaining quota for cache-miss requests served by the Storage Provider in bytes */ + cacheMissEgressQuota: bigint +} + +export interface GetDataSetStatsOptions { + /** The chain configuration containing FilBeam stats URL */ + chain: Chain + /** The data set ID to query */ + dataSetId: bigint | number | string + /** Optional override for request.json.get (for testing) */ + requestGetJson?: typeof request.json.get +} + +/** + * Retrieves remaining pay-per-byte statistics for a specific data set from FilBeam. + * + * Fetches the remaining CDN and cache miss egress quotas for a data set. These quotas + * track how many bytes can still be retrieved through FilBeam's trusted measurement layer + * before needing to add more credits: + * + * - **CDN Egress Quota**: Remaining bytes for all requests served by FilBeam (both cache-hit and cache-miss) + * - **Cache Miss Egress Quota**: Remaining bytes for cache-miss requests served by the Storage Provider + * + * @param options - The options for fetching data set stats + * @returns A promise that resolves to the data set statistics with remaining quotas as BigInt values + * + * @throws {GetDataSetStatsError} If the chain doesn't support FilBeam, data set is not found, the API returns an invalid response, or network errors occur + * + * @example + * ```typescript + * import { mainnet } from '@filoz/synapse-core/chains' + * + * const stats = await getDataSetStats({ chain: mainnet, dataSetId: 12345n }) + * console.log(`Remaining CDN Egress: ${stats.cdnEgressQuota} bytes`) + * console.log(`Remaining Cache Miss: ${stats.cacheMissEgressQuota} bytes`) + * ``` + */ +export async function getDataSetStats(options: GetDataSetStatsOptions): Promise { + if (!options.chain.filbeam) { + throw new GetDataSetStatsError(`Chain ${options.chain.id} (${options.chain.name}) does not support FilBeam`) + } + const baseUrl = options.chain.filbeam.statsBaseUrl + const url = `${baseUrl}/data-set/${options.dataSetId}` + const requestGetJson = options.requestGetJson ?? request.json.get + + const response = await requestGetJson(url) + + if (response.error) { + if (HttpError.is(response.error)) { + const status = response.error.response.status + if (status === 404) { + throw new GetDataSetStatsError(`Data set not found: ${options.dataSetId}`, { + cause: response.error, + }) + } + const errorText = await response.error.response.text().catch(() => 'Unknown error') + throw new GetDataSetStatsError(`Failed to fetch data set stats`, { + details: `HTTP ${status} ${response.error.response.statusText}: ${errorText}`, + cause: response.error, + }) + } + throw new GetDataSetStatsError('Unexpected error', { cause: response.error }) + } + + return validateStatsResponse(response.result) +} + +/** + * Validates that a string can be converted to a valid BigInt + */ +function parseBigInt(value: string, fieldName: string): bigint { + // Check if the string is a valid integer format (optional minus sign followed by digits) + if (!/^-?\d+$/.test(value)) { + throw new GetDataSetStatsError('Invalid response format', { + details: `${fieldName} is not a valid integer: "${value}"`, + }) + } + return BigInt(value) +} + +/** + * Validates the response from FilBeam stats API and returns DataSetStats + */ +export function validateStatsResponse(data: unknown): DataSetStats { + if (typeof data !== 'object' || data === null) { + throw new GetDataSetStatsError('Invalid response format', { + details: 'Response is not an object', + }) + } + + const response = data as Record + + if (typeof response.cdnEgressQuota !== 'string') { + throw new GetDataSetStatsError('Invalid response format', { + details: 'cdnEgressQuota must be a string', + }) + } + + if (typeof response.cacheMissEgressQuota !== 'string') { + throw new GetDataSetStatsError('Invalid response format', { + details: 'cacheMissEgressQuota must be a string', + }) + } + + return { + cdnEgressQuota: parseBigInt(response.cdnEgressQuota, 'cdnEgressQuota'), + cacheMissEgressQuota: parseBigInt(response.cacheMissEgressQuota, 'cacheMissEgressQuota'), + } +} diff --git a/packages/synapse-core/test/filbeam.test.ts b/packages/synapse-core/test/filbeam.test.ts new file mode 100644 index 000000000..ce0bddb68 --- /dev/null +++ b/packages/synapse-core/test/filbeam.test.ts @@ -0,0 +1,181 @@ +import assert from 'assert' +import { HttpError, type request } from 'iso-web/http' +import { calibration, devnet, mainnet } from '../src/chains.ts' +import { getDataSetStats, validateStatsResponse } from '../src/filbeam/stats.ts' + +type MockRequestGetJson = typeof request.json.get + +function isInvalidResponseFormatError(err: unknown): boolean { + if (!(err instanceof Error)) return false + if (err.name !== 'GetDataSetStatsError') return false + if (!err.message.includes('Invalid response format')) return false + return true +} + +describe('FilBeam Stats', () => { + describe('validateStatsResponse', () => { + it('should return valid DataSetStats for correct input', () => { + const result = validateStatsResponse({ + cdnEgressQuota: '1000000', + cacheMissEgressQuota: '500000', + }) + assert.deepStrictEqual(result, { cdnEgressQuota: 1000000n, cacheMissEgressQuota: 500000n }) + }) + + it('should handle large number strings', () => { + const result = validateStatsResponse({ + cdnEgressQuota: '9999999999999999999999999999', + cacheMissEgressQuota: '1234567890123456789012345678901234567890', + }) + assert.deepStrictEqual(result, { + cdnEgressQuota: 9999999999999999999999999999n, + cacheMissEgressQuota: 1234567890123456789012345678901234567890n, + }) + }) + + it('should handle zero values', () => { + const result = validateStatsResponse({ + cdnEgressQuota: '0', + cacheMissEgressQuota: '0', + }) + assert.deepStrictEqual(result, { cdnEgressQuota: 0n, cacheMissEgressQuota: 0n }) + }) + + const invalidInputCases: Record = { + 'response is null': null, + 'response is a string': 'string', + 'response is an array': [1, 2, 3], + 'response is a number': 123, + 'cdnEgressQuota is missing': { cacheMissEgressQuota: '1000' }, + 'cacheMissEgressQuota is missing': { cdnEgressQuota: '1000' }, + 'cdnEgressQuota is a number': { cdnEgressQuota: 1000, cacheMissEgressQuota: '500' }, + 'cdnEgressQuota is an object': { cdnEgressQuota: { value: 1000 }, cacheMissEgressQuota: '500' }, + 'cdnEgressQuota is a decimal': { cdnEgressQuota: '12.5', cacheMissEgressQuota: '500' }, + 'cdnEgressQuota is non-numeric': { cdnEgressQuota: 'abc', cacheMissEgressQuota: '500' }, + 'cacheMissEgressQuota is a number': { cdnEgressQuota: '1000', cacheMissEgressQuota: 500 }, + 'cacheMissEgressQuota is scientific notation': { cdnEgressQuota: '1000', cacheMissEgressQuota: '1e10' }, + 'cacheMissEgressQuota is empty string': { cdnEgressQuota: '1000', cacheMissEgressQuota: '' }, + } + for (const [name, input] of Object.entries(invalidInputCases)) { + it(`should throw error when ${name}`, () => { + assert.throws(() => validateStatsResponse(input), isInvalidResponseFormatError) + }) + } + }) + + describe('getDataSetStats', () => { + function isGetDataSetStatsError(err: unknown): boolean { + return err instanceof Error && err.name === 'GetDataSetStatsError' + } + + it('should fetch and return stats for mainnet', async () => { + let calledUrl: string | undefined + const mockRequestGetJson = async (url: unknown) => { + calledUrl = String(url) + return { result: { cdnEgressQuota: '1000', cacheMissEgressQuota: '500' } } + } + + const result = await getDataSetStats({ + chain: mainnet, + dataSetId: 'test-dataset', + requestGetJson: mockRequestGetJson as MockRequestGetJson, + }) + + assert.equal(calledUrl, 'https://stats.filbeam.com/data-set/test-dataset') + assert.deepStrictEqual(result, { cdnEgressQuota: 1000n, cacheMissEgressQuota: 500n }) + }) + + it('should fetch and return stats for calibration', async () => { + let calledUrl: string | undefined + const mockRequestGetJson = async (url: unknown) => { + calledUrl = String(url) + return { result: { cdnEgressQuota: '2000', cacheMissEgressQuota: '1000' } } + } + + const result = await getDataSetStats({ + chain: calibration, + dataSetId: 12345n, + requestGetJson: mockRequestGetJson as MockRequestGetJson, + }) + + assert.equal(calledUrl, 'https://calibration.stats.filbeam.com/data-set/12345') + assert.deepStrictEqual(result, { cdnEgressQuota: 2000n, cacheMissEgressQuota: 1000n }) + }) + + it('should throw error for HTTP 404', async () => { + const mockRequestGetJson = async () => ({ + error: new HttpError({ + response: new Response('Not found', { status: 404, statusText: 'Not Found' }), + request: new Request('https://stats.filbeam.com/data-set/non-existent'), + options: {}, + }), + }) + + try { + await getDataSetStats({ + chain: mainnet, + dataSetId: 'non-existent', + requestGetJson: mockRequestGetJson as MockRequestGetJson, + }) + assert.fail('Expected error to be thrown') + } catch (error) { + assert.ok(isGetDataSetStatsError(error)) + assert.ok((error as Error).message.includes('Data set not found: non-existent')) + } + }) + + it('should throw error for HTTP 500', async () => { + const mockRequestGetJson = async () => ({ + error: new HttpError({ + response: new Response('Internal Server Error', { status: 500, statusText: 'Internal Server Error' }), + request: new Request('https://stats.filbeam.com/data-set/test'), + options: {}, + }), + }) + + try { + await getDataSetStats({ + chain: mainnet, + dataSetId: 'test', + requestGetJson: mockRequestGetJson as MockRequestGetJson, + }) + assert.fail('Expected error to be thrown') + } catch (error) { + assert.ok(isGetDataSetStatsError(error)) + assert.ok((error as Error).message.includes('Failed to fetch data set stats')) + } + }) + + it('should throw error for non-HTTP errors', async () => { + const mockRequestGetJson = async () => ({ + error: new Error('Network error'), + }) + + try { + await getDataSetStats({ + chain: mainnet, + dataSetId: 'test', + requestGetJson: mockRequestGetJson as unknown as MockRequestGetJson, + }) + assert.fail('Expected error to be thrown') + } catch (error) { + assert.ok(isGetDataSetStatsError(error)) + assert.ok((error as Error).message.includes('Unexpected error')) + } + }) + + it('should throw error when chain does not support FilBeam', async () => { + try { + await getDataSetStats({ + chain: devnet, + dataSetId: '12345', + }) + assert.fail('Expected error to be thrown') + } catch (error) { + assert.ok(isGetDataSetStatsError(error)) + assert.ok((error as Error).message.includes('does not support FilBeam')) + assert.ok((error as Error).message.includes('31415926')) + } + }) + }) +}) diff --git a/packages/synapse-react/src/warm-storage/index.ts b/packages/synapse-react/src/warm-storage/index.ts index afe8eab15..4dfcae2c8 100644 --- a/packages/synapse-react/src/warm-storage/index.ts +++ b/packages/synapse-react/src/warm-storage/index.ts @@ -1,6 +1,7 @@ export * from './use-create-data-set.ts' export * from './use-data-sets.ts' export * from './use-delete-piece.ts' +export * from './use-egress-quota.ts' export * from './use-providers.ts' export * from './use-service-price.ts' export * from './use-upload.ts' diff --git a/packages/synapse-react/src/warm-storage/use-egress-quota.ts b/packages/synapse-react/src/warm-storage/use-egress-quota.ts new file mode 100644 index 000000000..dbb8e9ad2 --- /dev/null +++ b/packages/synapse-react/src/warm-storage/use-egress-quota.ts @@ -0,0 +1,47 @@ +import { getChain } from '@filoz/synapse-core/chains' +import { type DataSetStats, getDataSetStats } from '@filoz/synapse-core/filbeam' +import { skipToken, type UseQueryOptions, useQuery } from '@tanstack/react-query' +import { useChainId } from 'wagmi' + +export type { DataSetStats } + +/** + * The props for the useEgressQuota hook. + */ +export interface UseEgressQuotaProps { + /** The data set ID to query egress quota for */ + dataSetId?: bigint + query?: Omit, 'queryKey' | 'queryFn'> +} + +/** + * The result for the useEgressQuota hook. + */ +export type UseEgressQuotaResult = DataSetStats + +/** + * Get the egress quota for a data set from FilBeam. + * + * @param props - The props to use. + * @returns The egress quota for the data set. + * + * @example + * ```tsx + * const { data: egressQuota, isLoading } = useEgressQuota({ dataSetId: 123n }) + * if (egressQuota) { + * console.log(`CDN Egress: ${egressQuota.cdnEgressQuota}`) + * console.log(`Cache Miss Egress: ${egressQuota.cacheMissEgressQuota}`) + * } + * ``` + */ +export function useEgressQuota(props: UseEgressQuotaProps) { + const chainId = useChainId() + const chain = getChain(chainId) + const dataSetId = props.dataSetId + + return useQuery({ + ...props.query, + queryKey: ['synapse-filbeam-egress-quotas', chainId, dataSetId?.toString()], + queryFn: dataSetId != null ? () => getDataSetStats({ chain, dataSetId }) : skipToken, + }) +} diff --git a/packages/synapse-sdk/src/filbeam/service.ts b/packages/synapse-sdk/src/filbeam/service.ts index 9f195dcf3..a8fe0df29 100644 --- a/packages/synapse-sdk/src/filbeam/service.ts +++ b/packages/synapse-sdk/src/filbeam/service.ts @@ -9,23 +9,10 @@ */ import { asChain, type Chain } from '@filoz/synapse-core/chains' -import { createError } from '../utils/errors.ts' +import { getDataSetStats as coreGetDataSetStats, type DataSetStats } from '@filoz/synapse-core/filbeam' +import type { request } from 'iso-web/http' -/** - * Data set statistics from FilBeam. - * - * These quotas represent the remaining pay-per-byte allocation available for data retrieval - * through FilBeam's trusted measurement layer. The values decrease as data is served and - * represent how many bytes can still be retrieved before needing to add more credits. - * - * @interface DataSetStats - * @property {bigint} cdnEgressQuota - The remaining CDN egress quota for cache hits (data served directly from FilBeam's cache) in bytes - * @property {bigint} cacheMissEgressQuota - The remaining egress quota for cache misses (data retrieved from storage providers) in bytes - */ -export interface DataSetStats { - cdnEgressQuota: bigint - cacheMissEgressQuota: bigint -} +export type { DataSetStats } /** * Service for interacting with FilBeam infrastructure and APIs. @@ -37,9 +24,9 @@ export interface DataSetStats { * const stats = await synapse.filbeam.getDataSetStats(12345) * * // Monitor remaining pay-per-byte quotas - * const service = new FilBeamService('mainnet') + * const service = new FilBeamService(mainnet) * const stats = await service.getDataSetStats(12345) - * console.log('Remaining CDN Egress (cache hits):', stats.cdnEgressQuota) + * console.log('Remaining CDN Egress:', stats.cdnEgressQuota) * console.log('Remaining Cache Miss Egress:', stats.cacheMissEgressQuota) * ``` * @@ -50,42 +37,11 @@ export interface DataSetStats { */ export class FilBeamService { private readonly _chain: Chain - private readonly _fetch: typeof fetch + private readonly _requestGetJson: typeof request.json.get | undefined - constructor(chain: Chain, fetchImpl: typeof fetch = globalThis.fetch) { + constructor(chain: Chain, requestGetJson?: typeof request.json.get) { this._chain = asChain(chain) - this._fetch = fetchImpl - } - - /** - * Get the base stats URL for the current network - */ - private _getStatsBaseUrl(): string { - return this._chain.testnet ? 'https://calibration.stats.filbeam.com' : 'https://stats.filbeam.com' - } - - /** - * Validates the response from FilBeam stats API - */ - private _validateStatsResponse(data: unknown): { cdnEgressQuota: string; cacheMissEgressQuota: string } { - if (typeof data !== 'object' || data === null) { - throw createError('FilBeamService', 'validateStatsResponse', 'Response is not an object') - } - - const response = data as Record - - if (typeof response.cdnEgressQuota !== 'string') { - throw createError('FilBeamService', 'validateStatsResponse', 'cdnEgressQuota must be a string') - } - - if (typeof response.cacheMissEgressQuota !== 'string') { - throw createError('FilBeamService', 'validateStatsResponse', 'cacheMissEgressQuota must be a string') - } - - return { - cdnEgressQuota: response.cdnEgressQuota, - cacheMissEgressQuota: response.cacheMissEgressQuota, - } + this._requestGetJson = requestGetJson } /** @@ -95,8 +51,8 @@ export class FilBeamService { * track how many bytes can still be retrieved through FilBeam's trusted measurement layer * before needing to add more credits: * - * - **CDN Egress Quota**: Remaining bytes that can be served from FilBeam's cache (fast, direct delivery) - * - **Cache Miss Egress Quota**: Remaining bytes that can be retrieved from storage providers (triggers caching) + * - **CDN Egress Quota**: Remaining bytes that can be served by FilBeam (both cache-hit and cache-miss requests) + * - **Cache Miss Egress Quota**: Remaining bytes that can be retrieved from storage providers (cache-miss requests to origin) * * Both types of egress are billed based on volume. Query current pricing via * {@link WarmStorageService.getServicePrice} or see https://docs.filbeam.com for rates. @@ -123,35 +79,10 @@ export class FilBeamService { * ``` */ async getDataSetStats(dataSetId: string | number): Promise { - const baseUrl = this._getStatsBaseUrl() - const url = `${baseUrl}/data-set/${dataSetId}` - - const response = await this._fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, + return coreGetDataSetStats({ + chain: this._chain, + dataSetId, + requestGetJson: this._requestGetJson, }) - - if (response.status === 404) { - throw createError('FilBeamService', 'getDataSetStats', `Data set not found: ${dataSetId}`) - } - - if (response.status !== 200) { - const errorText = await response.text().catch(() => 'Unknown error') - throw createError( - 'FilBeamService', - 'getDataSetStats', - `HTTP ${response.status} ${response.statusText}: ${errorText}` - ) - } - - const data = await response.json() - const validated = this._validateStatsResponse(data) - - return { - cdnEgressQuota: BigInt(validated.cdnEgressQuota), - cacheMissEgressQuota: BigInt(validated.cacheMissEgressQuota), - } } } diff --git a/packages/synapse-sdk/src/test/filbeam-service.test.ts b/packages/synapse-sdk/src/test/filbeam-service.test.ts index 44299a2db..4d9389be9 100644 --- a/packages/synapse-sdk/src/test/filbeam-service.test.ts +++ b/packages/synapse-sdk/src/test/filbeam-service.test.ts @@ -1,176 +1,66 @@ import { calibration, mainnet } from '@filoz/synapse-core/chains' import { expect } from 'chai' +import type { request } from 'iso-web/http' import { FilBeamService } from '../filbeam/service.ts' +type MockRequestGetJson = typeof request.json.get + describe('FilBeamService', () => { describe('URL construction', () => { - it('should use mainnet URL for mainnet network', () => { - const mockFetch = async (): Promise => { - return {} as Response + it('should use mainnet URL for mainnet network', async () => { + let calledUrl: string | undefined + const mockRequestGetJson = async (url: unknown) => { + calledUrl = String(url) + return { result: { cdnEgressQuota: '100', cacheMissEgressQuota: '50' } } } - const service = new FilBeamService(mainnet, mockFetch) + const service = new FilBeamService(mainnet, mockRequestGetJson as MockRequestGetJson) + await service.getDataSetStats('test') - const baseUrl = (service as any)._getStatsBaseUrl() - expect(baseUrl).to.equal('https://stats.filbeam.com') + expect(calledUrl).to.equal('https://stats.filbeam.com/data-set/test') }) - it('should use calibration URL for calibration network', () => { - const mockFetch = async (): Promise => { - return {} as Response + it('should use calibration URL for calibration network', async () => { + let calledUrl: string | undefined + const mockRequestGetJson = async (url: unknown) => { + calledUrl = String(url) + return { result: { cdnEgressQuota: '100', cacheMissEgressQuota: '50' } } } - const service = new FilBeamService(calibration, mockFetch) + const service = new FilBeamService(calibration, mockRequestGetJson as MockRequestGetJson) + await service.getDataSetStats('test') - const baseUrl = (service as any)._getStatsBaseUrl() - expect(baseUrl).to.equal('https://calibration.stats.filbeam.com') + expect(calledUrl).to.equal('https://calibration.stats.filbeam.com/data-set/test') }) }) + // Detailed tests for HTTP error handling and response validation are in synapse-core. + // These smoke tests verify that FilBeamService delegates to synapse-core correctly. describe('getDataSetStats', () => { - it('should successfully fetch and parse remaining stats for mainnet', async () => { - const mockResponse = { - cdnEgressQuota: '217902493044', - cacheMissEgressQuota: '94243853808', - } - - const mockFetch = async (input: string | URL | Request): Promise => { - expect(input).to.equal('https://stats.filbeam.com/data-set/test-dataset-id') - return { - status: 200, - statusText: 'OK', - json: async () => mockResponse, - } as Response - } + it('should delegate to synapse-core for mainnet', async () => { + const mockRequestGetJson = async () => ({ + result: { cdnEgressQuota: '1000', cacheMissEgressQuota: '500' }, + }) - const service = new FilBeamService(mainnet, mockFetch) - const result = await service.getDataSetStats('test-dataset-id') + const service = new FilBeamService(mainnet, mockRequestGetJson as MockRequestGetJson) + const result = await service.getDataSetStats('test-dataset') expect(result).to.deep.equal({ - cdnEgressQuota: BigInt('217902493044'), - cacheMissEgressQuota: BigInt('94243853808'), + cdnEgressQuota: 1000n, + cacheMissEgressQuota: 500n, }) }) - it('should successfully fetch and parse remaining stats for calibration', async () => { - const mockResponse = { - cdnEgressQuota: '100000000000', - cacheMissEgressQuota: '50000000000', - } - - const mockFetch = async (input: string | URL | Request): Promise => { - expect(input).to.equal('https://calibration.stats.filbeam.com/data-set/123') - return { - status: 200, - statusText: 'OK', - json: async () => mockResponse, - } as Response - } + it('should delegate to synapse-core for calibration', async () => { + const mockRequestGetJson = async () => ({ + result: { cdnEgressQuota: '2000', cacheMissEgressQuota: '1000' }, + }) - const service = new FilBeamService(calibration, mockFetch) + const service = new FilBeamService(calibration, mockRequestGetJson as MockRequestGetJson) const result = await service.getDataSetStats(123) expect(result).to.deep.equal({ - cdnEgressQuota: BigInt('100000000000'), - cacheMissEgressQuota: BigInt('50000000000'), + cdnEgressQuota: 2000n, + cacheMissEgressQuota: 1000n, }) }) - - it('should handle 404 errors gracefully', async () => { - const mockFetch = async (): Promise => { - return { - status: 404, - statusText: 'Not Found', - text: async () => 'Data set not found', - } as Response - } - - const service = new FilBeamService(mainnet, mockFetch) - - try { - await service.getDataSetStats('non-existent') - expect.fail('Should have thrown an error') - } catch (error: any) { - expect(error.message).to.include('Data set not found: non-existent') - } - }) - - it('should handle other HTTP errors', async () => { - const mockFetch = async (): Promise => { - return { - status: 500, - statusText: 'Internal Server Error', - text: async () => 'Server error occurred', - } as Response - } - - const service = new FilBeamService(mainnet, mockFetch) - - try { - await service.getDataSetStats('test-dataset') - expect.fail('Should have thrown an error') - } catch (error: any) { - expect(error.message).to.include('HTTP 500 Internal Server Error') - } - }) - - it('should validate response is an object', async () => { - const mockFetch = async (): Promise => { - return { - status: 200, - statusText: 'OK', - json: async () => null, - } as Response - } - - const service = new FilBeamService(mainnet, mockFetch) - - try { - await service.getDataSetStats('test-dataset') - expect.fail('Should have thrown an error') - } catch (error: any) { - expect(error.message).to.include('Response is not an object') - } - }) - - it('should validate cdnEgressQuota is present', async () => { - const mockFetch = async (): Promise => { - return { - status: 200, - statusText: 'OK', - json: async () => ({ - cacheMissEgressQuota: '12345', - }), - } as Response - } - - const service = new FilBeamService(mainnet, mockFetch) - - try { - await service.getDataSetStats('test-dataset') - expect.fail('Should have thrown an error') - } catch (error: any) { - expect(error.message).to.include('cdnEgressQuota must be a string') - } - }) - - it('should validate cacheMissEgressQuota is present', async () => { - const mockFetch = async (): Promise => { - return { - status: 200, - statusText: 'OK', - json: async () => ({ - cdnEgressQuota: '12345', - }), - } as Response - } - - const service = new FilBeamService(mainnet, mockFetch) - - try { - await service.getDataSetStats('test-dataset') - expect.fail('Should have thrown an error') - } catch (error: any) { - expect(error.message).to.include('cacheMissEgressQuota must be a string') - } - }) }) })