Skip to content
Open
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
24 changes: 19 additions & 5 deletions packages/core/src/create-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import type {
IpnsPublishOptions,
IpnsResolveOptions,
} from './ipns/types.js';
import type { MeshkitClient, MeshkitConfig, RetrieveOptions, StoredObject, UploadOptions } from './types.js';
import { countPinsViaRpc } from './pin-count.js';
import type { ListPinsOptions, MeshkitClient, MeshkitConfig, RetrieveOptions, StoredObject, UploadOptions } from './types.js';
import { MeshkitError } from './types.js';

function concatChunks(chunks: Uint8Array[], totalLength: number): Uint8Array {
Expand Down Expand Up @@ -115,12 +116,25 @@ export function createMeshkitClient(config: MeshkitConfig): MeshkitClient {
return keys.map((key) => ({ id: key.id, name: key.name }));
},

async listPins(): Promise<string[]> {
const cids = new Set<string>();
async listPins(options?: ListPinsOptions): Promise<string[]> {
const cids: string[] = [];
const offset = options?.offset ?? 0;
let skipped = 0;
for await (const { cid } of ipfs.pin.ls({ type: 'all' })) {
cids.add(cid.toString());
if (skipped < offset) {
skipped++;
continue;
}
cids.push(cid.toString());
if (options?.limit !== undefined && cids.length >= options.limit) {
break;
}
}
return [...cids];
return cids;
},

async countPins() {
return countPinsViaRpc(config.apiUrl, config.headers);
},

list() {
Expand Down
16 changes: 13 additions & 3 deletions packages/core/src/create-filone-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { sha256 } from 'multiformats/hashes/sha2';
import * as raw from 'multiformats/codecs/raw';
import { decrypt, encrypt, isEncryptedPayload } from './crypto.js';
import { MeshkitError } from './types.js';
import type { MeshkitClient, RetrieveOptions, StoredObject, UploadOptions } from './types.js';
import type { ListPinsOptions, MeshkitClient, PinCount, RetrieveOptions, StoredObject, UploadOptions } from './types.js';

export interface S3StorageConfig {
accessKeyId: string;
Expand Down Expand Up @@ -183,9 +183,19 @@ export function createS3Client(config: S3StorageConfig): MeshkitClient {

async pin(_cid: string): Promise<void> {},

async listPins(): Promise<string[]> {
async listPins(options?: ListPinsOptions): Promise<string[]> {
const objects = await listAllObjects();
return objects.map((o) => o.key);
const keys = objects.map((o) => o.key);
const offset = options?.offset ?? 0;
return options?.limit === undefined
? keys.slice(offset)
: keys.slice(offset, offset + options.limit);
},

async countPins(): Promise<PinCount> {
const objects = await listAllObjects();
const total = objects.length;
return { direct: 0, recursive: total, indirect: 0, total };
},

async list(): Promise<StoredObject[]> {
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ export type {
IpnsPublishOptions,
IpnsPublishResult,
IpnsResolveOptions,
PinCount,
ListPinsOptions,
} from './types.js';
export { MeshkitError } from './types.js';
export { countPinsViaRpc, applyPinLsLine } from './pin-count.js';

export { Meshkit } from './meshkit.js';

Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/meshkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
IpnsResolveOptions,
} from './ipns/types.js';
import type {
ListPinsOptions,
Meshkit as MeshkitFacade,
MeshkitClient,
MeshkitInitOptions,
Expand Down Expand Up @@ -92,8 +93,12 @@ export class Meshkit implements MeshkitFacade {
return withPrimary(this.clients, (client) => client.listKeys());
}

listPins() {
return withPrimary(this.clients, (client) => client.listPins());
listPins(options?: ListPinsOptions) {
return withPrimary(this.clients, (client) => client.listPins(options));
}

countPins() {
return withPrimary(this.clients, (client) => client.countPins());
}

list(): Promise<StoredObject[]> {
Expand Down
95 changes: 95 additions & 0 deletions packages/core/src/pin-count.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { MeshkitError } from './types.js';
import type { PinCount } from './types.js';

interface PinLsLine {
Cid?: string;
Type?: string;
Keys?: Record<string, { Type?: string }>;
Pins?: string[];
}

/**
* Count pins by type on a Kubo node by streaming `pin ls --type=all`.
*
* Tallies counts line-by-line without accumulating CIDs, so memory usage is
* constant regardless of pinset size — unlike `listPins`, which returns the
* full list. Handles the streamed NDJSON format (`{"Cid":...,"Type":...}`)
* and the legacy `Keys` mapping. The legacy `Pins` array format carries no
* pin type and is ignored.
*/
export async function countPinsViaRpc(
apiUrl: string,
headers?: Record<string, string>,
): Promise<PinCount> {
const url = new URL('/api/v0/pin/ls', apiUrl);
url.searchParams.set('type', 'all');
url.searchParams.set('stream', 'true');

const response = await fetch(url, {
method: 'POST',
...(headers ? { headers } : {}),
});
if (!response.ok) {
throw new MeshkitError(
`Failed to list pins at ${apiUrl} (HTTP ${response.status}).`,
);
}
if (!response.body) {
throw new MeshkitError(`Failed to list pins at ${apiUrl} (empty body).`);
}

const counts: PinCount = { direct: 0, recursive: 0, indirect: 0, total: 0 };
let buffer = '';
const decoder = new TextDecoder();

// Node's fetch returns a web ReadableStream, async-iterable on Node 20+.
for await (const chunk of response.body as AsyncIterable<Uint8Array>) {
buffer += decoder.decode(chunk, { stream: true });
let newlineIndex: number;
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
applyPinLsLine(buffer.slice(0, newlineIndex), counts);
buffer = buffer.slice(newlineIndex + 1);
}
}
buffer += decoder.decode();
applyPinLsLine(buffer, counts);

return counts;
}

/** Tally a single NDJSON line from Kubo `pin ls` output into `counts`. */
export function applyPinLsLine(line: string, counts: PinCount): void {
const trimmed = line.trim();
if (!trimmed) {
return;
}

let parsed: PinLsLine;
try {
parsed = JSON.parse(trimmed) as PinLsLine;
} catch {
return;
}

if (parsed.Type) {
bump(counts, parsed.Type);
}
if (parsed.Keys) {
for (const entry of Object.values(parsed.Keys)) {
if (entry?.Type) {
bump(counts, entry.Type);
}
}
}
}

function bump(counts: PinCount, type: string): void {
if (type === 'direct') {
counts.direct++;
} else if (type === 'recursive') {
counts.recursive++;
} else if (type === 'indirect') {
counts.indirect++;
}
counts.total++;
}
48 changes: 44 additions & 4 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,33 @@ export interface StoredObject {
uploadedAt: string;
}

/**
* Pin counts by type, as reported by Kubo `pin ls --type=all`.
* `indirect` pins are deduplicated child blocks of recursive pins.
*/
export interface PinCount {
/** Number of direct pins. */
direct: number;
/** Number of recursive pins (roots). */
recursive: number;
/** Number of indirect pins (children of recursive pins). */
indirect: number;
/** Sum of all pin types. */
total: number;
}

/**
* Pagination options for listing pins.
* When `limit` is set, implementations stream and stop early instead of
* materializing the full pinset — important on nodes with millions of pins.
*/
export interface ListPinsOptions {
/** Maximum number of pinned CIDs to return. */
limit?: number;
/** Number of pins to skip before collecting results. */
offset?: number;
}

export interface MeshkitClient {
/**
* Upload raw bytes to the connected IPFS node. Returns the CID string.
Expand Down Expand Up @@ -141,8 +168,18 @@ export interface MeshkitClient {
/** List keys in the node's keystore (includes `"self"`). */
listKeys(): Promise<IpnsKey[]>;

/** List all pinned CIDs on the connected node. */
listPins(): Promise<string[]>;
/**
* List pinned CIDs on the connected node.
* When `options.limit` is set the pinset is streamed and iteration stops
* early instead of materializing every pin.
*/
listPins(options?: ListPinsOptions): Promise<string[]>;

/**
* Count pins by type on the connected node without returning the full list.
* Streams the pinset and tallies counts — safe for very large pinsets.
*/
countPins(): Promise<PinCount>;

/**
* List all stored objects with metadata.
Expand Down Expand Up @@ -213,8 +250,11 @@ export interface Meshkit {
/** List keys on the primary node's keystore. */
listKeys(): Promise<IpnsKey[]>;

/** List all pinned CIDs on the primary node. */
listPins(): Promise<string[]>;
/** List pinned CIDs on the primary node (see `ListPinsOptions` for pagination). */
listPins(options?: ListPinsOptions): Promise<string[]>;

/** Count pins by type on the primary node without returning the full list. */
countPins(): Promise<PinCount>;

/**
* List all stored objects with metadata.
Expand Down
50 changes: 50 additions & 0 deletions packages/core/test/create-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,56 @@ describe('createMeshkitClient', () => {
expect(ipfs.pin.ls).toHaveBeenCalledWith({ type: 'all' });
});

it('listPins stops early when a limit is given', async () => {
const yielded: string[] = [];
async function* pins() {
for (const cid of ['QmA', 'QmB', 'QmC', 'QmD']) {
yielded.push(cid);
yield { cid: { toString: () => cid }, type: 'recursive' };
}
}
ipfs.pin.ls.mockReturnValue(pins());

const client = createMeshkitClient({ apiUrl: 'http://127.0.0.1:5001' });
await expect(client.listPins({ limit: 2 })).resolves.toEqual(['QmA', 'QmB']);
// Iterator stopped after the limit instead of draining the pinset.
expect(yielded).toEqual(['QmA', 'QmB']);
});

it('listPins skips offset pins before collecting', async () => {
async function* pins() {
for (const cid of ['QmA', 'QmB', 'QmC']) {
yield { cid: { toString: () => cid }, type: 'recursive' };
}
}
ipfs.pin.ls.mockReturnValue(pins());

const client = createMeshkitClient({ apiUrl: 'http://127.0.0.1:5001' });
await expect(client.listPins({ offset: 1, limit: 1 })).resolves.toEqual([
'QmB',
]);
});

it('countPins streams the pinset and returns counts by type', async () => {
const body = [
'{"Cid":"QmA","Type":"recursive"}',
'{"Cid":"QmB","Type":"direct"}',
'{"Cid":"QmC","Type":"indirect"}',
].join('\n');
const fetchMock = vi.fn(async () => new Response(body));
vi.stubGlobal('fetch', fetchMock);

const client = createMeshkitClient({ apiUrl: 'http://127.0.0.1:5001' });
await expect(client.countPins()).resolves.toEqual({
direct: 1,
recursive: 1,
indirect: 1,
total: 3,
});

vi.unstubAllGlobals();
});

it('healthCheck calls ipfs.id()', async () => {
ipfs.id.mockResolvedValue({});

Expand Down
1 change: 1 addition & 0 deletions packages/core/test/helpers/mock-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function createMockClient(
generateKey: async () => ({ name: 'self', id: 'QmSelf' }),
listKeys: async () => [{ name: 'self', id: 'QmSelf' }],
listPins: async () => [],
countPins: async () => ({ direct: 0, recursive: 0, indirect: 0, total: 0 }),
healthCheck: async () => undefined,
...overrides,
};
Expand Down
45 changes: 45 additions & 0 deletions packages/core/test/meshkit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,4 +245,49 @@ describe('Meshkit operations', () => {
expect(listPins).toHaveBeenCalledOnce();
expect(secondaryListPins).not.toHaveBeenCalled();
});

it('listPins passes pagination options to the primary client', async () => {
const listPins = vi.fn(async () => ['QmB']);

vi.spyOn(health, 'filterHealthy').mockResolvedValue({
clients: [createMockClient({ listPins })],
urls: ['http://primary:5001'],
failed: [],
});

const mk = await Meshkit.init({ nodes: ['http://primary:5001'] });

await expect(mk.listPins({ limit: 1, offset: 1 })).resolves.toEqual(['QmB']);
expect(listPins).toHaveBeenCalledWith({ limit: 1, offset: 1 });
});

it('countPins uses primary node only', async () => {
const countPins = vi.fn(
async () => ({ direct: 0, recursive: 2, indirect: 1, total: 3 }),
);
const secondaryCountPins = vi.fn();

vi.spyOn(health, 'filterHealthy').mockResolvedValue({
clients: [
createMockClient({ countPins }),
createMockClient({ countPins: secondaryCountPins }),
],
urls: ['http://primary:5001', 'http://secondary:5001'],
failed: [],
});

const mk = await Meshkit.init({
nodes: ['http://primary:5001', 'http://secondary:5001'],
});

await expect(mk.countPins()).resolves.toEqual({
direct: 0,
recursive: 2,
indirect: 1,
total: 3,
});

expect(countPins).toHaveBeenCalledOnce();
expect(secondaryCountPins).not.toHaveBeenCalled();
});
});
Loading