@wovin/core 0.3.3 → 0.3.4

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 (46) hide show
  1. package/dist/applog/applog-utils.d.ts +12 -1
  2. package/dist/applog/applog-utils.d.ts.map +1 -1
  3. package/dist/applog.js +5 -1
  4. package/dist/{chunk-N5QPZNKD.js → chunk-523IW54O.js} +3 -3
  5. package/dist/{chunk-4MKPGQIM.js → chunk-7VHIXHNF.js} +97 -62
  6. package/dist/chunk-7VHIXHNF.js.map +1 -0
  7. package/dist/{chunk-H4YVJKB7.js → chunk-ERVSWD63.js} +2 -2
  8. package/dist/{chunk-N7SEGHU4.js → chunk-GQDLPZEZ.js} +33 -24
  9. package/dist/chunk-GQDLPZEZ.js.map +1 -0
  10. package/dist/{chunk-6CSJTSQP.js → chunk-OB7M55WA.js} +3 -3
  11. package/dist/{chunk-H3JNNTVP.js → chunk-PTZJ7LVV.js} +27 -6
  12. package/dist/chunk-PTZJ7LVV.js.map +1 -0
  13. package/dist/{chunk-BIYQEX3N.js → chunk-XEUQDEA4.js} +2 -2
  14. package/dist/index.js +11 -7
  15. package/dist/ipfs.js +4 -4
  16. package/dist/ipns/ipns-record.d.ts.map +1 -1
  17. package/dist/ipns/ipns-watcher.d.ts.map +1 -1
  18. package/dist/ipns.js +6 -8
  19. package/dist/ipns.js.map +1 -1
  20. package/dist/pubsub/snap-push.d.ts.map +1 -1
  21. package/dist/pubsub.js +4 -4
  22. package/dist/query/basic.d.ts.map +1 -1
  23. package/dist/query.js +3 -3
  24. package/dist/retrieve.js +4 -4
  25. package/dist/thread/basic.d.ts.map +1 -1
  26. package/dist/thread/mapped.d.ts.map +1 -1
  27. package/dist/thread/utils.d.ts.map +1 -1
  28. package/dist/thread.js +1 -1
  29. package/dist/viewmodel/index.js +2 -2
  30. package/package.json +1 -1
  31. package/src/applog/applog-utils.ts +33 -1
  32. package/src/ipns/ipns-record.test.ts +72 -0
  33. package/src/ipns/ipns-record.ts +23 -8
  34. package/src/ipns/ipns-watcher.ts +10 -11
  35. package/src/pubsub/snap-push.ts +25 -3
  36. package/src/query/basic.ts +124 -81
  37. package/src/thread/basic.ts +21 -2
  38. package/src/thread/mapped.ts +12 -4
  39. package/src/thread/utils.ts +5 -5
  40. package/dist/chunk-4MKPGQIM.js.map +0 -1
  41. package/dist/chunk-H3JNNTVP.js.map +0 -1
  42. package/dist/chunk-N7SEGHU4.js.map +0 -1
  43. /package/dist/{chunk-N5QPZNKD.js.map → chunk-523IW54O.js.map} +0 -0
  44. /package/dist/{chunk-H4YVJKB7.js.map → chunk-ERVSWD63.js.map} +0 -0
  45. /package/dist/{chunk-6CSJTSQP.js.map → chunk-OB7M55WA.js.map} +0 -0
  46. /package/dist/{chunk-BIYQEX3N.js.map → chunk-XEUQDEA4.js.map} +0 -0
@@ -95,8 +95,25 @@ 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+)/
98
+ /**
99
+ * Regex to detect Kubo IPNS sequence conflict in error messages.
100
+ *
101
+ * Matches messages like:
102
+ * "existing IPNS record has sequence 406 >= new record sequence 406"
103
+ *
104
+ * The operator between the two sequence numbers is matched as an opaque
105
+ * non-whitespace token (`\S{1,12}`) on purpose: Go's JSON encoder HTML-escapes
106
+ * `>` to `>`, so the string that actually reaches us through the storage
107
+ * proxy reads `... sequence 406 >= new record sequence 406 ...`. A regex
108
+ * hard-coded to `>=` (or even a non-digit bridge like `\D+`, which trips over
109
+ * the digits inside `>`) would silently fail to match, the
110
+ * sequence-coherence retry below would never fire, and the publish would
111
+ * surface the generic "All IPNS publish targets failed" error instead of
112
+ * self-healing.
113
+ *
114
+ * Capture 1 = existing (server) sequence, capture 2 = rejected (new) sequence.
115
+ */
116
+ const SEQ_CONFLICT_RE = /existing IPNS record has sequence (\d+) \S{1,12} new record sequence (\d+)/
100
117
 
