Skip to content

Commit b0bd0d5

Browse files
committed
feat: add pin name support
- add name parameter to pin.add and pin.addAll - add names flag to pin.ls (auto-enabled with name filter) - filter undefined/null values in URL parameters - handle mixed arrays in normalise-input - add validation for conflicting options - add comprehensive tests Note: one test fails due to kubo bug fixed in unreleased commit 38be7908b. Re-test once kubo 0.38+ ships with the fix.
1 parent d20a7ff commit b0bd0d5

File tree

10 files changed

+305
-12
lines changed

10 files changed

+305
-12
lines changed

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@
205205
"it-pipe": "^3.0.1",
206206
"it-tar": "^6.0.0",
207207
"it-to-buffer": "^4.0.5",
208-
"kubo": "^0.29.0",
208+
"kubo": "^0.37.0",
209209
"mock-ipfs-pinning-service": "^0.4.2",
210210
"nock": "^14.0.1",
211211
"p-defer": "^4.0.0",

src/lib/pins/normalise-input.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export interface Pinnable {
66
cid?: CID
77
recursive?: boolean
88
metadata?: any
9+
name?: string
910
}
1011

1112
export type ToPin = CID | string | Pinnable
@@ -15,6 +16,7 @@ export interface Pin {
1516
path: string | CID
1617
recursive: boolean
1718
metadata?: any
19+
name?: string
1820
}
1921

