@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.
Files changed (53) hide show
  1. package/dist/applog/applog-utils.d.ts +25 -1
  2. package/dist/applog/applog-utils.d.ts.map +1 -1
  3. package/dist/applog.js +5 -3
  4. package/dist/{chunk-2OXLPZQI.js → chunk-4MKPGQIM.js} +8 -16
  5. package/dist/chunk-4MKPGQIM.js.map +1 -0
  6. package/dist/{chunk-VGIACGWX.js → chunk-6CSJTSQP.js} +3 -3
  7. package/dist/{chunk-WVW4YXB5.js → chunk-BIYQEX3N.js} +2 -2
  8. package/dist/{chunk-Q4EMPWA3.js → chunk-H3JNNTVP.js} +9 -3
  9. package/dist/chunk-H3JNNTVP.js.map +1 -0
  10. package/dist/{chunk-EHO2BFFY.js → chunk-H4YVJKB7.js} +2 -2
  11. package/dist/chunk-N5QPZNKD.js +288 -0
  12. package/dist/chunk-N5QPZNKD.js.map +1 -0
  13. package/dist/{chunk-2PJFLZRC.js → chunk-N7SEGHU4.js} +2 -2
  14. package/dist/index.js +20 -10
  15. package/dist/ipfs/car.d.ts.map +1 -1
  16. package/dist/ipfs.js +4 -4
  17. package/dist/ipns/ipns-record.d.ts +19 -1
  18. package/dist/ipns/ipns-record.d.ts.map +1 -1
  19. package/dist/ipns/ipns-w3name.d.ts +31 -1
  20. package/dist/ipns/ipns-w3name.d.ts.map +1 -1
  21. package/dist/ipns/ipns-watcher.d.ts.map +1 -1
  22. package/dist/ipns.js +118 -21
  23. package/dist/ipns.js.map +1 -1
  24. package/dist/pubsub.js +4 -4
  25. package/dist/query/epoch-snapshot.d.ts +205 -0
  26. package/dist/query/epoch-snapshot.d.ts.map +1 -0
  27. package/dist/query.d.ts +1 -0
  28. package/dist/query.d.ts.map +1 -1
  29. package/dist/query.js +12 -4
  30. package/dist/retrieve.js +4 -4
  31. package/dist/thread.js +1 -1
  32. package/dist/viewmodel/index.js +4 -4
  33. package/dist/viewmodel/index.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/applog/applog-utils.test.ts +43 -1
  36. package/src/applog/applog-utils.ts +38 -21
  37. package/src/applog/object-values.test.ts +9 -9
  38. package/src/ipfs/car.ts +6 -0
  39. package/src/ipns/ipns-record.ts +95 -17
  40. package/src/ipns/ipns-w3name.ts +109 -5
  41. package/src/ipns/ipns-watcher.ts +4 -3
  42. package/src/query/epoch-snapshot.test.ts +594 -0
  43. package/src/query/epoch-snapshot.ts +392 -0
  44. package/src/query.ts +1 -0
  45. package/src/viewmodel/factory.ts +2 -2
  46. package/dist/chunk-2OXLPZQI.js.map +0 -1
  47. package/dist/chunk-OKXRRWNS.js +0 -138
  48. package/dist/chunk-OKXRRWNS.js.map +0 -1
  49. package/dist/chunk-Q4EMPWA3.js.map +0 -1
  50. /package/dist/{chunk-VGIACGWX.js.map → chunk-6CSJTSQP.js.map} +0 -0
  51. /package/dist/{chunk-WVW4YXB5.js.map → chunk-BIYQEX3N.js.map} +0 -0
  52. /package/dist/{chunk-EHO2BFFY.js.map → chunk-H4YVJKB7.js.map} +0 -0
  53. /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
- it('matching a literal array value requires a predicate (a bare array throws)', () => {
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 pattern fails loudly rather than matching ambiguously', () => {
100
- expect(() => matchPartStatic('at', ['a', 'b'] as any, 'a')).toThrow(/anyOf/)
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) {
@@ -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 sequence = await pickNextSequence(ipnsName, targets)
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
- const results = await Promise.allSettled(
124
- publishTargets.map(t => t.publish!(ipnsName, signed.recordBytes)),
125
- )
126
- const failures = results
127
- .map((r, i) => ({ r, name: publishTargets[i].name }))
128
- .filter(({ r }) => r.status === 'rejected') as { r: PromiseRejectedResult; name: string }[]
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
- } else if (failures.length === publishTargets.length) {
136
- throw new Error(`All IPNS publish targets failed: ${failures.map(({ r, name }) => `${name}: ${r.reason}`).join('; ')}`)
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
- return signed
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
  /**
@@ -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
- * Create an IPNSPublishTarget that publishes to W3Name service via HTTP POST.
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 w3nameTarget(serviceUrl = 'https://name.web3.storage'): IPNSPublishTarget {
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: 'w3name',
150
+ name: 'zt.ax',
86
151
  async publish(ipnsName: string, recordBytes: Uint8Array) {
87
- const res = await fetch(`${serviceUrl}/name/${ipnsName}`, {
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) throw new Error(`W3Name HTTP ${res.status}`)
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
  }
@@ -151,8 +151,9 @@ export function watchNameRaw(nameBaseUrl: string, name: string, options: WatchRa
151
151
  }
152
152
 
153
153
  ws.onerror = (event) => {
154
- WARN('[watchNameRaw] error for', name, event)
155
- options.onError?.(event)
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?.(event)
326
+ options.onError?.(new Error(errorMsg))
326
327
  }
327
328
  }
328
329