101
118
  /**
102
119
  * Options for {@link publishIPNSRecord}.
@@ -145,7 +162,7 @@ export async function publishIPNSRecord(
145
162
  let sequence: bigint | undefined
146
163
  let bumpFloor: bigint | undefined
147
164
 
148
- for (let attempt = 0; ; attempt++) {
165
+ for (let attempt = 0;; attempt++) {
149
166
  // Compute next sequence on first pass or after a conflict bump
150
167
  if (sequence === undefined) {
151
168
  sequence = await pickNextSequence(ipnsName, targets)
@@ -171,9 +188,7 @@ export async function publishIPNSRecord(
171
188
  }
172
189
 
173
190
  // 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
- )
191
+ const conflictReasons = failures.filter(({ r }) => SEQ_CONFLICT_RE.test(typeof r.reason === 'string' ? r.reason : String(r.reason)))
177
192
 
178
193
  if (conflictReasons.length > 0 && attempt < maxBumps) {
179
194
  // Parse the highest existing sequence from each conflict error
@@ -189,7 +204,7 @@ export async function publishIPNSRecord(
189
204
  if (bump > sequence) {
190
205
  console.warn(
191
206
  `[publishIPNSRecord] Sequence conflict: server seq ${bump - 1n} >= current ${sequence}, ` +
192
- `bumping to ${bump} (attempt ${attempt + 1}/${maxBumps})`,
207
+ `bumping to ${bump} (attempt ${attempt + 1}/${maxBumps})`,
193
208
  )
194
209
  sequence = bump
195
210
  continue
@@ -208,7 +223,7 @@ export async function publishIPNSRecord(
208
223
  if (conflictReasons.length > 0) {
209
224
  throw new Error(
210
225
  `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('; ')}`,
226
+ `All publish targets failed: ${failures.map(({ r, name }) => `${name}: ${r.reason}`).join('; ')}`,
212
227
  )
213
228
  }
214
229
  throw new Error(
@@ -17,21 +17,21 @@ const { WARN, LOG, DEBUG, ERROR } = Logger.setup(Logger.INFO) // eslint-disable-
17
17
  * these to fail at runtime and handle the error in their `onError` handler.
18
18
  */
19
19
  function buildWsUrl(nameBaseUrl: string, name: string) {
20
- const base = nameBaseUrl.replace(/\/+$/, '').replace(/^http/, 'ws')
21
- return `${base}/${name}/watch`
20
+ const base = nameBaseUrl.replace(/\/+$/, '').replace(/\/name$/, '').replace(/^http/, 'ws')
21
+ return `${base}/name/${name}/watch`
22
22
  }
23
23
  function buildHttpUrl(nameBaseUrl: string, name: string) {
24
- const base = nameBaseUrl.replace(/\/+$/, '')
25
- return `${base}/${name}`
24
+ const base = nameBaseUrl.replace(/\/+$/, '').replace(/\/name$/, '')
25
+ return `${base}/name/${name}`
26
26
  }
27
27
 