2022
function isIterable (thing: any): thing is IterableIterator<any> & Iterator<any> {
@@ -110,7 +112,14 @@ export async function * normaliseInput (input: Source): AsyncGenerator<Pin> {
110112
if (first.value.cid != null || first.value.path != null) {
111113
yield toPin(first.value)
112114
for (const obj of iterator) {
113-
yield toPin(obj)
115+
// Handle mixed iterables - obj could be CID or Pinnable
116+
if (isCID(obj)) {
117+
yield toPin({ cid: obj })
118+
} else if (typeof obj === 'string') {
119+
yield toPin({ path: obj })
120+
} else {
121+
yield toPin(obj)
122+
}
114123
}
115124
return
116125
}
@@ -146,7 +155,14 @@ export async function * normaliseInput (input: Source): AsyncGenerator<Pin> {
146155
if (first.value.cid != null || first.value.path != null) {
147156
yield toPin(first.value)
148157
for await (const obj of iterator) {
149-
yield toPin(obj)
158+
// Handle mixed async iterables - obj could be CID or Pinnable
159+
if (isCID(obj)) {
160+
yield toPin({ cid: obj })
161+
} else if (typeof obj === 'string') {
162+
yield toPin({ path: obj })
163+
} else {
164+
yield toPin(obj)
165+
}
150166
}
151167
return
152168
}
@@ -158,10 +174,10 @@ export async function * normaliseInput (input: Source): AsyncGenerator<Pin> {
158174
}
159175

160176
function toPin (input: Pinnable): Pin {
161-
const path = input.cid ?? `${input.path}`
177+
const path = input.cid ?? (input.path != null ? `${input.path}` : undefined)
162178

163179
if (path == null) {
164-
throw new InvalidParametersError('Unexpected input: Please path either a CID or an IPFS path')
180+
throw new InvalidParametersError('Unexpected input: Please pass either a CID or an IPFS path')
165181
}
166182

167183
const pin: Pin = {
@@ -173,5 +189,9 @@ function toPin (input: Pinnable): Pin {
173189
pin.metadata = input.metadata
174190
}
175191

192+
if (input.name != null) {
193+
pin.name = input.name
194+
}
195+
176196
return pin
177197
}

src/lib/to-url-search-params.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,15 @@ export function toUrlSearchParams ({ arg, searchParams, hashAlg, mtime, mode, ..
3535
arg = [arg]
3636
}
3737

38-
const urlSearchParams = new URLSearchParams(options)
38+
// Filter out undefined and null values to avoid sending "undefined" or "null" as strings
39+
const filteredOptions: Record<string, any> = {}
40+
for (const [key, value] of Object.entries(options)) {
41+
if (value !== undefined && value !== null) {
42+
filteredOptions[key] = value
43+
}
44+
}
45+
46+
const urlSearchParams = new URLSearchParams(filteredOptions)
3947

4048
arg.forEach((arg: any) => {
4149
urlSearchParams.append('arg', arg)

src/pin/add-all.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ import type { HTTPRPCClient } from '../lib/core.js'
66

77
export function createAddAll (client: HTTPRPCClient): PinAPI['addAll'] {
88
return async function * addAll (source, options = {}) {
9-
for await (const { path, recursive, metadata } of normaliseInput(source)) {
9+
for await (const { path, recursive, metadata, name } of normaliseInput(source)) {
1010
const res = await client.post('pin/add', {
1111
signal: options.signal,
1212
searchParams: toUrlSearchParams({
1313
...options,
14-
arg: path,
14+
arg: path.toString(),
1515
recursive,
1616
metadata: metadata != null ? JSON.stringify(metadata) : undefined,
17+
name: name ?? options.name,
1718
stream: true
1819
}),
1920
headers: options.headers

src/pin/index.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ export interface PinAddAllOptions extends HTTPRPCOptions {
4141
* Internal option used to control whether to create a repo write lock during a pinning operation
4242
*/
4343
lock?: boolean
44+
45+
/**
46+
* An optional name for created pin(s)
47+
*/
48+
name?: string
4449
}
4550

4651
export type PinAddInput = CID | PinAddInputWithOptions
@@ -65,16 +70,42 @@ export interface PinAddInputWithOptions {
6570
* A human readable string to store with this pin
6671
*/
6772
comments?: string
73+
74+
/**
75+
* An optional name for the created pin
76+
*/
77+
name?: string
6878
}
6979

7080
export type PinType = 'recursive' | 'direct' | 'indirect' | 'all'
7181

7282
export type PinQueryType = 'recursive' | 'direct' | 'indirect' | 'all'
7383

7484
export interface PinLsOptions extends HTTPRPCOptions {
85+
/**
86+
* Path(s) to specific object(s) to be listed
87+
*/
7588
paths?: CID | CID[] | string | string[]
89+
90+
/**
91+
* The type of pinned keys to list. Can be "direct", "indirect", "recursive", or "all".
92+
*
93+
* @default "all"
94+
*/
7695
type?: PinQueryType
96+
97+
/**
98+
* Limit returned pins to ones with names that contain the value provided (case-sensitive, partial match).
99+
* Implies names=true.
100+
*/
77101
name?: string
102+
103+
/**
104+
* Include pin names in the output (slower, disabled by default).
105+
*
106+
* @default false
107+
*/
108+
names?: boolean
78109
}
79110

80111
export interface PinLsResult {

src/pin/ls.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,25 @@ export function createLs (client: HTTPRPCClient): PinAPI['ls'] {
2727
paths = Array.isArray(options.paths) ? options.paths : [options.paths]
2828
}
2929

30+
// Check for conflicting options
31+
if (options.name != null && options.names === false) {
32+
throw new Error('Cannot use name filter when names is explicitly set to false')
33+
}
34+
35+
// Check for empty name filter
36+
if (options.name === '') {
37+
throw new Error('Name filter cannot be empty string')
38+
}
39+
40+
// If name filter is provided, automatically enable names flag
41+
const names = options.names ?? (options.name != null)
42+
3043
const res = await client.post('pin/ls', {
3144
signal: options.signal,
3245
searchParams: toUrlSearchParams({
3346
...options,
3447
arg: paths.map(path => `${path}`),
48+
names,
3549
stream: true
3650
}),
3751
headers: options.headers

test/interface-tests/src/pin/add-all.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,5 +117,102 @@ export function testAddAll (factory: Factory<KuboNode>, options: MochaConfig): v
117117
}
118118
}())
119119
})
120+
121+
it('should add pins with individual names', async () => {
122+
const pins = await all(ipfs.pin.addAll([
123+
{
124+
cid: fixtures.files[0].cid,
125+
name: 'pin-1'
126+
},
127+
{
128+
cid: fixtures.files[1].cid,
129+
name: 'pin-2'
130+
}
131+
]))
132+
133+
expect(pins).to.have.deep.members([
134+
fixtures.files[0].cid,
135+
fixtures.files[1].cid
136+
])
137+
138+
// Verify names were set (need to use names flag to see them)
139+
const pinList = await all(ipfs.pin.ls({ names: true }))
140+
const pin1 = pinList.find(p => p.cid.equals(fixtures.files[0].cid))
141+
const pin2 = pinList.find(p => p.cid.equals(fixtures.files[1].cid))
142+
143+
expect(pin1?.name).to.equal('pin-1')
144+
expect(pin2?.name).to.equal('pin-2')
145+
})
146+
147+
it('should add pins with a global name option', async () => {
148+
const globalName = 'global-pin-name'
149+
const pins = await all(ipfs.pin.addAll([
150+
fixtures.files[0].cid,
151+
fixtures.files[1].cid
152+
], { name: globalName }))
153+
154+
expect(pins).to.have.deep.members([
155+
fixtures.files[0].cid,
156+
fixtures.files[1].cid
157+
])
158+
159+
// Verify global name was applied (name filter automatically enables names)
160+
const pinList = await all(ipfs.pin.ls({ name: globalName }))
161+
expect(pinList).to.have.lengthOf(2)
162+
expect(pinList.map(p => p.name)).to.deep.equal([globalName, globalName])
163+
})
164+
165+
it('should prioritize individual names over global option', async () => {
166+
const globalName = 'global-name'
167+
const individualName = 'individual-name'
168+
169+
const pins = await all(ipfs.pin.addAll([
170+
{
171+
cid: fixtures.files[0].cid,
172+
name: individualName
173+
},
174+
fixtures.files[1].cid
175+
], { name: globalName }))
176+
177+
expect(pins).to.have.deep.members([
178+
fixtures.files[0].cid,
179+
fixtures.files[1].cid
180+
])
181+
182+
// Verify individual name took precedence (need names flag)
183+
const pinList = await all(ipfs.pin.ls({ names: true }))
184+
const pin1 = pinList.find(p => p.cid.equals(fixtures.files[0].cid))
185+
const pin2 = pinList.find(p => p.cid.equals(fixtures.files[1].cid))
186+
187+
expect(pin1?.name).to.equal(individualName)
188+
expect(pin2?.name).to.equal(globalName)
189+
})
190+
191+
it('should add pins without names', async () => {
192+
const pins = await all(ipfs.pin.addAll([
193+
fixtures.files[0].cid,
194+
fixtures.files[1].cid
195+
]))
196+
197+
expect(pins).to.have.deep.members([
198+
fixtures.files[0].cid,
199+
fixtures.files[1].cid
200+
])
201+
202+
// Verify no names were set
203+
// Without names flag, names should be undefined
204+
const pinList = await all(ipfs.pin.ls())
205+
const pin1 = pinList.find(p => p.cid.equals(fixtures.files[0].cid))
206+
const pin2 = pinList.find(p => p.cid.equals(fixtures.files[1].cid))
207+
expect(pin1?.name).to.be.undefined()
208+
expect(pin2?.name).to.be.undefined()
209+
210+
// Even with names flag, they should still be undefined since no names were set
211+
const pinListWithNames = await all(ipfs.pin.ls({ names: true }))
212+
const pin1WithNames = pinListWithNames.find(p => p.cid.equals(fixtures.files[0].cid))
213+
const pin2WithNames = pinListWithNames.find(p => p.cid.equals(fixtures.files[1].cid))
214+
expect(pin1WithNames?.name).to.be.undefined()
215+
expect(pin2WithNames?.name).to.be.undefined()
216+
})
120217
})
121218
}

test/interface-tests/src/pin/add.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,5 +177,63 @@ export function testAdd (factory: Factory<KuboNode>, options: MochaConfig): void
177177
type: 'indirect'
178178
})
179179
})
180+
181+
it('should add a pin with a name', async () => {
182+
const pinName = 'test-pin-name'
183+
const cid = await ipfs.pin.add(fixtures.files[0].cid, {
184+
name: pinName
185+
})
186+
expect(cid).to.deep.equal(fixtures.files[0].cid)
187+
188+
// Using name filter automatically enables names flag
189+
const pins = await all(ipfs.pin.ls({ name: pinName }))
190+
expect(pins).to.have.lengthOf(1)
191+
expect(pins[0]).to.deep.include({
192+
cid: fixtures.files[0].cid,
193+
type: 'recursive',
194+
name: pinName
195+
})
196+
})
197+
198+
it('should add a pin without a name', async () => {
199+
const cid = await ipfs.pin.add(fixtures.files[0].cid)
200+
expect(cid).to.deep.equal(fixtures.files[0].cid)
201+
202+
// Check without names flag - name should be undefined
203+
const pins = await all(ipfs.pin.ls({ paths: fixtures.files[0].cid }))
204+
expect(pins).to.have.lengthOf(1)
205+
expect(pins[0].cid).to.deep.equal(fixtures.files[0].cid)
206+
expect(pins[0].name).to.be.undefined()
207+
208+
// Also verify with names flag - should still be undefined since no name was set
209+
const pinsWithNames = await all(ipfs.pin.ls({ paths: fixtures.files[0].cid, names: true }))
210+
expect(pinsWithNames).to.have.lengthOf(1)
211+
expect(pinsWithNames[0].cid).to.deep.equal(fixtures.files[0].cid)
212+
expect(pinsWithNames[0].name).to.be.undefined()
213+
})
214+
215+
it('should update pin name when pinning again with different name', async () => {
216+
const firstName = 'first-name'
217+
const secondName = 'second-name'
218+
219+
// Pin with first name
220+
await ipfs.pin.add(fixtures.files[0].cid, { name: firstName })
221+
222+
// Verify first name (name filter automatically enables names flag)
223+
let pins = await all(ipfs.pin.ls({ name: firstName }))
224+
expect(pins).to.have.lengthOf(1)
225+
expect(pins[0].name).to.equal(firstName)
226+
227+
// Pin again with second name
228+
await ipfs.pin.add(fixtures.files[0].cid, { name: secondName })
229+
230+
// Verify name was updated
231+
pins = await all(ipfs.pin.ls({ name: firstName }))
232+
expect(pins).to.have.lengthOf(0)
233+
234+
pins = await all(ipfs.pin.ls({ name: secondName }))
235+
expect(pins).to.have.lengthOf(1)
236+
expect(pins[0].name).to.equal(secondName)
237+
})
180238
})
181239
}

0 commit comments

Comments
 (0)