From 7d1e77d7c0fb0440930e6cb15b23a2382e075365 Mon Sep 17 00:00:00 2001 From: tabcat Date: Fri, 24 Oct 2025 18:20:25 -0500 Subject: [PATCH 01/49] add refresh property to metadata --- packages/ipns/src/pb/metadata.proto | 2 ++ packages/ipns/src/pb/metadata.ts | 13 ++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/ipns/src/pb/metadata.proto b/packages/ipns/src/pb/metadata.proto index 143d9445b..6f0ae066e 100644 --- a/packages/ipns/src/pb/metadata.proto +++ b/packages/ipns/src/pb/metadata.proto @@ -6,4 +6,6 @@ message IPNSPublishMetadata { // Lifetime is the duration of the signature validity in milliseconds uint32 lifetime = 2; + + bool refresh = 3; } diff --git a/packages/ipns/src/pb/metadata.ts b/packages/ipns/src/pb/metadata.ts index c4d25d59d..88bea825c 100644 --- a/packages/ipns/src/pb/metadata.ts +++ b/packages/ipns/src/pb/metadata.ts @@ -5,6 +5,7 @@ import type { Uint8ArrayList } from 'uint8arraylist' export interface IPNSPublishMetadata { keyName: string lifetime: number + refresh: boolean } export namespace IPNSPublishMetadata { @@ -27,13 +28,19 @@ export namespace IPNSPublishMetadata { w.uint32(obj.lifetime) } + if ((obj.refresh != null && obj.refresh !== false)) { + w.uint32(16) + w.bool(obj.refresh) + } + if (opts.lengthDelimited !== false) { w.ldelim() } }, (reader, length, opts = {}) => { const obj: any = { keyName: '', - lifetime: 0 + lifetime: 0, + refresh: false } const end = length == null ? reader.len : reader.pos + length @@ -50,6 +57,10 @@ export namespace IPNSPublishMetadata { obj.lifetime = reader.uint32() break } + case 3: { + obj.refresh = reader.bool() + break + } default: { reader.skipType(tag & 7) break From 32f5d6164745f3b05477da36305ed2536536ee50 Mon Sep 17 00:00:00 2001 From: tabcat Date: Fri, 24 Oct 2025 18:21:57 -0500 Subject: [PATCH 02/49] conditionally refresh localStore record --- packages/ipns/src/local-store.ts | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/ipns/src/local-store.ts b/packages/ipns/src/local-store.ts index 4d325a8aa..9012adf54 100644 --- a/packages/ipns/src/local-store.ts +++ b/packages/ipns/src/local-store.ts @@ -54,18 +54,20 @@ export function localStore (datastore: Datastore, log: Logger): LocalStore { try { const key = dhtRoutingKey(routingKey) - // don't overwrite existing, identical records as this will affect the - // TTL - try { - const existingBuf = await datastore.get(key) - const existingRecord = Record.deserialize(existingBuf) - - if (uint8ArrayEquals(existingRecord.value, marshalledRecord)) { - return - } - } catch (err: any) { - if (err.name !== 'NotFoundError') { - throw err + if (options.metadata?.refresh !== true) { + // don't overwrite existing, identical records as this will affect the + // TTL + try { + const existingBuf = await datastore.get(key) + const existingRecord = Record.deserialize(existingBuf) + + if (uint8ArrayEquals(existingRecord.value, marshalledRecord)) { + return + } + } catch (err: any) { + if (err.name !== 'NotFoundError') { + throw err + } } } From 4c2e7b15f139adb53beaa96899eaefd0f17fbe14 Mon Sep 17 00:00:00 2001 From: tabcat Date: Fri, 24 Oct 2025 18:33:37 -0500 Subject: [PATCH 03/49] PutOptions.metadata is Partial --- packages/ipns/src/routing/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ipns/src/routing/index.ts b/packages/ipns/src/routing/index.ts index 4221faf4d..621650fc5 100644 --- a/packages/ipns/src/routing/index.ts +++ b/packages/ipns/src/routing/index.ts @@ -6,7 +6,7 @@ import type { AbortOptions } from '@libp2p/interface' import type { ProgressOptions } from 'progress-events' export interface PutOptions extends AbortOptions, ProgressOptions { - metadata?: IPNSPublishMetadata + metadata?: Partial } export interface GetOptions extends AbortOptions, ProgressOptions { From c6d5d0a8b9f1742a1b5667c8e402b91f25aa4ad8 Mon Sep 17 00:00:00 2001 From: tabcat Date: Fri, 24 Oct 2025 19:21:20 -0500 Subject: [PATCH 04/49] #republish method supports refresh --- packages/ipns/src/ipns/republisher.ts | 31 +++++++++++++++++++++------ packages/ipns/src/utils.ts | 16 +++++++++++--- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index 3a36bcf2f..c52d9ce53 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -1,13 +1,15 @@ import { Queue, repeatingTask } from '@libp2p/utils' import { createIPNSRecord, marshalIPNSRecord, unmarshalIPNSRecord } from 'ipns' import { DEFAULT_REPUBLISH_CONCURRENCY, DEFAULT_REPUBLISH_INTERVAL_MS, DEFAULT_TTL_NS } from '../constants.ts' -import { shouldRepublish } from '../utils.js' +import { shouldRefresh, shouldRepublish } from '../utils.js' import type { LocalStore } from '../local-store.js' import type { IPNSRouting } from '../routing/index.js' import type { AbortOptions, ComponentLogger, Libp2p, Logger, PrivateKey } from '@libp2p/interface' import type { Keychain } from '@libp2p/keychain' import type { RepeatingTask } from '@libp2p/utils' import type { IPNSRecord } from 'ipns' +import { ipnsValidator } from 'ipns/validator' +import type { IPNSRecordMetadata } from '../index.ts' export interface IPNSRepublisherComponents { logger: ComponentLogger @@ -77,7 +79,7 @@ export class IPNSRepublisher { }) try { - const recordsToRepublish: Array<{ routingKey: Uint8Array, record: IPNSRecord }> = [] + const recordsToRepublish: Array<{ routingKey: Uint8Array, record: Uint8Array, refresh?: boolean }> = [] // Find all records using the localStore.list method for await (const { routingKey, record, metadata, created } of this.localStore.list(options)) { @@ -95,6 +97,22 @@ export class IPNSRepublisher { continue } + if (metadata.refresh) { + try { + await ipnsValidator(routingKey, record) + } catch (err: any) { + this.log('unable to refresh expired record - %e', err) + await this.localStore.delete(routingKey, options) + continue + } + if (shouldRefresh(created)) { + recordsToRepublish.push({ routingKey, record, refresh: true }) + } else { + this.log.trace(`skipping record ${routingKey.toString()} within republish threshold`) + } + continue + } + // Only republish records that are within the DHT or record expiry threshold if (!shouldRepublish(ipnsRecord, created)) { this.log.trace(`skipping record ${routingKey.toString()}within republish threshold`) @@ -107,12 +125,12 @@ export class IPNSRepublisher { try { privKey = await this.keychain.exportKey(metadata.keyName) } catch (err: any) { - this.log.error(`missing key ${metadata.keyName}, skipping republishing record`, err) + this.log.error(`missing key ${metadata.keyName}, skipping republishing record for ${routingKey.toString()}`, err) continue } try { const updatedRecord = await createIPNSRecord(privKey, ipnsRecord.value, sequenceNumber, metadata.lifetime, { ...options, ttlNs }) - recordsToRepublish.push({ routingKey, record: updatedRecord }) + recordsToRepublish.push({ routingKey, record: marshalIPNSRecord(updatedRecord) }) } catch (err: any) { this.log.error(`error creating updated IPNS record for ${routingKey.toString()}`, err) continue @@ -122,13 +140,12 @@ export class IPNSRepublisher { this.log(`found ${recordsToRepublish.length} records to republish`) // Republish each record - for (const { routingKey, record } of recordsToRepublish) { + for (const { routingKey, record, refresh } of recordsToRepublish) { // Add job to queue to republish the record to all routers queue.add(async () => { try { - const marshaledRecord = marshalIPNSRecord(record) await Promise.all( - this.routers.map(r => r.put(routingKey, marshaledRecord, options)) + this.routers.map(r => r.put(routingKey, record, { ...options, metadata: { refresh } })) ) } catch (err: any) { this.log.error('error republishing record - %e', err) diff --git a/packages/ipns/src/utils.ts b/packages/ipns/src/utils.ts index 32e9aaa20..452d7957e 100644 --- a/packages/ipns/src/utils.ts +++ b/packages/ipns/src/utils.ts @@ -43,11 +43,9 @@ export function ipnsMetadataKey (key: Uint8Array): Key { export function shouldRepublish (ipnsRecord: IPNSRecord, created: Date): boolean { const now = Date.now() - const dhtExpiry = created.getTime() + DHT_EXPIRY_MS const recordExpiry = new Date(ipnsRecord.validity).getTime() - // If the DHT expiry is within the threshold, republish it - if (dhtExpiry - now < REPUBLISH_THRESHOLD) { + if (shouldRefresh(created)) { return true } @@ -59,6 +57,18 @@ export function shouldRepublish (ipnsRecord: IPNSRecord, created: Date): boolean return false } +export function shouldRefresh (created: Date): boolean { + const now = Date.now() + const dhtExpiry = created.getTime() + DHT_EXPIRY_MS + + // If the DHT expiry is within the threshold, republish it + if (dhtExpiry - now < REPUBLISH_THRESHOLD) { + return true + } + + return false +} + function isCID (obj?: any): obj is CID { return obj?.asCID === obj } From 28b48fd7255196629962456bc52587c80b4376cd Mon Sep 17 00:00:00 2001 From: tabcat Date: Fri, 24 Oct 2025 19:26:30 -0500 Subject: [PATCH 05/49] use overwrite options instead of metadata.refresh --- packages/ipns/src/ipns/republisher.ts | 8 ++++---- packages/ipns/src/local-store.ts | 2 +- packages/ipns/src/routing/index.ts | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index c52d9ce53..7ac4a5b99 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -79,7 +79,7 @@ export class IPNSRepublisher { }) try { - const recordsToRepublish: Array<{ routingKey: Uint8Array, record: Uint8Array, refresh?: boolean }> = [] + const recordsToRepublish: Array<{ routingKey: Uint8Array, record: Uint8Array, overwrite?: boolean }> = [] // Find all records using the localStore.list method for await (const { routingKey, record, metadata, created } of this.localStore.list(options)) { @@ -106,7 +106,7 @@ export class IPNSRepublisher { continue } if (shouldRefresh(created)) { - recordsToRepublish.push({ routingKey, record, refresh: true }) + recordsToRepublish.push({ routingKey, record, overwrite: true }) } else { this.log.trace(`skipping record ${routingKey.toString()} within republish threshold`) } @@ -140,12 +140,12 @@ export class IPNSRepublisher { this.log(`found ${recordsToRepublish.length} records to republish`) // Republish each record - for (const { routingKey, record, refresh } of recordsToRepublish) { + for (const { routingKey, record, overwrite } of recordsToRepublish) { // Add job to queue to republish the record to all routers queue.add(async () => { try { await Promise.all( - this.routers.map(r => r.put(routingKey, record, { ...options, metadata: { refresh } })) + this.routers.map(r => r.put(routingKey, record, { ...options, overwrite })) ) } catch (err: any) { this.log.error('error republishing record - %e', err) diff --git a/packages/ipns/src/local-store.ts b/packages/ipns/src/local-store.ts index 9012adf54..811fb7cdc 100644 --- a/packages/ipns/src/local-store.ts +++ b/packages/ipns/src/local-store.ts @@ -54,7 +54,7 @@ export function localStore (datastore: Datastore, log: Logger): LocalStore { try { const key = dhtRoutingKey(routingKey) - if (options.metadata?.refresh !== true) { + if (options.overwrite !== true) { // don't overwrite existing, identical records as this will affect the // TTL try { diff --git a/packages/ipns/src/routing/index.ts b/packages/ipns/src/routing/index.ts index 621650fc5..a9facf2b8 100644 --- a/packages/ipns/src/routing/index.ts +++ b/packages/ipns/src/routing/index.ts @@ -7,6 +7,7 @@ import type { ProgressOptions } from 'progress-events' export interface PutOptions extends AbortOptions, ProgressOptions { metadata?: Partial + overwrite?: boolean } export interface GetOptions extends AbortOptions, ProgressOptions { From 5fe9d7d40da2f2799ce493ae78ab53640aadbcf7 Mon Sep 17 00:00:00 2001 From: tabcat Date: Fri, 24 Oct 2025 19:46:20 -0500 Subject: [PATCH 06/49] add refresh and unrefresh to republisher --- packages/ipns/src/index.ts | 37 ++++++++++ packages/ipns/src/ipns.ts | 15 +++- packages/ipns/src/ipns/republisher.ts | 98 +++++++++++++++++++++++---- packages/ipns/src/utils.ts | 6 +- 4 files changed, 139 insertions(+), 17 deletions(-) diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index f4f7d3efd..7c9c9a9bf 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -251,6 +251,18 @@ export interface ResolveOptions extends AbortOptions, ProgressOptions + + /** + * Regularly publish the latest known existing IPNS record for `key` + * + * Refreshing keep an existing IPNS record resolvable until it expires or + * `unrefresh` is called for the same key + */ + refresh(key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options?: RefreshOptions): Promise + + /** + * Stop refreshing of an existing IPNS record + * + * This will delete the IPNS record from the datastore + * + * Note that the record may still be resolved by other peers until it expires + * or is no longer valid. + */ + unrefresh(key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options?: AbortOptions): Promise } export type { IPNSRouting } from './routing/index.js' diff --git a/packages/ipns/src/ipns.ts b/packages/ipns/src/ipns.ts index b1d8ff6d3..383285d5a 100644 --- a/packages/ipns/src/ipns.ts +++ b/packages/ipns/src/ipns.ts @@ -5,7 +5,7 @@ import { IPNSResolver } from './ipns/resolver.ts' import { localStore } from './local-store.js' import { helia } from './routing/helia.js' import { localStoreRouting } from './routing/local-store.ts' -import type { IPNSComponents, IPNS as IPNSInterface, IPNSOptions, IPNSPublishResult, IPNSResolveResult, PublishOptions, ResolveOptions } from './index.js' +import type { IPNSComponents, IPNS as IPNSInterface, IPNSOptions, IPNSPublishResult, IPNSRefreshResult, IPNSResolveResult, PublishOptions, RefreshOptions, ResolveOptions } from './index.js' import type { LocalStore } from './local-store.js' import type { IPNSRouting } from './routing/index.js' import type { AbortOptions, PeerId, PublicKey, Startable } from '@libp2p/interface' @@ -34,13 +34,14 @@ export class IPNS implements IPNSInterface, Startable { routers: this.routers, localStore: this.localStore }) - this.republisher = new IPNSRepublisher(components, { + this.resolver = new IPNSResolver(components, { ...init, routers: this.routers, localStore: this.localStore }) - this.resolver = new IPNSResolver(components, { + this.republisher = new IPNSRepublisher(components, { ...init, + resolver: this.resolver, routers: this.routers, localStore: this.localStore }) @@ -84,4 +85,12 @@ export class IPNS implements IPNSInterface, Startable { async unpublish (keyName: string, options?: AbortOptions): Promise { return this.publisher.unpublish(keyName, options) } + + async refresh(key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: RefreshOptions = {}): Promise { + return this.republisher.refresh(key, options) + } + + async unrefresh(key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: AbortOptions = {}): Promise { + return this.republisher.unrefresh(key, options) + } } diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index 7ac4a5b99..76147f57b 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -1,15 +1,18 @@ import { Queue, repeatingTask } from '@libp2p/utils' -import { createIPNSRecord, marshalIPNSRecord, unmarshalIPNSRecord } from 'ipns' +import { createIPNSRecord, marshalIPNSRecord, multihashFromIPNSRoutingKey, multihashToIPNSRoutingKey, unmarshalIPNSRecord } from 'ipns' import { DEFAULT_REPUBLISH_CONCURRENCY, DEFAULT_REPUBLISH_INTERVAL_MS, DEFAULT_TTL_NS } from '../constants.ts' -import { shouldRefresh, shouldRepublish } from '../utils.js' +import { keyToMultihash, shouldRefresh, shouldRepublish } from '../utils.js' import type { LocalStore } from '../local-store.js' import type { IPNSRouting } from '../routing/index.js' -import type { AbortOptions, ComponentLogger, Libp2p, Logger, PrivateKey } from '@libp2p/interface' +import { NotFoundError, type AbortOptions, type ComponentLogger, type Libp2p, type Logger, type PeerId, type PrivateKey, type PublicKey } from '@libp2p/interface' import type { Keychain } from '@libp2p/keychain' import type { RepeatingTask } from '@libp2p/utils' import type { IPNSRecord } from 'ipns' import { ipnsValidator } from 'ipns/validator' -import type { IPNSRecordMetadata } from '../index.ts' +import { ipnsSelector, type IPNSRefreshResult, type RefreshOptions } from '../index.ts' +import { equals as uint8ArrayEquals } from 'uint8arrays/equals' +import type { CID, MultihashDigest } from 'multiformats/cid' +import type { IPNSResolver } from './resolver.ts' export interface IPNSRepublisherComponents { logger: ComponentLogger @@ -19,6 +22,7 @@ export interface IPNSRepublisherComponents { export interface IPNSRepublisherInit { republishConcurrency?: number republishInterval?: number + resolver: IPNSResolver routers: IPNSRouting[] localStore: LocalStore } @@ -26,6 +30,7 @@ export interface IPNSRepublisherInit { export class IPNSRepublisher { public readonly routers: IPNSRouting[] private readonly localStore: LocalStore + private readonly resolver: IPNSResolver private readonly republishTask: RepeatingTask private readonly log: Logger private readonly keychain: Keychain @@ -35,6 +40,7 @@ export class IPNSRepublisher { constructor (components: IPNSRepublisherComponents, init: IPNSRepublisherInit) { this.log = components.logger.forComponent('helia:ipns') this.localStore = init.localStore + this.resolver = init.resolver this.keychain = components.libp2p.services.keychain this.republishConcurrency = init.republishConcurrency || DEFAULT_REPUBLISH_CONCURRENCY this.started = components.libp2p.status === 'started' @@ -79,7 +85,7 @@ export class IPNSRepublisher { }) try { - const recordsToRepublish: Array<{ routingKey: Uint8Array, record: Uint8Array, overwrite?: boolean }> = [] + const recordsToRepublish: Array<{ routingKey: Uint8Array, record: IPNSRecord, overwrite?: boolean }> = [] // Find all records using the localStore.list method for await (const { routingKey, record, metadata, created } of this.localStore.list(options)) { @@ -98,18 +104,22 @@ export class IPNSRepublisher { } if (metadata.refresh) { + // resolve the latest record + let latestRecord: IPNSRecord try { - await ipnsValidator(routingKey, record) + const { record } = await this.resolver.resolve(multihashFromIPNSRoutingKey(routingKey)) + latestRecord = record } catch (err: any) { - this.log('unable to refresh expired record - %e', err) - await this.localStore.delete(routingKey, options) + this.log('unable to find record to refresh - %e', err) continue } - if (shouldRefresh(created)) { - recordsToRepublish.push({ routingKey, record, overwrite: true }) - } else { + + if (!shouldRefresh(created)) { this.log.trace(`skipping record ${routingKey.toString()} within republish threshold`) + continue } + + recordsToRepublish.push({ routingKey, record: latestRecord, overwrite: true }) continue } @@ -130,7 +140,7 @@ export class IPNSRepublisher { } try { const updatedRecord = await createIPNSRecord(privKey, ipnsRecord.value, sequenceNumber, metadata.lifetime, { ...options, ttlNs }) - recordsToRepublish.push({ routingKey, record: marshalIPNSRecord(updatedRecord) }) + recordsToRepublish.push({ routingKey, record: updatedRecord }) } catch (err: any) { this.log.error(`error creating updated IPNS record for ${routingKey.toString()}`, err) continue @@ -145,7 +155,7 @@ export class IPNSRepublisher { queue.add(async () => { try { await Promise.all( - this.routers.map(r => r.put(routingKey, record, { ...options, overwrite })) + this.routers.map(r => r.put(routingKey, marshalIPNSRecord(record), { ...options, overwrite })) ) } catch (err: any) { this.log.error('error republishing record - %e', err) @@ -158,4 +168,66 @@ export class IPNSRepublisher { await queue.onIdle(options) // Wait for all jobs to complete } + + async refresh(key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: RefreshOptions = {}): Promise { + let records: IPNSRecord[] = [] + let publishedRecord: IPNSRecord | null = null + const digest = keyToMultihash(key) + const routingKey = multihashToIPNSRoutingKey(digest) + + // collect records for key + if (options.record != null) { + // add user supplied record + await ipnsValidator(routingKey, marshalIPNSRecord(options.record)) + records.push(options.record) + } + try { + // add local record + const { record } = await this.resolver.resolve(key, { offline: true }) + records.push(record) + } catch (err: any) { + if (err.name !== 'NotFoundError') { + throw err + } + } + try { + // add published record + const { record } = await this.resolver.resolve(key) + publishedRecord = record + records.push(record) + } catch (err: any) { + if (err.name !== 'NotFoundError') { + throw err + } + } + + if (records.length === 0) { + throw new NotFoundError(`Found no records to refresh for key ${routingKey.toString()}`) + } + + // check if record is already published + const selectedRecord = records[ipnsSelector(routingKey, records.map(marshalIPNSRecord))] + const marshaledRecord = marshalIPNSRecord(selectedRecord) + if (options.force !== true && publishedRecord != null && uint8ArrayEquals(marshaledRecord, marshalIPNSRecord(publishedRecord))) { + throw new Error('The record is already being published') + } + + // publish record to routers + try { + // overwrite so Record.created is reset for #republish + const putOptions = { ...options, metadata: { refresh: true }, overwrite: true } + await Promise.all( + this.routers.map(r => r.put(routingKey, marshaledRecord, putOptions)) + ) + } catch (err: any) { + this.log.error('error republishing record - %e', err) + } + + return { record: selectedRecord } + } + + async unrefresh(key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: AbortOptions = {}): Promise { + const routingKey = multihashToIPNSRoutingKey(keyToMultihash(key)) + await this.localStore.delete(routingKey) + } } diff --git a/packages/ipns/src/utils.ts b/packages/ipns/src/utils.ts index 452d7957e..0bb5c7b5a 100644 --- a/packages/ipns/src/utils.ts +++ b/packages/ipns/src/utils.ts @@ -1,4 +1,4 @@ -import { InvalidParametersError } from '@libp2p/interface' +import { InvalidParametersError, isPeerId, isPublicKey, type PeerId, type PublicKey } from '@libp2p/interface' import { Key } from 'interface-datastore' import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import { DHT_EXPIRY_MS, REPUBLISH_THRESHOLD } from './constants.ts' @@ -88,3 +88,7 @@ export function isLibp2pCID (obj?: any): obj is CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId) { + return isPublicKey(key) || isPeerId(key) ? key.toMultihash() : isLibp2pCID(key) ? key.multihash : key +} From a67063f6038ffcac2f02fe266b8529528dead829 Mon Sep 17 00:00:00 2001 From: tabcat Date: Sat, 25 Oct 2025 14:48:58 -0500 Subject: [PATCH 07/49] add RefreshOptions.repeat --- packages/ipns/src/index.ts | 7 +++++++ packages/ipns/src/ipns/republisher.ts | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index 7c9c9a9bf..72b83c185 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -261,6 +261,13 @@ export interface RefreshOptions { * Force the record to be published immediately even if it's already resolvable */ force?: boolean + + /** + * Refresh the latest known record for the key on a regularly basis + * + * @default true + */ + repeat?: boolean } export interface ResolveResult { diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index 76147f57b..7214f3b01 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -215,7 +215,11 @@ export class IPNSRepublisher { // publish record to routers try { // overwrite so Record.created is reset for #republish - const putOptions = { ...options, metadata: { refresh: true }, overwrite: true } + const putOptions = { + ...options, + metadata: options.repeat ? { refresh: true } : undefined, + overwrite: true + } await Promise.all( this.routers.map(r => r.put(routingKey, marshaledRecord, putOptions)) ) From fa80d541b67e81195c64c06e37040ca37c1ef59f Mon Sep 17 00:00:00 2001 From: tabcat Date: Sat, 25 Oct 2025 14:50:09 -0500 Subject: [PATCH 08/49] add @default jsdoc tage to Refresh.force --- packages/ipns/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index 72b83c185..8d3db9939 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -259,6 +259,8 @@ export interface RefreshOptions { /** * Force the record to be published immediately even if it's already resolvable + * + * @default false */ force?: boolean From b90b87794a1fd6af94b54b9e0017c9f6c1f7efec Mon Sep 17 00:00:00 2001 From: tabcat Date: Sat, 25 Oct 2025 19:02:41 -0500 Subject: [PATCH 09/49] fix bool tag --- packages/ipns/src/pb/metadata.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ipns/src/pb/metadata.ts b/packages/ipns/src/pb/metadata.ts index 88bea825c..7ac62f04d 100644 --- a/packages/ipns/src/pb/metadata.ts +++ b/packages/ipns/src/pb/metadata.ts @@ -29,7 +29,7 @@ export namespace IPNSPublishMetadata { } if ((obj.refresh != null && obj.refresh !== false)) { - w.uint32(16) + w.uint32(24) w.bool(obj.refresh) } From 1e0602e593d4a607bf8d63aa370a95d60254c3b7 Mon Sep 17 00:00:00 2001 From: tabcat Date: Sat, 25 Oct 2025 22:07:58 -0500 Subject: [PATCH 10/49] log unable to refresh record as error --- packages/ipns/src/ipns/republisher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index 7214f3b01..d200e2606 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -110,7 +110,7 @@ export class IPNSRepublisher { const { record } = await this.resolver.resolve(multihashFromIPNSRoutingKey(routingKey)) latestRecord = record } catch (err: any) { - this.log('unable to find record to refresh - %e', err) + this.log.error('unable to find record to refresh - %e', err) continue } From 9286a631c3aa3c728fb45810beae093c2496e18b Mon Sep 17 00:00:00 2001 From: tabcat Date: Sun, 26 Oct 2025 15:14:38 -0500 Subject: [PATCH 11/49] move refresh records processing outside iterator --- packages/ipns/src/ipns/republisher.ts | 66 +++++++++++++++++---------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index d200e2606..b28c1555f 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -2,7 +2,7 @@ import { Queue, repeatingTask } from '@libp2p/utils' import { createIPNSRecord, marshalIPNSRecord, multihashFromIPNSRoutingKey, multihashToIPNSRoutingKey, unmarshalIPNSRecord } from 'ipns' import { DEFAULT_REPUBLISH_CONCURRENCY, DEFAULT_REPUBLISH_INTERVAL_MS, DEFAULT_TTL_NS } from '../constants.ts' import { keyToMultihash, shouldRefresh, shouldRepublish } from '../utils.js' -import type { LocalStore } from '../local-store.js' +import type { ListResult, LocalStore } from '../local-store.js' import type { IPNSRouting } from '../routing/index.js' import { NotFoundError, type AbortOptions, type ComponentLogger, type Libp2p, type Logger, type PeerId, type PrivateKey, type PublicKey } from '@libp2p/interface' import type { Keychain } from '@libp2p/keychain' @@ -85,7 +85,8 @@ export class IPNSRepublisher { }) try { - const recordsToRepublish: Array<{ routingKey: Uint8Array, record: IPNSRecord, overwrite?: boolean }> = [] + const recordsToRepublish: Array<{ routingKey: Uint8Array, record: IPNSRecord }> = [] + const recordsToRefresh: Array> = [] // Find all records using the localStore.list method for await (const { routingKey, record, metadata, created } of this.localStore.list(options)) { @@ -95,6 +96,14 @@ export class IPNSRepublisher { this.log(`no metadata found for record ${routingKey.toString()}, skipping`) continue } + + if (metadata.refresh) { + // processing records to refresh may require writing to localStore + // so that is done outside of query iterator + recordsToRefresh.push({ routingKey, created }) + continue + } + let ipnsRecord: IPNSRecord try { ipnsRecord = unmarshalIPNSRecord(record) @@ -103,26 +112,6 @@ export class IPNSRepublisher { continue } - if (metadata.refresh) { - // resolve the latest record - let latestRecord: IPNSRecord - try { - const { record } = await this.resolver.resolve(multihashFromIPNSRoutingKey(routingKey)) - latestRecord = record - } catch (err: any) { - this.log.error('unable to find record to refresh - %e', err) - continue - } - - if (!shouldRefresh(created)) { - this.log.trace(`skipping record ${routingKey.toString()} within republish threshold`) - continue - } - - recordsToRepublish.push({ routingKey, record: latestRecord, overwrite: true }) - continue - } - // Only republish records that are within the DHT or record expiry threshold if (!shouldRepublish(ipnsRecord, created)) { this.log.trace(`skipping record ${routingKey.toString()}within republish threshold`) @@ -149,19 +138,46 @@ export class IPNSRepublisher { this.log(`found ${recordsToRepublish.length} records to republish`) - // Republish each record - for (const { routingKey, record, overwrite } of recordsToRepublish) { + // Republish or refresh each record + for (const { routingKey, record } of recordsToRepublish) { // Add job to queue to republish the record to all routers queue.add(async () => { try { await Promise.all( - this.routers.map(r => r.put(routingKey, marshalIPNSRecord(record), { ...options, overwrite })) + this.routers.map(r => r.put(routingKey, marshalIPNSRecord(record), options)) ) } catch (err: any) { this.log.error('error republishing record - %e', err) } }, options) } + for (const { routingKey, created } of recordsToRefresh) { + // resolve the latest record + let latestRecord: IPNSRecord + try { + const { record } = await this.resolver.resolve(multihashFromIPNSRoutingKey(routingKey)) + latestRecord = record + } catch (err: any) { + this.log.error('unable to find record to refresh - %e', err) + continue + } + + if (!shouldRefresh(created)) { + this.log.trace(`skipping record ${routingKey.toString()} within republish threshold`) + continue + } + + // Add job to queue to refresh the record to all routers + queue.add(async () => { + try { + await Promise.all( + this.routers.map(r => r.put(routingKey, marshalIPNSRecord(latestRecord), { ...options, overwrite: true })) + ) + } catch (err: any) { + this.log.error('error refreshing record - %e', err) + } + }, options) + } } catch (err: any) { this.log.error('error during republish - %e', err) } From 486373c85a46ce61c14a6dbccfe058821bd80dec Mon Sep 17 00:00:00 2001 From: tabcat Date: Sun, 26 Oct 2025 15:20:43 -0500 Subject: [PATCH 12/49] test refresh feature in #republish tests --- packages/ipns/test/republish.spec.ts | 74 ++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index a057c5327..eacfc4728 100644 --- a/packages/ipns/test/republish.spec.ts +++ b/packages/ipns/test/republish.spec.ts @@ -10,6 +10,9 @@ import { localStore } from '../src/local-store.js' import { createIPNS } from './fixtures/create-ipns.js' import type { IPNS } from '../src/ipns.js' import type { CreateIPNSResult } from './fixtures/create-ipns.js' +import { dhtRoutingKey, ipnsMetadataKey } from '../src/utils.ts' +import { Record } from '@libp2p/kad-dht' +import { IPNSPublishMetadata } from '../src/pb/metadata.ts' // Helper to await until a stub is called function waitForStubCall (stub: sinon.SinonStub, callCount = 1): Promise { @@ -139,6 +142,31 @@ describe('republish', () => { const republishedRecord = unmarshalIPNSRecord(callArgs[1]) expect(republishedRecord.sequence).to.equal(2n) // Incremented from 1n }) + + it('should only refresh some records', async () => { + const key = await generateKeyPair('Ed25519') + const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // create a dht record with a created time < now - REPUBLISH_THRESHOLD + const dhtRecord = new Record(routingKey, marshalIPNSRecord(record), new Date(Date.now() - 24 * 60 * 60 * 1000)) + + // Store the dht record and metadata in the real datastore + await result.datastore.put(dhtRoutingKey(routingKey), dhtRecord.serialize()) + await result.datastore.put(ipnsMetadataKey(routingKey), IPNSPublishMetadata.encode({ refresh: true })) + + // Start publishing + await start(name) + await waitForStubCall(putStubCustom) + + // Verify the record was republished with incremented sequence + expect(putStubCustom.called).to.be.true() + const callArgs = putStubCustom.firstCall.args + expect(callArgs[0]).to.deep.equal(routingKey) + + const republishedRecord = unmarshalIPNSRecord(callArgs[1]) + expect(republishedRecord.sequence).to.equal(1n) // Incremented from 1n + }) }) describe('record processing', () => { @@ -203,6 +231,28 @@ describe('republish', () => { const republishedRecord = unmarshalIPNSRecord(callArgs[1]) expect(republishedRecord.sequence).to.equal(6n) // Incremented from 5n }) + + it('should skip refreshing records created within republish threshold', async () => { + const key = await generateKeyPair('Ed25519') + const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // Store the record in the real datastore using the localStore + const store = localStore(result.datastore, result.log) + await store.put(routingKey, marshalIPNSRecord(record), { + metadata: { + refresh: true + } + }) + + // Start publishing + await start(name) + await new Promise(resolve => setTimeout(resolve, 20)) + + // Should not republish due to unmarshal error + expect(putStubCustom.called).to.be.false() + expect(putStubHelia.called).to.be.false() + }) }) describe('TTL and lifetime', () => { @@ -401,6 +451,30 @@ describe('republish', () => { expect(putStubHelia.called).to.be.false() }) + it('should handle unable to find record to refresh', async () => { + const key = await generateKeyPair('Ed25519') + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // create an expired record + const record = await createIPNSRecord(key, testCid, 1n, 0) + + // Store the record in the real datastore using the localStore + const store = localStore(result.datastore, result.log) + await store.put(routingKey, marshalIPNSRecord(record), { + metadata: { + refresh: true + } + }) + + // Start publishing + await start(name) + await new Promise(resolve => setTimeout(resolve, 20)) + + // Should not republish due to unmarshal error + expect(putStubCustom.called).to.be.false() + expect(putStubHelia.called).to.be.false() + }) + it('should continue republishing other records when one record fails', async () => { const key1 = await generateKeyPair('Ed25519') const key2 = await generateKeyPair('Ed25519') From c7f1345949524c390cfc095c8dc4cc99c44bd8fd Mon Sep 17 00:00:00 2001 From: tabcat Date: Sun, 26 Oct 2025 20:05:03 -0500 Subject: [PATCH 13/49] wrap refresh logic in try/catch --- packages/ipns/src/index.ts | 7 ++- packages/ipns/src/ipns/republisher.ts | 77 ++++++++++++++------------- 2 files changed, 45 insertions(+), 39 deletions(-) diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index 8d3db9939..beb795f91 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -201,6 +201,11 @@ export type ResolveProgressEvents = ProgressEvent<'ipns:resolve:success', IPNSRecord> | ProgressEvent<'ipns:resolve:error', Error> +export type RefreshProgressEvents = + ProgressEvent<'ipns:refresh:start'> | + ProgressEvent<'ipns:refresh:success', IPNSRecord> | + ProgressEvent<'ipns:refresh:error', Error> + export type DatastoreProgressEvents = ProgressEvent<'ipns:routing:datastore:put'> | ProgressEvent<'ipns:routing:datastore:get'> | @@ -251,7 +256,7 @@ export interface ResolveOptions extends AbortOptions, ProgressOptions { /** * A candidate IPNS record to use if no newer records are found */ diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index b28c1555f..9db237247 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -13,6 +13,7 @@ import { ipnsSelector, type IPNSRefreshResult, type RefreshOptions } from '../in import { equals as uint8ArrayEquals } from 'uint8arrays/equals' import type { CID, MultihashDigest } from 'multiformats/cid' import type { IPNSResolver } from './resolver.ts' +import { CustomProgressEvent } from 'progress-events' export interface IPNSRepublisherComponents { logger: ComponentLogger @@ -191,59 +192,59 @@ export class IPNSRepublisher { const digest = keyToMultihash(key) const routingKey = multihashToIPNSRoutingKey(digest) - // collect records for key - if (options.record != null) { - // add user supplied record - await ipnsValidator(routingKey, marshalIPNSRecord(options.record)) - records.push(options.record) - } try { - // add local record - const { record } = await this.resolver.resolve(key, { offline: true }) - records.push(record) - } catch (err: any) { - if (err.name !== 'NotFoundError') { - throw err + // collect records for key + if (options.record != null) { + // add user supplied record + await ipnsValidator(routingKey, marshalIPNSRecord(options.record)) + records.push(options.record) } - } - try { - // add published record - const { record } = await this.resolver.resolve(key) - publishedRecord = record - records.push(record) - } catch (err: any) { - if (err.name !== 'NotFoundError') { - throw err + try { + // add local record + const { record } = await this.resolver.resolve(key, { offline: true }) + records.push(record) + } catch (err: any) { + if (err.name !== 'NotFoundError') { + throw err + } + } + try { + // add published record + const { record } = await this.resolver.resolve(key) + publishedRecord = record + records.push(record) + } catch (err: any) { + if (err.name !== 'NotFoundError') { + throw err + } + } + if (records.length === 0) { + throw new NotFoundError(`Found no records to refresh for key ${routingKey.toString()}`) } - } - - if (records.length === 0) { - throw new NotFoundError(`Found no records to refresh for key ${routingKey.toString()}`) - } - // check if record is already published - const selectedRecord = records[ipnsSelector(routingKey, records.map(marshalIPNSRecord))] - const marshaledRecord = marshalIPNSRecord(selectedRecord) - if (options.force !== true && publishedRecord != null && uint8ArrayEquals(marshaledRecord, marshalIPNSRecord(publishedRecord))) { - throw new Error('The record is already being published') - } + // check if record is already published + const selectedRecord = records[ipnsSelector(routingKey, records.map(marshalIPNSRecord))] + const marshaledRecord = marshalIPNSRecord(selectedRecord) + if (options.force !== true && publishedRecord != null && uint8ArrayEquals(marshaledRecord, marshalIPNSRecord(publishedRecord))) { + throw new Error('The record is already being published') + } - // publish record to routers - try { - // overwrite so Record.created is reset for #republish + // publish record to routers const putOptions = { ...options, metadata: options.repeat ? { refresh: true } : undefined, + // overwrite so Record.created is reset for #republish overwrite: true } await Promise.all( this.routers.map(r => r.put(routingKey, marshaledRecord, putOptions)) ) + + return { record: selectedRecord } } catch (err: any) { - this.log.error('error republishing record - %e', err) + options.onProgress?.(new CustomProgressEvent('ipns:refresh:error', err)) + throw err } - - return { record: selectedRecord } } async unrefresh(key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: AbortOptions = {}): Promise { From f2f6ea54d163caf8014d6954eb3c058eaa666cd2 Mon Sep 17 00:00:00 2001 From: tabcat Date: Tue, 28 Oct 2025 18:13:36 -0500 Subject: [PATCH 14/49] refactor(ipns)!: nocache does not read cache --- packages/ipns/src/ipns/resolver.ts | 6 +++++ packages/ipns/src/routing/local-store.ts | 29 ++++++++++++++++-------- packages/ipns/test/resolve.spec.ts | 4 ++++ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/ipns/src/ipns/resolver.ts b/packages/ipns/src/ipns/resolver.ts index 009f0383b..8685ab4c5 100644 --- a/packages/ipns/src/ipns/resolver.ts +++ b/packages/ipns/src/ipns/resolver.ts @@ -9,6 +9,7 @@ import * as Digest from 'multiformats/hashes/digest' import { DEFAULT_TTL_NS } from '../constants.ts' import { InvalidValueError, RecordsFailedValidationError, UnsupportedMultibasePrefixError, UnsupportedMultihashCodecError } from '../errors.js' import { isCodec, IDENTITY_CODEC, SHA2_256_CODEC, isLibp2pCID } from '../utils.js' +import { LocalStoreRouting } from '../routing/local-store.ts' import type { IPNSResolveResult, ResolveOptions, ResolveResult } from '../index.js' import type { LocalStore } from '../local-store.js' import type { IPNSRouting } from '../routing/index.js' @@ -177,6 +178,11 @@ export class IPNSResolver { this.routers.map(async (router) => { let record: Uint8Array + // skip checking cache when nocache is true + if (router instanceof LocalStoreRouting && options.nocache === true) { + return + } + try { record = await router.get(routingKey, { ...options, diff --git a/packages/ipns/src/routing/local-store.ts b/packages/ipns/src/routing/local-store.ts index 6a7f4c049..45ac2a8c1 100644 --- a/packages/ipns/src/routing/local-store.ts +++ b/packages/ipns/src/routing/local-store.ts @@ -1,18 +1,27 @@ import type { LocalStore } from '../local-store.ts' import type { IPNSRouting, PutOptions, GetOptions } from './index.ts' +export class LocalStoreRouting { + private localStore: LocalStore + + constructor(localStore: LocalStore) { + this.localStore = localStore + } + + async put (routingKey: Uint8Array, marshaledRecord: Uint8Array, options?: PutOptions): Promise { + await this.localStore.put(routingKey, marshaledRecord, options) + } + + async get (routingKey: Uint8Array, options?: GetOptions): Promise { + const { record } = await this.localStore.get(routingKey, options) + + return record + } +} + /** * Returns an IPNSRouting implementation that reads/writes to the local store */ export function localStoreRouting (localStore: LocalStore): IPNSRouting { - return { - async put (routingKey: Uint8Array, marshaledRecord: Uint8Array, options?: PutOptions): Promise { - await localStore.put(routingKey, marshaledRecord, options) - }, - async get (routingKey: Uint8Array, options?: GetOptions): Promise { - const { record } = await localStore.get(routingKey, options) - - return record - } - } + return new LocalStoreRouting(localStore) } diff --git a/packages/ipns/test/resolve.spec.ts b/packages/ipns/test/resolve.spec.ts index 4f0d9d26e..3572c3746 100644 --- a/packages/ipns/test/resolve.spec.ts +++ b/packages/ipns/test/resolve.spec.ts @@ -113,11 +113,15 @@ describe('resolve', () => { heliaRouting.get.resolves(marshalIPNSRecord(record)) + const callCount = cacheGetSpy.callCount const resolvedValue = await name.resolve(publicKey, { nocache: true }) expect(resolvedValue.cid.toString()).to.equal(cid.toV1().toString()) + // check that get only called once after resolve as result of put + expect(cacheGetSpy.callCount).to.equal(callCount + 1) + expect(heliaRouting.get.called).to.be.true() expect(customRouting.get.called).to.be.true() From 85b912fa577943d33ec8dfe3005ebe8479fc753d Mon Sep 17 00:00:00 2001 From: tabcat Date: Tue, 28 Oct 2025 18:24:18 -0500 Subject: [PATCH 15/49] cleanup record comments in refresh --- packages/ipns/src/ipns/republisher.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index 9db237247..f619f3404 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -195,12 +195,12 @@ export class IPNSRepublisher { try { // collect records for key if (options.record != null) { - // add user supplied record + // user supplied record await ipnsValidator(routingKey, marshalIPNSRecord(options.record)) records.push(options.record) } try { - // add local record + // local record const { record } = await this.resolver.resolve(key, { offline: true }) records.push(record) } catch (err: any) { @@ -209,7 +209,7 @@ export class IPNSRepublisher { } } try { - // add published record + // published record const { record } = await this.resolver.resolve(key) publishedRecord = record records.push(record) From ec19f5403e37a59d9180f796bf21355c778146d8 Mon Sep 17 00:00:00 2001 From: tabcat Date: Tue, 28 Oct 2025 18:25:43 -0500 Subject: [PATCH 16/49] published record via nocache --- packages/ipns/src/ipns/republisher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index f619f3404..10407f010 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -210,7 +210,7 @@ export class IPNSRepublisher { } try { // published record - const { record } = await this.resolver.resolve(key) + const { record } = await this.resolver.resolve(key, { nocache: true }) publishedRecord = record records.push(record) } catch (err: any) { From 7108a724fb05698e8353db7a4549443467271e05 Mon Sep 17 00:00:00 2001 From: tabcat Date: Wed, 29 Oct 2025 01:13:54 -0500 Subject: [PATCH 17/49] shorten error message in refresh --- packages/ipns/src/ipns/republisher.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index 10407f010..935bdea4c 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -219,14 +219,14 @@ export class IPNSRepublisher { } } if (records.length === 0) { - throw new NotFoundError(`Found no records to refresh for key ${routingKey.toString()}`) + throw new NotFoundError('Found no records to refresh') } // check if record is already published const selectedRecord = records[ipnsSelector(routingKey, records.map(marshalIPNSRecord))] const marshaledRecord = marshalIPNSRecord(selectedRecord) if (options.force !== true && publishedRecord != null && uint8ArrayEquals(marshaledRecord, marshalIPNSRecord(publishedRecord))) { - throw new Error('The record is already being published') + throw new Error('Record already published') } // publish record to routers From 3ab3052e36a2f92408dc76efd38ab0bb0efcdb2c Mon Sep 17 00:00:00 2001 From: tabcat Date: Wed, 29 Oct 2025 01:20:24 -0500 Subject: [PATCH 18/49] fix putOptions.metadata --- packages/ipns/src/ipns/republisher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index 935bdea4c..1f76860a4 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -232,7 +232,7 @@ export class IPNSRepublisher { // publish record to routers const putOptions = { ...options, - metadata: options.repeat ? { refresh: true } : undefined, + metadata: options.repeat !== false ? { refresh: true } : undefined, // overwrite so Record.created is reset for #republish overwrite: true } From 3142df1b56a248499a189e38b4884888595f9488 Mon Sep 17 00:00:00 2001 From: tabcat Date: Wed, 29 Oct 2025 01:41:14 -0500 Subject: [PATCH 19/49] add refresh tests --- packages/ipns/test/republish.spec.ts | 180 ++++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 1 deletion(-) diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index eacfc4728..8bec8756f 100644 --- a/packages/ipns/test/republish.spec.ts +++ b/packages/ipns/test/republish.spec.ts @@ -3,7 +3,7 @@ import { generateKeyPair } from '@libp2p/crypto/keys' import { start, stop } from '@libp2p/interface' import { expect } from 'aegir/chai' -import { createIPNSRecord, marshalIPNSRecord, unmarshalIPNSRecord, multihashToIPNSRoutingKey } from 'ipns' +import { createIPNSRecord, marshalIPNSRecord, unmarshalIPNSRecord, multihashToIPNSRoutingKey, multihashFromIPNSRoutingKey } from 'ipns' import { CID } from 'multiformats/cid' import sinon from 'sinon' import { localStore } from '../src/local-store.js' @@ -510,4 +510,182 @@ describe('republish', () => { expect(putStubHelia.called).to.be.true() }) }) + + describe('refresh', () => { + let getStubCustom: sinon.SinonStub + let getStubHelia: sinon.SinonStub + + beforeEach(async () => { + result = await createIPNS() + name = result.name + + // Stub the routers by default to reject + getStubCustom = sinon.stub().rejects() + getStubHelia = sinon.stub().rejects() + // @ts-ignore + result.customRouting.get = getStubCustom + // @ts-ignore + result.heliaRouting.get = getStubHelia + }) + + describe('basic functionality', () => { + it('should lookup latest record in cache and in routers', async () => { + const key = await generateKeyPair('Ed25519') + const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // Store the record in the real datastore + const store = localStore(result.datastore, result.log) + await store.put(routingKey, marshalIPNSRecord(record)) + + // @ts-ignore + const storeGetSpy = sinon.spy(name.localStore, 'get') + + await name.refresh(multihashFromIPNSRoutingKey(routingKey)) + + expect(storeGetSpy.called).to.be.true() + expect(getStubCustom.called).to.be.true() + expect(getStubHelia.called).to.be.true() + }) + + it('should only publish once if repeat is false', async () => { + const key = await generateKeyPair('Ed25519') + const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // Store the record in the real datastore + const store = localStore(result.datastore, result.log) + await store.put(routingKey, marshalIPNSRecord(record)) + + await name.refresh(multihashFromIPNSRoutingKey(routingKey), { repeat: false }) + + expect(() => result.datastore.get(ipnsMetadataKey(routingKey))).to.throw('Not Found') + }) + + it('should use options.record if necessary', async () => { + const key = await generateKeyPair('Ed25519') + const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + const refreshed = await name.refresh(multihashFromIPNSRoutingKey(routingKey), { record }) + + expect(refreshed.record).to.equal(record) + }) + + it('should write to metadata', async () => { + const key = await generateKeyPair('Ed25519') + const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // Store the record in the real datastore + const store = localStore(result.datastore, result.log) + await store.put(routingKey, marshalIPNSRecord(record)) + + await name.refresh(multihashFromIPNSRoutingKey(routingKey)) + + expect(() => result.datastore.get(ipnsMetadataKey(routingKey))).to.not.throw() + }) + + it('should overwrite the created date on the dht record', async () => { + const key = await generateKeyPair('Ed25519') + const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // Store the record in the real datastore + const store = localStore(result.datastore, result.log) + await store.put(routingKey, marshalIPNSRecord(record)) + + const { created } = await store.get(routingKey) + + await name.refresh(multihashFromIPNSRoutingKey(routingKey)) + + const { created: newCreated }= await store.get(routingKey) + + expect(newCreated).does.not.equal(created) + }) + + it('should publish the latest record record on all routers and return the published record', async () => { + const key = await generateKeyPair('Ed25519') + const record1 = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + const record2 = await createIPNSRecord(key, testCid, 2n, 24 * 60 * 60 * 1000) + const record3 = await createIPNSRecord(key, testCid, 3n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // Store the record in the real datastore + const store = localStore(result.datastore, result.log) + await store.put(routingKey, marshalIPNSRecord(record1)) + + // Stub the routers by default to reject + getStubCustom = sinon.stub().resolves(marshalIPNSRecord(record2)) + getStubHelia = sinon.stub().resolves(marshalIPNSRecord(record3)) + // @ts-ignore + result.customRouting.get = getStubCustom + // @ts-ignore + result.heliaRouting.get = getStubHelia + + // @ts-ignore + const storePutSpy = sinon.spy(name.localStore, 'put') + + const refreshed = await name.refresh(multihashFromIPNSRoutingKey(routingKey), { force: true }) + + expect(storePutSpy.called).to.be.true() + expect(result.customRouting.put.called).to.be.true() + expect(result.heliaRouting.put.called).to.be.true() + expect(refreshed.record.sequence).to.equal(3n) + }) + }) + + describe('error handling', () => { + it('should call options.onProgress on error', async () => { + const key = await generateKeyPair('Ed25519') + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + const onProgress = sinon.stub().resolves() + + await expect(name.refresh(multihashFromIPNSRoutingKey(routingKey), { onProgress })).to.be.rejected() + expect(onProgress.called).to.be.true() + }) + + it('should throw if options.record is invalid', async () => { + const key = await generateKeyPair('Ed25519') + const record = await createIPNSRecord(key, testCid, 1n, -1) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + await expect(name.refresh(multihashFromIPNSRoutingKey(routingKey), { record })).to.be.rejectedWith('record has expired') + }) + + it('should throw if no records were found to refresh', async () => { + const key = await generateKeyPair('Ed25519') + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + await expect(name.refresh(multihashFromIPNSRoutingKey(routingKey))).to.be.rejectedWith('Found no records to refresh') + }) + + it('should throw if the record is already published', async () => { + const key = await generateKeyPair('Ed25519') + const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + getStubCustom = sinon.stub().resolves(marshalIPNSRecord(record)) + // @ts-ignore + result.customRouting.get = getStubCustom + + await expect(name.refresh(multihashFromIPNSRoutingKey(routingKey))).to.be.rejectedWith('Record already published') + }) + }) + + describe('unrefresh', () => { + it('removes the local record and metadata', async () => { + const key = await generateKeyPair('Ed25519') + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // Store empty data in the real datastore + await result.datastore.put(dhtRoutingKey(routingKey), new Uint8Array()) + await result.datastore.put(ipnsMetadataKey(routingKey), new Uint8Array()) + + expect(async () => result.datastore.get(dhtRoutingKey(routingKey))).to.be.rejected + expect(async () => result.datastore.get(ipnsMetadataKey(routingKey))).to.be.rejected + }) + }) + }) }) From 1a5f2bcc25b7e424e8bdf586a1dc566914ee91b3 Mon Sep 17 00:00:00 2001 From: tabcat Date: Wed, 29 Oct 2025 16:31:04 -0500 Subject: [PATCH 20/49] lint --fix --- packages/ipns/src/ipns.ts | 4 ++-- packages/ipns/src/ipns/republisher.ts | 18 ++++++++++-------- packages/ipns/src/ipns/resolver.ts | 2 +- packages/ipns/src/routing/local-store.ts | 2 +- packages/ipns/src/utils.ts | 3 ++- packages/ipns/test/republish.spec.ts | 8 ++++---- 6 files changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/ipns/src/ipns.ts b/packages/ipns/src/ipns.ts index 383285d5a..1eac74363 100644 --- a/packages/ipns/src/ipns.ts +++ b/packages/ipns/src/ipns.ts @@ -86,11 +86,11 @@ export class IPNS implements IPNSInterface, Startable { return this.publisher.unpublish(keyName, options) } - async refresh(key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: RefreshOptions = {}): Promise { + async refresh (key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: RefreshOptions = {}): Promise { return this.republisher.refresh(key, options) } - async unrefresh(key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: AbortOptions = {}): Promise { + async unrefresh (key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: AbortOptions = {}): Promise { return this.republisher.unrefresh(key, options) } } diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index 6b0cd2b64..aaea3bcb4 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -1,19 +1,21 @@ +import { NotFoundError } from '@libp2p/interface' import { Queue, repeatingTask } from '@libp2p/utils' import { createIPNSRecord, marshalIPNSRecord, multihashFromIPNSRoutingKey, multihashToIPNSRoutingKey, unmarshalIPNSRecord } from 'ipns' +import { ipnsValidator } from 'ipns/validator' +import { CustomProgressEvent } from 'progress-events' +import { equals as uint8ArrayEquals } from 'uint8arrays/equals' import { DEFAULT_REPUBLISH_CONCURRENCY, DEFAULT_REPUBLISH_INTERVAL_MS, DEFAULT_TTL_NS } from '../constants.ts' +import { ipnsSelector } from '../index.ts' import { keyToMultihash, shouldRefresh, shouldRepublish } from '../utils.js' +import type {AbortOptions, ComponentLogger, Libp2p, Logger, PeerId, PrivateKey, PublicKey} from '@libp2p/interface'; import type { ListResult, LocalStore } from '../local-store.js' import type { IPNSRouting } from '../routing/index.js' -import { NotFoundError, type AbortOptions, type ComponentLogger, type Libp2p, type Logger, type PeerId, type PrivateKey, type PublicKey } from '@libp2p/interface' import type { Keychain } from '@libp2p/keychain' import type { RepeatingTask } from '@libp2p/utils' import type { IPNSRecord } from 'ipns' -import { ipnsValidator } from 'ipns/validator' -import { ipnsSelector, type IPNSRefreshResult, type RefreshOptions } from '../index.ts' -import { equals as uint8ArrayEquals } from 'uint8arrays/equals' +import type { IPNSRefreshResult, RefreshOptions } from '../index.ts' import type { CID, MultihashDigest } from 'multiformats/cid' import type { IPNSResolver } from './resolver.ts' -import { CustomProgressEvent } from 'progress-events' export interface IPNSRepublisherComponents { logger: ComponentLogger @@ -186,8 +188,8 @@ export class IPNSRepublisher { await queue.onIdle(options) // Wait for all jobs to complete } - async refresh(key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: RefreshOptions = {}): Promise { - let records: IPNSRecord[] = [] + async refresh (key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: RefreshOptions = {}): Promise { + const records: IPNSRecord[] = [] let publishedRecord: IPNSRecord | null = null const digest = keyToMultihash(key) const routingKey = multihashToIPNSRoutingKey(digest) @@ -247,7 +249,7 @@ export class IPNSRepublisher { } } - async unrefresh(key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: AbortOptions = {}): Promise { + async unrefresh (key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: AbortOptions = {}): Promise { const routingKey = multihashToIPNSRoutingKey(keyToMultihash(key)) await this.localStore.delete(routingKey) } diff --git a/packages/ipns/src/ipns/resolver.ts b/packages/ipns/src/ipns/resolver.ts index 8685ab4c5..a94477cb3 100644 --- a/packages/ipns/src/ipns/resolver.ts +++ b/packages/ipns/src/ipns/resolver.ts @@ -8,8 +8,8 @@ import { CID } from 'multiformats/cid' import * as Digest from 'multiformats/hashes/digest' import { DEFAULT_TTL_NS } from '../constants.ts' import { InvalidValueError, RecordsFailedValidationError, UnsupportedMultibasePrefixError, UnsupportedMultihashCodecError } from '../errors.js' -import { isCodec, IDENTITY_CODEC, SHA2_256_CODEC, isLibp2pCID } from '../utils.js' import { LocalStoreRouting } from '../routing/local-store.ts' +import { isCodec, IDENTITY_CODEC, SHA2_256_CODEC, isLibp2pCID } from '../utils.js' import type { IPNSResolveResult, ResolveOptions, ResolveResult } from '../index.js' import type { LocalStore } from '../local-store.js' import type { IPNSRouting } from '../routing/index.js' diff --git a/packages/ipns/src/routing/local-store.ts b/packages/ipns/src/routing/local-store.ts index 45ac2a8c1..5fe9bf425 100644 --- a/packages/ipns/src/routing/local-store.ts +++ b/packages/ipns/src/routing/local-store.ts @@ -4,7 +4,7 @@ import type { IPNSRouting, PutOptions, GetOptions } from './index.ts' export class LocalStoreRouting { private localStore: LocalStore - constructor(localStore: LocalStore) { + constructor (localStore: LocalStore) { this.localStore = localStore } diff --git a/packages/ipns/src/utils.ts b/packages/ipns/src/utils.ts index 0bb5c7b5a..f49c7b446 100644 --- a/packages/ipns/src/utils.ts +++ b/packages/ipns/src/utils.ts @@ -1,7 +1,8 @@ -import { InvalidParametersError, isPeerId, isPublicKey, type PeerId, type PublicKey } from '@libp2p/interface' +import { InvalidParametersError, isPeerId, isPublicKey } from '@libp2p/interface' import { Key } from 'interface-datastore' import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import { DHT_EXPIRY_MS, REPUBLISH_THRESHOLD } from './constants.ts' +import type { PeerId, PublicKey } from '@libp2p/interface' import type { IPNSRecord } from 'ipns' import type { CID } from 'multiformats/cid' import type { MultihashDigest } from 'multiformats/hashes/interface' diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index 8bec8756f..c39377d17 100644 --- a/packages/ipns/test/republish.spec.ts +++ b/packages/ipns/test/republish.spec.ts @@ -2,17 +2,17 @@ import { generateKeyPair } from '@libp2p/crypto/keys' import { start, stop } from '@libp2p/interface' +import { Record } from '@libp2p/kad-dht' import { expect } from 'aegir/chai' import { createIPNSRecord, marshalIPNSRecord, unmarshalIPNSRecord, multihashToIPNSRoutingKey, multihashFromIPNSRoutingKey } from 'ipns' import { CID } from 'multiformats/cid' import sinon from 'sinon' import { localStore } from '../src/local-store.js' +import { IPNSPublishMetadata } from '../src/pb/metadata.ts' +import { dhtRoutingKey, ipnsMetadataKey } from '../src/utils.ts' import { createIPNS } from './fixtures/create-ipns.js' import type { IPNS } from '../src/ipns.js' import type { CreateIPNSResult } from './fixtures/create-ipns.js' -import { dhtRoutingKey, ipnsMetadataKey } from '../src/utils.ts' -import { Record } from '@libp2p/kad-dht' -import { IPNSPublishMetadata } from '../src/pb/metadata.ts' // Helper to await until a stub is called function waitForStubCall (stub: sinon.SinonStub, callCount = 1): Promise { @@ -599,7 +599,7 @@ describe('republish', () => { await name.refresh(multihashFromIPNSRoutingKey(routingKey)) - const { created: newCreated }= await store.get(routingKey) + const { created: newCreated } = await store.get(routingKey) expect(newCreated).does.not.equal(created) }) From 13ace87c2f93eef6486c1727ee3c2aae913d4da4 Mon Sep 17 00:00:00 2001 From: tabcat Date: Wed, 29 Oct 2025 16:38:25 -0500 Subject: [PATCH 21/49] manual linter fixes --- packages/ipns/src/ipns/republisher.ts | 6 +++--- packages/ipns/src/utils.ts | 12 ++++++++++-- packages/ipns/test/republish.spec.ts | 4 ++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index aaea3bcb4..44e4f1c6b 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -7,15 +7,15 @@ import { equals as uint8ArrayEquals } from 'uint8arrays/equals' import { DEFAULT_REPUBLISH_CONCURRENCY, DEFAULT_REPUBLISH_INTERVAL_MS, DEFAULT_TTL_NS } from '../constants.ts' import { ipnsSelector } from '../index.ts' import { keyToMultihash, shouldRefresh, shouldRepublish } from '../utils.js' -import type {AbortOptions, ComponentLogger, Libp2p, Logger, PeerId, PrivateKey, PublicKey} from '@libp2p/interface'; +import type { IPNSRefreshResult, RefreshOptions } from '../index.ts' import type { ListResult, LocalStore } from '../local-store.js' +import type { IPNSResolver } from './resolver.ts' import type { IPNSRouting } from '../routing/index.js' +import type { AbortOptions, ComponentLogger, Libp2p, Logger, PeerId, PrivateKey, PublicKey } from '@libp2p/interface' import type { Keychain } from '@libp2p/keychain' import type { RepeatingTask } from '@libp2p/utils' import type { IPNSRecord } from 'ipns' -import type { IPNSRefreshResult, RefreshOptions } from '../index.ts' import type { CID, MultihashDigest } from 'multiformats/cid' -import type { IPNSResolver } from './resolver.ts' export interface IPNSRepublisherComponents { logger: ComponentLogger diff --git a/packages/ipns/src/utils.ts b/packages/ipns/src/utils.ts index f49c7b446..62839bb49 100644 --- a/packages/ipns/src/utils.ts +++ b/packages/ipns/src/utils.ts @@ -90,6 +90,14 @@ export function isLibp2pCID (obj?: any): obj is CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId) { - return isPublicKey(key) || isPeerId(key) ? key.toMultihash() : isLibp2pCID(key) ? key.multihash : key +export function keyToMultihash (key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId): MultihashDigest<0x00 | 0x012> { + if (isPublicKey(key) || isPeerId(key)) { + return key.toMultihash() + } + + if (isLibp2pCID(key)) { + return key.multihash + } + + return key } diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index c39377d17..da632589c 100644 --- a/packages/ipns/test/republish.spec.ts +++ b/packages/ipns/test/republish.spec.ts @@ -683,8 +683,8 @@ describe('republish', () => { await result.datastore.put(dhtRoutingKey(routingKey), new Uint8Array()) await result.datastore.put(ipnsMetadataKey(routingKey), new Uint8Array()) - expect(async () => result.datastore.get(dhtRoutingKey(routingKey))).to.be.rejected - expect(async () => result.datastore.get(ipnsMetadataKey(routingKey))).to.be.rejected + expect(() => result.datastore.get(dhtRoutingKey(routingKey))).to.throw('Not found') + expect(() => result.datastore.get(ipnsMetadataKey(routingKey))).to.throw('Not found') }) }) }) From d4788cea9c944a1f3e76fbe6d52d9b4e844b3824 Mon Sep 17 00:00:00 2001 From: tabcat Date: Wed, 29 Oct 2025 16:57:30 -0500 Subject: [PATCH 22/49] fix unrefresh test --- packages/ipns/test/republish.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index da632589c..c1e009541 100644 --- a/packages/ipns/test/republish.spec.ts +++ b/packages/ipns/test/republish.spec.ts @@ -683,8 +683,10 @@ describe('republish', () => { await result.datastore.put(dhtRoutingKey(routingKey), new Uint8Array()) await result.datastore.put(ipnsMetadataKey(routingKey), new Uint8Array()) - expect(() => result.datastore.get(dhtRoutingKey(routingKey))).to.throw('Not found') - expect(() => result.datastore.get(ipnsMetadataKey(routingKey))).to.throw('Not found') + await name.unrefresh(multihashFromIPNSRoutingKey(routingKey)) + + expect(() => result.datastore.get(dhtRoutingKey(routingKey))).to.throw('Not Found') + expect(() => result.datastore.get(ipnsMetadataKey(routingKey))).to.throw('Not Found') }) }) }) From 85c9e7c2574229ba4adca818a4c88bdefce42778 Mon Sep 17 00:00:00 2001 From: tabcat Date: Wed, 29 Oct 2025 21:22:16 -0500 Subject: [PATCH 23/49] undo pointless change --- packages/ipns/src/ipns/republisher.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index 44e4f1c6b..0d9487871 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -146,8 +146,9 @@ export class IPNSRepublisher { // Add job to queue to republish the record to all routers queue.add(async () => { try { + const marshaledRecord = marshalIPNSRecord(record) await Promise.all( - this.routers.map(r => r.put(routingKey, marshalIPNSRecord(record), options)) + this.routers.map(r => r.put(routingKey, marshaledRecord, options)) ) } catch (err: any) { this.log.error('error republishing record - %e', err) From e1a70170ba056cd92e30485b16ec8502ba2a7ab2 Mon Sep 17 00:00:00 2001 From: tabcat Date: Wed, 29 Oct 2025 22:19:13 -0500 Subject: [PATCH 24/49] spy on localStore not datastore --- packages/ipns/test/resolve.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/ipns/test/resolve.spec.ts b/packages/ipns/test/resolve.spec.ts index 3572c3746..b2b43e00f 100644 --- a/packages/ipns/test/resolve.spec.ts +++ b/packages/ipns/test/resolve.spec.ts @@ -113,14 +113,15 @@ describe('resolve', () => { heliaRouting.get.resolves(marshalIPNSRecord(record)) - const callCount = cacheGetSpy.callCount + // @ts-ignore + const storePutSpy = sinon.spy(name.localStore, 'put') const resolvedValue = await name.resolve(publicKey, { nocache: true }) expect(resolvedValue.cid.toString()).to.equal(cid.toV1().toString()) - // check that get only called once after resolve as result of put - expect(cacheGetSpy.callCount).to.equal(callCount + 1) + // check that localStore.get not called + expect(storePutSpy.called).to.be.false() expect(heliaRouting.get.called).to.be.true() expect(customRouting.get.called).to.be.true() From c572206fb8996a123b0ba1c3173d4c1a70eb1e83 Mon Sep 17 00:00:00 2001 From: tabcat Date: Thu, 30 Oct 2025 20:42:23 -0500 Subject: [PATCH 25/49] rename refresh to republish and remove unrefresh --- packages/ipns/src/index.ts | 33 +++++++----------- packages/ipns/src/ipns.ts | 12 +++---- packages/ipns/src/ipns/publisher.ts | 12 ++++--- packages/ipns/src/ipns/republisher.ts | 23 +++++------- packages/ipns/test/republish.spec.ts | 50 +++++++++------------------ 5 files changed, 49 insertions(+), 81 deletions(-) diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index beb795f91..8e11e1982 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -201,10 +201,10 @@ export type ResolveProgressEvents = ProgressEvent<'ipns:resolve:success', IPNSRecord> | ProgressEvent<'ipns:resolve:error', Error> -export type RefreshProgressEvents = - ProgressEvent<'ipns:refresh:start'> | - ProgressEvent<'ipns:refresh:success', IPNSRecord> | - ProgressEvent<'ipns:refresh:error', Error> +export type RepublishProgressEvents = + ProgressEvent<'ipns:republish:start'> | + ProgressEvent<'ipns:republish:success', IPNSRecord> | + ProgressEvent<'ipns:republish:error', Error> export type DatastoreProgressEvents = ProgressEvent<'ipns:routing:datastore:put'> | @@ -256,7 +256,7 @@ export interface ResolveOptions extends AbortOptions, ProgressOptions { +export interface RepublishOptions extends AbortOptions, ProgressOptions { /** * A candidate IPNS record to use if no newer records are found */ @@ -270,7 +270,7 @@ export interface RefreshOptions extends AbortOptions, ProgressOptions + unpublish(keyName: string | CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options?: AbortOptions): Promise /** - * Regularly publish the latest known existing IPNS record for `key` + * Republish the latest known existing record to all routers * - * Refreshing keep an existing IPNS record resolvable until it expires or - * `unrefresh` is called for the same key - */ - refresh(key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options?: RefreshOptions): Promise - - /** - * Stop refreshing of an existing IPNS record + * This will automatically be done regularly unless `options.repeat` is false * - * This will delete the IPNS record from the datastore - * - * Note that the record may still be resolved by other peers until it expires - * or is no longer valid. + * Use `unpublish` to stop republishing a key */ - unrefresh(key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options?: AbortOptions): Promise + republish(key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options?: RepublishOptions): Promise } export type { IPNSRouting } from './routing/index.js' diff --git a/packages/ipns/src/ipns.ts b/packages/ipns/src/ipns.ts index 1eac74363..01d767949 100644 --- a/packages/ipns/src/ipns.ts +++ b/packages/ipns/src/ipns.ts @@ -5,7 +5,7 @@ import { IPNSResolver } from './ipns/resolver.ts' import { localStore } from './local-store.js' import { helia } from './routing/helia.js' import { localStoreRouting } from './routing/local-store.ts' -import type { IPNSComponents, IPNS as IPNSInterface, IPNSOptions, IPNSPublishResult, IPNSRefreshResult, IPNSResolveResult, PublishOptions, RefreshOptions, ResolveOptions } from './index.js' +import type { IPNSComponents, IPNS as IPNSInterface, IPNSOptions, IPNSPublishResult, IPNSRepublishResult, IPNSResolveResult, PublishOptions, RepublishOptions, ResolveOptions } from './index.js' import type { LocalStore } from './local-store.js' import type { IPNSRouting } from './routing/index.js' import type { AbortOptions, PeerId, PublicKey, Startable } from '@libp2p/interface' @@ -82,15 +82,11 @@ export class IPNS implements IPNSInterface, Startable { return this.resolver.resolve(key, options) } - async unpublish (keyName: string, options?: AbortOptions): Promise { + async unpublish (keyName: string | CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options?: AbortOptions): Promise { return this.publisher.unpublish(keyName, options) } - async refresh (key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: RefreshOptions = {}): Promise { - return this.republisher.refresh(key, options) - } - - async unrefresh (key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: AbortOptions = {}): Promise { - return this.republisher.unrefresh(key, options) + async republish (key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: RepublishOptions = {}): Promise { + return this.republisher.republish(key, options) } } diff --git a/packages/ipns/src/ipns/publisher.ts b/packages/ipns/src/ipns/publisher.ts index c1e19173f..ca226d934 100644 --- a/packages/ipns/src/ipns/publisher.ts +++ b/packages/ipns/src/ipns/publisher.ts @@ -4,6 +4,7 @@ import { createIPNSRecord, marshalIPNSRecord, multihashToIPNSRoutingKey, unmarsh import { CID } from 'multiformats/cid' import { CustomProgressEvent } from 'progress-events' import { DEFAULT_LIFETIME_MS, DEFAULT_TTL_NS } from '../constants.ts' +import { keyToMultihash } from '../utils.ts' import type { IPNSPublishResult, PublishOptions } from '../index.js' import type { LocalStore } from '../local-store.js' import type { IPNSRouting } from '../routing/index.js' @@ -88,10 +89,13 @@ export class IPNSPublisher { } } - async unpublish (keyName: string, options?: AbortOptions): Promise { - const { publicKey } = await this.keychain.exportKey(keyName) - const digest = publicKey.toMultihash() - const routingKey = multihashToIPNSRoutingKey(digest) + async unpublish (keyName: string | CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options?: AbortOptions): Promise { + if (typeof keyName === 'string') { + const { publicKey } = await this.keychain.exportKey(keyName) + keyName = publicKey.toMultihash() + } + + const routingKey = multihashToIPNSRoutingKey(keyToMultihash(keyName)) await this.localStore.delete(routingKey, options) } } diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index 0d9487871..e6c7b5355 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -7,7 +7,7 @@ import { equals as uint8ArrayEquals } from 'uint8arrays/equals' import { DEFAULT_REPUBLISH_CONCURRENCY, DEFAULT_REPUBLISH_INTERVAL_MS, DEFAULT_TTL_NS } from '../constants.ts' import { ipnsSelector } from '../index.ts' import { keyToMultihash, shouldRefresh, shouldRepublish } from '../utils.js' -import type { IPNSRefreshResult, RefreshOptions } from '../index.ts' +import type { IPNSRepublishResult, RepublishOptions } from '../index.ts' import type { ListResult, LocalStore } from '../local-store.js' import type { IPNSResolver } from './resolver.ts' import type { IPNSRouting } from '../routing/index.js' @@ -101,8 +101,6 @@ export class IPNSRepublisher { } if (metadata.refresh) { - // processing records to refresh may require writing to localStore - // so that is done outside of query iterator recordsToRefresh.push({ routingKey, created }) continue } @@ -141,7 +139,7 @@ export class IPNSRepublisher { this.log(`found ${recordsToRepublish.length} records to republish`) - // Republish or refresh each record + // Republish each record for (const { routingKey, record } of recordsToRepublish) { // Add job to queue to republish the record to all routers queue.add(async () => { @@ -162,7 +160,7 @@ export class IPNSRepublisher { const { record } = await this.resolver.resolve(multihashFromIPNSRoutingKey(routingKey)) latestRecord = record } catch (err: any) { - this.log.error('unable to find record to refresh - %e', err) + this.log.error('unable to find existing record to republish - %e', err) continue } @@ -171,14 +169,14 @@ export class IPNSRepublisher { continue } - // Add job to queue to refresh the record to all routers + // Add job to queue to republish the existing record to all routers queue.add(async () => { try { await Promise.all( this.routers.map(r => r.put(routingKey, marshalIPNSRecord(latestRecord), { ...options, overwrite: true })) ) } catch (err: any) { - this.log.error('error refreshing record - %e', err) + this.log.error('error republishing existing record - %e', err) } }, options) } @@ -189,7 +187,7 @@ export class IPNSRepublisher { await queue.onIdle(options) // Wait for all jobs to complete } - async refresh (key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: RefreshOptions = {}): Promise { + async republish (key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: RepublishOptions = {}): Promise { const records: IPNSRecord[] = [] let publishedRecord: IPNSRecord | null = null const digest = keyToMultihash(key) @@ -222,7 +220,7 @@ export class IPNSRepublisher { } } if (records.length === 0) { - throw new NotFoundError('Found no records to refresh') + throw new NotFoundError('Found no existing records to republish') } // check if record is already published @@ -245,13 +243,8 @@ export class IPNSRepublisher { return { record: selectedRecord } } catch (err: any) { - options.onProgress?.(new CustomProgressEvent('ipns:refresh:error', err)) + options.onProgress?.(new CustomProgressEvent('ipns:republish:error', err)) throw err } } - - async unrefresh (key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: AbortOptions = {}): Promise { - const routingKey = multihashToIPNSRoutingKey(keyToMultihash(key)) - await this.localStore.delete(routingKey) - } } diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index c1e009541..663d1fef1 100644 --- a/packages/ipns/test/republish.spec.ts +++ b/packages/ipns/test/republish.spec.ts @@ -143,7 +143,7 @@ describe('republish', () => { expect(republishedRecord.sequence).to.equal(2n) // Incremented from 1n }) - it('should only refresh some records', async () => { + it('should republish existing records', async () => { const key = await generateKeyPair('Ed25519') const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) @@ -232,7 +232,7 @@ describe('republish', () => { expect(republishedRecord.sequence).to.equal(6n) // Incremented from 5n }) - it('should skip refreshing records created within republish threshold', async () => { + it('should skip republishing existing records created within republish threshold', async () => { const key = await generateKeyPair('Ed25519') const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) @@ -451,7 +451,7 @@ describe('republish', () => { expect(putStubHelia.called).to.be.false() }) - it('should handle unable to find record to refresh', async () => { + it('should handle unable to find existing record to republish', async () => { const key = await generateKeyPair('Ed25519') const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) @@ -511,7 +511,7 @@ describe('republish', () => { }) }) - describe('refresh', () => { + describe('republish', () => { let getStubCustom: sinon.SinonStub let getStubHelia: sinon.SinonStub @@ -541,7 +541,7 @@ describe('republish', () => { // @ts-ignore const storeGetSpy = sinon.spy(name.localStore, 'get') - await name.refresh(multihashFromIPNSRoutingKey(routingKey)) + await name.republish(multihashFromIPNSRoutingKey(routingKey)) expect(storeGetSpy.called).to.be.true() expect(getStubCustom.called).to.be.true() @@ -557,7 +557,7 @@ describe('republish', () => { const store = localStore(result.datastore, result.log) await store.put(routingKey, marshalIPNSRecord(record)) - await name.refresh(multihashFromIPNSRoutingKey(routingKey), { repeat: false }) + await name.republish(multihashFromIPNSRoutingKey(routingKey), { repeat: false }) expect(() => result.datastore.get(ipnsMetadataKey(routingKey))).to.throw('Not Found') }) @@ -567,9 +567,9 @@ describe('republish', () => { const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) - const refreshed = await name.refresh(multihashFromIPNSRoutingKey(routingKey), { record }) + const republished = await name.republish(multihashFromIPNSRoutingKey(routingKey), { record }) - expect(refreshed.record).to.equal(record) + expect(republished.record).to.equal(record) }) it('should write to metadata', async () => { @@ -581,7 +581,7 @@ describe('republish', () => { const store = localStore(result.datastore, result.log) await store.put(routingKey, marshalIPNSRecord(record)) - await name.refresh(multihashFromIPNSRoutingKey(routingKey)) + await name.republish(multihashFromIPNSRoutingKey(routingKey)) expect(() => result.datastore.get(ipnsMetadataKey(routingKey))).to.not.throw() }) @@ -597,7 +597,7 @@ describe('republish', () => { const { created } = await store.get(routingKey) - await name.refresh(multihashFromIPNSRoutingKey(routingKey)) + await name.republish(multihashFromIPNSRoutingKey(routingKey)) const { created: newCreated } = await store.get(routingKey) @@ -626,12 +626,12 @@ describe('republish', () => { // @ts-ignore const storePutSpy = sinon.spy(name.localStore, 'put') - const refreshed = await name.refresh(multihashFromIPNSRoutingKey(routingKey), { force: true }) + const republished = await name.republish(multihashFromIPNSRoutingKey(routingKey), { force: true }) expect(storePutSpy.called).to.be.true() expect(result.customRouting.put.called).to.be.true() expect(result.heliaRouting.put.called).to.be.true() - expect(refreshed.record.sequence).to.equal(3n) + expect(republished.record.sequence).to.equal(3n) }) }) @@ -642,7 +642,7 @@ describe('republish', () => { const onProgress = sinon.stub().resolves() - await expect(name.refresh(multihashFromIPNSRoutingKey(routingKey), { onProgress })).to.be.rejected() + await expect(name.republish(multihashFromIPNSRoutingKey(routingKey), { onProgress })).to.be.rejected() expect(onProgress.called).to.be.true() }) @@ -651,14 +651,14 @@ describe('republish', () => { const record = await createIPNSRecord(key, testCid, 1n, -1) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) - await expect(name.refresh(multihashFromIPNSRoutingKey(routingKey), { record })).to.be.rejectedWith('record has expired') + await expect(name.republish(multihashFromIPNSRoutingKey(routingKey), { record })).to.be.rejectedWith('record has expired') }) - it('should throw if no records were found to refresh', async () => { + it('should throw if no existing records were found to republish', async () => { const key = await generateKeyPair('Ed25519') const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) - await expect(name.refresh(multihashFromIPNSRoutingKey(routingKey))).to.be.rejectedWith('Found no records to refresh') + await expect(name.republish(multihashFromIPNSRoutingKey(routingKey))).to.be.rejectedWith('Found no existing records to republish') }) it('should throw if the record is already published', async () => { @@ -670,23 +670,7 @@ describe('republish', () => { // @ts-ignore result.customRouting.get = getStubCustom - await expect(name.refresh(multihashFromIPNSRoutingKey(routingKey))).to.be.rejectedWith('Record already published') - }) - }) - - describe('unrefresh', () => { - it('removes the local record and metadata', async () => { - const key = await generateKeyPair('Ed25519') - const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) - - // Store empty data in the real datastore - await result.datastore.put(dhtRoutingKey(routingKey), new Uint8Array()) - await result.datastore.put(ipnsMetadataKey(routingKey), new Uint8Array()) - - await name.unrefresh(multihashFromIPNSRoutingKey(routingKey)) - - expect(() => result.datastore.get(dhtRoutingKey(routingKey))).to.throw('Not Found') - expect(() => result.datastore.get(ipnsMetadataKey(routingKey))).to.throw('Not Found') + await expect(name.republish(multihashFromIPNSRoutingKey(routingKey))).to.be.rejectedWith('Record already published') }) }) }) From 7108a53638ad0b79c8554ff27be832ef65b0bb24 Mon Sep 17 00:00:00 2001 From: tabcat Date: Thu, 30 Oct 2025 20:42:33 -0500 Subject: [PATCH 26/49] update example --- packages/ipns/README.md | 18 ++++++------------ packages/ipns/src/index.ts | 18 ++++++------------ 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/packages/ipns/README.md b/packages/ipns/README.md index ca0be04ff..3d2cf3605 100644 --- a/packages/ipns/README.md +++ b/packages/ipns/README.md @@ -188,18 +188,12 @@ const parsedCid: CID = CID.parse(ipnsName) const delegatedClient = createDelegatedRoutingV1HttpApiClient('https://delegated-ipfs.dev') const record = await delegatedClient.getIPNS(parsedCid) -const routingKey = multihashToIPNSRoutingKey(parsedCid.multihash) -const marshaledRecord = marshalIPNSRecord(record) - -// validate that they key corresponds to the record -await ipnsValidator(routingKey, marshaledRecord) - -// publish record to routing -await Promise.all( - name.routers.map(async r => { - await r.put(routingKey, marshaledRecord) - }) -) +// publish the latest existing record to routing +// use `options.force` if the record is already published +const { record: latestRecord } = await name.republish(parsedCID, { record }) + +// stop republishing a key +await unpublish(parsedCID) ``` # Install diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index 8e11e1982..699b2e26d 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -159,18 +159,12 @@ * const delegatedClient = createDelegatedRoutingV1HttpApiClient('https://delegated-ipfs.dev') * const record = await delegatedClient.getIPNS(parsedCid) * - * const routingKey = multihashToIPNSRoutingKey(parsedCid.multihash) - * const marshaledRecord = marshalIPNSRecord(record) - * - * // validate that they key corresponds to the record - * await ipnsValidator(routingKey, marshaledRecord) - * - * // publish record to routing - * await Promise.all( - * name.routers.map(async r => { - * await r.put(routingKey, marshaledRecord) - * }) - * ) + * // publish the latest existing record to routing + * // use `options.force` if the record is already published + * const { record: latestRecord } = await name.republish(parsedCID, { record }) + * + * // stop republishing a key + * await unpublish(parsedCID) * ``` */ From 82df957500e860b0740885d395cb77aaaf1c2fee Mon Sep 17 00:00:00 2001 From: tabcat Date: Thu, 30 Oct 2025 22:32:59 -0500 Subject: [PATCH 27/49] fix resolve test --- packages/ipns/test/resolve.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ipns/test/resolve.spec.ts b/packages/ipns/test/resolve.spec.ts index b2b43e00f..9239add33 100644 --- a/packages/ipns/test/resolve.spec.ts +++ b/packages/ipns/test/resolve.spec.ts @@ -114,7 +114,7 @@ describe('resolve', () => { heliaRouting.get.resolves(marshalIPNSRecord(record)) // @ts-ignore - const storePutSpy = sinon.spy(name.localStore, 'put') + const storePutSpy = Sinon.spy(name.localStore, 'put') const resolvedValue = await name.resolve(publicKey, { nocache: true }) From c4aa5fd7454149adac84cf169fef96cdba952889 Mon Sep 17 00:00:00 2001 From: tabcat Date: Thu, 30 Oct 2025 22:34:09 -0500 Subject: [PATCH 28/49] fix docs --- packages/ipns/README.md | 4 ++-- packages/ipns/src/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ipns/README.md b/packages/ipns/README.md index 3d2cf3605..ff0e0c5c9 100644 --- a/packages/ipns/README.md +++ b/packages/ipns/README.md @@ -190,10 +190,10 @@ const record = await delegatedClient.getIPNS(parsedCid) // publish the latest existing record to routing // use `options.force` if the record is already published -const { record: latestRecord } = await name.republish(parsedCID, { record }) +const { record: latestRecord } = await name.republish(parsedCid, { record }) // stop republishing a key -await unpublish(parsedCID) +await name.unpublish(parsedCid) ``` # Install diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index 699b2e26d..de40c3a99 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -161,10 +161,10 @@ * * // publish the latest existing record to routing * // use `options.force` if the record is already published - * const { record: latestRecord } = await name.republish(parsedCID, { record }) + * const { record: latestRecord } = await name.republish(parsedCid, { record }) * * // stop republishing a key - * await unpublish(parsedCID) + * await name.unpublish(parsedCid) * ``` */ From 0f493a5e5abba9cc708da62c1600d2fbde46e7f9 Mon Sep 17 00:00:00 2001 From: tabcat Date: Thu, 30 Oct 2025 23:05:05 -0500 Subject: [PATCH 29/49] fix localStore spy --- packages/ipns/test/resolve.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ipns/test/resolve.spec.ts b/packages/ipns/test/resolve.spec.ts index 9239add33..f16c7800a 100644 --- a/packages/ipns/test/resolve.spec.ts +++ b/packages/ipns/test/resolve.spec.ts @@ -114,14 +114,14 @@ describe('resolve', () => { heliaRouting.get.resolves(marshalIPNSRecord(record)) // @ts-ignore - const storePutSpy = Sinon.spy(name.localStore, 'put') + const storeGetSpy = Sinon.spy(name.localStore, 'get') const resolvedValue = await name.resolve(publicKey, { nocache: true }) expect(resolvedValue.cid.toString()).to.equal(cid.toV1().toString()) // check that localStore.get not called - expect(storePutSpy.called).to.be.false() + expect(storeGetSpy.called).to.be.false() expect(heliaRouting.get.called).to.be.true() expect(customRouting.get.called).to.be.true() From 488469dbfe2fde86689c80bfd87455c4976a0d8b Mon Sep 17 00:00:00 2001 From: tabcat Date: Fri, 31 Oct 2025 01:40:50 -0500 Subject: [PATCH 30/49] move shouldRefresh check before recordsToRefresh.push --- packages/ipns/src/ipns/republisher.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index e6c7b5355..7958af7aa 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -101,6 +101,10 @@ export class IPNSRepublisher { } if (metadata.refresh) { + if (!shouldRefresh(created)) { + this.log.trace(`skipping record ${routingKey.toString()} within republish threshold`) + continue + } recordsToRefresh.push({ routingKey, created }) continue } @@ -164,11 +168,6 @@ export class IPNSRepublisher { continue } - if (!shouldRefresh(created)) { - this.log.trace(`skipping record ${routingKey.toString()} within republish threshold`) - continue - } - // Add job to queue to republish the existing record to all routers queue.add(async () => { try { From 9bebca33637cea7690869e8cde95ee044e37084c Mon Sep 17 00:00:00 2001 From: tabcat Date: Fri, 31 Oct 2025 01:43:56 -0500 Subject: [PATCH 31/49] recordsToRefresh -> keysToRepublish --- packages/ipns/src/ipns/republisher.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index 7958af7aa..8ea637091 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -89,7 +89,7 @@ export class IPNSRepublisher { try { const recordsToRepublish: Array<{ routingKey: Uint8Array, record: IPNSRecord }> = [] - const recordsToRefresh: Array> = [] + const keysToRepublish: Array = [] // Find all records using the localStore.list method for await (const { routingKey, record, metadata, created } of this.localStore.list(options)) { @@ -105,7 +105,7 @@ export class IPNSRepublisher { this.log.trace(`skipping record ${routingKey.toString()} within republish threshold`) continue } - recordsToRefresh.push({ routingKey, created }) + keysToRepublish.push(routingKey) continue } @@ -157,7 +157,7 @@ export class IPNSRepublisher { } }, options) } - for (const { routingKey, created } of recordsToRefresh) { + for (const routingKey of keysToRepublish) { // resolve the latest record let latestRecord: IPNSRecord try { From 77ea1c9ad1b1adca6e04e30bca31239d09205b5f Mon Sep 17 00:00:00 2001 From: tabcat Date: Fri, 31 Oct 2025 01:45:39 -0500 Subject: [PATCH 32/49] fix linter error --- packages/ipns/src/ipns/republisher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index 8ea637091..db61a47c0 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -8,7 +8,7 @@ import { DEFAULT_REPUBLISH_CONCURRENCY, DEFAULT_REPUBLISH_INTERVAL_MS, DEFAULT_T import { ipnsSelector } from '../index.ts' import { keyToMultihash, shouldRefresh, shouldRepublish } from '../utils.js' import type { IPNSRepublishResult, RepublishOptions } from '../index.ts' -import type { ListResult, LocalStore } from '../local-store.js' +import type { LocalStore } from '../local-store.js' import type { IPNSResolver } from './resolver.ts' import type { IPNSRouting } from '../routing/index.js' import type { AbortOptions, ComponentLogger, Libp2p, Logger, PeerId, PrivateKey, PublicKey } from '@libp2p/interface' From d25aa37a2ea9551f55a5a586dc36bfe69d2a429a Mon Sep 17 00:00:00 2001 From: tabcat Date: Tue, 4 Nov 2025 10:39:01 -0600 Subject: [PATCH 33/49] remove additional hour for tolerance --- packages/ipns/test/republish.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index 663d1fef1..d0accd560 100644 --- a/packages/ipns/test/republish.spec.ts +++ b/packages/ipns/test/republish.spec.ts @@ -13,6 +13,7 @@ import { dhtRoutingKey, ipnsMetadataKey } from '../src/utils.ts' import { createIPNS } from './fixtures/create-ipns.js' import type { IPNS } from '../src/ipns.js' import type { CreateIPNSResult } from './fixtures/create-ipns.js' +import { REPUBLISH_THRESHOLD } from '../src/constants.ts' // Helper to await until a stub is called function waitForStubCall (stub: sinon.SinonStub, callCount = 1): Promise { @@ -148,8 +149,9 @@ describe('republish', () => { const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) - // create a dht record with a created time < now - REPUBLISH_THRESHOLD - const dhtRecord = new Record(routingKey, marshalIPNSRecord(record), new Date(Date.now() - 24 * 60 * 60 * 1000)) + // create a dht record with a timeReceived < now - REPUBLISH_THRESHOLD + const timeReceived = new Date(Date.now() - REPUBLISH_THRESHOLD - 60 * 60 * 1000) + const dhtRecord = new Record(routingKey, marshalIPNSRecord(record), timeReceived) // Store the dht record and metadata in the real datastore await result.datastore.put(dhtRoutingKey(routingKey), dhtRecord.serialize()) From 754f5153a36bfa906f7f8a4d9ef386cda6f9ebc5 Mon Sep 17 00:00:00 2001 From: tabcat Date: Tue, 4 Nov 2025 10:41:04 -0600 Subject: [PATCH 34/49] fix lint error --- packages/ipns/test/republish.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index d0accd560..40b9d4f5b 100644 --- a/packages/ipns/test/republish.spec.ts +++ b/packages/ipns/test/republish.spec.ts @@ -7,13 +7,13 @@ import { expect } from 'aegir/chai' import { createIPNSRecord, marshalIPNSRecord, unmarshalIPNSRecord, multihashToIPNSRoutingKey, multihashFromIPNSRoutingKey } from 'ipns' import { CID } from 'multiformats/cid' import sinon from 'sinon' +import { REPUBLISH_THRESHOLD } from '../src/constants.ts' import { localStore } from '../src/local-store.js' import { IPNSPublishMetadata } from '../src/pb/metadata.ts' import { dhtRoutingKey, ipnsMetadataKey } from '../src/utils.ts' import { createIPNS } from './fixtures/create-ipns.js' import type { IPNS } from '../src/ipns.js' import type { CreateIPNSResult } from './fixtures/create-ipns.js' -import { REPUBLISH_THRESHOLD } from '../src/constants.ts' // Helper to await until a stub is called function waitForStubCall (stub: sinon.SinonStub, callCount = 1): Promise { From 25b513c9099784958912f42c29e9e21741ac9fd5 Mon Sep 17 00:00:00 2001 From: tabcat Date: Mon, 2 Feb 2026 18:27:37 +0530 Subject: [PATCH 35/49] replace refresh with upkeep in protobuf --- packages/ipns/src/pb/metadata.proto | 10 +++++++++- packages/ipns/src/pb/metadata.ts | 30 +++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/packages/ipns/src/pb/metadata.proto b/packages/ipns/src/pb/metadata.proto index 6f0ae066e..8159107f1 100644 --- a/packages/ipns/src/pb/metadata.proto +++ b/packages/ipns/src/pb/metadata.proto @@ -1,11 +1,19 @@ syntax = "proto3"; +enum Upkeep { + republish = 0; // Republish the record with an updated ttl (key must be in keychain) + refresh = 1; // Republish the record as is (while it is still valid) + none = 3; // Disables automated republishing for the record +} + message IPNSPublishMetadata { // The name of the key that was used to publish the record string keyName = 1; // Lifetime is the duration of the signature validity in milliseconds + // Relevant for Upkeep.REPUBLISH uint32 lifetime = 2; - bool refresh = 3; + // How the record should be republished + Upkeep upkeep = 3; } diff --git a/packages/ipns/src/pb/metadata.ts b/packages/ipns/src/pb/metadata.ts index 7ac62f04d..f95d463f4 100644 --- a/packages/ipns/src/pb/metadata.ts +++ b/packages/ipns/src/pb/metadata.ts @@ -1,11 +1,29 @@ -import { decodeMessage, encodeMessage, message } from 'protons-runtime' +import { decodeMessage, encodeMessage, enumeration, message } from 'protons-runtime' import type { Codec, DecodeOptions } from 'protons-runtime' import type { Uint8ArrayList } from 'uint8arraylist' +export enum Upkeep { + republish = 'republish', + refresh = 'refresh', + none = 'none' +} + +enum __UpkeepValues { + republish = 0, + refresh = 1, + none = 3 +} + +export namespace Upkeep { + export const codec = (): Codec => { + return enumeration(__UpkeepValues) + } +} + export interface IPNSPublishMetadata { keyName: string lifetime: number - refresh: boolean + upkeep: Upkeep } export namespace IPNSPublishMetadata { @@ -28,9 +46,9 @@ export namespace IPNSPublishMetadata { w.uint32(obj.lifetime) } - if ((obj.refresh != null && obj.refresh !== false)) { + if (obj.upkeep != null && __UpkeepValues[obj.upkeep] !== 0) { w.uint32(24) - w.bool(obj.refresh) + Upkeep.codec().encode(obj.upkeep, w) } if (opts.lengthDelimited !== false) { @@ -40,7 +58,7 @@ export namespace IPNSPublishMetadata { const obj: any = { keyName: '', lifetime: 0, - refresh: false + upkeep: Upkeep.republish } const end = length == null ? reader.len : reader.pos + length @@ -58,7 +76,7 @@ export namespace IPNSPublishMetadata { break } case 3: { - obj.refresh = reader.bool() + obj.upkeep = Upkeep.codec().decode(reader) break } default: { From d983e48b638a6631413ffd0cb483b776cc58d61f Mon Sep 17 00:00:00 2001 From: tabcat Date: Mon, 2 Feb 2026 18:58:55 +0530 Subject: [PATCH 36/49] add upkeep to Publish/RepublishOptions --- packages/ipns/src/index.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index de40c3a99..ac4457618 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -213,7 +213,7 @@ export interface PublishOptions extends AbortOptions, ProgressOptions Date: Mon, 2 Feb 2026 19:04:09 +0530 Subject: [PATCH 37/49] clean up comment --- packages/ipns/src/pb/metadata.proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ipns/src/pb/metadata.proto b/packages/ipns/src/pb/metadata.proto index 8159107f1..3f61277aa 100644 --- a/packages/ipns/src/pb/metadata.proto +++ b/packages/ipns/src/pb/metadata.proto @@ -14,6 +14,6 @@ message IPNSPublishMetadata { // Relevant for Upkeep.REPUBLISH uint32 lifetime = 2; - // How the record should be republished + // Republishing policy Upkeep upkeep = 3; } From cd4c899dadf855ba8fc63b1b3ce228e35e187c88 Mon Sep 17 00:00:00 2001 From: tabcat Date: Mon, 2 Feb 2026 19:09:08 +0530 Subject: [PATCH 38/49] skip looking for published records if force = true --- packages/ipns/src/ipns/republisher.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index db61a47c0..d3536b149 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -209,10 +209,12 @@ export class IPNSRepublisher { } } try { - // published record - const { record } = await this.resolver.resolve(key, { nocache: true }) - publishedRecord = record - records.push(record) + if (options.force !== true) { + // published record + const { record } = await this.resolver.resolve(key, { nocache: true }) + publishedRecord = record + records.push(record) + } } catch (err: any) { if (err.name !== 'NotFoundError') { throw err @@ -225,7 +227,7 @@ export class IPNSRepublisher { // check if record is already published const selectedRecord = records[ipnsSelector(routingKey, records.map(marshalIPNSRecord))] const marshaledRecord = marshalIPNSRecord(selectedRecord) - if (options.force !== true && publishedRecord != null && uint8ArrayEquals(marshaledRecord, marshalIPNSRecord(publishedRecord))) { + if (publishedRecord != null && uint8ArrayEquals(marshaledRecord, marshalIPNSRecord(publishedRecord))) { throw new Error('Record already published') } From 775d0b3de4043c17353c9bc2f4519e2f7504c633 Mon Sep 17 00:00:00 2001 From: tabcat Date: Mon, 2 Feb 2026 19:28:15 +0530 Subject: [PATCH 39/49] publish and republish use upkeep policy --- packages/ipns/src/ipns/publisher.ts | 6 ++++-- packages/ipns/src/ipns/republisher.ts | 11 +++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/ipns/src/ipns/publisher.ts b/packages/ipns/src/ipns/publisher.ts index ca226d934..f370eeaee 100644 --- a/packages/ipns/src/ipns/publisher.ts +++ b/packages/ipns/src/ipns/publisher.ts @@ -12,6 +12,7 @@ import type { AbortOptions, ComponentLogger, Libp2p, PeerId, PrivateKey, PublicK import type { Keychain } from '@libp2p/keychain' import type { Datastore } from 'interface-datastore' import type { MultihashDigest } from 'multiformats/hashes/interface' +import { IPNSPublishMetadata, Upkeep } from '../pb/metadata.ts' export interface IPNSPublisherComponents { datastore: Datastore @@ -58,13 +59,14 @@ export class IPNSPublisher { const record = await createIPNSRecord(privKey, value, sequenceNumber, lifetime, { ...options, ttlNs }) const marshaledRecord = marshalIPNSRecord(record) + const metadata: IPNSPublishMetadata = { keyName, lifetime, upkeep: Upkeep[options.upkeep ?? 'republish'] } if (options.offline === true) { // only store record locally - await this.localStore.put(routingKey, marshaledRecord, { ...options, metadata: { keyName, lifetime } }) + await this.localStore.put(routingKey, marshaledRecord, { ...options, metadata }) } else { // publish record to routing (including the local store) await Promise.all(this.routers.map(async r => { - await r.put(routingKey, marshaledRecord, { ...options, metadata: { keyName, lifetime } }) + await r.put(routingKey, marshaledRecord, { ...options, metadata }) })) } diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index d3536b149..756ee4555 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -16,6 +16,7 @@ import type { Keychain } from '@libp2p/keychain' import type { RepeatingTask } from '@libp2p/utils' import type { IPNSRecord } from 'ipns' import type { CID, MultihashDigest } from 'multiformats/cid' +import { Upkeep } from '../pb/metadata.ts' export interface IPNSRepublisherComponents { logger: ComponentLogger @@ -100,7 +101,13 @@ export class IPNSRepublisher { continue } - if (metadata.refresh) { + if (metadata.upkeep === Upkeep.none) { + // Skip republishing, disabled for this record + this.log(`republishing is disabled for record ${routingKey.toString()}, skipping`) + continue + } + + if (metadata.upkeep === Upkeep.refresh) { if (!shouldRefresh(created)) { this.log.trace(`skipping record ${routingKey.toString()} within republish threshold`) continue @@ -234,7 +241,7 @@ export class IPNSRepublisher { // publish record to routers const putOptions = { ...options, - metadata: options.repeat !== false ? { refresh: true } : undefined, + metadata: { upkeep: Upkeep[options.upkeep ?? 'refresh'] }, // overwrite so Record.created is reset for #republish overwrite: true } From a4d5cf8c3dcf86626c404df87db3f1107057940b Mon Sep 17 00:00:00 2001 From: tabcat Date: Mon, 2 Feb 2026 19:30:25 +0530 Subject: [PATCH 40/49] republish offline --- packages/ipns/src/ipns/republisher.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index 756ee4555..c39570378 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -245,9 +245,13 @@ export class IPNSRepublisher { // overwrite so Record.created is reset for #republish overwrite: true } - await Promise.all( - this.routers.map(r => r.put(routingKey, marshaledRecord, putOptions)) - ) + if (options.offline) { + await this.localStore.put(routingKey, marshaledRecord, putOptions) + } else { + await Promise.all( + this.routers.map(r => r.put(routingKey, marshaledRecord, putOptions)) + ) + } return { record: selectedRecord } } catch (err: any) { From e2b1ff68eb36d76b2d4543d2613169cffd259fec Mon Sep 17 00:00:00 2001 From: tabcat Date: Mon, 2 Feb 2026 19:39:35 +0530 Subject: [PATCH 41/49] test republish disabled records and fix tests --- packages/ipns/test/republish.spec.ts | 30 +++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index 40b9d4f5b..6e6fc559d 100644 --- a/packages/ipns/test/republish.spec.ts +++ b/packages/ipns/test/republish.spec.ts @@ -9,7 +9,7 @@ import { CID } from 'multiformats/cid' import sinon from 'sinon' import { REPUBLISH_THRESHOLD } from '../src/constants.ts' import { localStore } from '../src/local-store.js' -import { IPNSPublishMetadata } from '../src/pb/metadata.ts' +import { IPNSPublishMetadata, Upkeep } from '../src/pb/metadata.ts' import { dhtRoutingKey, ipnsMetadataKey } from '../src/utils.ts' import { createIPNS } from './fixtures/create-ipns.js' import type { IPNS } from '../src/ipns.js' @@ -155,7 +155,7 @@ describe('republish', () => { // Store the dht record and metadata in the real datastore await result.datastore.put(dhtRoutingKey(routingKey), dhtRecord.serialize()) - await result.datastore.put(ipnsMetadataKey(routingKey), IPNSPublishMetadata.encode({ refresh: true })) + await result.datastore.put(ipnsMetadataKey(routingKey), IPNSPublishMetadata.encode({ upkeep: Upkeep.refresh })) // Start publishing await start(name) @@ -188,6 +188,26 @@ describe('republish', () => { expect(putStubCustom.called).to.be.false() }) + it('should skip records with republishing disabled', async () => { + const key = await generateKeyPair('Ed25519') + const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // Store the record without metadata (simulate old records) + const store = localStore(result.datastore, result.log) + await store.put(routingKey, marshalIPNSRecord(record), { + metadata: { + upkeep: Upkeep.none + } + }) + + await start(name) + await new Promise(resolve => setTimeout(resolve, 20)) + + // Verify no records were republished + expect(putStubCustom.called).to.be.false() + }) + it('should handle invalid records gracefully', async () => { const routingKey = new Uint8Array([1, 2, 3, 4]) @@ -243,7 +263,7 @@ describe('republish', () => { const store = localStore(result.datastore, result.log) await store.put(routingKey, marshalIPNSRecord(record), { metadata: { - refresh: true + upkeep: Upkeep.refresh } }) @@ -464,7 +484,7 @@ describe('republish', () => { const store = localStore(result.datastore, result.log) await store.put(routingKey, marshalIPNSRecord(record), { metadata: { - refresh: true + upkeep: Upkeep.refresh } }) @@ -559,7 +579,7 @@ describe('republish', () => { const store = localStore(result.datastore, result.log) await store.put(routingKey, marshalIPNSRecord(record)) - await name.republish(multihashFromIPNSRoutingKey(routingKey), { repeat: false }) + await name.republish(multihashFromIPNSRoutingKey(routingKey), { upkeep: 'none' }) expect(() => result.datastore.get(ipnsMetadataKey(routingKey))).to.throw('Not Found') }) From 732b74bda4a3f3e5344aff028a05a227b4bcf736 Mon Sep 17 00:00:00 2001 From: tabcat Date: Mon, 2 Feb 2026 19:40:31 +0530 Subject: [PATCH 42/49] lint --fix --- packages/ipns/src/index.ts | 4 ++-- packages/ipns/src/ipns/publisher.ts | 2 +- packages/ipns/src/ipns/republisher.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index ac4457618..106c35390 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -235,7 +235,7 @@ export interface PublishOptions extends AbortOptions, ProgressOptions Date: Mon, 2 Feb 2026 19:50:28 +0530 Subject: [PATCH 43/49] test: remove redudant test --- packages/ipns/test/republish.spec.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index 6e6fc559d..205b2073c 100644 --- a/packages/ipns/test/republish.spec.ts +++ b/packages/ipns/test/republish.spec.ts @@ -570,20 +570,6 @@ describe('republish', () => { expect(getStubHelia.called).to.be.true() }) - it('should only publish once if repeat is false', async () => { - const key = await generateKeyPair('Ed25519') - const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) - const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) - - // Store the record in the real datastore - const store = localStore(result.datastore, result.log) - await store.put(routingKey, marshalIPNSRecord(record)) - - await name.republish(multihashFromIPNSRoutingKey(routingKey), { upkeep: 'none' }) - - expect(() => result.datastore.get(ipnsMetadataKey(routingKey))).to.throw('Not Found') - }) - it('should use options.record if necessary', async () => { const key = await generateKeyPair('Ed25519') const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) From 3a28a2b9f01dd6c6ecfa239f61c8a6097e450c54 Mon Sep 17 00:00:00 2001 From: tabcat Date: Mon, 2 Feb 2026 20:05:49 +0530 Subject: [PATCH 44/49] republishing offline skips public resolution --- packages/ipns/src/ipns/republisher.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index 2ed7d5b4d..1305d3186 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -216,7 +216,7 @@ export class IPNSRepublisher { } } try { - if (options.force !== true) { + if (options.offline !== true) { // published record const { record } = await this.resolver.resolve(key, { nocache: true }) publishedRecord = record @@ -234,7 +234,7 @@ export class IPNSRepublisher { // check if record is already published const selectedRecord = records[ipnsSelector(routingKey, records.map(marshalIPNSRecord))] const marshaledRecord = marshalIPNSRecord(selectedRecord) - if (publishedRecord != null && uint8ArrayEquals(marshaledRecord, marshalIPNSRecord(publishedRecord))) { + if (options.force !== true && publishedRecord != null && uint8ArrayEquals(marshaledRecord, marshalIPNSRecord(publishedRecord))) { throw new Error('Record already published') } From 816856298880783456fcb6ff01c407dd595bac41 Mon Sep 17 00:00:00 2001 From: tabcat Date: Mon, 2 Feb 2026 20:06:02 +0530 Subject: [PATCH 45/49] test: offline republish --- packages/ipns/test/republish.spec.ts | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index 205b2073c..49414df8a 100644 --- a/packages/ipns/test/republish.spec.ts +++ b/packages/ipns/test/republish.spec.ts @@ -641,6 +641,36 @@ describe('republish', () => { expect(result.heliaRouting.put.called).to.be.true() expect(republished.record.sequence).to.equal(3n) }) + + it('should publish the local record record offline and return the local record', async () => { + const key = await generateKeyPair('Ed25519') + const record1 = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + const record2 = await createIPNSRecord(key, testCid, 2n, 24 * 60 * 60 * 1000) + const record3 = await createIPNSRecord(key, testCid, 3n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // Store the record in the real datastore + const store = localStore(result.datastore, result.log) + await store.put(routingKey, marshalIPNSRecord(record1)) + + // Stub the routers by default to reject + getStubCustom = sinon.stub().resolves(marshalIPNSRecord(record2)) + getStubHelia = sinon.stub().resolves(marshalIPNSRecord(record3)) + // @ts-ignore + result.customRouting.get = getStubCustom + // @ts-ignore + result.heliaRouting.get = getStubHelia + + // @ts-ignore + const storePutSpy = sinon.spy(name.localStore, 'put') + + const republished = await name.republish(multihashFromIPNSRoutingKey(routingKey), { force: true, offline: true }) + + expect(storePutSpy.called).to.be.true() + expect(result.customRouting.put.called).to.be.false() + expect(result.heliaRouting.put.called).to.be.false() + expect(republished.record.sequence).to.equal(1n) + }) }) describe('error handling', () => { From b7044488f99ca6dd263a0544bd1811772e2afd54 Mon Sep 17 00:00:00 2001 From: tabcat Date: Mon, 2 Feb 2026 20:06:28 +0530 Subject: [PATCH 46/49] clearer comment --- packages/ipns/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index 106c35390..d0c872238 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -271,7 +271,7 @@ export interface RepublishOptions extends AbortOptions, ProgressOptions Date: Tue, 3 Feb 2026 14:51:24 +0530 Subject: [PATCH 47/49] style and fix publish metadata --- packages/ipns/src/ipns/publisher.ts | 4 ++-- packages/ipns/src/ipns/republisher.ts | 12 +++--------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/ipns/src/ipns/publisher.ts b/packages/ipns/src/ipns/publisher.ts index 4165848aa..9c13bf631 100644 --- a/packages/ipns/src/ipns/publisher.ts +++ b/packages/ipns/src/ipns/publisher.ts @@ -4,7 +4,7 @@ import { createIPNSRecord, marshalIPNSRecord, multihashToIPNSRoutingKey, unmarsh import { CID } from 'multiformats/cid' import { CustomProgressEvent } from 'progress-events' import { DEFAULT_LIFETIME_MS, DEFAULT_TTL_NS } from '../constants.ts' -import { IPNSPublishMetadata, Upkeep } from '../pb/metadata.ts' +import { Upkeep } from '../pb/metadata.ts' import { keyToMultihash } from '../utils.ts' import type { IPNSPublishResult, PublishOptions } from '../index.js' import type { LocalStore } from '../local-store.js' @@ -59,7 +59,7 @@ export class IPNSPublisher { const record = await createIPNSRecord(privKey, value, sequenceNumber, lifetime, { ...options, ttlNs }) const marshaledRecord = marshalIPNSRecord(record) - const metadata: IPNSPublishMetadata = { keyName, lifetime, upkeep: Upkeep[options.upkeep ?? 'republish'] } + const metadata = { keyName, lifetime, upkeep: Upkeep[options.upkeep ?? 'republish'] } if (options.offline === true) { // only store record locally await this.localStore.put(routingKey, marshaledRecord, { ...options, metadata }) diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index 1305d3186..85bea204b 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -238,18 +238,12 @@ export class IPNSRepublisher { throw new Error('Record already published') } - // publish record to routers - const putOptions = { - ...options, - metadata: { upkeep: Upkeep[options.upkeep ?? 'refresh'] }, - // overwrite so Record.created is reset for #republish - overwrite: true - } + const metadata = { upkeep: Upkeep[options.upkeep ?? 'refresh'] } if (options.offline) { - await this.localStore.put(routingKey, marshaledRecord, putOptions) + await this.localStore.put(routingKey, marshaledRecord, { ...options, overwrite: true, metadata }) } else { await Promise.all( - this.routers.map(r => r.put(routingKey, marshaledRecord, putOptions)) + this.routers.map(r => r.put(routingKey, marshaledRecord, { ...options, overwrite: true, metadata })) ) } From f6950bfcfaa2fb8d1856e70180ea6ddacd8d829b Mon Sep 17 00:00:00 2001 From: tabcat Date: Tue, 3 Feb 2026 14:52:05 +0530 Subject: [PATCH 48/49] chore!: remove unused metadata interface This is likely unused as there are no metadata shapes exposed to the user --- packages/ipns/src/index.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index d0c872238..4f411665c 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -34,7 +34,11 @@ * value. * * ```TypeScript - * import { createHelia } from 'helia' + * import { createHelia } from 'helia'export interface IPNSRecordMetadata { + keyName: string + lifetime: number + upkeep: Upkeep +} * import { ipns } from '@helia/ipns' * import { unixfs } from '@helia/unixfs' * import { generateKeyPair } from '@libp2p/crypto/keys' @@ -238,11 +242,6 @@ export interface PublishOptions extends AbortOptions, ProgressOptions { /** * Do not query the network for the IPNS record From 025e34d62fb33d6d34afe0550a5d6f3a79733b08 Mon Sep 17 00:00:00 2001 From: tabcat Date: Tue, 3 Feb 2026 18:22:30 +0530 Subject: [PATCH 49/49] just check if already published --- packages/ipns/src/ipns/republisher.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index 85bea204b..60f4c9650 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -3,7 +3,6 @@ import { Queue, repeatingTask } from '@libp2p/utils' import { createIPNSRecord, marshalIPNSRecord, multihashFromIPNSRoutingKey, multihashToIPNSRoutingKey, unmarshalIPNSRecord } from 'ipns' import { ipnsValidator } from 'ipns/validator' import { CustomProgressEvent } from 'progress-events' -import { equals as uint8ArrayEquals } from 'uint8arrays/equals' import { DEFAULT_REPUBLISH_CONCURRENCY, DEFAULT_REPUBLISH_INTERVAL_MS, DEFAULT_TTL_NS } from '../constants.ts' import { ipnsSelector } from '../index.ts' import { Upkeep } from '../pb/metadata.ts' @@ -232,12 +231,13 @@ export class IPNSRepublisher { } // check if record is already published - const selectedRecord = records[ipnsSelector(routingKey, records.map(marshalIPNSRecord))] - const marshaledRecord = marshalIPNSRecord(selectedRecord) - if (options.force !== true && publishedRecord != null && uint8ArrayEquals(marshaledRecord, marshalIPNSRecord(publishedRecord))) { + if (options.force !== true && publishedRecord != null) { throw new Error('Record already published') } + const selectedRecord = records[ipnsSelector(routingKey, records.map(marshalIPNSRecord))] + const marshaledRecord = marshalIPNSRecord(selectedRecord) + const metadata = { upkeep: Upkeep[options.upkeep ?? 'refresh'] } if (options.offline) { await this.localStore.put(routingKey, marshaledRecord, { ...options, overwrite: true, metadata })