@wovin/core 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/applog/applog-utils.d.ts +25 -1
- package/dist/applog/applog-utils.d.ts.map +1 -1
- package/dist/applog.js +5 -3
- package/dist/{chunk-2OXLPZQI.js → chunk-4MKPGQIM.js} +8 -16
- package/dist/chunk-4MKPGQIM.js.map +1 -0
- package/dist/{chunk-VGIACGWX.js → chunk-6CSJTSQP.js} +3 -3
- package/dist/{chunk-WVW4YXB5.js → chunk-BIYQEX3N.js} +2 -2
- package/dist/{chunk-Q4EMPWA3.js → chunk-H3JNNTVP.js} +9 -3
- package/dist/chunk-H3JNNTVP.js.map +1 -0
- package/dist/{chunk-EHO2BFFY.js → chunk-H4YVJKB7.js} +2 -2
- package/dist/chunk-N5QPZNKD.js +288 -0
- package/dist/chunk-N5QPZNKD.js.map +1 -0
- package/dist/{chunk-2PJFLZRC.js → chunk-N7SEGHU4.js} +2 -2
- package/dist/index.js +20 -10
- package/dist/ipfs/car.d.ts.map +1 -1
- package/dist/ipfs.js +4 -4
- package/dist/ipns/ipns-record.d.ts +19 -1
- package/dist/ipns/ipns-record.d.ts.map +1 -1
- package/dist/ipns/ipns-w3name.d.ts +31 -1
- package/dist/ipns/ipns-w3name.d.ts.map +1 -1
- package/dist/ipns/ipns-watcher.d.ts.map +1 -1
- package/dist/ipns.js +118 -21
- package/dist/ipns.js.map +1 -1
- package/dist/pubsub.js +4 -4
- package/dist/query/epoch-snapshot.d.ts +205 -0
- package/dist/query/epoch-snapshot.d.ts.map +1 -0
- package/dist/query.d.ts +1 -0
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +12 -4
- package/dist/retrieve.js +4 -4
- package/dist/thread.js +1 -1
- package/dist/viewmodel/index.js +4 -4
- package/dist/viewmodel/index.js.map +1 -1
- package/package.json +1 -1
- package/src/applog/applog-utils.test.ts +43 -1
- package/src/applog/applog-utils.ts +38 -21
- package/src/applog/object-values.test.ts +9 -9
- package/src/ipfs/car.ts +6 -0
- package/src/ipns/ipns-record.ts +95 -17
- package/src/ipns/ipns-w3name.ts +109 -5
- package/src/ipns/ipns-watcher.ts +4 -3
- package/src/query/epoch-snapshot.test.ts +594 -0
- package/src/query/epoch-snapshot.ts +392 -0
- package/src/query.ts +1 -0
- package/src/viewmodel/factory.ts +2 -2
- package/dist/chunk-2OXLPZQI.js.map +0 -1
- package/dist/chunk-OKXRRWNS.js +0 -138
- package/dist/chunk-OKXRRWNS.js.map +0 -1
- package/dist/chunk-Q4EMPWA3.js.map +0 -1
- /package/dist/{chunk-VGIACGWX.js.map → chunk-6CSJTSQP.js.map} +0 -0
- /package/dist/{chunk-WVW4YXB5.js.map → chunk-BIYQEX3N.js.map} +0 -0
- /package/dist/{chunk-EHO2BFFY.js.map → chunk-H4YVJKB7.js.map} +0 -0
- /package/dist/{chunk-2PJFLZRC.js.map → chunk-N7SEGHU4.js.map} +0 -0
|
@@ -74,15 +74,12 @@ describe('object/array applog values', () => {
|
|
|
74
74
|
expect(index.get(valueKey({ a: 9 }))).toHaveLength(1)
|
|
75
75
|
})
|
|
76
76
|
|
|
77
|
-
it('matchPartStatic matches a literal object value by deep equality', () => {
|
|
77
|
+
it('matchPartStatic matches a literal object/array value by deep equality', () => {
|
|
78
78
|
expect(matchPartStatic('vl', { a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true)
|
|
79
79
|
expect(matchPartStatic('vl', { a: 1 }, { a: 2 })).toBe(false)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// bare arrays are reserved/rejected, so an array value is matched via a predicate
|
|
84
|
-
expect(matchPartStatic('vl', (v: any) => valueEq(v, [1, 2, 3]), [1, 2, 3])).toBe(true)
|
|
85
|
-
expect(() => matchPartStatic('vl', [1, 2, 3] as any, [1, 2, 3])).toThrow(/anyOf/)
|
|
80
|
+
// a bare array is a literal value, matched deeply (NOT set-membership)
|
|
81
|
+
expect(matchPartStatic('vl', [1, 2, 3], [1, 2, 3])).toBe(true)
|
|
82
|
+
expect(matchPartStatic('vl', [1, 2], [1, 2, 3])).toBe(false)
|
|
86
83
|
})
|
|
87
84
|
|
|
88
85
|
it('anyOf(...) provides set-membership and matches via matchPartStatic', () => {
|
|
@@ -96,8 +93,11 @@ describe('object/array applog values', () => {
|
|
|
96
93
|
expect(matchPartStatic('vl', isDark, { theme: 'light' })).toBe(false)
|
|
97
94
|
})
|
|
98
95
|
|
|
99
|
-
it('a bare array
|
|
100
|
-
|
|
96
|
+
it('a bare array is a literal value, not set-membership (use anyOf for membership)', () => {
|
|
97
|
+
// ['a','b'] is the literal value to match, so it does NOT match the string 'a'
|
|
98
|
+
expect(matchPartStatic('at', ['a', 'b'], 'a')).toBe(false)
|
|
99
|
+
// membership is explicit via anyOf
|
|
100
|
+
expect(matchPartStatic('at', anyOf('a', 'b'), 'a')).toBe(true)
|
|
101
101
|
})
|
|
102
102
|
|
|
103
103
|
it('anyOf rejects object/array members (would silently never match)', () => {
|
package/src/ipfs/car.ts
CHANGED
|
@@ -69,6 +69,12 @@ export async function decodePubFromBlocks(
|
|
|
69
69
|
// New(er) format
|
|
70
70
|
if (!applogsCID) applogsCID = root.applogs // Save from first snapshot
|
|
71
71
|
const applogsBlock = (await getDecodedBlock(blockStore, root.applogs)) as SnapBlockLogsOrChunks
|
|
72
|
+
if (!applogsBlock) {
|
|
73
|
+
throw ERROR('[decodePubFromBlocks] applogs block missing from blockStore', {
|
|
74
|
+
cid: root.applogs.toString(),
|
|
75
|
+
rootCID: cidStr,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
72
78
|
pubLogsArray = await unchunkApplogsBlock(applogsBlock, blockStore)
|
|
73
79
|
// Info only from first (most recent) snapshot
|
|
74
80
|
if (!firstInfo) {
|
package/src/ipns/ipns-record.ts
CHANGED
|
@@ -95,6 +95,21 @@ export async function resolveIPNSSequence(
|
|
|
95
95
|
return 0n
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/** Regex to detect Kubo IPNS sequence conflict in error messages */
|
|
99
|
+
const SEQ_CONFLICT_RE = /existing IPNS record has sequence (\d+) >= new record sequence (\d+)/
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Options for {@link publishIPNSRecord}.
|
|
103
|
+
*/
|
|
104
|
+
export interface PublishIPNSRecordOptions {
|
|
105
|
+
/**
|
|
106
|
+
* Max number of times to bump the sequence on "existing >= new" conflict.
|
|
107
|
+
* Defaults to 3. Each bump re-signs the record with a higher sequence
|
|
108
|
+
* and retries all targets.
|
|
109
|
+
*/
|
|
110
|
+
maxSequenceBumps?: number
|
|
111
|
+
}
|
|
112
|
+
|
|
98
113
|
/**
|
|
99
114
|
* Create a signed IPNS record and publish to all configured targets.
|
|
100
115
|
*
|
|
@@ -105,38 +120,101 @@ export async function resolveIPNSSequence(
|
|
|
105
120
|
*
|
|
106
121
|
* Publish fan-out: only targets with a `publish` method are called.
|
|
107
122
|
* Throws if every publish-capable target fails; partial failures are logged.
|
|
123
|
+
*
|
|
124
|
+
* **Sequence-coherence retry:** if a publish target rejects with the Kubo
|
|
125
|
+
* "existing IPNS record has sequence X >= new record sequence Y" error,
|
|
126
|
+
* the function parses `X`, bumps the sequence to `max(X + 1n, Y + 1n)`,
|
|
127
|
+
* re-signs the record, and retries (up to `maxSequenceBumps` times).
|
|
128
|
+
* This is the "increment and clobber" mechanism that recovers from
|
|
129
|
+
* sequence drift without requiring server-side `--force` support.
|
|
108
130
|
*/
|
|
109
131
|
export async function publishIPNSRecord(
|
|
110
132
|
privateKey: Uint8Array,
|
|
111
133
|
cid: CID,
|
|
112
134
|
targets: IPNSPublishTarget[],
|
|
135
|
+
options?: PublishIPNSRecordOptions,
|
|
113
136
|
): Promise<SignedIPNSRecord> {
|
|
114
137
|
const ipnsName = ipnsNameFromPrivateKey(privateKey)
|
|
115
|
-
const
|
|
116
|
-
const signed = await createSignedIPNSRecord(privateKey, cid, sequence)
|
|
138
|
+
const maxBumps = options?.maxSequenceBumps ?? 3
|
|
117
139
|
|
|
118
140
|
const publishTargets = targets.filter(t => typeof t.publish === 'function')
|
|
119
141
|
if (publishTargets.length === 0) {
|
|
120
142
|
throw new Error('No publish-capable targets supplied to publishIPNSRecord')
|
|
121
143
|
}
|
|
122
144
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (failures.length > 0 && failures.length < publishTargets.length) {
|
|
131
|
-
// Partial failure — log but don't throw
|
|
132
|
-
for (const { r, name } of failures) {
|
|
133
|
-
console.warn(`[publishIPNSRecord] target '${name}' failed:`, r.reason)
|
|
145
|
+
let sequence: bigint | undefined
|
|
146
|
+
let bumpFloor: bigint | undefined
|
|
147
|
+
|
|
148
|
+
for (let attempt = 0; ; attempt++) {
|
|
149
|
+
// Compute next sequence on first pass or after a conflict bump
|
|
150
|
+
if (sequence === undefined) {
|
|
151
|
+
sequence = await pickNextSequence(ipnsName, targets)
|
|
134
152
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
153
|
+
if (bumpFloor !== undefined) {
|
|
154
|
+
// bumpFloor was set on previous attempt from a conflict error
|
|
155
|
+
if (bumpFloor > sequence) sequence = bumpFloor
|
|
156
|
+
bumpFloor = undefined
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const signed = await createSignedIPNSRecord(privateKey, cid, sequence)
|
|
138
160
|
|
|
139
|
-
|
|
161
|
+
const results = await Promise.allSettled(
|
|
162
|
+
publishTargets.map(t => t.publish!(ipnsName, signed.recordBytes)),
|
|
163
|
+
)
|
|
164
|
+
const failures = results
|
|
165
|
+
.map((r, i) => ({ r, name: publishTargets[i].name }))
|
|
166
|
+
.filter(({ r }) => r.status === 'rejected') as { r: PromiseRejectedResult; name: string }[]
|
|
167
|
+
|
|
168
|
+
// All succeeded — done
|
|
169
|
+
if (failures.length === 0) {
|
|
170
|
+
return signed
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Check if any failure is a sequence conflict we can recover from
|
|
174
|
+
const conflictReasons = failures.filter(({ r }) =>
|
|
175
|
+
SEQ_CONFLICT_RE.test(typeof r.reason === 'string' ? r.reason : String(r.reason)),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if (conflictReasons.length > 0 && attempt < maxBumps) {
|
|
179
|
+
// Parse the highest existing sequence from each conflict error
|
|
180
|
+
let bump = sequence
|
|
181
|
+
for (const { r } of conflictReasons) {
|
|
182
|
+
const msg = typeof r.reason === 'string' ? r.reason : String(r.reason)
|
|
183
|
+
const m = msg.match(SEQ_CONFLICT_RE)
|
|
184
|
+
if (m) {
|
|
185
|
+
const existing = BigInt(m[1])
|
|
186
|
+
if (existing >= bump) bump = existing + 1n
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (bump > sequence) {
|
|
190
|
+
console.warn(
|
|
191
|
+
`[publishIPNSRecord] Sequence conflict: server seq ${bump - 1n} >= current ${sequence}, ` +
|
|
192
|
+
`bumping to ${bump} (attempt ${attempt + 1}/${maxBumps})`,
|
|
193
|
+
)
|
|
194
|
+
sequence = bump
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Partial failure (some targets succeeded) — log and return the signed record
|
|
200
|
+
if (failures.length < publishTargets.length) {
|
|
201
|
+
for (const { r, name } of failures) {
|
|
202
|
+
console.warn(`[publishIPNSRecord] target '${name}' failed:`, r.reason)
|
|
203
|
+
}
|
|
204
|
+
return signed
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Every publish-capable target failed and we cannot recover
|
|
208
|
+
if (conflictReasons.length > 0) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`IPNS sequence conflict not resolved after ${Math.min(attempt + 1, maxBumps)} bumps. ` +
|
|
211
|
+
`All publish targets failed: ${failures.map(({ r, name }) => `${name}: ${r.reason}`).join('; ')}`,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
throw new Error(
|
|
215
|
+
`All IPNS publish targets failed: ${failures.map(({ r, name }) => `${name}: ${r.reason}`).join('; ')}`,
|
|
216
|
+
)
|
|
217
|
+
}
|
|
140
218
|
}
|
|
141
219
|
|
|
142
220
|
/**
|
package/src/ipns/ipns-w3name.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { IPNSPublishTarget } from '@wovin/core/ipns'
|
|
2
2
|
import { Logger } from 'besonders-logger'
|
|
3
3
|
import { base64pad } from 'multiformats/bases/base64'
|
|
4
|
+
import { resolveIPNSSequence, unmarshalIPNSRecord } from './ipns-record.ts'
|
|
4
5
|
import { CID } from 'multiformats/cid'
|
|
5
6
|
import * as W3Name from 'w3name'
|
|
6
7
|
|
|
@@ -77,22 +78,125 @@ async function _publishIPNSImpl(ipnsPrivateKey: Uint8Array, cid: CID): Promise<W
|
|
|
77
78
|
return revision // Return for Kubo integration or other uses
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
// zt.ax naming service target (primary)
|
|
83
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
80
85
|
/**
|
|
81
|
-
*
|
|
86
|
+
* Resolve the current IPNS sequence from a zt.ax-compatible naming service.
|
|
87
|
+
*
|
|
88
|
+
* The naming service API uses the simple W3NameRecord format:
|
|
89
|
+
* `GET <baseUrl>/<ipnsName>` → `{ value: "/ipfs/<cid>", seq?: number, validity?: string }`
|
|
90
|
+
*
|
|
91
|
+
* Also supports the legacy w3name record format (`{ record: "<base64>", value: "..." }`)
|
|
92
|
+
* for backward compatibility with hybrid services.
|
|
82
93
|
*/
|
|
83
|
-
export function
|
|
94
|
+
export async function resolveZtaxSequence(
|
|
95
|
+
baseUrl: string,
|
|
96
|
+
ipnsName: string,
|
|
97
|
+
): Promise<bigint | null> {
|
|
98
|
+
const url = `${baseUrl.replace(/\/+$/, '')}/${ipnsName}`
|
|
99
|
+
let response: Response
|
|
100
|
+
try {
|
|
101
|
+
response = await fetch(url)
|
|
102
|
+
} catch (err) {
|
|
103
|
+
throw new Error(`Network error resolving sequence from ${baseUrl}: ${err}`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (response.status === 404) return null
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
throw new Error(`HTTP ${response.status} resolving sequence from ${baseUrl}: ${response.statusText}`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const body: Record<string, unknown> = await response.json()
|
|
112
|
+
|
|
113
|
+
// Format 1: simple W3NameRecord with seq number
|
|
114
|
+
if (typeof body.seq === 'number') {
|
|
115
|
+
return BigInt(body.seq)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Format 2: legacy w3name record with base64-encoded protobuf
|
|
119
|
+
if (typeof body.record === 'string') {
|
|
120
|
+
try {
|
|
121
|
+
const bytes = base64pad.baseDecode(body.record)
|
|
122
|
+
const entry = unmarshalIPNSRecord(bytes)
|
|
123
|
+
return entry.sequence
|
|
124
|
+
} catch {
|
|
125
|
+
WARN('[resolveZtaxSequence] failed to unmarshal record for', ipnsName)
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// No sequence info — treat as never published or zero
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Create an IPNSPublishTarget that publishes to a zt.ax naming service.
|
|
136
|
+
*
|
|
137
|
+
* Uses HTTP POST to publish signed IPNS records and HTTP GET to resolve
|
|
138
|
+
* the current sequence number, following the same wire format as the
|
|
139
|
+
* standard w3name API but at a configurable base URL.
|
|
140
|
+
*
|
|
141
|
+
* The naming service is expected to support:
|
|
142
|
+
* - `POST <baseUrl>/<ipnsName>` — publish signed IPNS record (base64-encoded body)
|
|
143
|
+
* - `GET <baseUrl>/<ipnsName>` — resolve current record (`{ value, seq?, record? }`)
|
|
144
|
+
*
|
|
145
|
+
* @param baseUrl - Base URL of the naming service (default: https://ipfs.zt.ax)
|
|
146
|
+
*/
|
|
147
|
+
export function ztaxTarget(baseUrl = 'https://ipfs.zt.ax'): IPNSPublishTarget {
|
|
148
|
+
const cleanBase = baseUrl.replace(/\/+$/, '')
|
|
84
149
|
return {
|
|
85
|
-
name: '
|
|
150
|
+
name: 'zt.ax',
|
|
86
151
|
async publish(ipnsName: string, recordBytes: Uint8Array) {
|
|
87
|
-
const
|
|
152
|
+
const url = `${cleanBase}/${ipnsName}`
|
|
153
|
+
DEBUG(`[ztaxTarget] POST ${url}`)
|
|
154
|
+
const res = await fetch(url, {
|
|
88
155
|
method: 'POST',
|
|
89
156
|
body: base64pad.baseEncode(recordBytes),
|
|
90
157
|
})
|
|
91
|
-
if (!res.ok)
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
let detail = ''
|
|
160
|
+
try {
|
|
161
|
+
const body = await res.json() as Record<string, unknown>
|
|
162
|
+
detail = body.message ? ` — ${body.message}` : ''
|
|
163
|
+
} catch {
|
|
164
|
+
detail = res.statusText ? ` — ${res.statusText}` : ''
|
|
165
|
+
}
|
|
166
|
+
throw new Error(`zt.ax HTTP ${res.status}${detail}`)
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
async resolveSequence(ipnsName: string): Promise<bigint | null> {
|
|
170
|
+
return resolveZtaxSequence(cleanBase, ipnsName)
|
|
92
171
|
},
|
|
93
172
|
}
|
|
94
173
|
}
|
|
95
174
|
|
|
175
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
176
|
+
// w3name naming service target (deprecated — read‑only backup)
|
|
177
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Create an IPNSPublishTarget for the w3name service.
|
|
181
|
+
*
|
|
182
|
+
* @deprecated w3name is shut down for publishing. This target is kept as a
|
|
183
|
+
* read‑only backup for sequence resolution only (no publish method).
|
|
184
|
+
* Use {@link ztaxTarget} for all publishing.
|
|
185
|
+
*
|
|
186
|
+
* @param serviceUrl - Base URL of the w3name-compatible service (default: https://name.web3.storage)
|
|
187
|
+
*/
|
|
188
|
+
export function w3nameTarget(serviceUrl = 'https://name.web3.storage'): IPNSPublishTarget {
|
|
189
|
+
return {
|
|
190
|
+
name: 'w3name',
|
|
191
|
+
// No publish() — w3name is read-only; publishing goes through ztaxTarget
|
|
192
|
+
async resolveSequence(ipnsName: string): Promise<bigint | null> {
|
|
193
|
+
return resolveIPNSSequence(serviceUrl, ipnsName)
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
96
200
|
export async function generateIpnsKey() {
|
|
97
201
|
return W3Name.create() // Returns W3Name.WritableName type
|
|
98
202
|
}
|
package/src/ipns/ipns-watcher.ts
CHANGED
|
@@ -151,8 +151,9 @@ export function watchNameRaw(nameBaseUrl: string, name: string, options: WatchRa
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
ws.onerror = (event) => {
|
|
154
|
-
|
|
155
|
-
|
|
154
|
+
const errorMsg = event instanceof ErrorEvent ? event.message : 'WebSocket error'
|
|
155
|
+
WARN('[watchNameRaw] error for', name, ':', errorMsg)
|
|
156
|
+
options.onError?.(new Error(errorMsg))
|
|
156
157
|
}
|
|
157
158
|
|
|
158
159
|
ws.onclose = (event) => {
|
|
@@ -322,7 +323,7 @@ export class IpnsWatcher {
|
|
|
322
323
|
|
|
323
324
|
// Still call the error handler for unexpected errors
|
|
324
325
|
if (errorMsg !== 'Unexpected EOF') {
|
|
325
|
-
options.onError?.(
|
|
326
|
+
options.onError?.(new Error(errorMsg))
|
|
326
327
|
}
|
|
327
328
|
}
|
|
328
329
|
|