@wovin/core 0.2.0 → 0.3.0

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 (115) hide show
  1. package/dist/applog/applog-utils.d.ts +15 -0
  2. package/dist/applog/applog-utils.d.ts.map +1 -1
  3. package/dist/applog/datom-types.d.ts +63 -7
  4. package/dist/applog/datom-types.d.ts.map +1 -1
  5. package/dist/applog.js +7 -1
  6. package/dist/blockstore.js +2 -0
  7. package/dist/blockstore.js.map +1 -1
  8. package/dist/{chunk-L5EEEGE6.js → chunk-2OXLPZQI.js} +747 -679
  9. package/dist/chunk-2OXLPZQI.js.map +1 -0
  10. package/dist/{chunk-QZXKQCAY.js → chunk-2PJFLZRC.js} +7 -2
  11. package/dist/{chunk-QZXKQCAY.js.map → chunk-2PJFLZRC.js.map} +1 -1
  12. package/dist/chunk-64EJIJAJ.js +17 -0
  13. package/dist/chunk-64EJIJAJ.js.map +1 -0
  14. package/dist/chunk-7QEGHKR4.js +17 -0
  15. package/dist/chunk-7QEGHKR4.js.map +1 -0
  16. package/dist/{chunk-PD3C7XUM.js → chunk-EHO2BFFY.js} +2 -2
  17. package/dist/chunk-ICBK7NC4.js +27 -0
  18. package/dist/chunk-ICBK7NC4.js.map +1 -0
  19. package/dist/{chunk-CPSDKFBG.js → chunk-OKXRRWNS.js} +5 -14
  20. package/dist/chunk-OKXRRWNS.js.map +1 -0
  21. package/dist/{chunk-3WZVG277.js → chunk-Q4EMPWA3.js} +17 -9
  22. package/dist/chunk-Q4EMPWA3.js.map +1 -0
  23. package/dist/{chunk-J2FDHGOZ.js → chunk-VGIACGWX.js} +3 -3
  24. package/dist/{chunk-3JZMOEOD.js → chunk-WVW4YXB5.js} +2 -2
  25. package/dist/chunk-XF4DWOAE.js +25 -0
  26. package/dist/chunk-XF4DWOAE.js.map +1 -0
  27. package/dist/index.js +17 -9
  28. package/dist/ipfs/car.d.ts.map +1 -1
  29. package/dist/ipfs.js +4 -4
  30. package/dist/ipns/gateway-resolver.d.ts +21 -0
  31. package/dist/ipns/gateway-resolver.d.ts.map +1 -0
  32. package/dist/ipns/ipns-record.d.ts +28 -7
  33. package/dist/ipns/ipns-record.d.ts.map +1 -1
  34. package/dist/ipns/ipns-w3name.d.ts +15 -0
  35. package/dist/ipns/ipns-w3name.d.ts.map +1 -0
  36. package/dist/ipns/ipns-watcher.d.ts +190 -0
  37. package/dist/ipns/ipns-watcher.d.ts.map +1 -0
  38. package/dist/ipns.d.ts +3 -0
  39. package/dist/ipns.d.ts.map +1 -1
  40. package/dist/ipns.js +488 -8
  41. package/dist/ipns.js.map +1 -1
  42. package/dist/pubsub/snap-push.d.ts +2 -2
  43. package/dist/pubsub/snap-push.d.ts.map +1 -1
  44. package/dist/pubsub.js +4 -4
  45. package/dist/query/basic.d.ts +3 -3
  46. package/dist/query/basic.d.ts.map +1 -1
  47. package/dist/query/entity-collection.d.ts.map +1 -1
  48. package/dist/query/matchers.d.ts +12 -1
  49. package/dist/query/matchers.d.ts.map +1 -1
  50. package/dist/query.js +7 -5
  51. package/dist/retrieve.js +4 -4
  52. package/dist/thread/indexes.d.ts +3 -2
  53. package/dist/thread/indexes.d.ts.map +1 -1
  54. package/dist/thread.js +1 -1
  55. package/dist/viewmodel/adapters/arktype.d.ts +33 -0
  56. package/dist/viewmodel/adapters/arktype.d.ts.map +1 -0
  57. package/dist/viewmodel/adapters/arktype.js +7 -0
  58. package/dist/viewmodel/adapters/arktype.js.map +1 -0
  59. package/dist/viewmodel/adapters/typebox.d.ts +35 -0
  60. package/dist/viewmodel/adapters/typebox.d.ts.map +1 -0
  61. package/dist/viewmodel/adapters/typebox.js +7 -0
  62. package/dist/viewmodel/adapters/typebox.js.map +1 -0
  63. package/dist/viewmodel/adapters/typia.d.ts +40 -0
  64. package/dist/viewmodel/adapters/typia.d.ts.map +1 -0
  65. package/dist/viewmodel/adapters/typia.js +7 -0
  66. package/dist/viewmodel/adapters/typia.js.map +1 -0
  67. package/dist/viewmodel/adapters/zod.d.ts +30 -0
  68. package/dist/viewmodel/adapters/zod.d.ts.map +1 -0
  69. package/dist/viewmodel/adapters/zod.js +7 -0
  70. package/dist/viewmodel/adapters/zod.js.map +1 -0
  71. package/dist/viewmodel/builder.d.ts +40 -0
  72. package/dist/viewmodel/builder.d.ts.map +1 -0
  73. package/dist/viewmodel/examples/all-adapters.d.ts +26 -0
  74. package/dist/viewmodel/examples/all-adapters.d.ts.map +1 -0
  75. package/dist/viewmodel/factory.d.ts +38 -0
  76. package/dist/viewmodel/factory.d.ts.map +1 -0
  77. package/dist/viewmodel/index.d.ts +10 -0
  78. package/dist/viewmodel/index.d.ts.map +1 -0
  79. package/dist/viewmodel/index.js +313 -0
  80. package/dist/viewmodel/index.js.map +1 -0
  81. package/dist/viewmodel/schema-adapter.d.ts +16 -0
  82. package/dist/viewmodel/schema-adapter.d.ts.map +1 -0
  83. package/dist/viewmodel/types.d.ts +97 -0
  84. package/dist/viewmodel/types.d.ts.map +1 -0
  85. package/package.json +29 -3
  86. package/src/applog/applog-utils.ts +48 -4
  87. package/src/applog/datom-types.ts +24 -5
  88. package/src/applog/object-values.test.ts +106 -0
  89. package/src/ipfs/car.ts +8 -2
  90. package/src/ipns/gateway-resolver.ts +63 -0
  91. package/src/ipns/ipns-record.ts +68 -17
  92. package/src/ipns/ipns-w3name.ts +103 -0
  93. package/src/ipns/ipns-watcher.ts +607 -0
  94. package/src/ipns.ts +3 -0
  95. package/src/pubsub/snap-push.ts +8 -6
  96. package/src/query/entity-collection.ts +2 -1
  97. package/src/query/matchers.ts +23 -1
  98. package/src/thread/basic.ts +2 -2
  99. package/src/thread/indexes.ts +15 -9
  100. package/src/viewmodel/adapters/arktype.ts +44 -0
  101. package/src/viewmodel/adapters/typebox.ts +59 -0
  102. package/src/viewmodel/adapters/typia.ts +50 -0
  103. package/src/viewmodel/adapters/zod.ts +55 -0
  104. package/src/viewmodel/builder.ts +71 -0
  105. package/src/viewmodel/examples/all-adapters.ts +206 -0
  106. package/src/viewmodel/factory.ts +330 -0
  107. package/src/viewmodel/index.ts +22 -0
  108. package/src/viewmodel/schema-adapter.ts +27 -0
  109. package/src/viewmodel/types.ts +152 -0
  110. package/dist/chunk-3WZVG277.js.map +0 -1
  111. package/dist/chunk-CPSDKFBG.js.map +0 -1
  112. package/dist/chunk-L5EEEGE6.js.map +0 -1
  113. /package/dist/{chunk-PD3C7XUM.js.map → chunk-EHO2BFFY.js.map} +0 -0
  114. /package/dist/{chunk-J2FDHGOZ.js.map → chunk-VGIACGWX.js.map} +0 -0
  115. /package/dist/{chunk-3JZMOEOD.js.map → chunk-WVW4YXB5.js.map} +0 -0
