@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
@@ -0,0 +1,607 @@
1
+ import { Logger } from 'besonders-logger'
2
+ import { CID } from 'multiformats/cid'
3
+ import ReconnectingWebSocket, { type Options as PartysocketOptions } from 'partysocket/ws'
4
+
5
+ export type { PartysocketOptions }
6
+
7
+ const { WARN, LOG, DEBUG, ERROR } = Logger.setup(Logger.INFO) // eslint-disable-line unused-imports/no-unused-vars
8
+
9
+ /**
10
+ * `nameBaseUrl` is required for both `watchNameRaw` and `IpnsWatcher`. The
11
+ * legacy `https://name.web3.storage` endpoint (used by the old hardcoded
12
+ * `NAME_WS_URL`/`NAME_HTTP_URL` constants) is **shut down** — these classes
13
+ * will not work without a real, configured naming service that supports
14
+ * WebSocket subscriptions to `/name/<ipns>/watch` and HTTP GET on `/name/<ipns>`.
15
+ *
16
+ * As of writing no such public service exists, so most callers should expect
17
+ * these to fail at runtime and handle the error in their `onError` handler.
18
+ */
19
+ function buildWsUrl(nameBaseUrl: string, name: string) {
20
+ const base = nameBaseUrl.replace(/\/+$/, '').replace(/^http/, 'ws')
21
+ return `${base}/${name}/watch`
22
+ }
23
+ function buildHttpUrl(nameBaseUrl: string, name: string) {
24
+ const base = nameBaseUrl.replace(/\/+$/, '')
25
+ return `${base}/${name}`
26
+ }
27
+
28
+ function requireNameBaseUrl(nameBaseUrl: string | undefined, fn: string): string {
29
+ if (!nameBaseUrl) {
30
+ throw new Error(
31
+ `[${fn}] nameBaseUrl is required. The legacy default `
32
+ + `https://name.web3.storage is shut down. Pass the base URL of a naming `
33
+ + `service that supports WebSocket subscriptions to /name/<ipns>/watch `
34
+ + `and HTTP GET on /name/<ipns>.`,
35
+ )
36
+ }
37
+ return nameBaseUrl
38
+ }
39
+
40
+ export interface W3NameRecord {
41
+ value: string // e.g. "/ipfs/bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"
42
+ seq?: number
43
+ validity?: string
44
+ }
45
+
46
+ /**
47
+ * Debug info provided when a stale WebSocket connection is detected.
48
+ */
49
+ export interface StaleConnectionInfo {
50
+ /** When the WebSocket connection was established */
51
+ connectedAt: Date
52
+ /** When we last received a WebSocket message */
53
+ lastMessageAt: Date | null
54
+ /** How long since last message (ms) */
55
+ silenceDuration: number
56
+ /** The stale value from WebSocket */
57
+ staleValue: string | null
58
+ /** The current value from HTTP */
59
+ currentValue: string
60
+ }
61
+
62
+ /**
63
+ * Enriched IPNS update with parsed CID and change detection.
64
+ * Backwards compatible - `.value` still works as before.
65
+ */
66
+ export interface IpnsUpdate {
67
+ /** Raw IPNS value string (e.g. '/ipfs/bafy...') — same as W3NameRecord.value */
68
+ value: string
69
+ /** Parsed CID if value is valid IPFS path, null otherwise */
70
+ cid: CID | null
71
+ /** Previous value (null on first update) */
72
+ lastValue: string | null
73
+ /** Whether this is a change from lastValue */
74
+ isNew: boolean
75
+ /** Original W3NameRecord for access to seq/validity */
76
+ record: W3NameRecord
77
+ }
78
+
79
+ /**
80
+ * Parse CID from IPNS value string (e.g. "/ipfs/bafybeig...")
81
+ * @returns CID if valid, null otherwise
82
+ */
83
+ function parseCidFromIpnsValue(value: string): CID | null {
84
+ try {
85
+ // Strip /ipfs/ prefix if present
86
+ const cidStr = value.startsWith('/ipfs/') ? value.slice(6) : value
87
+ return CID.parse(cidStr)
88
+ } catch {
89
+ DEBUG('[parseCidFromIpnsValue] failed to parse:', value)
90
+ return null
91
+ }
92
+ }
93
+
94
+ export interface WatchRawOptions {
95
+ /** Called when the IPNS record is updated */
96
+ onUpdate: (record: W3NameRecord) => void
97
+ /** Called when an error occurs */
98
+ onError?: (error: Event | Error) => void
99
+ /** Called when the connection is opened */
100
+ onOpen?: () => void
101
+ /** Called when the connection is closed */
102
+ onClose?: (event: CloseEvent) => void
103
+ }
104
+
105
+ export interface WatchRawSubscription {
106
+ /** Close the WebSocket connection */
107
+ close: () => void
108
+ /** The underlying WebSocket instance */
109
+ ws: WebSocket
110
+ }
111
+
112
+ /**
113
+ * Low-level WebSocket watcher for IPNS (no reconnect logic).
114
+ * Use this when you want full control over connection lifecycle.
115
+ * For most cases, prefer `watchName` or `IpnsWatcher` which handle reconnection.
116
+ *
117
+ * @param nameBaseUrl - Base URL of a naming service that supports `/name/<ipns>/watch` (WebSocket) and `/name/<ipns>` (HTTP)
118
+ * @param name - The IPNS name/key to watch
119
+ * @param options - Callback options
120
+ * @returns Subscription with close() and ws
121
+ *
122
+ * @example
123
+ * ```ts
124
+ * const sub = watchNameRaw('https://name.example.com', 'k51qzi5u...', {
125
+ * onUpdate: (record) => console.log('Update:', record.value),
126
+ * onClose: () => console.log('Disconnected - handle reconnect yourself'),
127
+ * })
128
+ * ```
129
+ */
130
+ export function watchNameRaw(nameBaseUrl: string, name: string, options: WatchRawOptions): WatchRawSubscription {
131
+ const resolvedBase = requireNameBaseUrl(nameBaseUrl, 'watchNameRaw')
132
+ const url = buildWsUrl(resolvedBase, name)
133
+ DEBUG('[watchNameRaw] connecting to', url)
134
+
135
+ const ws = new WebSocket(url)
136
+
137
+ ws.onopen = () => {
138
+ LOG('[watchNameRaw] connected to', name)
139
+ options.onOpen?.()
140
+ }
141
+
142
+ ws.onmessage = (event) => {
143
+ try {
144
+ const record: W3NameRecord = JSON.parse(event.data)
145
+ DEBUG('[watchNameRaw] received update for', name, record)
146
+ options.onUpdate(record)
147
+ } catch (err) {
148
+ WARN('[watchNameRaw] failed to parse message:', event.data, err)
149
+ options.onError?.(err instanceof Error ? err : new Error(String(err)))
150
+ }
151
+ }
152
+
153
+ ws.onerror = (event) => {
154
+ WARN('[watchNameRaw] error for', name, event)
155
+ options.onError?.(event)
156
+ }
157
+
158
+ ws.onclose = (event) => {
159
+ DEBUG('[watchNameRaw] closed for', name, 'code:', event.code)
160
+ options.onClose?.(event)
161
+ }
162
+
163
+ return {
164
+ close: () => {
165
+ DEBUG('[watchNameRaw] closing connection for', name)
166
+ ws.close()
167
+ },
168
+ ws,
169
+ }
170
+ }
171
+
172
+ export interface IpnsWatcherOptions {
173
+ /** Called when the IPNS record is updated (enriched payload with CID and change detection) */
174
+ onUpdate: (update: IpnsUpdate) => void | Promise<void>
175
+ /** Called when an error occurs */
176
+ onError?: (error: Error | Event) => void
177
+ /** Called when the connection is opened/reconnected */
178
+ onConnected?: () => void
179
+ /** Called when the connection is closed */
180
+ onDisconnected?: () => void
181
+ /** Fetch current IPNS state on first connect (default: false) */
182
+ fetchInitialState?: boolean
183
+ /** Fetch current IPNS state on reconnect to catch missed updates (default: true) */
184
+ catchUpOnReconnect?: boolean
185
+ /** If true, call onUpdate even when value hasn't changed (default: false) */
186
+ includeUnchanged?: boolean
187
+ /**
188
+ * Enable periodic liveness checks via HTTP to detect zombie connections (default: true).
189
+ * When enabled, periodically fetches current IPNS value and forces reconnect if it
190
+ * differs from the last WebSocket update.
191
+ */
192
+ livenessCheck?: boolean
193
+ /**
194
+ * Liveness check interval in milliseconds (default: 3600000 = 1 hour).
195
+ * Only used when livenessCheck is enabled.
196
+ */
197
+ livenessCheckInterval?: number
198
+ /**
199
+ * Called when a stale connection is detected (WebSocket missed updates).
200
+ * Provides debug info about the connection state.
201
+ */
202
+ onStaleConnection?: (info: StaleConnectionInfo) => void
203
+ /**
204
+ * Partysocket options (passed through to ReconnectingWebSocket).
205
+ * Useful options: startClosed, maxReconnectionDelay, minReconnectionDelay, etc.
206
+ * @see https://github.com/partykit/partykit/tree/main/packages/partysocket
207
+ */
208
+ wsOptions?: PartysocketOptions
209
+ }
210
+
211
+ /**
212
+ * Robust IPNS watcher with auto-reconnect and catch-up logic.
213
+ * Uses partysocket for reliable WebSocket reconnection.
214
+ *
215
+ * @example
216
+ * ```ts
217
+ * const watcher = new IpnsWatcher('k51qzi5uqu...', {
218
+ * onUpdate: (update) => console.log('New CID:', update.cid?.toString()),
219
+ * onError: (err) => console.error('Error:', err),
220
+ * })
221
+ *
222
+ * // Later, to stop watching:
223
+ * watcher.close()
224
+ * ```
225
+ */
226
+ const DEFAULT_LIVENESS_INTERVAL = 3600000 // 1 hour
227
+
228
+ export class IpnsWatcher {
229
+ private name: string
230
+ private nameBaseUrl: string
231
+ private ws: ReconnectingWebSocket
232
+ private lastKnownValue: string | null = null
233
+ private options: IpnsWatcherOptions
234
+ private isFirstConnect = true
235
+ private livenessTimer: ReturnType<typeof setInterval> | null = null
236
+ private connectedAt: Date | null = null
237
+ private lastMessageAt: Date | null = null
238
+
239
+ constructor(nameBaseUrl: string, name: string, options: IpnsWatcherOptions) {
240
+ this.nameBaseUrl = requireNameBaseUrl(nameBaseUrl, 'IpnsWatcher')
241
+ this.name = name
242
+ this.options = options
243
+
244
+ const url = buildWsUrl(this.nameBaseUrl, name)
245
+ DEBUG('[IpnsWatcher] creating for', name)
246
+
247
+ this.ws = new ReconnectingWebSocket(url, [], {
248
+ maxReconnectionDelay: 900000, // 15min
249
+ minReconnectionDelay: 5000,
250
+ reconnectionDelayGrowFactor: 2,
251
+ maxRetries: Infinity,
252
+ ...options.wsOptions,
253
+ })
254
+
255
+ this.ws.onopen = () => {
256
+ LOG('[IpnsWatcher] connected to', name)
257
+ this.connectedAt = new Date()
258
+ options.onConnected?.()
259
+
260
+ // Check for current state on first connect if requested
261
+ if (this.isFirstConnect && (options.fetchInitialState ?? false)) {
262
+ this.checkForMissedUpdates()
263
+ }
264
+ // Check for missed updates on reconnect
265
+ else if (!this.isFirstConnect && (options.catchUpOnReconnect ?? true)) {
266
+ this.checkForMissedUpdates()
267
+ }
268
+
269
+ this.isFirstConnect = false
270
+
271
+ // Start liveness checking (default: enabled)
272
+ if (options.livenessCheck !== false) {
273
+ this.startLivenessCheck()
274
+ }
275
+ }
276
+
277
+ this.ws.onmessage = (event) => {
278
+ this.lastMessageAt = new Date()
279
+ try {
280
+ const record: W3NameRecord = JSON.parse(event.data as string)
281
+ DEBUG('[IpnsWatcher] received update for', name, record)
282
+
283
+ const lastValue = this.lastKnownValue
284
+ const isNew = record.value !== lastValue
285
+ const cid = parseCidFromIpnsValue(record.value)
286
+
287
+ // Skip unchanged values unless includeUnchanged is set
288
+ if (!isNew && !options.includeUnchanged) {
289
+ DEBUG('[IpnsWatcher] skipping unchanged value for', name)
290
+ return
291
+ }
292
+
293
+ // Update lastKnownValue after the skip check
294
+ this.lastKnownValue = record.value
295
+
296
+ const update: IpnsUpdate = {
297
+ value: record.value,
298
+ cid,
299
+ lastValue,
300
+ isNew,
301
+ record,
302
+ }
303
+
304
+ void options.onUpdate(update)
305
+ } catch (err) {
306
+ WARN('[IpnsWatcher] failed to parse message:', event.data, err)
307
+ options.onError?.(err instanceof Error ? err : new Error(String(err)))
308
+ }
309
+ }
310
+
311
+ this.ws.onerror = (event) => {
312
+ // Extract meaningful error info instead of logging entire ErrorEvent
313
+ const errorMsg = event instanceof ErrorEvent ? event.message : 'WebSocket error'
314
+
315
+ // "Unexpected EOF" is a normal disconnection - partysocket will auto-reconnect
316
+ // Log at INFO level as it's expected behavior, not an error
317
+ if (errorMsg === 'Unexpected EOF') {
318
+ LOG('[IpnsWatcher] error for', name, ':', errorMsg, '(auto-reconnect enabled)')
319
+ } else {
320
+ WARN('[IpnsWatcher] error for', name, ':', errorMsg)
321
+ }
322
+
323
+ // Still call the error handler for unexpected errors
324
+ if (errorMsg !== 'Unexpected EOF') {
325
+ options.onError?.(event)
326
+ }
327
+ }
328
+
329
+ this.ws.onclose = () => {
330
+ DEBUG('[IpnsWatcher] disconnected from', name)
331
+ this.stopLivenessCheck()
332
+ this.connectedAt = null
333
+ this.lastMessageAt = null
334
+ options.onDisconnected?.()
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Resolve current IPNS value via HTTP API to catch missed updates
340
+ */
341
+ private async checkForMissedUpdates(): Promise<void> {
342
+ try {
343
+ DEBUG('[IpnsWatcher] checking for missed updates for', this.name)
344
+ const response = await fetch(buildHttpUrl(this.nameBaseUrl, this.name))
345
+
346
+ if (!response.ok) {
347
+ if (response.status === 404) {
348
+ DEBUG('[IpnsWatcher] IPNS not yet published:', this.name)
349
+ return
350
+ }
351
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
352
+ }
353
+
354
+ const record: W3NameRecord = await response.json()
355
+ const lastValue = this.lastKnownValue
356
+ const isNew = record.value !== lastValue
357
+ const cid = parseCidFromIpnsValue(record.value)
358
+
359
+ // Skip unchanged values unless includeUnchanged is set
360
+ if (!isNew && !this.options.includeUnchanged) {
361
+ DEBUG('[IpnsWatcher] no new updates for', this.name)
362
+ return
363
+ }
364
+
365
+ const logMsg = lastValue === null
366
+ ? '[IpnsWatcher] fetched initial state for'
367
+ : '[IpnsWatcher] caught missed update for'
368
+ LOG(logMsg, this.name, {
369
+ previous: lastValue,
370
+ current: record.value,
371
+ })
372
+
373
+ // Update lastKnownValue after the skip check
374
+ this.lastKnownValue = record.value
375
+
376
+ const update: IpnsUpdate = {
377
+ value: record.value,
378
+ cid,
379
+ lastValue,
380
+ isNew,
381
+ record,
382
+ }
383
+
384
+ void this.options.onUpdate(update)
385
+ } catch (err) {
386
+ WARN('[IpnsWatcher] failed to check for missed updates:', this.name, err)
387
+ this.options.onError?.(err instanceof Error ? err : new Error(String(err)))
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Start periodic liveness checks to detect zombie connections.
393
+ */
394
+ private startLivenessCheck(): void {
395
+ this.stopLivenessCheck() // Clear any existing timer
396
+ const interval = this.options.livenessCheckInterval ?? DEFAULT_LIVENESS_INTERVAL
397
+ DEBUG('[IpnsWatcher] starting liveness check for', this.name, 'interval:', interval)
398
+
399
+ this.livenessTimer = setInterval(() => {
400
+ void this.performLivenessCheck()
401
+ }, interval)
402
+ }
403
+
404
+ /**
405
+ * Stop periodic liveness checks.
406
+ */
407
+ private stopLivenessCheck(): void {
408
+ if (this.livenessTimer !== null) {
409
+ DEBUG('[IpnsWatcher] stopping liveness check for', this.name)
410
+ clearInterval(this.livenessTimer)
411
+ this.livenessTimer = null
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Perform a single liveness check via HTTP.
417
+ * If the HTTP value differs from lastKnownValue, the connection is stale.
418
+ */
419
+ private async performLivenessCheck(): Promise<void> {
420
+ try {
421
+ DEBUG('[IpnsWatcher] performing liveness check for', this.name)
422
+ const response = await fetch(buildHttpUrl(this.nameBaseUrl, this.name))
423
+
424
+ if (!response.ok) {
425
+ if (response.status === 404) {
426
+ // IPNS not published - if we also have null, that's consistent
427
+ if (this.lastKnownValue === null) {
428
+ DEBUG('[IpnsWatcher] liveness check OK (both null) for', this.name)
429
+ return
430
+ }
431
+ // We have a value but HTTP says 404 - this shouldn't happen normally
432
+ WARN('[IpnsWatcher] liveness check inconsistent (we have value, HTTP 404) for', this.name)
433
+ return
434
+ }
435
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
436
+ }
437
+
438
+ const record: W3NameRecord = await response.json()
439
+
440
+ // Check if values match
441
+ if (record.value === this.lastKnownValue) {
442
+ DEBUG('[IpnsWatcher] liveness check OK for', this.name)
443
+ return
444
+ }
445
+
446
+ // Stale connection detected!
447
+ const now = new Date()
448
+ const silenceDuration = this.lastMessageAt
449
+ ? now.getTime() - this.lastMessageAt.getTime()
450
+ : this.connectedAt
451
+ ? now.getTime() - this.connectedAt.getTime()
452
+ : 0
453
+
454
+ const staleInfo: StaleConnectionInfo = {
455
+ connectedAt: this.connectedAt ?? now,
456
+ lastMessageAt: this.lastMessageAt,
457
+ silenceDuration,
458
+ staleValue: this.lastKnownValue,
459
+ currentValue: record.value,
460
+ }
461
+
462
+ WARN('[IpnsWatcher] stale connection detected for', this.name, {
463
+ connectedAt: staleInfo.connectedAt.toISOString(),
464
+ lastMessageAt: staleInfo.lastMessageAt?.toISOString() ?? 'never',
465
+ silenceDuration: `${Math.round(silenceDuration / 1000)}s`,
466
+ staleValue: staleInfo.staleValue,
467
+ currentValue: staleInfo.currentValue,
468
+ })
469
+
470
+ // Notify via callback
471
+ this.options.onStaleConnection?.(staleInfo)
472
+
473
+ // Fire immediate update with the current value
474
+ const lastValue = this.lastKnownValue
475
+ const cid = parseCidFromIpnsValue(record.value)
476
+ this.lastKnownValue = record.value
477
+
478
+ const update: IpnsUpdate = {
479
+ value: record.value,
480
+ cid,
481
+ lastValue,
482
+ isNew: true,
483
+ record,
484
+ }
485
+ void this.options.onUpdate(update)
486
+
487
+ // Force reconnect to get a fresh connection
488
+ LOG('[IpnsWatcher] forcing reconnect due to stale connection for', this.name)
489
+ this.ws.reconnect()
490
+ } catch (err) {
491
+ // Don't treat HTTP errors as stale - could be network issue
492
+ WARN('[IpnsWatcher] liveness check failed for', this.name, err)
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Manually start/reconnect the WebSocket.
498
+ * Only needed if you used `wsOptions: { startClosed: true }`.
499
+ */
500
+ start(): void {
501
+ LOG('[IpnsWatcher] starting watcher for', this.name)
502
+ this.ws.reconnect()
503
+ }
504
+
505
+ /**
506
+ * Alias for close() - for backward compatibility
507
+ */
508
+ stop(): void {
509
+ this.close()
510
+ }
511
+
512
+ /**
513
+ * Close the WebSocket connection and stop watching
514
+ */
515
+ close(): void {
516
+ LOG('[IpnsWatcher] closing watcher for', this.name)
517
+ this.stopLivenessCheck()
518
+ this.ws.close()
519
+ }
520
+
521
+ /**
522
+ * Get the last known IPNS value
523
+ */
524
+ get lastValue(): string | null {
525
+ return this.lastKnownValue
526
+ }
527
+
528
+ /**
529
+ * Get the WebSocket ready state
530
+ */
531
+ get readyState(): number {
532
+ return this.ws.readyState
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Create an IPNS watcher with auto-reconnect and catch-up logic.
538
+ * Convenience function that creates and returns an IpnsWatcher instance.
539
+ *
540
+ * @param nameBaseUrl - Base URL of a naming service that supports `/name/<ipns>/watch` (WebSocket) and `/name/<ipns>` (HTTP)
541
+ * @param name - The IPNS name/key to watch (e.g. "k51qzi5u...")
542
+ * @param options - Callback options for handling events
543
+ * @returns An IpnsWatcher instance with close() method
544
+ */
545
+ export function watchName(nameBaseUrl: string, name: string, options: IpnsWatcherOptions): IpnsWatcher {
546
+ return new IpnsWatcher(nameBaseUrl, name, options)
547
+ }
548
+
549
+ /**
550
+ * Watch an IPNS name and return updates as an async iterator.
551
+ * Includes auto-reconnect - iterator continues through disconnections.
552
+ *
553
+ * @param nameBaseUrl - Base URL of a naming service that supports `/name/<ipns>/watch` (WebSocket) and `/name/<ipns>` (HTTP)
554
+ * @param name - The IPNS name/key to watch
555
+ * @param signal - Optional AbortSignal to stop the watch
556
+ *
557
+ * @example
558
+ * ```ts
559
+ * const controller = new AbortController()
560
+ * for await (const update of watchNameIterator('https://name.example.com', 'k51qzi5u...', controller.signal)) {
561
+ * console.log('Update:', update.cid?.toString())
562
+ * }
563
+ * ```
564
+ */
565
+ export async function* watchNameIterator(
566
+ nameBaseUrl: string,
567
+ name: string,
568
+ signal?: AbortSignal,
569
+ ): AsyncGenerator<IpnsUpdate, void, unknown> {
570
+ const queue: IpnsUpdate[] = []
571
+ let resolve: (() => void) | null = null
572
+ let error: Error | null = null
573
+
574
+ const watcher = new IpnsWatcher(nameBaseUrl, name, {
575
+ onUpdate: (update) => {
576
+ queue.push(update)
577
+ resolve?.()
578
+ },
579
+ onError: (err) => {
580
+ error = err instanceof Error ? err : new Error('WebSocket error')
581
+ resolve?.()
582
+ },
583
+ })
584
+
585
+ signal?.addEventListener('abort', () => {
586
+ watcher.close()
587
+ })
588
+
589
+ try {
590
+ while (!signal?.aborted) {
591
+ if (queue.length > 0) {
592
+ yield queue.shift()!
593
+ } else if (error) {
594
+ // Log error but continue - partysocket will reconnect
595
+ WARN('[watchNameIterator] error occurred, continuing:', error)
596
+ error = null
597
+ } else {
598
+ await new Promise<void>((r) => {
599
+ resolve = r
600
+ })
601
+ resolve = null
602
+ }
603
+ }
604
+ } finally {
605
+ watcher.close()
606
+ }
607
+ }
package/src/ipns.ts CHANGED
@@ -1 +1,4 @@
1
+ export * from './ipns/gateway-resolver.ts'
1
2
  export * from './ipns/ipns-record.ts'
3
+ export * from './ipns/ipns-w3name.ts'
4
+ export * from './ipns/ipns-watcher.ts'
@@ -15,6 +15,7 @@ import type {
15
15
  import { BlockStoreish, DecodedCar, getDecodedBlock, makeCarBlob } from '../ipfs/car.ts'
16
16
  import { encodeBlockOriginal, prepareForPub } from '../ipfs/ipfs-utils.ts'
17
17
  import { lastWriteWins } from './../query/basic.ts'
18
+ import { anyOf } from '../query/matchers.ts'
18
19
  import { ApplogsOrThread, getLogsFromThread, Thread } from '../thread.ts'
19
20
  import { rollingFilter } from '../thread/filters.ts'
20
21
  import { keepTruthy } from '../utils.ts'
@@ -146,7 +147,7 @@ export async function prepareSnapshotForPush(
146
147
  const infoLogs = [
147
148
  ...rollingFilter(lastWriteWins(appThread), { // TODO: use static filter for performance
148
149
  en: agent.ag,
149
- at: ['agent/ecdh', 'agent/jwkd', 'agent/appAgent'],
150
+ at: anyOf('agent/ecdh', 'agent/jwkd', 'agent/appAgent'),
150
151
  }).applogs,
151
152
  ...(shareNameLog ? [shareNameLog] : []),
152
153
  ...(shareCounterLog ? [shareCounterLog] : []),
@@ -227,21 +228,22 @@ export async function chunkApplogs(applogCids: CID<unknown, 297, 18, 1>[], size
227
228
  DEBUG(`[chunkApplogs] ${applogCids.length} logs chunked into ${chunks.length}`, { applogCids, root, blocks, chunks, dagJson })
228
229
  return { rootCID: root.cid, blocks, chunks }
229
230
  }
230
- export async function unchunkApplogsBlock(block: SnapBlockLogsOrChunks, blockStore: BlockStoreish): Promise<CID[]> {
231
+ export async function unchunkApplogsBlock(block: SnapBlockLogsOrChunks | null | undefined, blockStore: BlockStoreish): Promise<CID[]> {
232
+ if (!block) return []
231
233
  if (isSnapBlockChunks(block)) {
232
234
  return (await Promise.all(
233
235
  block.chunks.map(async (chunkCid) => {
234
236
  const block = (await getDecodedBlock(blockStore, chunkCid)) as SnapBlockLogs
235
- if (!block.logs) throw ERROR(`Weird chunk`, block)
237
+ if (!block?.logs) throw ERROR(`Weird chunk`, block)
236
238
  return block.logs
237
239
  }),
238
240
  )).flat()
239
241
  } else {
240
- return block.logs
242
+ return block.logs ?? []
241
243
  }
242
244
  }
243
- export function isSnapBlockChunks(block: SnapBlockLogsOrChunks): block is SnapBlockChunks {
244
- return (block as any).chunks
245
+ export function isSnapBlockChunks(block: SnapBlockLogsOrChunks | null | undefined): block is SnapBlockChunks {
246
+ return !!block && 'chunks' in block
245
247
  }
246
248
  /**
247
249
  * @param applogs Encrypted or plain applogs
@@ -3,6 +3,7 @@ import { Applog, ApplogValue, DatalogQueryPattern, EntityID } from '../applog/da
3
3
  import { isInitEvent, Thread } from '../thread/basic.ts'
4
4
  import { makeFilter, rollingFilter } from '../thread/filters.ts'
5
5
  import { resolveKeyMapper } from './basic.ts'
6
+ import { anyOf } from './matchers.ts'
6
7
  import { memoizedFn } from './memoized.ts'
7
8
  import { SubscribableImpl } from './subscribable.ts'
8
9
  import type { Subscribable } from './subscribable.ts'
@@ -49,7 +50,7 @@ const _liveEntityCollection = memoizedFn('liveEntityCollection',
49
50
  DEBUG('liveEntityCollection', discoveryPattern, liveAttributes)
50
51
  const discoveryAttr = discoveryPattern.at as string
51
52
  const allAttrs = new Set([discoveryAttr, ...liveAttributes])
52
- const filtered = rollingFilter(thread, { at: [...allAttrs] })
53
+ const filtered = rollingFilter(thread, { at: anyOf(...allAttrs) })
53
54
  const isDiscoveryMatch = makeFilter(discoveryPattern)
54
55
  const attrSet = new Set<string>(liveAttributes)
55
56
  const key = resolveKeyMapper(opts)
@@ -1,4 +1,4 @@
1
- import { DatomPart } from '../applog/datom-types.ts'
1
+ import { ApplogValue, DatomPart } from '../applog/datom-types.ts'
2
2
 
3
3
  export function includes(str: string) {
4
4
  return (vl: DatomPart) => vl?.includes?.(str)
@@ -6,3 +6,25 @@ export function includes(str: string) {
6
6
  export function includedIn(arr: string[]) {
7
7
  return (vl: DatomPart) => arr?.includes?.(vl)
8
8
  }
9
+
10
+ /**
11
+ * Set-membership matcher: matches when the field equals any of `vals`. Use this instead of a
12
+ * bare array in a query pattern — bare arrays are rejected by the matcher (a bare array is
13
+ * ambiguous now that `vl` can hold a literal array value).
14
+ *
15
+ * Returns a `Set`, which the matcher engine checks via `.has` (O(1)). Members must be
16
+ * primitives: `Set.has` is referential, so object/array members would silently never match —
17
+ * we throw rather than fail silently. For object membership, use a predicate matcher, e.g.
18
+ * `(v) => members.some(m => isEqual(v, m))`.
19
+ */
20
+ export function anyOf<T extends ApplogValue>(...vals: T[]): ReadonlySet<T> {
21
+ for (const v of vals) {
22
+ if (v !== null && typeof v === 'object') {
23
+ throw new Error(
24
+ `[anyOf] object/array members are not supported (referential Set membership would silently never match).`
25
+ + ` Use a predicate matcher instead, e.g. (v) => members.some(m => isEqual(v, m)).`,
26
+ )
27
+ }
28
+ }
29
+ return new Set(vals)
30
+ }