@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.
- package/dist/applog/applog-utils.d.ts +15 -0
- package/dist/applog/applog-utils.d.ts.map +1 -1
- package/dist/applog/datom-types.d.ts +63 -7
- package/dist/applog/datom-types.d.ts.map +1 -1
- package/dist/applog.js +7 -1
- package/dist/blockstore.js +2 -0
- package/dist/blockstore.js.map +1 -1
- package/dist/{chunk-L5EEEGE6.js → chunk-2OXLPZQI.js} +747 -679
- package/dist/chunk-2OXLPZQI.js.map +1 -0
- package/dist/{chunk-QZXKQCAY.js → chunk-2PJFLZRC.js} +7 -2
- package/dist/{chunk-QZXKQCAY.js.map → chunk-2PJFLZRC.js.map} +1 -1
- package/dist/chunk-64EJIJAJ.js +17 -0
- package/dist/chunk-64EJIJAJ.js.map +1 -0
- package/dist/chunk-7QEGHKR4.js +17 -0
- package/dist/chunk-7QEGHKR4.js.map +1 -0
- package/dist/{chunk-PD3C7XUM.js → chunk-EHO2BFFY.js} +2 -2
- package/dist/chunk-ICBK7NC4.js +27 -0
- package/dist/chunk-ICBK7NC4.js.map +1 -0
- package/dist/{chunk-CPSDKFBG.js → chunk-OKXRRWNS.js} +5 -14
- package/dist/chunk-OKXRRWNS.js.map +1 -0
- package/dist/{chunk-3WZVG277.js → chunk-Q4EMPWA3.js} +17 -9
- package/dist/chunk-Q4EMPWA3.js.map +1 -0
- package/dist/{chunk-J2FDHGOZ.js → chunk-VGIACGWX.js} +3 -3
- package/dist/{chunk-3JZMOEOD.js → chunk-WVW4YXB5.js} +2 -2
- package/dist/chunk-XF4DWOAE.js +25 -0
- package/dist/chunk-XF4DWOAE.js.map +1 -0
- package/dist/index.js +17 -9
- package/dist/ipfs/car.d.ts.map +1 -1
- package/dist/ipfs.js +4 -4
- package/dist/ipns/gateway-resolver.d.ts +21 -0
- package/dist/ipns/gateway-resolver.d.ts.map +1 -0
- package/dist/ipns/ipns-record.d.ts +28 -7
- package/dist/ipns/ipns-record.d.ts.map +1 -1
- package/dist/ipns/ipns-w3name.d.ts +15 -0
- package/dist/ipns/ipns-w3name.d.ts.map +1 -0
- package/dist/ipns/ipns-watcher.d.ts +190 -0
- package/dist/ipns/ipns-watcher.d.ts.map +1 -0
- package/dist/ipns.d.ts +3 -0
- package/dist/ipns.d.ts.map +1 -1
- package/dist/ipns.js +488 -8
- package/dist/ipns.js.map +1 -1
- package/dist/pubsub/snap-push.d.ts +2 -2
- package/dist/pubsub/snap-push.d.ts.map +1 -1
- package/dist/pubsub.js +4 -4
- package/dist/query/basic.d.ts +3 -3
- package/dist/query/basic.d.ts.map +1 -1
- package/dist/query/entity-collection.d.ts.map +1 -1
- package/dist/query/matchers.d.ts +12 -1
- package/dist/query/matchers.d.ts.map +1 -1
- package/dist/query.js +7 -5
- package/dist/retrieve.js +4 -4
- package/dist/thread/indexes.d.ts +3 -2
- package/dist/thread/indexes.d.ts.map +1 -1
- package/dist/thread.js +1 -1
- package/dist/viewmodel/adapters/arktype.d.ts +33 -0
- package/dist/viewmodel/adapters/arktype.d.ts.map +1 -0
- package/dist/viewmodel/adapters/arktype.js +7 -0
- package/dist/viewmodel/adapters/arktype.js.map +1 -0
- package/dist/viewmodel/adapters/typebox.d.ts +35 -0
- package/dist/viewmodel/adapters/typebox.d.ts.map +1 -0
- package/dist/viewmodel/adapters/typebox.js +7 -0
- package/dist/viewmodel/adapters/typebox.js.map +1 -0
- package/dist/viewmodel/adapters/typia.d.ts +40 -0
- package/dist/viewmodel/adapters/typia.d.ts.map +1 -0
- package/dist/viewmodel/adapters/typia.js +7 -0
- package/dist/viewmodel/adapters/typia.js.map +1 -0
- package/dist/viewmodel/adapters/zod.d.ts +30 -0
- package/dist/viewmodel/adapters/zod.d.ts.map +1 -0
- package/dist/viewmodel/adapters/zod.js +7 -0
- package/dist/viewmodel/adapters/zod.js.map +1 -0
- package/dist/viewmodel/builder.d.ts +40 -0
- package/dist/viewmodel/builder.d.ts.map +1 -0
- package/dist/viewmodel/examples/all-adapters.d.ts +26 -0
- package/dist/viewmodel/examples/all-adapters.d.ts.map +1 -0
- package/dist/viewmodel/factory.d.ts +38 -0
- package/dist/viewmodel/factory.d.ts.map +1 -0
- package/dist/viewmodel/index.d.ts +10 -0
- package/dist/viewmodel/index.d.ts.map +1 -0
- package/dist/viewmodel/index.js +313 -0
- package/dist/viewmodel/index.js.map +1 -0
- package/dist/viewmodel/schema-adapter.d.ts +16 -0
- package/dist/viewmodel/schema-adapter.d.ts.map +1 -0
- package/dist/viewmodel/types.d.ts +97 -0
- package/dist/viewmodel/types.d.ts.map +1 -0
- package/package.json +29 -3
- package/src/applog/applog-utils.ts +48 -4
- package/src/applog/datom-types.ts +24 -5
- package/src/applog/object-values.test.ts +106 -0
- package/src/ipfs/car.ts +8 -2
- package/src/ipns/gateway-resolver.ts +63 -0
- package/src/ipns/ipns-record.ts +68 -17
- package/src/ipns/ipns-w3name.ts +103 -0
- package/src/ipns/ipns-watcher.ts +607 -0
- package/src/ipns.ts +3 -0
- package/src/pubsub/snap-push.ts +8 -6
- package/src/query/entity-collection.ts +2 -1
- package/src/query/matchers.ts +23 -1
- package/src/thread/basic.ts +2 -2
- package/src/thread/indexes.ts +15 -9
- package/src/viewmodel/adapters/arktype.ts +44 -0
- package/src/viewmodel/adapters/typebox.ts +59 -0
- package/src/viewmodel/adapters/typia.ts +50 -0
- package/src/viewmodel/adapters/zod.ts +55 -0
- package/src/viewmodel/builder.ts +71 -0
- package/src/viewmodel/examples/all-adapters.ts +206 -0
- package/src/viewmodel/factory.ts +330 -0
- package/src/viewmodel/index.ts +22 -0
- package/src/viewmodel/schema-adapter.ts +27 -0
- package/src/viewmodel/types.ts +152 -0
- package/dist/chunk-3WZVG277.js.map +0 -1
- package/dist/chunk-CPSDKFBG.js.map +0 -1
- package/dist/chunk-L5EEEGE6.js.map +0 -1
- /package/dist/{chunk-PD3C7XUM.js.map → chunk-EHO2BFFY.js.map} +0 -0
- /package/dist/{chunk-J2FDHGOZ.js.map → chunk-VGIACGWX.js.map} +0 -0
- /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
|
-
|
|
29
|
-
|
|
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({
|
|
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({
|
|
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:
|
|
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
|
-
|
|
76
|
-
|
|
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
|
+
}
|
package/src/ipns/ipns-record.ts
CHANGED
|
@@ -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
|
|
9
|
-
ipnsName: string
|
|
10
|
-
value: string
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
//
|
|
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
|
-
*
|
|
85
|
-
*
|
|
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
|
|
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
|
-
|
|
124
|
+
publishTargets.map(t => t.publish!(ipnsName, signed.recordBytes)),
|
|
100
125
|
)
|
|
101
126
|
const failures = results
|
|
102
|
-
.map((r, i) => ({ r, 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 <
|
|
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 ===
|
|
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
|
+
}
|