28
28
  function requireNameBaseUrl(nameBaseUrl: string | undefined, fn: string): string {
29
29
  if (!nameBaseUrl) {
30
30
  throw new Error(
31
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>.`,
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
35
  )
36
36
  }
37
37
  return nameBaseUrl
@@ -261,8 +261,7 @@ export class IpnsWatcher {
261
261
  // Check for current state on first connect if requested
262
262
  if (this.isFirstConnect && (options.fetchInitialState ?? false)) {
263
263
  this.checkForMissedUpdates()
264
- }
265
- // Check for missed updates on reconnect
264
+ } // Check for missed updates on reconnect
266
265
  else if (!this.isFirstConnect && (options.catchUpOnReconnect ?? true)) {
267
266
  this.checkForMissedUpdates()
268
267
  }
@@ -449,8 +448,8 @@ export class IpnsWatcher {
449
448
  const silenceDuration = this.lastMessageAt
450
449
  ? now.getTime() - this.lastMessageAt.getTime()
451
450
  : this.connectedAt
452
- ? now.getTime() - this.connectedAt.getTime()
453
- : 0
451
+ ? now.getTime() - this.connectedAt.getTime()
452
+ : 0
454
453
 
455
454
  const staleInfo: StaleConnectionInfo = {
456
455
  connectedAt: this.connectedAt ?? now,
@@ -3,6 +3,7 @@ import { Logger } from 'besonders-logger'
3
3
  import { CID } from 'multiformats/cid'
4
4
  import stringify from 'safe-stable-stringify'
5
5
  import { ensureTsPvAndFinalizeApplog } from '../applog/applog-helpers.ts'
6
+ import { applogsSummary } from '../applog/applog-utils.ts'
6
7
  import type {
7
8
  Applog,
8
9
  ApplogArrayMaybeEncrypted,
@@ -159,10 +160,31 @@ export async function prepareSnapshotForPush(
159
160
  const applogsToEncode = keepTruthy(maybeEncryptedApplogs)
160
161
  const infologsToEncode = keepTruthy(infoLogs)
161
162
  if (!applogsToEncode.length) {
162
- throw ERROR('no valid applogs', { agent, maybeEncryptedApplogs, infoLogs, applogsToEncode, infologsToEncode, prevSnapCID })
163
+ // all applogs were filtered out as falsy show raw→kept counts + a sample so we can see what came in
164
+ throw ERROR('no valid applogs', {
165
+ agent: agent.did,
166
+ prevSnapCID: prevSnapCID?.toString(),
167
+ counts: {
168
+ rawApplogs: maybeEncryptedApplogs.length,
169
+ keptApplogs: applogsToEncode.length,
170
+ rawInfo: infoLogs.length,
171
+ keptInfo: infologsToEncode.length,
172
+ },
173
+ rawApplogsSample: applogsSummary(maybeEncryptedApplogs),
174
+ })
163
175
  }
164
176
  if (!infologsToEncode.length) {
165
- throw ERROR('no valid infologs', { agent, maybeEncryptedApplogs, infoLogs, applogsToEncode, infologsToEncode, prevSnapCID })
177
+ throw ERROR('no valid infologs', {
178
+ agent: agent.did,
179
+ prevSnapCID: prevSnapCID?.toString(),
180
+ counts: {
181
+ rawApplogs: maybeEncryptedApplogs.length,
182
+ keptApplogs: applogsToEncode.length,
183
+ rawInfo: infoLogs.length,
184
+ keptInfo: infologsToEncode.length,
185
+ },
186
+ rawInfoSample: applogsSummary(infoLogs),
187
+ })
166
188
  }
167
189
  const encodedSnapshot = await encodeSnapshotAsCar(agent, applogsToEncode, infologsToEncode, prevSnapCID, prevCounter)
168
190
  DEBUG('inPrepareSnapshotForPush', { encodedSnapshot })
@@ -253,7 +275,7 @@ export async function encodeSnapshotApplogsAsCar(
253
275
  applogs: ApplogArrayMaybeEncryptedRO,
254
276
  ) {
255
277
  const encoded = await encodeApplogsAsIPLD(applogs)
256
- if (!encoded) throw ERROR('invalid applogs cannot continue', { applogs, encoded })
278
+ if (!encoded) throw ERROR('invalid applogs cannot continue', { encoded, applogs: applogsSummary(applogs) })
257
279
  const { cids, encodedApplogs } = encoded
258
280
  const root = { applogs: cids }
259
281
  const encodedRoot = await encodeBlockOriginal(root)
@@ -1,10 +1,26 @@
1
- import { AgentHash, Applog, ApplogValue, CidString, DatalogQueryPattern, EntityID, SearchContext, ValueOrMatcher } from '../applog/datom-types.ts'
1
+ import {
2
+ AgentHash,
3
+ Applog,
4
+ ApplogValue,
5
+ CidString,
6
+ DatalogQueryPattern,
7
+ EntityID,
8
+ SearchContext,
9
+ ValueOrMatcher,
10
+ } from '../applog/datom-types.ts'
2
11
 
3
12
  import { Logger } from 'besonders-logger'
4
13
 
5
14
  import { isEmpty } from 'lodash-es'
6
15
  import stringify from 'safe-stable-stringify'
7
- import { isLaterByTsAndPv, isoDateStrCompare, isVariable, resolveOrRemoveVariables, sortApplogsByTs } from '../applog/applog-utils.ts'
16
+ import {
17
+ applogsSummary,
18
+ isLaterByTsAndPv,
19
+ isoDateStrCompare,
20
+ isVariable,
21
+ resolveOrRemoveVariables,
22
+ sortApplogsByTs,
23
+ } from '../applog/applog-utils.ts'
8
24
  import { createDebugName } from '../utils/debug-name.ts'
9
25
  import { isInitEvent, StaticThread, Thread, ThreadEvent } from '../thread/basic.ts'
10
26
  import { hasFilter, makeFilter, rollingFilter, rollingMapper, ThreadOnlyCurrent } from '../thread/filters.ts'
@@ -239,10 +255,8 @@ export const withoutDeleted = memoizedFn('withoutDeleted', function withoutDelet
239
255
  // Pass-through: a parent log goes through to subscribers iff the entity
240
256
  // wasn't hidden before AND isn't hidden now. Transitions are owned by the
241
257
  // synthetic streams above.
242
- const passAdded = delta.added.filter(log =>
243
- !hiddenBefore.has(log.en) && !isHidden(log.en))
244
- const passRemoved = delta.removed?.filter(log =>
245
- !hiddenBefore.has(log.en)) ?? []
258
+ const passAdded = delta.added.filter(log => !hiddenBefore.has(log.en) && !isHidden(log.en))
259
+ const passRemoved = delta.removed?.filter(log => !hiddenBefore.has(log.en)) ?? []
246
260
 
247
261
  return {
248
262
  added: [...passAdded, ...syntheticAdditions],
@@ -324,21 +338,28 @@ export function queryStepOnce(
324
338
  const matchingLogs = filter(thread.applogs)
325
339
  const varMapper = createObjMapper(variablesToFill)
326
340
 
327
- const nodes = matchingLogs.map(log => makeQueryNode(
328
- log, node, varMapper,
329
- createDebugName({
330
- caller: 'QueryNode',
331
- thread,
332
- pattern: `${stringify(Object.assign({}, node?.variables, varMapper(log)))}@${stringify(patternWithResolvedVars)}`,
333
- }),
334
- ))
341
+ const nodes = matchingLogs.map(log =>
342
+ makeQueryNode(
343
+ log,
344
+ node,
345
+ varMapper,
346
+ createDebugName({
347
+ caller: 'QueryNode',
348
+ thread,
349
+ pattern: `${stringify(Object.assign({}, node?.variables, varMapper(log)))}@${stringify(patternWithResolvedVars)}`,
350
+ }),
351
+ )
352
+ )
335
353
 
336
354
  if (VERBOSE.isEnabled) VERBOSE(`[queryStepOnce.doQuery] nodes:`, nodes.map(n => n.variables))
337
355
  if (opts.debug) {
338
- LOG(`[queryStepOnce] step result:`, nodes.map(({ variables, logsOfThisNode: thread }) => ({
339
- variables,
340
- thread,
341
- })))
356
+ LOG(
357
+ `[queryStepOnce] step result:`,
358
+ nodes.map(({ variables, logsOfThisNode: thread }) => ({
359
+ variables,
360
+ thread,
361
+ })),
362
+ )
342
363
  }
343
364
 
344
365
  return nodes
@@ -404,7 +425,9 @@ export const liveQueryStep = memoizedFn('liveQueryStep', function liveQueryStep(
404
425
 
405
426
  function makeNode(log: Applog): QueryNode {
406
427
  return makeQueryNode(
407
- log, node, varMapper,
428
+ log,
429
+ node,
430
+ varMapper,
408
431
  createDebugName({
409
432
  caller: 'QueryNode',
410
433
  thread: applogsMatchingStatic,
@@ -418,31 +441,33 @@ export const liveQueryStep = memoizedFn('liveQueryStep', function liveQueryStep(
418
441
 
419
442
  if (VERBOSE.isEnabled) VERBOSE(`[liveQueryStep.doQuery] initial nodes:`, initialNodes.map(n => n.variables))
420
443
  if (opts.debug) {
421
- LOG(`[liveQueryStep] step result:`, initialNodes.map(({ variables, logsOfThisNode: thread }) => ({
422
- variables,
423
- thread,
424
- })))
444
+ LOG(
445
+ `[liveQueryStep] step result:`,
446
+ initialNodes.map(({ variables, logsOfThisNode: thread }) => ({
447
+ variables,
448
+ thread,
449
+ })),
450
+ )
425
451
  }
426
452
 
427
453
  // Upstream subscription activates lazily — only when someone subscribes to us
428
454
  const result = new SubscribableArrayImpl<QueryNode>(
429
455
  initialNodes,
430
- () => applogsMatchingStatic.subscribe((event) => {
431
- if (isInitEvent(event)) {
432
- result._reset(event.init.map(makeNode))
433
- } else {
434
- if (event.added.length) {
435
- result._push(...event.added.map(makeNode))
436
- }
437
- if (event.removed?.length) {
438
- const removedCids = new Set(event.removed.map(log => log.cid))
439
- const toRemove = result.items.filter(qn =>
440
- removedCids.has(qn.logsOfThisNode.applogs[0]?.cid)
441
- )
442
- if (toRemove.length) result._remove(toRemove)
456
+ () =>
457
+ applogsMatchingStatic.subscribe((event) => {
458
+ if (isInitEvent(event)) {
459
+ result._reset(event.init.map(makeNode))
460
+ } else {
461
+ if (event.added.length) {
462
+ result._push(...event.added.map(makeNode))
463
+ }
464
+ if (event.removed?.length) {
465
+ const removedCids = new Set(event.removed.map(log => log.cid))
466
+ const toRemove = result.items.filter(qn => removedCids.has(qn.logsOfThisNode.applogs[0]?.cid))
467
+ if (toRemove.length) result._remove(toRemove)
468
+ }
443
469
  }
444
- }
445
- }, 'derived'),
470
+ }, 'derived'),
446
471
  )
447
472
  return result
448
473
  }
@@ -466,9 +491,9 @@ export const liveQueryStep = memoizedFn('liveQueryStep', function liveQueryStep(
466
491
  initialItems,
467
492
  () => {
468
493
  const subsByInputNode = new Map<QueryNode, {
469
- inner: SubscribableArray<QueryNode>,
470
- unsub: Unsubscribe,
471
- nodes: QueryNode[],
494
+ inner: SubscribableArray<QueryNode>
495
+ unsub: Unsubscribe
496
+ nodes: QueryNode[]
472
497
  }>()
473
498
 
474
499
  function wireInner(inputNode: QueryNode, inner: SubscribableArray<QueryNode>): QueryNode[] {
@@ -521,7 +546,8 @@ export const liveQueryStep = memoizedFn('liveQueryStep', function liveQueryStep(
521
546
  const prevUnsub = nodeSet.subscribe((event) => {
522
547
  if (isArrayInitEvent(event)) {
523
548
  for (const [, entry] of subsByInputNode) {
524
- entry.unsub(); entry.inner.dispose()
549
+ entry.unsub()
550
+ entry.inner.dispose()
525
551
  }
526
552
  subsByInputNode.clear()
527
553
  const allNodes: QueryNode[] = []
@@ -550,7 +576,8 @@ export const liveQueryStep = memoizedFn('liveQueryStep', function liveQueryStep(
550
576
  return () => {
551
577
  prevUnsub()
552
578
  for (const [, entry] of subsByInputNode) {
553
- entry.unsub(); entry.inner.dispose()
579
+ entry.unsub()
580
+ entry.inner.dispose()
554
581
  }
555
582
  subsByInputNode.clear()
556
583
  }
@@ -688,7 +715,10 @@ export const liveQueryNot = memoizedFn('liveQueryNot', function liveQueryNot(
688
715
  }
689
716
  }, 'derived')
690
717
 
691
- return () => { threadUnsub(); upstreamUnsub() }
718
+ return () => {
719
+ threadUnsub()
720
+ upstreamUnsub()
721
+ }
692
722
  },
693
723
  )
694
724
 
@@ -755,27 +785,28 @@ export const liveFilterAndMap = memoizedFn('liveFilterAndMap', function liveFilt
755
785
  const initial = filtered.applogs.map(mapAndTrack)
756
786
  const result = new SubscribableArrayImpl<R>(
757
787
  initial,
758
- () => filtered.subscribe((event) => {
759
- if (isInitEvent(event)) {
760
- cidToMapped.clear()
761
- result._reset(event.init.map(mapAndTrack))
762
- } else {
763
- if (event.added.length) result._push(...event.added.map(mapAndTrack))
764
- if (event.removed?.length) {
765
- const toRemove: R[] = []
766
- for (const log of event.removed) {
767
- const r = cidToMapped.get(log.cid)
768
- if (r === undefined) {
769
- WARN(`[liveFilterAndMap] removed log not in cidToMapped`, { log })
770
- continue
788
+ () =>
789
+ filtered.subscribe((event) => {
790
+ if (isInitEvent(event)) {
791
+ cidToMapped.clear()
792
+ result._reset(event.init.map(mapAndTrack))
793
+ } else {
794
+ if (event.added.length) result._push(...event.added.map(mapAndTrack))
795
+ if (event.removed?.length) {
796
+ const toRemove: R[] = []
797
+ for (const log of event.removed) {
798
+ const r = cidToMapped.get(log.cid)
799
+ if (r === undefined) {
800
+ WARN(`[liveFilterAndMap] removed log not in cidToMapped`, { log })
801
+ continue
802
+ }
803
+ cidToMapped.delete(log.cid)
804
+ toRemove.push(r)
771
805
  }
772
- cidToMapped.delete(log.cid)
773
- toRemove.push(r)
806
+ if (toRemove.length) result._remove(toRemove)
774
807
  }
775
- if (toRemove.length) result._remove(toRemove)
776
808
  }
777
- }
778
- }, 'derived'),
809
+ }, 'derived'),
779
810
  )
780
811
  return result
781
812
  }, { argsDebugName: (thread, pattern) => createDebugName({ caller: 'liveFilterAndMap', thread, pattern }) })
@@ -809,9 +840,10 @@ export const liveQueryAndMap = memoizedFn('liveQueryAndMap', function liveQueryA
809
840
 
810
841
  const result = new SubscribableArrayImpl<R>(
811
842
  computeAll(),
812
- () => live.subscribe(() => {
813
- result._reset(computeAll())
814
- }, 'derived'),
843
+ () =>
844
+ live.subscribe(() => {
845
+ result._reset(computeAll())
846
+ }, 'derived'),
815
847
  )
816
848
  return result
817
849
  }, { argsDebugName: (thread, pattern) => createDebugName({ caller: 'liveQueryAndMap', thread, pattern }) })
@@ -856,9 +888,10 @@ export const liveQueryEntity = memoizedFn('liveQueryEntity', function liveQueryE
856
888
 
857
889
  const result = new SubscribableImpl<Record<string, ApplogValue> | null>(
858
890
  compute(),
859
- () => filtered.subscribe(() => {
860
- result._set(compute())
861
- }, 'derived'),
891
+ () =>
892
+ filtered.subscribe(() => {
893
+ result._set(compute())
894
+ }, 'derived'),
862
895
  )
863
896
  return result
864
897
  }, {
@@ -882,9 +915,10 @@ export const liveEntityAt = memoizedFn('liveEntityAt', function liveEntityAt<T e
882
915
 
883
916
  const result = new SubscribableImpl<T | null>(
884
917
  compute(),
885
- () => filtered.subscribe(() => {
886
- result._set(compute())
887
- }, 'derived'),
918
+ () =>
919
+ filtered.subscribe(() => {
920
+ result._set(compute())
921
+ }, 'derived'),
888
922
  )
889
923
  return result
890
924
  }, {
@@ -968,7 +1002,10 @@ export const liveEntityOverlapCount = memoizedFn(
968
1002
  () => {
969
1003
  const unsub1 = threadA.subscribe(() => result._set(compute()), 'derived')
970
1004
  const unsub2 = threadB.subscribe(() => result._set(compute()), 'derived')
971
- return () => { unsub1(); unsub2() }
1005
+ return () => {
1006
+ unsub1()
1007
+ unsub2()
1008
+ }
972
1009
  },
973
1010
  )
974
1011
  return result
@@ -985,7 +1022,9 @@ export const querySingle = memoizedFn('querySingle', function querySingle(
985
1022
  if (result.isEmpty) return null
986
1023
  if (result.size > 1) throw ERROR(`[querySingle] got`, result.size, `results:`, result)
987
1024
  const logsOfThisNode = result.nodes[0].logsOfThisNode
988
- if (logsOfThisNode.size != 1) throw ERROR(`[querySingle] single result, but got`, logsOfThisNode.size, `logs:`, logsOfThisNode.applogs)
1025
+ if (logsOfThisNode.size != 1) {
1026
+ throw ERROR(`[querySingle] single result, but got`, logsOfThisNode.size, `logs:`, applogsSummary(logsOfThisNode.applogs))
1027
+ }
989
1028
  return logsOfThisNode.applogs[0]
990
1029
  }, {
991
1030
  argsDebugName: (thread, pattern) => createDebugName({ caller: 'querySingle', thread, pattern }),
@@ -1031,9 +1070,10 @@ export const liveQuerySingle = memoizedFn('liveQuerySingle', function liveQueryS
1031
1070
 
1032
1071
  const result = new SubscribableImpl<Applog | null>(
1033
1072
  compute(),
1034
- () => live.subscribe(() => {
1035
- result._set(compute())
1036
- }),
1073
+ () =>
1074
+ live.subscribe(() => {
1075
+ result._set(compute())
1076
+ }),
1037
1077
  )
1038
1078
  return result
1039
1079
  }, {
@@ -1063,9 +1103,10 @@ export const liveQuerySingleAndMap = memoizedFn(
1063
1103
 
1064
1104
  const result = new SubscribableImpl<any>(
1065
1105
  compute(),
1066
- () => liveSingle.subscribe(() => {
1067
- result._set(compute())
1068
- }),
1106
+ () =>
1107
+ liveSingle.subscribe(() => {
1108
+ result._set(compute())
1109
+ }),
1069
1110
  )
1070
1111
  return result
1071
1112
  },
@@ -1214,7 +1255,8 @@ function warnIfDisjointQuerySteps(patterns: DatalogQueryPattern[]) {
1214
1255
  if (stepVars.size === 0) {
1215
1256
  WARN(
1216
1257
  `[query] Step ${i} has no variables — it produces identical results regardless of previous steps (cartesian product).`,
1217
- `Patterns:`, patterns,
1258
+ `Patterns:`,
1259
+ patterns,
1218
1260
  )
1219
1261
  continue
1220
1262
  }
@@ -1225,7 +1267,8 @@ function warnIfDisjointQuerySteps(patterns: DatalogQueryPattern[]) {
1225
1267
  `This produces a cartesian product instead of a join.`,
1226
1268
  `Step ${i} variables: {${[...stepVars].join(', ')}}`,
1227
1269
  `Reachable from prior steps: {${[...reachable].join(', ')}}`,
1228
- `Patterns:`, patterns,
1270
+ `Patterns:`,
1271
+ patterns,
1229
1272
  )
1230
1273
  }
1231
1274
  for (const v of stepVars) reachable.add(v)
@@ -71,7 +71,10 @@ export abstract class Thread implements SubscribableArray<Applog> {
71
71
 
72
72
  protected notifySubscribers(event: ThreadEvent) {
73
73
  this._version++
74
- DEBUG(`[thread: ${this.name}] notifying`, this._derivedSubscribers.length, 'derived +', this._subscribers.length, 'subscribers of', { ...event, subs: this._subscribers })
74
+ DEBUG(`[thread: ${this.name}] notifying`, this._derivedSubscribers.length, 'derived +', this._subscribers.length, 'subscribers of', {
75
+ ...event,
76
+ subs: this._subscribers,
77
+ })
75
78
  const derived = [...this._derivedSubscribers] // snapshot — safe if a subscriber unsubs during iteration
76
79
  for (const subscriber of derived) {
77
80
  subscriber(event)
@@ -83,7 +86,9 @@ export abstract class Thread implements SubscribableArray<Applog> {
83
86
  }
84
87
 
85
88
  // ── SubscribableArray<Applog> ──
86
- get items(): readonly Applog[] { return this._applogs }
89
+ get items(): readonly Applog[] {
90
+ return this._applogs
91
+ }
87
92
 
88
93
  dispose() {
89
94
  this._derivedSubscribers.length = 0
@@ -186,6 +191,20 @@ export abstract class Thread implements SubscribableArray<Applog> {
186
191
  get hasParents() {
187
192
  return !!this.parents?.length
188
193
  }
194
+
195
+ // Compact representation for server-side / plain console logging (Node's util.inspect).
196
+ // ⚠ Only consulted by Node — browser devtools ignore this symbol and keep rendering the
197
+ // live, expandable object, so passing `thread` to a logger stays clickable in devtools
198
+ // while no longer splatting every applog + the whole parent chain + subscribers to stdout.
199
+ [Symbol.for('nodejs.util.inspect.custom')]() {
200
+ // Must never throw — an inspect that throws breaks the very error log it's part of.
201
+ // e.g. `ERROR('falsy applogs of thread', s)` where s._applogs is malformed.
202
+ try {
203
+ return `${this.constructor.name}<${this.nameAndSizeUntracked}${this.filters?.length ? ` |${this.filters.join(',')}` : ''}>`
204
+ } catch {
205
+ return `${this.constructor.name}<${this.name} (size?)>`
206
+ }
207
+ }
189
208
  }
190
209
 
191
210
  export const getLogsFromThread = (logsOrThread: ApplogsOrThread) => logsOrThread instanceof Thread ? logsOrThread.applogs : logsOrThread
@@ -1,15 +1,22 @@
1
1
  import { Logger } from 'besonders-logger'
2
2
  import { DebouncedFunc, pull, sortedIndexBy } from 'lodash-es'
3
3
  import { Applog, ApplogForInsert } from '../applog/datom-types.ts'
4
+ import { applogsSummary } from '../applog/applog-utils.ts'
4
5
  import { arrayIfSingle } from '../types/typescript-utils.ts'
5
6
  import { isInitEvent, Thread, ThreadEvent } from './basic.ts'
6
7
 
7
8
  const { WARN, LOG, DEBUG, VERBOSE, ERROR } = Logger.setup(Logger.INFO) // eslint-disable-line no-unused-vars
8
9
 
10
+ /** Bounded, debuggable one-line summary of a ThreadEvent (init vs added/removed with counts + samples). */
11
+ const threadEventSummary = (e: ThreadEvent) =>
12
+ isInitEvent(e)
13
+ ? `init ${applogsSummary(e.init)}`
14
+ : `added ${applogsSummary(e.added)}${e.removed ? ` removed ${applogsSummary(e.removed)}` : ''}`
15
+
9
16
  // ── ThreadDerivation ────────────────────────────────────────────
10
17
 
11
18
  /** Delta event — only incremental changes (no init) */
12
- export type DeltaEvent = { added: readonly Applog[], removed: readonly Applog[] | null }
19
+ export type DeltaEvent = { added: readonly Applog[]; removed: readonly Applog[] | null }
13
20
 
14
21
  /** Context passed to mapDelta — explicit deps, no `this` needed */
15
22
  export interface DeltaContext {
@@ -210,9 +217,10 @@ export class MappedThread extends Thread {
210
217
  this._applogs.splice(idx, 1)
211
218
  } else {
212
219
  throw ERROR(`MappedThread{${this.name}} toRemove: log not found`, toRemove, {
213
- thread: this,
214
- event,
215
- result,
220
+ thread: this.nameAndSizeUntracked,
221
+ currentLogs: applogsSummary(this._applogs),
222
+ event: threadEventSummary(event),
223
+ result: threadEventSummary(result),
216
224
  })
217
225
  }
218
226
  }
@@ -3,7 +3,7 @@ import { curry, debounce, partial, pull, uniq, uniqBy, uniqWith } from 'lodash-e
3
3
 
4
4
  import { CID } from 'multiformats'
5
5
  import { ensureTsPvAndFinalizeApplogs, joinThreads } from '../applog/applog-helpers.ts'
6
- import { compareApplogsByEnAt, compareApplogsByTs, objEqualByKeys } from '../applog/applog-utils.ts'
6
+ import { applogsSummary, compareApplogsByEnAt, compareApplogsByTs, objEqualByKeys } from '../applog/applog-utils.ts'
7
7
  import { Applog, ApplogForInsert, CidString, EntityID } from '../applog/datom-types.ts'
8
8
  import { Thread } from './basic.ts'
9
9
  import { MappedThread } from './mapped.ts'
@@ -39,7 +39,7 @@ export function debounceWrites(
39
39
  let insertQueue: Array<Applog | ApplogForInsert> = []
40
40
 
41
41
  const debouncedCommit = debounce(() => {
42
- WARN(`Debounce tail`, { thread, mappedThread, insertQueue })
42
+ DEBUG(`Debounce tail`, { thread: thread.nameAndSizeUntracked, queued: applogsSummary(insertQueue) })
43
43
  const toInsert = ensureTsPvAndFinalizeApplogs(
44
44
  // ? uniq, sure - but which one is used? (update: seems the first one, so reverse)
45
45
  uniqWith(insertQueue.reverse(), removeDuplicatesWith),
@@ -49,7 +49,7 @@ export function debounceWrites(
49
49
  insertQueue.splice(0, insertQueue.length) // clear queue
50
50
  }, wait)
51
51
  const handleInsert = (applogs: Applog[] | ApplogForInsert[]) => {
52
- DEBUG(`Debounce input:`, applogs, { thread, mappedThread, insertQueue })
52
+ DEBUG(`Debounce input:`, applogsSummary(applogs), { thread: thread.nameAndSizeUntracked, queued: insertQueue.length })
53
53
  insertQueue.push(...applogs)
54
54
  debouncedCommit()
55
55
  return null // don't insert anything
@@ -131,8 +131,8 @@ export const excludeApplogsContainedIn = (
131
131
  const excludeCids: Set<string> = exclude instanceof Set
132
132
  ? new Set([...exclude].map(c => c.toString()))
133
133
  : exclude instanceof Thread
134
- ? new Set(exclude.applogsCids)
135
- : new Set(exclude.map(a => a.cid))
134
+ ? new Set(exclude.applogsCids)
135
+ : new Set(exclude.map(a => a.cid))
136
136
 
137
137
  return applogs.filter(applog => {
138
138
  if (!applog.cid) {