@@ -25,8 +25,14 @@ export type CidString = Tagged<string, CID>
25
25
  export type IpnsString = Tagged<CidString, 'IPNS'>
26
26
  export type AgentID = EntityID
27
27
  export type Attribute = string
28
- export type ApplogValue = string | boolean | number | null // TODO: use Tagged types
29
- // ? allow objects? or just as serialized strings? Or serialize everything anyways?
28
+ // JSON-serializable value space for a datom's `vl` field.
29
+ export type JsonPrimitive = string | boolean | number | null
30
+ export interface JsonObject {
31
+ [key: string]: JsonValue
32
+ }
33
+ export type JsonArray = JsonValue[]
34
+ export type JsonValue = JsonPrimitive | JsonObject | JsonArray
35
+ export type ApplogValue = JsonValue // TODO: use Tagged types
30
36
 
31
37
  export interface Atom {
32
38
  en: EntityID
@@ -112,18 +118,31 @@ type MapKeysStripPrefix<SELECT extends string, TPrefix extends string> = {
112
118
  }
113
119
 
114
120
  FormatRegistry.Set('CID', (value) => !!value.match(isCID))
115
- export const CIDTB = Type.String({ format: 'CID' })
121
+ export const CIDTB = Type.String({ pattern: isCID.source })
116
122
  export type CIDTB = Static<typeof EntityID>
117
123
 
118
124
  const isURL = /^http([s]?):\/\/.*\..*/
119
125
  FormatRegistry.Set('URL', (value) => !!value.match(isURL))
120
- export const URL = Type.String({ format: 'URL' })
126
+ export const URL = Type.String({ pattern: isURL.source })
121
127
  export type URL = Static<typeof URL>
122
128
 
129
+ // Recursive JSON value: primitives plus nested arrays/objects (mirrors ApplogValue / JsonValue).
130
+ export const JsonValueTB = Type.Recursive(This =>
131
+ Type.Union([
132
+ Type.String(),
133
+ Type.Number(),
134
+ Type.Boolean(),
135
+ Type.Null(),
136
+ Type.Array(This),
137
+ Type.Record(Type.String(), This),
138
+ ])
139
+ )
140
+ export type JsonValueTB = Static<typeof JsonValueTB>
141
+
123
142
  export const AppLogNoCidTB = Type.Object({
124
143
  en: EntityID, // EntityID
125
144
  at: Type.String(), // Attribute
126
- vl: Nullable(Type.Union([Type.String(), Type.Boolean(), Type.Number()])), // TODO refactor to semantic typesafe ApplogValue
145
+ vl: JsonValueTB, // ApplogValue (JSON-serializable: primitives, arrays, objects)
127
146
  ts: Type.String(), // Timestamp
128
147
  ag: Type.String(), // AgentHash
129
148
  pv: Nullable(CIDTB), // CidString
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Tests for JSON object/array support in a datom's `vl` field.
3
+ *
4
+ * Covers: schema validation, deep-equality dedup, value-index keying,
5
+ * matcher semantics (deep-eq literal match, anyOf membership, predicate
6
+ * for nested access) and the fail-loud behaviour for bare-array patterns.
7
+ */
8
+ import { describe, expect, it } from 'vitest'
9
+ import { isValidApplog } from './datom-types.ts'
10
+ import { matchPartStatic, valueEq, valueKey } from './applog-utils.ts'
11
+ import { anyOf } from '../query/matchers.ts'
12
+ import { finalizeApplogForInsert } from './applog-helpers.ts'
13
+ import { applogsByAttrValue } from '../thread/indexes.ts'
14
+ import { ThreadInMemory } from '../thread/writeable.ts'
15
+ import type { Applog, ApplogForInsert } from './datom-types.ts'
16
+
17
+ let tsCounter = 0
18
+ function makeLog(spec: Pick<ApplogForInsert, 'en' | 'at' | 'vl'> & Partial<Applog>): Applog {
19
+ tsCounter++
20
+ return finalizeApplogForInsert({
21
+ ts: new Date(1700000000000 + tsCounter * 1000).toISOString(),
22
+ pv: null,
23
+ ag: 'testAgent',
24
+ ...spec,
25
+ } as ApplogForInsert, {})
26
+ }
27
+
28
+ describe('object/array applog values', () => {
29
+ it('schema accepts nested objects and arrays as vl', () => {
30
+ const obj = makeLog({ en: 'e1', at: 'config', vl: { theme: 'dark', nested: { n: [1, 2, 3] } } })
31
+ const arr = makeLog({ en: 'e1', at: 'tags', vl: ['a', 'b', { x: 1 }] })
32
+ expect(isValidApplog(obj)).toBe(true)
33
+ expect(isValidApplog(arr)).toBe(true)
34
+ // primitives still valid
35
+ expect(isValidApplog(makeLog({ en: 'e1', at: 'name', vl: 'hi' }))).toBe(true)
36
+ expect(isValidApplog(makeLog({ en: 'e1', at: 'n', vl: null }))).toBe(true)
37
+ })
38
+
39
+ it('valueEq compares object/array values by structure', () => {
40
+ expect(valueEq({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true)
41
+ expect(valueEq([1, 2, 3], [1, 2, 3])).toBe(true)
42
+ expect(valueEq({ a: 1 }, { a: 2 })).toBe(false)
43
+ expect(valueEq('x', 'x')).toBe(true)
44
+ })
45
+
46
+ it('valueKey canonicalizes objects (key-order independent) without colliding with strings', () => {
47
+ expect(valueKey({ a: 1, b: 2 })).toBe(valueKey({ b: 2, a: 1 }))
48
+ // a literal string equal to an object's serialization must NOT collide
49
+ expect(valueKey('{"a":1}')).not.toBe(valueKey({ a: 1 }))
50
+ // primitives pass through as their own value
51
+ expect(valueKey('hi')).toBe('hi')
52
+ expect(valueKey(42)).toBe(42)
53
+ expect(valueKey(null)).toBe(null)
54
+ })
55
+
56
+ it('hasApplogWithDiffTs matches structurally-equal object values (deep, key-order independent)', () => {
57
+ const existing = makeLog({ en: 'e1', at: 'config', vl: { a: 1, b: 2 } })
58
+ const thread = ThreadInMemory.fromArray([existing], 'dedup')
59
+ // same en/at/ag, deep-equal vl with different key order → recognised as the same datom
60
+ expect(thread.hasApplogWithDiffTs({ en: 'e1', at: 'config', vl: { b: 2, a: 1 }, ag: 'testAgent' } as any)).toBeTruthy()
61
+ // a structurally different value is NOT matched
62
+ expect(thread.hasApplogWithDiffTs({ en: 'e1', at: 'config', vl: { a: 9 }, ag: 'testAgent' } as any)).toBeFalsy()
63
+ })
64
+
65
+ it('applogsByAttrValue groups structurally-equal object values into one bucket', () => {
66
+ const logs = [
67
+ makeLog({ en: 'e1', at: 'config', vl: { a: 1, b: 2 } }),
68
+ makeLog({ en: 'e2', at: 'config', vl: { b: 2, a: 1 } }),
69
+ makeLog({ en: 'e3', at: 'config', vl: { a: 9 } }),
70
+ ]
71
+ const thread = ThreadInMemory.fromArray(logs, 'idx')
72
+ const index = applogsByAttrValue(thread, 'config').value
73
+ expect(index.get(valueKey({ a: 1, b: 2 }))).toHaveLength(2)
74
+ expect(index.get(valueKey({ a: 9 }))).toHaveLength(1)
75
+ })
76
+
77
+ it('matchPartStatic matches a literal object value by deep equality', () => {
78
+ expect(matchPartStatic('vl', { a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true)
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/)
86
+ })
87
+
88
+ it('anyOf(...) provides set-membership and matches via matchPartStatic', () => {
89
+ expect(matchPartStatic('at', anyOf('a', 'b', 'c'), 'b')).toBe(true)
90
+ expect(matchPartStatic('at', anyOf('a', 'b'), 'z')).toBe(false)
91
+ })
92
+
93
+ it('a predicate matcher can reach into nested object values', () => {
94
+ const isDark = (v: any) => v?.theme === 'dark'
95
+ expect(matchPartStatic('vl', isDark, { theme: 'dark' })).toBe(true)
96
+ expect(matchPartStatic('vl', isDark, { theme: 'light' })).toBe(false)
97
+ })
98
+
99
+ it('a bare array pattern fails loudly rather than matching ambiguously', () => {
100
+ expect(() => matchPartStatic('at', ['a', 'b'] as any, 'a')).toThrow(/anyOf/)
101
+ })
102
+
103
+ it('anyOf rejects object/array members (would silently never match)', () => {
104
+ expect(() => anyOf({ a: 1 } as any)).toThrow(/object\/array/)
105
+ })
106
+ })
package/src/ipfs/car.ts CHANGED
@@ -72,8 +72,14 @@ export async function decodePubFromBlocks(
72
72
  pubLogsArray = await unchunkApplogsBlock(applogsBlock, blockStore)
73
73
  // Info only from first (most recent) snapshot
74
74
  if (!firstInfo) {
75
- firstInfo = (await getDecodedBlock(blockStore, root.info)) as SnapBlockLogs
76
- DEBUG(`new format - infoLogs`, firstInfo.logs.map(l => ({ [l.toString()]: l })))
75
+ const decoded = (await getDecodedBlock(blockStore, root.info)) as SnapBlockLogs
76
+ if (decoded) {
77
+ firstInfo = decoded
78
+ DEBUG(`new format - infoLogs`, firstInfo.logs.map(l => ({ [l.toString()]: l })))
79
+ } else {
80
+ WARN(`[decodePubFromBlocks] info block not found for ${root.info}, using empty info`)
81
+ firstInfo = { logs: [] }
82
+ }
77
83
  }
78
84
  // TODO: verify signatures
79
85
  } else {
@@ -0,0 +1,63 @@
1
+ import { CID } from 'multiformats/cid'
2
+ import { Logger } from 'besonders-logger'
3
+
4
+ const { WARN, LOG, DEBUG } = Logger.setup(Logger.INFO) // eslint-disable-line unused-imports/no-unused-vars
5
+
6
+ /**
7
+ * Resolve an IPNS name to a CID using a public IPFS gateway's HTTP HEAD response.
8
+ *
9
+ * Mechanism: per the IPFS HTTP Gateway spec, `HEAD <gateway>/ipns/<name>/` returns the
10
+ * IPNS-resolved root CID in the `X-Ipfs-Roots` response header (space-separated, ordered
11
+ * from root to leaf). The first entry is the IPNS-resolved CID.
12
+ *
13
+ * This works against any gateway that follows the spec and exposes CORS for HEAD
14
+ * (most public gateways do, e.g. ipfs.zt.ax, ipfs.io, dweb.link).
15
+ *
16
+ * The legacy w3name HTTP endpoint (`GET <base>/name/<ipns>` returning `{value: "/ipfs/<cid>"}`)
17
+ * is also supported here for back-compat with self-hosted w3name-like services — but the
18
+ * X-Ipfs-Roots path is preferred since it's the standardised gateway mechanism.
19
+ *
20
+ * @param ipns - The IPNS name (k51... string)
21
+ * @param gateways - List of gateway base URLs (e.g. `["https://ipfs.zt.ax"]`)
22
+ * @returns The resolved CID, or null if no gateway could resolve the name
23
+ */
24
+ export async function resolveIPNSViaGateway(ipns: string, gateways: string[]): Promise<CID | null> {
25
+ if (!ipns.startsWith('k51')) return null // only IPNS libp2p-key names go through gateway
26
+ if (!gateways?.length) return null
27
+
28
+ for (const rawGateway of gateways) {
29
+ const gateway = rawGateway.replace(/\/+$/, '')
30
+ const url = `${gateway}/ipns/${ipns}/`
31
+ try {
32
+ DEBUG(`[resolveIPNSViaGateway] HEAD ${url}`)
33
+ const response = await fetch(url, { method: 'HEAD' })
34
+ if (!response.ok) {
35
+ DEBUG(`[resolveIPNSViaGateway] ${gateway} returned ${response.status}`)
36
+ continue
37
+ }
38
+ const roots = response.headers.get('x-ipfs-roots') ?? response.headers.get('X-Ipfs-Roots')
39
+ if (roots) {
40
+ const first = roots.split(/[\s,]+/)[0]?.trim()
41
+ if (first) {
42
+ const cid = CID.parse(first)
43
+ DEBUG(`[resolveIPNSViaGateway] resolved via ${gateway} x-ipfs-roots:`, cid.toString())
44
+ return cid
45
+ }
46
+ }
47
+ // Some gateways omit X-Ipfs-Roots but put the CID in etag (e.g. `<cid>.dag-json`)
48
+ const etag = response.headers.get('etag')
49
+ if (etag) {
50
+ const m = etag.match(/^"?([a-z0-9]+)/i)
51
+ if (m) {
52
+ const cid = CID.parse(m[1])
53
+ DEBUG(`[resolveIPNSViaGateway] resolved via ${gateway} etag:`, cid.toString())
54
+ return cid
55
+ }
56
+ }
57
+ WARN(`[resolveIPNSViaGateway] ${gateway} returned 200 but no usable CID header`)
58
+ } catch (err) {
59
+ WARN(`[resolveIPNSViaGateway] ${gateway} failed:`, err)
60
+ }
61
+ }
62
+ return null
63
+ }
@@ -5,9 +5,9 @@ import { base64pad } from 'multiformats/bases/base64'
5
5
  import type { CID } from 'multiformats/cid'
6
6
 
7
7
  export interface SignedIPNSRecord {
8
- recordBytes: Uint8Array // marshalled protobuf, signed — the wire format
9
- ipnsName: string // k51... string
10
- value: string // /ipfs/<cid>
8
+ recordBytes: Uint8Array // marshalled protobuf, signed — the wire format
9
+ ipnsName: string // k51... string
10
+ value: string // /ipfs/<cid>
11
11
  sequence: bigint
12
12
  }
13
13
 
@@ -37,16 +37,31 @@ export async function createSignedIPNSRecord(
37
37
 
38
38
  export { unmarshalIPNSRecord }
39
39
 
40
- /** A target that can receive a signed IPNS record */
40
+ /**
41
+ * A target that can receive a signed IPNS record, advertise its current
42
+ * sequence, or both.
43
+ *
44
+ * - `publish` is called by `publishIPNSRecord` to actually store the record
45
+ * on the target's backing service. Targets without `publish` are skipped
46
+ * during the fan-out (e.g. a sequence-only source).
47
+ * - `resolveSequence` is consulted by `publishIPNSRecord` to compute the
48
+ * next IPNS sequence. The first target to return a value (including
49
+ * `null` for "never published") wins. Throw to indicate a transient
50
+ * error — the caller will try the next target.
51
+ *
52
+ * Most real targets (e.g. a storage connector) provide both. A simple
53
+ * "track sequence in localStorage" target only needs `resolveSequence`.
54
+ */
41
55
  export interface IPNSPublishTarget {
42
56
  name: string
43
- publish(ipnsName: string, recordBytes: Uint8Array): Promise<void>
57
+ publish?(ipnsName: string, recordBytes: Uint8Array): Promise<void>
58
+ resolveSequence?(ipnsName: string): Promise<bigint | null>
44
59
  }
45
60
 
46
61
  /**
47
- * Resolve current IPNS sequence number from a naming service.
62
+ * Resolve the current IPNS sequence from a generic naming service.
48
63
  * Returns null if the name was never published (404).
49
- * Throws on network/server errors.
64
+ * Throws on network/server errors so the caller can try a different target.
50
65
  */
51
66
  export async function resolveIPNSSequence(
52
67
  nameServiceUrl: string,
@@ -75,41 +90,77 @@ export async function resolveIPNSSequence(
75
90
  return entry.sequence
76
91
  }
77
92
 
78
- // Some servers return value but not raw record — can't get sequence
93
+ // Server only returned a value, no raw record — can't know the exact sequence.
94
+ // Most services still accept this as "previous published"; treat as seq=0n.
79
95
  return 0n
80
96
  }
81
97
 
82
98
  /**
83
99
  * Create a signed IPNS record and publish to all configured targets.
84
- * Resolves sequence from sequenceServiceUrl, creates record once, fans out.
85
- * Throws if ALL targets fail; warns on partial failure.
100
+ *
101
+ * Sequence resolution: walks `targets` in order asking each one with a
102
+ * `resolveSequence` method. The first target to successfully return a value
103
+ * (including `null` for "never published") determines the next sequence.
104
+ * If every target throws or none supports `resolveSequence`, falls back to 0n.
105
+ *
106
+ * Publish fan-out: only targets with a `publish` method are called.
107
+ * Throws if every publish-capable target fails; partial failures are logged.
86
108
  */
87
109
  export async function publishIPNSRecord(
88
110
  privateKey: Uint8Array,
89
111
  cid: CID,
90
112
  targets: IPNSPublishTarget[],
91
- sequenceServiceUrl = 'https://name.web3.storage',
92
113
  ): Promise<SignedIPNSRecord> {
93
114
  const ipnsName = ipnsNameFromPrivateKey(privateKey)
94
- const currentSeq = await resolveIPNSSequence(sequenceServiceUrl, ipnsName)
95
- const sequence = currentSeq != null ? currentSeq + 1n : 0n
115
+ const sequence = await pickNextSequence(ipnsName, targets)
96
116
  const signed = await createSignedIPNSRecord(privateKey, cid, sequence)
97
117
 
118
+ const publishTargets = targets.filter(t => typeof t.publish === 'function')
119
+ if (publishTargets.length === 0) {
120
+ throw new Error('No publish-capable targets supplied to publishIPNSRecord')
121
+ }
122
+
98
123
  const results = await Promise.allSettled(
99
- targets.map(t => t.publish(ipnsName, signed.recordBytes))
124
+ publishTargets.map(t => t.publish!(ipnsName, signed.recordBytes)),
100
125
  )
101
126
  const failures = results
102
- .map((r, i) => ({ r, name: targets[i].name }))
127
+ .map((r, i) => ({ r, name: publishTargets[i].name }))
103
128
  .filter(({ r }) => r.status === 'rejected') as { r: PromiseRejectedResult; name: string }[]
104
129
 
105
- if (failures.length > 0 && failures.length < targets.length) {
130
+ if (failures.length > 0 && failures.length < publishTargets.length) {
106
131
  // Partial failure — log but don't throw
107
132
  for (const { r, name } of failures) {
108
133
  console.warn(`[publishIPNSRecord] target '${name}' failed:`, r.reason)
109
134
  }
110
- } else if (failures.length === targets.length) {
135
+ } else if (failures.length === publishTargets.length) {
111
136
  throw new Error(`All IPNS publish targets failed: ${failures.map(({ r, name }) => `${name}: ${r.reason}`).join('; ')}`)
112
137
  }
113
138
 
114
139
  return signed
115
140
  }
141
+
142
+ /**
143
+ * Walk targets, asking each to resolve the current IPNS sequence. First success wins.
144
+ * Falls back to 0n if no target can answer.
145
+ */
146
+ async function pickNextSequence(
147
+ ipnsName: string,
148
+ targets: IPNSPublishTarget[],
149
+ ): Promise<bigint> {
150
+ const capable = targets.filter(t => typeof t.resolveSequence === 'function')
151
+ if (capable.length === 0) {
152
+ return 0n
153
+ }
154
+
155
+ for (const target of capable) {
156
+ try {
157
+ const current = await target.resolveSequence!(ipnsName)
158
+ return current == null ? 0n : current + 1n
159
+ } catch (err) {
160
+ console.warn(`[publishIPNSRecord] target '${target.name}' sequence resolve failed:`, err)
161
+ }
162
+ }
163
+
164
+ console.warn(`[publishIPNSRecord] no target could resolve sequence for ${ipnsName} — starting at 0n`)
165
+ return 0n
166
+ }
@@ -0,0 +1,103 @@
1
+ import type { IPNSPublishTarget } from '@wovin/core/ipns'
2
+ import { Logger } from 'besonders-logger'
3
+ import { base64pad } from 'multiformats/bases/base64'
4
+ import { CID } from 'multiformats/cid'
5
+ import * as W3Name from 'w3name'
6
+
7
+ const { WARN, LOG, DEBUG, ERROR } = Logger.setup(Logger.INFO)
8
+
9
+ /**
10
+ * Try to resolve IPNS name to get existing revision.
11
+ * Returns null if record doesn't exist (404).
12
+ * Throws on network errors or server errors.
13
+ *
14
+ * This does a custom HTTP check first to distinguish 404 from network errors,
15
+ * then delegates to W3Name.resolve() for validation if record exists.
16
+ */
17
+ async function tryResolveIPNS(ipns: W3Name.WritableName): Promise<W3Name.Revision | null> {
18
+ const url = `https://name.web3.storage/name/${ipns.toString()}`
19
+
20
+ let response: Response
21
+ try {
22
+ response = await fetch(url, { signal: AbortSignal.timeout(30_000) })
23
+ } catch (err) {
24
+ // Network error (no connection, DNS failure, etc.)
25
+ throw ERROR('[w3name] Network error resolving IPNS:', err)
26
+ }
27
+
28
+ // 404 = record never published
29
+ if (response.status === 404) {
30
+ DEBUG('[w3name] IPNS record not found (never published):', ipns.toString())
31
+ return null
32
+ }
33
+
34
+ // Other HTTP errors (5xx server error, etc.)
35
+ if (!response.ok) {
36
+ throw ERROR(`[w3name] HTTP ${response.status} resolving IPNS:`, response.statusText)
37
+ }
38
+
39
+ // Success - use W3Name.resolve to get validated Revision
40
+ // (We could parse the record ourselves, but W3Name does validation/signature checks)
41
+ const existing = await W3Name.resolve(ipns)
42
+ return existing
43
+ }
44
+
45
+ /**
46
+ * Publish CID to IPNS, automatically handling increment vs v0.
47
+ * Returns the revision for further processing (e.g., Kubo integration).
48
+ */
49
+ export async function publishIPNS(ipnsPrivateKey: Uint8Array, cid: CID): Promise<W3Name.Revision> {
50
+ const TIMEOUT_MS = 30_000
51
+ const timeout = new Promise<never>((_, reject) =>
52
+ setTimeout(() => reject(new Error(`publishIPNS timed out after ${TIMEOUT_MS}ms`)), TIMEOUT_MS),
53
+ )
54
+ return Promise.race([_publishIPNSImpl(ipnsPrivateKey, cid), timeout])
55
+ }
56
+
57
+ async function _publishIPNSImpl(ipnsPrivateKey: Uint8Array, cid: CID): Promise<W3Name.Revision> {
58
+ const value = `/ipfs/${cid}`
59
+ const ipns = await W3Name.from(ipnsPrivateKey)
60
+
61
+ let revision: W3Name.Revision
62
+ const existing = await tryResolveIPNS(ipns)
63
+
64
+ if (existing) {
65
+ // Record exists - increment sequence number
66
+ revision = await W3Name.increment(existing, value)
67
+ DEBUG('[w3name] incrementing revision for', ipns.toString())
68
+ } else {
69
+ // First publish - use v0
70
+ revision = await W3Name.v0(ipns, value)
71
+ DEBUG('[w3name] creating initial revision for', ipns.toString())
72
+ }
73
+
74
+ await W3Name.publish(revision, ipns.key)
75
+ DEBUG('[w3name] published', cid.toString(), 'to', ipns.toString())
76
+
77
+ return revision // Return for Kubo integration or other uses
78
+ }
79
+
80
+ /**
81
+ * Create an IPNSPublishTarget that publishes to W3Name service via HTTP POST.
82
+ */
83
+ export function w3nameTarget(serviceUrl = 'https://name.web3.storage'): IPNSPublishTarget {
84
+ return {
85
+ name: 'w3name',
86
+ async publish(ipnsName: string, recordBytes: Uint8Array) {
87
+ const res = await fetch(`${serviceUrl}/name/${ipnsName}`, {
88
+ method: 'POST',
89
+ body: base64pad.baseEncode(recordBytes),
90
+ })
91
+ if (!res.ok) throw new Error(`W3Name HTTP ${res.status}`)
92
+ },
93
+ }
94
+ }
95
+
96
+ export async function generateIpnsKey() {
97
+ return W3Name.create() // Returns W3Name.WritableName type
98
+ }
99
+
100
+ export async function getW3NamePublic(pk: Uint8Array) {
101
+ const ipns = await W3Name.from(pk)
102
+ return ipns.toString()
103
+ }