From 0280a260b69067707facfad117510163b2633efd Mon Sep 17 00:00:00 2001 From: nijoe1 Date: Tue, 1 Sep 2026 08:14:17 +0300 Subject: [PATCH 1/2] chore(deps): update synapse-core to 0.8.1 and synapse-sdk to 1.2.1 synapse-core 0.8 makes list queries cursor-paginated ({items, nextCursor} pages) and replaces activePieceCount with a bounded hasActivePieces flag. - dataset list / wallet costs: walk every page via paginate(), keeping the old return-everything behavior past 100 datasets. - piece list / dataset details: --offset becomes --cursor; hasMore/nextOffset become an optional nextCursor (absent = last page). The fetch-all CTA is gone with the total count; the next-page CTA carries the exact --cursor command instead. dataset details reports hasActivePieces. - utils: the Chain type export is now FilecoinChain. - test mocks: page shapes for getPdpDataSets/getPiecesWithMetadata; the viem/actions mock spreads the real module because the root @filoz/synapse-core entry (imported for paginate) needs readContract. - SKILL.md: pagination docs and examples updated (schema-gated). getPiecesWithMetadata is deprecated upstream (removal planned in FilOzone/synapse-sdk#936, replacement: getPieces + PieceAdded events or an indexer); kept for now, follow up when removal ships. Bundled mainnet addresses move to filecoin-services v1.3.1 via this bump. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014V3GfgFSQNRB3XCxB6Qo8u --- cli/bun.lock | 14 ++-- cli/package.json | 4 +- cli/src/commands/dataset/details.ts | 71 ++++++++++---------- cli/src/commands/dataset/list.ts | 9 ++- cli/src/commands/piece/list.ts | 60 +++++++++-------- cli/src/commands/wallet/costs.ts | 11 ++- cli/src/utils.ts | 17 +++-- cli/tests/command-mocks.ts | 17 +++-- cli/tests/synapse-commands.test.ts | 100 +++++++++++++--------------- skills/foc-cli/SKILL.md | 6 +- 10 files changed, 156 insertions(+), 153 deletions(-) diff --git a/cli/bun.lock b/cli/bun.lock index 1d22f78..093d60c 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -6,8 +6,8 @@ "name": "synapse-cli", "dependencies": { "@clack/prompts": "^1.0.0", - "@filoz/synapse-core": "^0.7.0", - "@filoz/synapse-sdk": "^1.1.0", + "@filoz/synapse-core": "^0.8.1", + "@filoz/synapse-sdk": "^1.2.1", "conf": "^15.0.2", "incur": "^0.4.19", "terminal-link": "^5.0.0", @@ -47,9 +47,9 @@ "@clack/prompts": ["@clack/prompts@1.1.0", "", { "dependencies": { "@clack/core": "1.1.0", "sisteransi": "^1.0.5" } }, "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g=="], - "@filoz/synapse-core": ["@filoz/synapse-core@0.7.0", "", { "dependencies": { "dnum": "^2.15.0", "iso-web": "^3.1.2", "multiformats": "^14.0.0", "ox": "^0.14.29", "p-locate": "^7.0.0", "p-queue": "^9.1.2", "p-some": "^7.0.0", "sync-multihash-sha2": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "viem": "2.x" } }, "sha512-54G+yH1DrqXpCrtSZF5SYvNma3W2iA/LH6hLWmADy5tW1CkPlvtHZ5essqrJtlk3lfNRYMWk3+13ejzXxDAWWw=="], + "@filoz/synapse-core": ["@filoz/synapse-core@0.8.1", "", { "dependencies": { "dnum": "^2.15.0", "iso-web": "^3.1.2", "multiformats": "^14.0.0", "ox": "^0.14.29", "p-locate": "^7.0.0", "p-some": "^7.0.0", "sync-multihash-sha2": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "viem": "2.x" } }, "sha512-FdslKdfi9uSjB+Z9Uq16DQzgZyRuKIFi+sSwxmOKCf2a1KYDY0peHfoc5+LJtrnOXKnfEcFUo7lBGvGVl95P3w=="], - "@filoz/synapse-sdk": ["@filoz/synapse-sdk@1.1.0", "", { "dependencies": { "@filoz/synapse-core": "^0.7.0", "multiformats": "^14.0.0" }, "peerDependencies": { "viem": "2.x" } }, "sha512-C5paiXZivxzEAujb7VA5HOA26QFaQ+om55yNpDTMLFR0zbGu7kjIWfQ0nXwziYczl1sm9UmNe7qa2SyIKw4hkQ=="], + "@filoz/synapse-sdk": ["@filoz/synapse-sdk@1.2.1", "", { "dependencies": { "@filoz/synapse-core": "^0.8.1", "multiformats": "^14.0.0" }, "peerDependencies": { "viem": "2.x" } }, "sha512-hi+dPOt56vckYNSUOk7Ln7/ROFBuo0HpRmJXU/0CXMoPZkHLsH7IeSXTP0hcEO4NOuJeTBtSQ8zGClZl2SXBSw=="], "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0-alpha.4", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-/KEo3ZJ50HlagHp0lz2vPgfBZFtXHu6zTBXT9XqPc+4O9i4+IbBVWKq/a9yAfv/ifp0u3+mRo8SUk5kMKzhN8A=="], @@ -139,14 +139,10 @@ "p-locate": ["p-locate@7.0.0", "", { "dependencies": { "p-limit": "^7.2.0" } }, "sha512-FRPW2lT1b/B8/CNkCOZ/Xl4mz52CWzwb+/dLa0GcCrH7u7djFf36VftuRJ5w/eCr1YXtbTGPuGoEDVSk14EwNQ=="], - "p-queue": ["p-queue@9.2.0", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g=="], - "p-retry": ["p-retry@8.0.0", "", { "dependencies": { "is-network-error": "^1.3.0" } }, "sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A=="], "p-some": ["p-some@7.0.0", "", {}, "sha512-9ldWF6puBzuchsUq7M1THjwwmoiXesqRdpB4WH0D7urKXdGkIaDqVhQS2BSfRRYZ970j9gm3U4/h9hHQx2G1Ug=="], - "p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="], - "random-int": ["random-int@3.1.0", "", {}, "sha512-h8CRz8cpvzj0hC/iH/1Gapgcl2TQ6xtnCpyOI5WvWfXf/yrDx2DOU+tD9rX23j36IF11xg1KqB9W11Z18JPMdw=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -193,8 +189,6 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "p-queue/eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], - "viem/ox": ["ox@0.14.0", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-WLOB7IKnmI3Ol6RAqY7CJdZKl8QaI44LN91OGF1061YIeN6bL5IsFcdp7+oQShRyamE/8fW/CBRWhJAOzI35Dw=="], } } diff --git a/cli/package.json b/cli/package.json index 6492a1d..bd25ebe 100644 --- a/cli/package.json +++ b/cli/package.json @@ -55,8 +55,8 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@filoz/synapse-core": "^0.7.0", - "@filoz/synapse-sdk": "^1.1.0", + "@filoz/synapse-core": "^0.8.1", + "@filoz/synapse-sdk": "^1.2.1", "conf": "^15.0.2", "incur": "^0.4.19", "terminal-link": "^5.0.0", diff --git a/cli/src/commands/dataset/details.ts b/cli/src/commands/dataset/details.ts index dbe8171..120f0b2 100644 --- a/cli/src/commands/dataset/details.ts +++ b/cli/src/commands/dataset/details.ts @@ -16,10 +16,12 @@ export const detailsCommand = { .number() .default(314159) .describe('Chain ID. 314159 = Calibration, 314 = Mainnet'), - offset: z.coerce + cursor: z.coerce .number() - .default(0) - .describe('Piece offset to start from (for pagination)'), + .optional() + .describe( + 'Resume from a previous page: pass the nextCursor value that page returned. Omit for the first page.' + ), limit: z.coerce .number() .default(100) @@ -37,7 +39,7 @@ export const detailsCommand = { live: z.boolean(), managed: z.boolean(), terminating: z.boolean(), - activePieceCount: z.string(), + hasActivePieces: z.boolean(), metadata: z.record(z.string(), z.string()), }), pieces: z.array( @@ -49,8 +51,12 @@ export const detailsCommand = { metadata: z.record(z.string(), z.string()), }) ), - hasMore: z.boolean(), - nextOffset: z.number().optional(), + nextCursor: z + .string() + .optional() + .describe( + 'Present when more pieces remain — pass it back as --cursor to fetch the next page' + ), }), async run(c: any) { const out = new OutputContext(c) @@ -71,14 +77,13 @@ export const detailsCommand = { ) } - const offset = c.options.offset ?? 0 const limit = c.options.limit ?? 100 out.step('Fetching pieces and metadata') - const { pieces, hasMore } = await getPiecesWithMetadata(client, { + const { items, nextCursor } = await getPiecesWithMetadata(client, { dataSet: ds, address: client.account.address, - offset: BigInt(offset), + cursor: BigInt(c.options.cursor ?? 0), limit: BigInt(limit), }) @@ -91,11 +96,11 @@ export const detailsCommand = { live: !!ds.live, managed: !!ds.managed, terminating: ds.pdpEndEpoch > 0n, - activePieceCount: ds.activePieceCount, + hasActivePieces: ds.hasActivePieces, metadata: ds.metadata, } - const piecesList = pieces.map((piece: any) => { + const piecesList = items.map((piece: any) => { const cid = piece.cid.toString() return { id: piece.id, @@ -106,37 +111,31 @@ export const detailsCommand = { } }) - const nextOffset = offset + piecesList.length - const totalPieces = Number(ds.activePieceCount) - const nextPage = hasMore - ? [ - { - command: 'dataset details', - options: { - dataSetId: c.options.dataSetId, - offset: nextOffset, - limit, - }, - description: `Show the next page of pieces (offset ${nextOffset})`, - }, - { - command: 'dataset details', - options: { - dataSetId: c.options.dataSetId, - offset: 0, - limit: totalPieces, + // Cursors are opaque continuation values; there is no total piece count + // any more (synapse-core 0.8 dropped activePieceCount), so "fetch all" + // means following nextCursor pages until it stops appearing. + const nextPage = + nextCursor !== undefined + ? [ + { + command: 'dataset details', + options: { + dataSetId: c.options.dataSetId, + cursor: Number(nextCursor), + limit, + }, + description: `Show the next page of pieces (cursor ${nextCursor})`, }, - description: `Fetch all ${totalPieces} pieces in one call`, - }, - ] - : [] + ] + : [] return out.done( { dataset, pieces: piecesList, - hasMore, - ...(hasMore ? { nextOffset } : {}), + ...(nextCursor !== undefined + ? { nextCursor: nextCursor.toString() } + : {}), }, { cta: chainCta(c.options.chain, { diff --git a/cli/src/commands/dataset/list.ts b/cli/src/commands/dataset/list.ts index 5ddf2fd..b6aaee0 100644 --- a/cli/src/commands/dataset/list.ts +++ b/cli/src/commands/dataset/list.ts @@ -1,3 +1,4 @@ +import { paginate } from '@filoz/synapse-core' import { getPdpDataSets } from '@filoz/synapse-core/warm-storage' import { z } from 'incur' import { getBlockNumber } from 'viem/actions' @@ -42,8 +43,14 @@ export const listCommand = { try { out.step('Listing data sets') + // getPdpDataSets returns bounded pages (default 100); walk every page so + // the listing stays complete for wallets with many datasets. const [dataSets, blockNumber] = await Promise.all([ - getPdpDataSets(client, { address: client.account.address }), + Array.fromAsync( + paginate(({ cursor }) => + getPdpDataSets(client, { address: client.account.address, cursor }) + ) + ), getBlockNumber(client), ]) diff --git a/cli/src/commands/piece/list.ts b/cli/src/commands/piece/list.ts index 3039a44..4e8fcbe 100644 --- a/cli/src/commands/piece/list.ts +++ b/cli/src/commands/piece/list.ts @@ -18,10 +18,12 @@ export const listCommand = { .number() .default(314159) .describe('Chain ID. 314159 = Calibration, 314 = Mainnet'), - offset: z.coerce + cursor: z.coerce .number() - .default(0) - .describe('Piece offset to start from (for pagination)'), + .optional() + .describe( + 'Resume from a previous page: pass the nextCursor value that page returned. Omit for the first page.' + ), limit: z.coerce .number() .default(100) @@ -40,8 +42,12 @@ export const listCommand = { metadata: z.record(z.string(), z.string()), }) ), - hasMore: z.boolean(), - nextOffset: z.number().optional(), + nextCursor: z + .string() + .optional() + .describe( + 'Present when more pieces remain — pass it back as --cursor to fetch the next page' + ), }), examples: [ { args: { dataSetId: 42 }, description: 'List pieces in dataset #42' }, @@ -60,18 +66,17 @@ export const listCommand = { if (!dataSet) return out.fail('NOT_FOUND', `Dataset ${c.args.dataSetId} not found`) - const offset = c.options.offset ?? 0 const limit = c.options.limit ?? 100 out.step('Fetching pieces') - const { pieces, hasMore } = await getPiecesWithMetadata(client, { + const { items, nextCursor } = await getPiecesWithMetadata(client, { dataSet, address: client.account.address, - offset: BigInt(offset), + cursor: BigInt(c.options.cursor ?? 0), limit: BigInt(limit), }) - const piecesList = pieces.map((piece: any) => { + const piecesList = items.map((piece: any) => { const cid = piece.cid.toString() return { id: piece.id, @@ -81,32 +86,29 @@ export const listCommand = { } }) - const nextOffset = offset + piecesList.length - const totalPieces = Number(dataSet.activePieceCount) - const nextPage = hasMore - ? [ - { - command: 'piece list', - args: { dataSetId: c.args.dataSetId }, - options: { offset: nextOffset, limit }, - description: `Show the next page of pieces (offset ${nextOffset})`, - }, - { - command: 'piece list', - args: { dataSetId: c.args.dataSetId }, - options: { offset: 0, limit: totalPieces }, - description: `Fetch all ${totalPieces} pieces in one call`, - }, - ] - : [] + // Cursors are opaque continuation values; there is no total piece count + // any more (synapse-core 0.8 dropped activePieceCount), so "fetch all" + // means following nextCursor pages until it stops appearing. + const nextPage = + nextCursor !== undefined + ? [ + { + command: 'piece list', + args: { dataSetId: c.args.dataSetId }, + options: { cursor: Number(nextCursor), limit }, + description: `Show the next page of pieces (cursor ${nextCursor})`, + }, + ] + : [] return out.done( { dataSetId: c.args.dataSetId.toString(), datasetScannerUrl: datasetScannerUrl(c.args.dataSetId, chain), pieces: piecesList, - hasMore, - ...(hasMore ? { nextOffset } : {}), + ...(nextCursor !== undefined + ? { nextCursor: nextCursor.toString() } + : {}), }, { cta: chainCta(c.options.chain, { diff --git a/cli/src/commands/wallet/costs.ts b/cli/src/commands/wallet/costs.ts index 51a9dfd..a894368 100644 --- a/cli/src/commands/wallet/costs.ts +++ b/cli/src/commands/wallet/costs.ts @@ -1,3 +1,4 @@ +import { paginate } from '@filoz/synapse-core' import { formatBalance } from '@filoz/synapse-core/utils' import { getPdpDataSets } from '@filoz/synapse-core/warm-storage' import { z } from 'incur' @@ -58,9 +59,13 @@ export const costsCommand = { out.step('Getting costs') const copies = c.options.copies ?? 2 - const dataSets = await getPdpDataSets(client, { - address: client.account.address, - }) + // Paged since synapse-core 0.8: walk every page so reuse still sees all + // active datasets, not just the first hundred. + const dataSets = await Array.fromAsync( + paginate(({ cursor }) => + getPdpDataSets(client, { address: client.account.address, cursor }) + ) + ) // Active, non-terminating datasets only. Contexts are created one at a // time via createContext — the plural createContexts rejects datasets // sharing a provider. diff --git a/cli/src/utils.ts b/cli/src/utils.ts index c8082af..b866705 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -1,6 +1,6 @@ import { homedir } from 'node:os' import { resolve } from 'node:path' -import type { Chain } from '@filoz/synapse-core/chains' +import type { FilecoinChain } from '@filoz/synapse-core/chains' import terminalLink from 'terminal-link' /** @@ -15,27 +15,30 @@ export function expandHome(path: string): string { return path } -function networkSlug(chain: Chain): string { +function networkSlug(chain: FilecoinChain): string { return chain.id === 314 ? 'mainnet' : 'calibration' } -export function hashLink(hash: string, chain: Chain) { +export function hashLink(hash: string, chain: FilecoinChain) { return terminalLink(hash, `${chain.blockExplorers?.default?.url}/tx/${hash}`) } -export function datasetScannerUrl(dataSetId: string | bigint, chain: Chain) { +export function datasetScannerUrl( + dataSetId: string | bigint, + chain: FilecoinChain +) { return `https://pdp.vxb.ai/${networkSlug(chain)}/dataset/${dataSetId.toString()}` } -export function pieceScannerUrl(pieceCid: string, chain: Chain) { +export function pieceScannerUrl(pieceCid: string, chain: FilecoinChain) { return `https://pdp.vxb.ai/${networkSlug(chain)}/piece/${pieceCid}` } -export function txExplorerUrl(hash: string, chain: Chain) { +export function txExplorerUrl(hash: string, chain: FilecoinChain) { return `${chain.blockExplorers?.default?.url}/tx/${hash}` } -export function dealbotDashboardUrl(chain: Chain) { +export function dealbotDashboardUrl(chain: FilecoinChain) { return chain.id === 314 ? 'https://dealbot.filoz.org' : 'https://staging.dealbot.filoz.org' diff --git a/cli/tests/command-mocks.ts b/cli/tests/command-mocks.ts index 92c471d..d7acce0 100644 --- a/cli/tests/command-mocks.ts +++ b/cli/tests/command-mocks.ts @@ -1,4 +1,5 @@ import { mock } from 'bun:test' +import * as viemActions from 'viem/actions' export function cid(value: string) { return { @@ -176,13 +177,13 @@ const fakeDataSet = { live: true, managed: false, pdpEndEpoch: 0n, - activePieceCount: 2n, + hasActivePieces: true, metadata: { label: 'dataset', }, } -export const getPdpDataSets = mock(async () => [fakeDataSet]) +export const getPdpDataSets = mock(async () => ({ items: [fakeDataSet] })) export const getPdpDataSet = mock(async () => fakeDataSet) const fakePiece = { @@ -195,8 +196,7 @@ const fakePiece = { } export const getPiecesWithMetadata = mock(async () => ({ - pieces: [fakePiece], - hasMore: false, + items: [fakePiece], })) export const createDataSet = mock(async () => ({ @@ -387,7 +387,11 @@ mock.module('@filoz/synapse-core/pay', () => ({ getAccountSummary, })) +// Spread the real module: the root '@filoz/synapse-core' entry (imported for +// paginate) transitively pulls other viem actions, and a two-export mock made +// that import fail with "Export named 'readContract' not found". mock.module('viem/actions', () => ({ + ...viemActions, getBlockNumber, waitForTransactionReceipt, })) @@ -487,11 +491,10 @@ export function resetCommandMocks() { async () => fakeProviderSelectionInput ) fetchMock.mockImplementation(async () => new Response(null, { status: 200 })) - getPdpDataSets.mockImplementation(async () => [fakeDataSet]) + getPdpDataSets.mockImplementation(async () => ({ items: [fakeDataSet] })) getPdpDataSet.mockImplementation(async () => fakeDataSet) getPiecesWithMetadata.mockImplementation(async () => ({ - pieces: [fakePiece], - hasMore: false, + items: [fakePiece], })) createDataSet.mockImplementation(async () => ({ diff --git a/cli/tests/synapse-commands.test.ts b/cli/tests/synapse-commands.test.ts index 0b77385..8f2ee0f 100644 --- a/cli/tests/synapse-commands.test.ts +++ b/cli/tests/synapse-commands.test.ts @@ -1086,11 +1086,9 @@ describe('wallet commands', () => { providerId: 78n, pdpEndEpoch: 999n, } - getPdpDataSets.mockResolvedValueOnce([ - activeDataSet, - sameProviderDataSet, - terminatingDataSet, - ] as any) + getPdpDataSets.mockResolvedValueOnce({ + items: [activeDataSet, sameProviderDataSet, terminatingDataSet], + } as any) const result = await costsCommand.run( commandContext({ @@ -1100,6 +1098,7 @@ describe('wallet commands', () => { expect(getPdpDataSets).toHaveBeenCalledWith(fakeWalletClient, { address: fakeWalletClient.account.address, + cursor: 0n, }) expect(synapseStorage.createContext).toHaveBeenCalledTimes(2) expect(synapseStorage.createContext).toHaveBeenCalledWith({ @@ -1125,7 +1124,7 @@ describe('wallet commands', () => { }) test('wallet costs prices two new datasets for an empty wallet without provider selection', async () => { - getPdpDataSets.mockResolvedValueOnce([] as any) + getPdpDataSets.mockResolvedValueOnce({ items: [] } as any) const result = await costsCommand.run( commandContext({ @@ -1150,15 +1149,17 @@ describe('wallet commands', () => { // must price the copies the next upload will write — not every dataset the // wallet has ever created. test('wallet costs with one active dataset prices the second copy as a new dataset', async () => { - getPdpDataSets.mockResolvedValueOnce([ - { - dataSetId: 42n, - providerId: 77n, - live: true, - managed: true, - pdpEndEpoch: 0n, - }, - ] as any) + getPdpDataSets.mockResolvedValueOnce({ + items: [ + { + dataSetId: 42n, + providerId: 77n, + live: true, + managed: true, + pdpEndEpoch: 0n, + }, + ], + } as any) await costsCommand.run( commandContext({ options: { extraBytes: 1024, extraRunway: 1 } }) @@ -1174,11 +1175,13 @@ describe('wallet commands', () => { test('wallet costs with three active datasets prices only the requested copies', async () => { const base = { providerId: 77n, live: true, managed: true, pdpEndEpoch: 0n } - getPdpDataSets.mockResolvedValueOnce([ - { ...base, dataSetId: 42n }, - { ...base, dataSetId: 43n }, - { ...base, dataSetId: 44n }, - ] as any) + getPdpDataSets.mockResolvedValueOnce({ + items: [ + { ...base, dataSetId: 42n }, + { ...base, dataSetId: 43n }, + { ...base, dataSetId: 44n }, + ], + } as any) await costsCommand.run( commandContext({ options: { extraBytes: 1024, extraRunway: 1 } }) @@ -1193,7 +1196,7 @@ describe('wallet commands', () => { }) test('wallet costs --copies overrides how many contexts are priced', async () => { - getPdpDataSets.mockResolvedValueOnce([] as any) + getPdpDataSets.mockResolvedValueOnce({ items: [] } as any) await costsCommand.run( commandContext({ @@ -1344,6 +1347,7 @@ describe('dataset commands', () => { expect(getPdpDataSets).toHaveBeenCalledWith(fakeWalletClient, { address: fakeWalletClient.account.address, + cursor: 0n, }) expect(getBlockNumber).toHaveBeenCalledWith(fakeWalletClient) expect(result).toMatchObject({ @@ -1374,7 +1378,7 @@ describe('dataset commands', () => { expect(getPiecesWithMetadata).toHaveBeenCalledWith(fakeWalletClient, { dataSet: expect.anything(), address: fakeWalletClient.account.address, - offset: 0n, + cursor: 0n, limit: 100n, }) expect(result.dataset).toMatchObject({ @@ -1386,7 +1390,7 @@ describe('dataset commands', () => { live: true, managed: false, terminating: false, - activePieceCount: '2', + hasActivePieces: true, metadata: { label: 'dataset' }, }) expect(result.pieces).toEqual([ @@ -1398,7 +1402,7 @@ describe('dataset commands', () => { metadata: { name: 'file.txt' }, }, ]) - expect(result.hasMore).toBe(false) + expect(result.nextCursor).toBeUndefined() }) test('dataset terminate calls Synapse Core and maps the termination event', async () => { @@ -1419,7 +1423,7 @@ describe('dataset commands', () => { test('dataset details returns an object for empty piece metadata', async () => { getPiecesWithMetadata.mockImplementationOnce(async () => ({ - pieces: [ + items: [ { id: 8n, cid: cid('baga-empty-metadata'), @@ -1427,7 +1431,6 @@ describe('dataset commands', () => { metadata: {}, }, ], - hasMore: false, })) const result = await datasetDetailsCommand.run( @@ -1447,7 +1450,7 @@ describe('dataset commands', () => { test('dataset details paginates and emits a next-page CTA when more pieces remain', async () => { getPiecesWithMetadata.mockImplementationOnce(async () => ({ - pieces: [ + items: [ { id: 7n, cid: cid('baga-page1'), @@ -1455,30 +1458,24 @@ describe('dataset commands', () => { metadata: { name: 'file.txt' }, }, ], - hasMore: true, + nextCursor: 8n, })) const result = await datasetDetailsCommand.run( - commandContext({ options: { dataSetId: 42, offset: 5, limit: 1 } }) + commandContext({ options: { dataSetId: 42, cursor: 5, limit: 1 } }) ) expect(getPiecesWithMetadata).toHaveBeenCalledWith(fakeWalletClient, { dataSet: expect.anything(), address: fakeWalletClient.account.address, - offset: 5n, + cursor: 5n, limit: 1n, }) - expect(result.hasMore).toBe(true) - expect(result.nextOffset).toBe(6) + expect(result.nextCursor).toBe('8') expect(result.cta.commands).toContainEqual({ command: 'dataset details', - options: { chain: 314159, dataSetId: 42, offset: 6, limit: 1 }, - description: 'Show the next page of pieces (offset 6)', - }) - expect(result.cta.commands).toContainEqual({ - command: 'dataset details', - options: { chain: 314159, dataSetId: 42, offset: 0, limit: 2 }, - description: 'Fetch all 2 pieces in one call', + options: { chain: 314159, dataSetId: 42, cursor: 8, limit: 1 }, + description: 'Show the next page of pieces (cursor 8)', }) }) }) @@ -1495,7 +1492,7 @@ describe('piece commands', () => { expect(getPiecesWithMetadata).toHaveBeenCalledWith(fakeWalletClient, { dataSet: expect.anything(), address: fakeWalletClient.account.address, - offset: 0n, + cursor: 0n, limit: 100n, }) expect(result).toMatchObject({ @@ -1510,12 +1507,12 @@ describe('piece commands', () => { }, ], }) - expect(result.hasMore).toBe(false) + expect(result.nextCursor).toBeUndefined() }) test('piece list paginates and emits a next-page CTA when more pieces remain', async () => { getPiecesWithMetadata.mockImplementationOnce(async () => ({ - pieces: [ + items: [ { id: 7n, cid: cid('baga-page1'), @@ -1523,35 +1520,28 @@ describe('piece commands', () => { metadata: { name: 'file.txt' }, }, ], - hasMore: true, + nextCursor: 8n, })) const result = await pieceListCommand.run( commandContext({ args: { dataSetId: 42 }, - options: { offset: 5, limit: 1 }, + options: { cursor: 5, limit: 1 }, }) ) expect(getPiecesWithMetadata).toHaveBeenCalledWith(fakeWalletClient, { dataSet: expect.anything(), address: fakeWalletClient.account.address, - offset: 5n, + cursor: 5n, limit: 1n, }) - expect(result.hasMore).toBe(true) - expect(result.nextOffset).toBe(6) - expect(result.cta.commands).toContainEqual({ - command: 'piece list', - args: { dataSetId: 42 }, - options: { chain: 314159, offset: 6, limit: 1 }, - description: 'Show the next page of pieces (offset 6)', - }) + expect(result.nextCursor).toBe('8') expect(result.cta.commands).toContainEqual({ command: 'piece list', args: { dataSetId: 42 }, - options: { chain: 314159, offset: 0, limit: 2 }, - description: 'Fetch all 2 pieces in one call', + options: { chain: 314159, cursor: 8, limit: 1 }, + description: 'Show the next page of pieces (cursor 8)', }) }) diff --git a/skills/foc-cli/SKILL.md b/skills/foc-cli/SKILL.md index 0b7973d..30b4cd7 100644 --- a/skills/foc-cli/SKILL.md +++ b/skills/foc-cli/SKILL.md @@ -159,7 +159,7 @@ To acceptance-test a whole dataset, list its piece CIDs via `piece list` or `dat | Command | Description | |---------|-------------| | `dataset list` | All datasets with provider, CDN status, state | -| `dataset details -d [--offset N] [--limit M]` | Dataset metadata + pieces. Lists up to `--limit` pieces (default 100) starting at `--offset`; when more remain it returns `hasMore` + `nextOffset` plus two CTAs: the exact next-page command and a fetch-all command (`--offset 0 --limit `) | +| `dataset details -d [--cursor N] [--limit M]` | Dataset metadata (including `hasActivePieces`) + pieces. Lists up to `--limit` pieces (default 100); when more remain it returns `nextCursor` plus a CTA with the exact next-page command (`--cursor `) | | `dataset create [--cdn]` | Create dataset with a provider from `provider list` | | `dataset terminate ` | Stop PDP service for a dataset | @@ -167,7 +167,7 @@ To acceptance-test a whole dataset, list its piece CIDs via `piece list` or `dat | Command | Description | |---------|-------------| -| `piece list [--offset N] [--limit M]` | Pieces in dataset with CID + metadata. Paginated (default 100/page); when `hasMore` is true it returns two CTAs: the next-page command (`--offset `) and a fetch-all command (`--offset 0 --limit `) — use fetch-all when you need every piece CID (e.g. to download/verify a whole dataset) | +| `piece list [--cursor N] [--limit M]` | Pieces in dataset with CID + metadata. Paginated (default 100/page); when more remain it returns `nextCursor` plus a CTA with the exact next-page command (`--cursor `). To collect every piece CID (e.g. to download/verify a whole dataset), follow `nextCursor` pages until it stops appearing, or raise `--limit` | | `piece remove ` | Remove piece from dataset | ### Provider Info @@ -212,7 +212,7 @@ npx foc-cli piece list 42 # all piece CIDs (fetch npx foc-cli dataset list npx foc-cli dataset details -d 42 npx foc-cli piece list 42 -npx foc-cli piece list 42 --offset 100 --limit 100 # next page when hasMore is true +npx foc-cli piece list 42 --cursor 100 --limit 100 # next page: --cursor takes the previous nextCursor npx foc-cli piece remove 42 7 npx foc-cli dataset terminate 42 ``` From 2c39b27285df979e1867c78b6ad818f4383f8f5f Mon Sep 17 00:00:00 2001 From: nijoe1 Date: Tue, 1 Sep 2026 09:02:00 +0300 Subject: [PATCH 2/2] fix(cli): keep pagination cursors exact and cover multi-page walks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursors are opaque bigints from the SDK, and both --cursor input (z.coerce.number()) and the next-page CTA (Number(nextCursor)) silently corrupted values above 2^53 — 9007199254740993 round-tripped as 9007199254740992. The option is now a validated decimal string in piece list and dataset details, converted with BigInt only at the SDK boundary, and the CTA carries the string unchanged. Regression tests pin the exact round-trip at 2^53 + 1 for both commands. Also add two-page regression tests for dataset list and wallet costs: page 1 returns a nextCursor, page 2 answers that exact cursor, and the assertions cover cursor progression plus the accumulated datasets and cost inputs — exercising the retain-past-100-datasets behavior the migration claimed but never tested. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014V3GfgFSQNRB3XCxB6Qo8u --- cli/src/commands/dataset/details.ts | 12 ++- cli/src/commands/piece/list.ts | 12 ++- cli/tests/command-mocks.ts | 2 +- cli/tests/synapse-commands.test.ts | 123 +++++++++++++++++++++++++++- 4 files changed, 136 insertions(+), 13 deletions(-) diff --git a/cli/src/commands/dataset/details.ts b/cli/src/commands/dataset/details.ts index 120f0b2..1c71970 100644 --- a/cli/src/commands/dataset/details.ts +++ b/cli/src/commands/dataset/details.ts @@ -16,11 +16,15 @@ export const detailsCommand = { .number() .default(314159) .describe('Chain ID. 314159 = Calibration, 314 = Mainnet'), - cursor: z.coerce - .number() + cursor: z + .string() + .regex( + /^\d+$/, + 'must be the decimal nextCursor value from a previous page' + ) .optional() .describe( - 'Resume from a previous page: pass the nextCursor value that page returned. Omit for the first page.' + 'Resume from a previous page: pass the nextCursor value that page returned (an opaque decimal string). Omit for the first page.' ), limit: z.coerce .number() @@ -121,7 +125,7 @@ export const detailsCommand = { command: 'dataset details', options: { dataSetId: c.options.dataSetId, - cursor: Number(nextCursor), + cursor: nextCursor.toString(), limit, }, description: `Show the next page of pieces (cursor ${nextCursor})`, diff --git a/cli/src/commands/piece/list.ts b/cli/src/commands/piece/list.ts index 4e8fcbe..adf5c65 100644 --- a/cli/src/commands/piece/list.ts +++ b/cli/src/commands/piece/list.ts @@ -18,11 +18,15 @@ export const listCommand = { .number() .default(314159) .describe('Chain ID. 314159 = Calibration, 314 = Mainnet'), - cursor: z.coerce - .number() + cursor: z + .string() + .regex( + /^\d+$/, + 'must be the decimal nextCursor value from a previous page' + ) .optional() .describe( - 'Resume from a previous page: pass the nextCursor value that page returned. Omit for the first page.' + 'Resume from a previous page: pass the nextCursor value that page returned (an opaque decimal string). Omit for the first page.' ), limit: z.coerce .number() @@ -95,7 +99,7 @@ export const listCommand = { { command: 'piece list', args: { dataSetId: c.args.dataSetId }, - options: { cursor: Number(nextCursor), limit }, + options: { cursor: nextCursor.toString(), limit }, description: `Show the next page of pieces (cursor ${nextCursor})`, }, ] diff --git a/cli/tests/command-mocks.ts b/cli/tests/command-mocks.ts index d7acce0..796e2ea 100644 --- a/cli/tests/command-mocks.ts +++ b/cli/tests/command-mocks.ts @@ -169,7 +169,7 @@ export const fetchMock = mock( ) globalThis.fetch = fetchMock as unknown as typeof fetch -const fakeDataSet = { +export const fakeDataSet = { dataSetId: 42n, clientDataSetId: 100n, provider: fakeProvider, diff --git a/cli/tests/synapse-commands.test.ts b/cli/tests/synapse-commands.test.ts index 8f2ee0f..ed51ca9 100644 --- a/cli/tests/synapse-commands.test.ts +++ b/cli/tests/synapse-commands.test.ts @@ -9,6 +9,7 @@ import { claimTokens, configStore, createDataSet, + fakeDataSet, fakeProvider, fakeWalletClient, fetchMock, @@ -1195,6 +1196,37 @@ describe('wallet commands', () => { }) }) + test('wallet costs walks every dataset page before choosing reuse candidates', async () => { + const base = { providerId: 77n, live: true, managed: true, pdpEndEpoch: 0n } + getPdpDataSets + .mockImplementationOnce(async () => ({ + items: [{ ...base, dataSetId: 42n }], + nextCursor: 7n, + })) + .mockImplementationOnce(async () => ({ + items: [{ ...base, dataSetId: 43n }], + })) + + await costsCommand.run( + commandContext({ options: { extraBytes: 1024, extraRunway: 1 } }) + ) + + expect(getPdpDataSets).toHaveBeenNthCalledWith(1, fakeWalletClient, { + address: fakeWalletClient.account.address, + cursor: 0n, + }) + expect(getPdpDataSets).toHaveBeenNthCalledWith(2, fakeWalletClient, { + address: fakeWalletClient.account.address, + cursor: 7n, + }) + expect(synapseStorage.createContext).toHaveBeenCalledTimes(2) + expect(synapseStorage.prepare).toHaveBeenCalledWith({ + dataSize: 1024n, + extraRunwayEpochs: 86400n, + context: [{ dataSetId: 42n }, { dataSetId: 43n }], + }) + }) + test('wallet costs --copies overrides how many contexts are priced', async () => { getPdpDataSets.mockResolvedValueOnce({ items: [] } as any) @@ -1462,7 +1494,7 @@ describe('dataset commands', () => { })) const result = await datasetDetailsCommand.run( - commandContext({ options: { dataSetId: 42, cursor: 5, limit: 1 } }) + commandContext({ options: { dataSetId: 42, cursor: '5', limit: 1 } }) ) expect(getPiecesWithMetadata).toHaveBeenCalledWith(fakeWalletClient, { @@ -1474,10 +1506,65 @@ describe('dataset commands', () => { expect(result.nextCursor).toBe('8') expect(result.cta.commands).toContainEqual({ command: 'dataset details', - options: { chain: 314159, dataSetId: 42, cursor: 8, limit: 1 }, + options: { chain: 314159, dataSetId: 42, cursor: '8', limit: 1 }, description: 'Show the next page of pieces (cursor 8)', }) }) + + // Cursors are opaque bigints from the SDK; 9007199254740993 (2^53 + 1) is + // not representable as a JS number, so any Number() round-trip corrupts it. + test('dataset details keeps cursors above 2^53 exact end to end', async () => { + getPiecesWithMetadata.mockImplementationOnce(async () => ({ + items: [], + nextCursor: 9007199254740995n, + })) + + const result = await datasetDetailsCommand.run( + commandContext({ + options: { dataSetId: 42, cursor: '9007199254740993', limit: 1 }, + }) + ) + + expect(getPiecesWithMetadata).toHaveBeenCalledWith(fakeWalletClient, { + dataSet: expect.anything(), + address: fakeWalletClient.account.address, + cursor: 9007199254740993n, + limit: 1n, + }) + expect(result.nextCursor).toBe('9007199254740995') + expect(result.cta.commands).toContainEqual({ + command: 'dataset details', + options: { + chain: 314159, + dataSetId: 42, + cursor: '9007199254740995', + limit: 1, + }, + description: 'Show the next page of pieces (cursor 9007199254740995)', + }) + }) + + test('dataset list walks every page and keeps all datasets', async () => { + const secondDataSet = { ...fakeDataSet, dataSetId: 43n } + getPdpDataSets + .mockImplementationOnce(async () => ({ + items: [fakeDataSet], + nextCursor: 101n, + })) + .mockImplementationOnce(async () => ({ items: [secondDataSet] })) + + const result = await datasetListCommand.run(commandContext()) + + expect(getPdpDataSets).toHaveBeenNthCalledWith(1, fakeWalletClient, { + address: fakeWalletClient.account.address, + cursor: 0n, + }) + expect(getPdpDataSets).toHaveBeenNthCalledWith(2, fakeWalletClient, { + address: fakeWalletClient.account.address, + cursor: 101n, + }) + expect(result.datasets.map((ds: any) => ds.dataSetId)).toEqual(['42', '43']) + }) }) describe('piece commands', () => { @@ -1526,7 +1613,7 @@ describe('piece commands', () => { const result = await pieceListCommand.run( commandContext({ args: { dataSetId: 42 }, - options: { cursor: 5, limit: 1 }, + options: { cursor: '5', limit: 1 }, }) ) @@ -1540,11 +1627,39 @@ describe('piece commands', () => { expect(result.cta.commands).toContainEqual({ command: 'piece list', args: { dataSetId: 42 }, - options: { chain: 314159, cursor: 8, limit: 1 }, + options: { chain: 314159, cursor: '8', limit: 1 }, description: 'Show the next page of pieces (cursor 8)', }) }) + test('piece list keeps cursors above 2^53 exact end to end', async () => { + getPiecesWithMetadata.mockImplementationOnce(async () => ({ + items: [], + nextCursor: 9007199254740995n, + })) + + const result = await pieceListCommand.run( + commandContext({ + args: { dataSetId: 42 }, + options: { cursor: '9007199254740993', limit: 1 }, + }) + ) + + expect(getPiecesWithMetadata).toHaveBeenCalledWith(fakeWalletClient, { + dataSet: expect.anything(), + address: fakeWalletClient.account.address, + cursor: 9007199254740993n, + limit: 1n, + }) + expect(result.nextCursor).toBe('9007199254740995') + expect(result.cta.commands).toContainEqual({ + command: 'piece list', + args: { dataSetId: 42 }, + options: { chain: 314159, cursor: '9007199254740995', limit: 1 }, + description: 'Show the next page of pieces (cursor 9007199254740995)', + }) + }) + test('piece remove schedules deletion and waits for the transaction', async () => { const result = await pieceRemoveCommand.run( commandContext({ args: { dataSetId: 42, pieceId: 7 } })