@wovin/core 0.1.36 → 0.2.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 (212) hide show
  1. package/README.md +0 -12
  2. package/dist/applog/applog-helpers.d.ts +12 -12
  3. package/dist/applog/applog-helpers.d.ts.map +1 -1
  4. package/dist/applog/applog-utils.d.ts +25 -6
  5. package/dist/applog/applog-utils.d.ts.map +1 -1
  6. package/dist/applog/datom-types.d.ts +4 -5
  7. package/dist/applog/datom-types.d.ts.map +1 -1
  8. package/dist/applog.d.ts +3 -3
  9. package/dist/applog.d.ts.map +1 -1
  10. package/dist/{applog.min.js → applog.js} +6 -7
  11. package/dist/blockstore.d.ts +1 -1
  12. package/dist/blockstore.d.ts.map +1 -1
  13. package/dist/{blockstore.min.js → blockstore.js} +1 -3
  14. package/dist/{blockstore.min.js.map → blockstore.js.map} +1 -1
  15. package/dist/{chunk-KXMTKPF4.min.js → chunk-3JZMOEOD.js} +8 -8
  16. package/dist/chunk-3JZMOEOD.js.map +1 -0
  17. package/dist/chunk-3WZVG277.js +434 -0
  18. package/dist/chunk-3WZVG277.js.map +1 -0
  19. package/dist/chunk-7Z5YDQKK.js +1 -0
  20. package/dist/chunk-CPSDKFBG.js +147 -0
  21. package/dist/chunk-CPSDKFBG.js.map +1 -0
  22. package/dist/chunk-E46VTKTZ.js +1 -0
  23. package/dist/{chunk-H3VQJP56.min.js → chunk-J2FDHGOZ.js} +9 -9
  24. package/dist/chunk-J2FDHGOZ.js.map +1 -0
  25. package/dist/chunk-L5EEEGE6.js +1862 -0
  26. package/dist/chunk-L5EEEGE6.js.map +1 -0
  27. package/dist/{chunk-BRC7LSM6.min.js → chunk-PD3C7XUM.js} +5 -5
  28. package/dist/chunk-PD3C7XUM.js.map +1 -0
  29. package/dist/chunk-QZXKQCAY.js +1026 -0
  30. package/dist/chunk-QZXKQCAY.js.map +1 -0
  31. package/dist/{chunk-QPGEBDMJ.min.js → chunk-YDAKBU6Q.js} +1 -1
  32. package/dist/chunk-YDAKBU6Q.js.map +1 -0
  33. package/dist/chunk-ZAADLBSB.js +36 -0
  34. package/dist/chunk-ZAADLBSB.js.map +1 -0
  35. package/dist/index.d.ts +7 -7
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/{index.min.js → index.js} +73 -46
  38. package/dist/ipfs/car.d.ts +11 -11
  39. package/dist/ipfs/car.d.ts.map +1 -1
  40. package/dist/ipfs/ipfs-utils.d.ts +2 -2
  41. package/dist/ipfs/ipfs-utils.d.ts.map +1 -1
  42. package/dist/ipfs.d.ts +3 -3
  43. package/dist/ipfs.d.ts.map +1 -1
  44. package/dist/{ipfs.min.js → ipfs.js} +7 -10
  45. package/dist/ipns.d.ts +1 -1
  46. package/dist/ipns.d.ts.map +1 -1
  47. package/dist/ipns.js +64 -0
  48. package/dist/ipns.js.map +1 -0
  49. package/dist/pubsub/pub-pull.d.ts +3 -3
  50. package/dist/pubsub/pub-pull.d.ts.map +1 -1
  51. package/dist/pubsub/pubsub-types.d.ts +3 -3
  52. package/dist/pubsub/pubsub-types.d.ts.map +1 -1
  53. package/dist/pubsub/snap-push.d.ts +4 -4
  54. package/dist/pubsub/snap-push.d.ts.map +1 -1
  55. package/dist/pubsub/ucan.d.ts +1 -1
  56. package/dist/pubsub/ucan.d.ts.map +1 -1
  57. package/dist/pubsub.d.ts +4 -4
  58. package/dist/pubsub.d.ts.map +1 -1
  59. package/dist/{pubsub.min.js → pubsub.js} +7 -10
  60. package/dist/query/attr-helpers.d.ts +5 -0
  61. package/dist/query/attr-helpers.d.ts.map +1 -0
  62. package/dist/query/basic.d.ts +85 -21
  63. package/dist/query/basic.d.ts.map +1 -1
  64. package/dist/query/divergences.d.ts +5 -5
  65. package/dist/query/divergences.d.ts.map +1 -1
  66. package/dist/query/entity-collection.d.ts +19 -0
  67. package/dist/query/entity-collection.d.ts.map +1 -0
  68. package/dist/query/matchers.d.ts +1 -1
  69. package/dist/query/matchers.d.ts.map +1 -1
  70. package/dist/query/memoized.d.ts +66 -0
  71. package/dist/query/memoized.d.ts.map +1 -0
  72. package/dist/query/situations.d.ts +2 -1
  73. package/dist/query/situations.d.ts.map +1 -1
  74. package/dist/query/subscribable.d.ts +111 -0
  75. package/dist/query/subscribable.d.ts.map +1 -0
  76. package/dist/query/types.d.ts +54 -14
  77. package/dist/query/types.d.ts.map +1 -1
  78. package/dist/query.d.ts +9 -5
  79. package/dist/query.d.ts.map +1 -1
  80. package/dist/{query.min.js → query.js} +51 -32
  81. package/dist/retrieve/index.d.ts +1 -1
  82. package/dist/retrieve/index.d.ts.map +1 -1
  83. package/dist/retrieve/update-thread.d.ts +3 -3
  84. package/dist/retrieve/update-thread.d.ts.map +1 -1
  85. package/dist/retrieve.d.ts +1 -1
  86. package/dist/retrieve.d.ts.map +1 -1
  87. package/dist/retrieve.js +14 -0
  88. package/dist/thread/basic.d.ts +15 -19
  89. package/dist/thread/basic.d.ts.map +1 -1
  90. package/dist/thread/filters.d.ts +8 -10
  91. package/dist/thread/filters.d.ts.map +1 -1
  92. package/dist/thread/indexes.d.ts +56 -0
  93. package/dist/thread/indexes.d.ts.map +1 -0
  94. package/dist/thread/mapped.d.ts +40 -11
  95. package/dist/thread/mapped.d.ts.map +1 -1
  96. package/dist/thread/utils.d.ts +5 -5
  97. package/dist/thread/utils.d.ts.map +1 -1
  98. package/dist/thread/writeable.d.ts +2 -2
  99. package/dist/thread/writeable.d.ts.map +1 -1
  100. package/dist/thread.d.ts +6 -5
  101. package/dist/thread.d.ts.map +1 -1
  102. package/dist/{thread.min.js → thread.js} +9 -6
  103. package/dist/types/typescript-utils.d.ts +6 -5
  104. package/dist/types/typescript-utils.d.ts.map +1 -1
  105. package/dist/types.d.ts +1 -1
  106. package/dist/types.d.ts.map +1 -1
  107. package/dist/{types.min.js → types.js} +3 -4
  108. package/dist/utils/debug-name.d.ts +13 -0
  109. package/dist/utils/debug-name.d.ts.map +1 -0
  110. package/dist/utils.d.ts +1 -1
  111. package/dist/utils.d.ts.map +1 -1
  112. package/dist/utils.js +9 -0
  113. package/package.json +32 -23
  114. package/src/applog/applog-helpers.ts +155 -0
  115. package/src/applog/applog-utils.test.ts +108 -0
  116. package/src/applog/applog-utils.ts +507 -0
  117. package/src/applog/datom-types.ts +148 -0
  118. package/src/applog.ts +3 -0
  119. package/src/blockstore/index.ts +36 -0
  120. package/src/blockstore.ts +1 -0
  121. package/src/index.ts +8 -0
  122. package/src/ipfs/car.ts +291 -0
  123. package/src/ipfs/fetch-snapshot-chain.ts +135 -0
  124. package/src/ipfs/ipfs-utils.ts +132 -0
  125. package/src/ipfs.ts +3 -0
  126. package/src/ipns/ipns-record.ts +115 -0
  127. package/src/ipns.ts +1 -0
  128. package/src/pubsub/UCAN Specs Overview.md +217 -0
  129. package/src/pubsub/connector.ts +9 -0
  130. package/src/pubsub/pub-pull.ts +31 -0
  131. package/src/pubsub/pubsub-types.ts +90 -0
  132. package/src/pubsub/snap-push.ts +277 -0
  133. package/src/pubsub/ucan-example.ts +61 -0
  134. package/src/pubsub/ucan.ts +56 -0
  135. package/src/pubsub.ts +4 -0
  136. package/src/query/attr-helpers.ts +5 -0
  137. package/src/query/basic.ts +1245 -0
  138. package/src/query/divergences.ts +50 -0
  139. package/src/query/entity-collection.ts +131 -0
  140. package/src/query/liveFilterAndMap.test.ts +102 -0
  141. package/src/query/matchers.ts +8 -0
  142. package/src/query/memoized.test.ts +151 -0
  143. package/src/query/memoized.ts +180 -0
  144. package/src/query/query-steps.ts +4 -0
  145. package/src/query/query.test.ts +538 -0
  146. package/src/query/situations.ts +261 -0
  147. package/src/query/subscribable.test.ts +245 -0
  148. package/src/query/subscribable.ts +234 -0
  149. package/src/query/types.ts +155 -0
  150. package/src/query/withoutDeleted.test.ts +204 -0
  151. package/src/query.ts +9 -0
  152. package/src/retrieve/index.ts +1 -0
  153. package/src/retrieve/update-thread.ts +248 -0
  154. package/src/retrieve.ts +1 -0
  155. package/src/test/perf/query.1m.perf.test.ts +94 -0
  156. package/src/test/perf/query.perf.test.ts +389 -0
  157. package/src/test/perf/query.realdata.perf.test.ts +182 -0
  158. package/src/thread/basic.ts +209 -0
  159. package/src/thread/filters.ts +227 -0
  160. package/src/thread/indexes.ts +250 -0
  161. package/src/thread/joinThreads.test.ts +304 -0
  162. package/src/thread/mapped.ts +226 -0
  163. package/src/thread/utils.ts +144 -0
  164. package/src/thread/writeable.ts +163 -0
  165. package/src/thread.ts +6 -0
  166. package/src/types/typescript-utils.ts +64 -0
  167. package/src/types.ts +1 -0
  168. package/src/utils/debug-name.ts +54 -0
  169. package/src/utils.ts +4 -0
  170. package/dist/chunk-2Y2PYHGR.min.js +0 -65
  171. package/dist/chunk-2Y2PYHGR.min.js.map +0 -1
  172. package/dist/chunk-5MMGBK2U.min.js +0 -1
  173. package/dist/chunk-7IDQIMQO.min.js +0 -1
  174. package/dist/chunk-BRC7LSM6.min.js.map +0 -1
  175. package/dist/chunk-COXXILXC.min.js +0 -512
  176. package/dist/chunk-COXXILXC.min.js.map +0 -1
  177. package/dist/chunk-GDX2OO7L.min.js +0 -9080
  178. package/dist/chunk-GDX2OO7L.min.js.map +0 -1
  179. package/dist/chunk-H3VQJP56.min.js.map +0 -1
  180. package/dist/chunk-HYMC7W6S.min.js +0 -1549
  181. package/dist/chunk-HYMC7W6S.min.js.map +0 -1
  182. package/dist/chunk-KEHU7HGZ.min.js +0 -5216
  183. package/dist/chunk-KEHU7HGZ.min.js.map +0 -1
  184. package/dist/chunk-KXMTKPF4.min.js.map +0 -1
  185. package/dist/chunk-PHITDXZT.min.js +0 -36
  186. package/dist/chunk-QO2KMGDN.min.js +0 -3771
  187. package/dist/chunk-QO2KMGDN.min.js.map +0 -1
  188. package/dist/chunk-QPGEBDMJ.min.js.map +0 -1
  189. package/dist/chunk-WXLCBTHX.min.js +0 -1606
  190. package/dist/chunk-WXLCBTHX.min.js.map +0 -1
  191. package/dist/ipns.min.js +0 -6419
  192. package/dist/ipns.min.js.map +0 -1
  193. package/dist/mobx/mobx-utils.d.ts +0 -82
  194. package/dist/mobx/mobx-utils.d.ts.map +0 -1
  195. package/dist/mobx.d.ts +0 -2
  196. package/dist/mobx.d.ts.map +0 -1
  197. package/dist/mobx.min.js +0 -141
  198. package/dist/retrieve.min.js +0 -17
  199. package/dist/types.min.js.map +0 -1
  200. package/dist/utils.min.js +0 -10
  201. package/dist/utils.min.js.map +0 -1
  202. /package/dist/{applog.min.js.map → applog.js.map} +0 -0
  203. /package/dist/{chunk-5MMGBK2U.min.js.map → chunk-7Z5YDQKK.js.map} +0 -0
  204. /package/dist/{chunk-7IDQIMQO.min.js.map → chunk-E46VTKTZ.js.map} +0 -0
  205. /package/dist/{chunk-PHITDXZT.min.js.map → index.js.map} +0 -0
  206. /package/dist/{index.min.js.map → ipfs.js.map} +0 -0
  207. /package/dist/{ipfs.min.js.map → pubsub.js.map} +0 -0
  208. /package/dist/{mobx.min.js.map → query.js.map} +0 -0
  209. /package/dist/{pubsub.min.js.map → retrieve.js.map} +0 -0
  210. /package/dist/{query.min.js.map → thread.js.map} +0 -0
  211. /package/dist/{retrieve.min.js.map → types.js.map} +0 -0
  212. /package/dist/{thread.min.js.map → utils.js.map} +0 -0
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Tests for joinThreads — the multi-parent thread merge.
3
+ *
4
+ * Covers: initial merge, incremental adds, deduplication, setParents,
5
+ * triggerRemap, and subscriber notifications.
6
+ */
7
+ import { afterEach, describe, expect, it, vi } from 'vitest'
8
+ import { finalizeApplogForInsert, joinThreads } from '../applog/applog-helpers.ts'
9
+ import { sortApplogsByTs } from '../applog/applog-utils.ts'
10
+ import type { Applog, ApplogForInsert, CidString } from '../applog/datom-types.ts'
11
+ import { isInitEvent, type ThreadEvent } from './basic.ts'
12
+ import { ThreadInMemory } from './writeable.ts'
13
+
14
+ // ─── Helpers ────────────────────────────────────────────────────
15
+
16
+ let tsCounter = 0
17
+
18
+ /** Create a valid Applog with proper CID (can be used with insertRaw) */
19
+ function makeLog(overrides: Partial<Applog> & Pick<ApplogForInsert, 'en' | 'at' | 'vl'>): Applog {
20
+ tsCounter++
21
+ const base = {
22
+ ts: overrides.ts ?? new Date(1700000000000 + tsCounter * 1000).toISOString(),
23
+ pv: null,
24
+ ag: 'testAgent',
25
+ ...overrides,
26
+ }
27
+ // Use finalizeApplogForInsert to get a real CID
28
+ return finalizeApplogForInsert(base as ApplogForInsert, {})
29
+ }
30
+
31
+ function makeLogs(...specs: Array<Partial<Applog> & Pick<ApplogForInsert, 'en' | 'at' | 'vl'>>): Applog[] {
32
+ return sortApplogsByTs(specs.map(s => makeLog(s)))
33
+ }
34
+
35
+ function threadFrom(logs: Applog[], name = 'test'): ThreadInMemory {
36
+ return ThreadInMemory.fromArray(logs, name)
37
+ }
38
+
39
+ afterEach(() => {
40
+ tsCounter = 0
41
+ })
42
+
43
+ // ═════════════════════════════════════════════════════════════════
44
+ // Initial merge (compute)
45
+ // ═════════════════════════════════════════════════════════════════
46
+
47
+ describe('joinThreads — initial merge', () => {
48
+ it('merges applogs from two threads', () => {
49
+ const logsA = makeLogs(
50
+ { en: 'e1', at: 'name', vl: 'Alice' },
51
+ )
52
+ const logsB = makeLogs(
53
+ { en: 'e2', at: 'name', vl: 'Bob' },
54
+ )
55
+ const joined = joinThreads([threadFrom(logsA, 'A'), threadFrom(logsB, 'B')])
56
+
57
+ expect(joined.size).toBe(2)
58
+ expect(joined.applogs.map(l => l.vl)).toContain('Alice')
59
+ expect(joined.applogs.map(l => l.vl)).toContain('Bob')
60
+ })
61
+
62
+ it('deduplicates identical applogs across threads', () => {
63
+ const shared = makeLog({ en: 'e1', at: 'name', vl: 'Alice' })
64
+ const a = threadFrom([shared], 'A')
65
+ const b = threadFrom([shared], 'B')
66
+ const joined = joinThreads([a, b])
67
+
68
+ expect(joined.size).toBe(1)
69
+ })
70
+
71
+ it('sorts merged result by timestamp', () => {
72
+ const early = makeLog({ en: 'e1', at: 'x', vl: 1, ts: '2020-01-01T00:00:00Z' })
73
+ const late = makeLog({ en: 'e2', at: 'x', vl: 2, ts: '2025-01-01T00:00:00Z' })
74
+ // Give them to threads in reverse order
75
+ const joined = joinThreads([threadFrom([late], 'A'), threadFrom([early], 'B')])
76
+
77
+ expect(joined.applogs[0]).toBe(early)
78
+ expect(joined.applogs[1]).toBe(late)
79
+ })
80
+
81
+ it('handles empty threads', () => {
82
+ const logsA = makeLogs({ en: 'e1', at: 'name', vl: 'Alice' })
83
+ const joined = joinThreads([threadFrom(logsA, 'A'), threadFrom([], 'B')])
84
+
85
+ expect(joined.size).toBe(1)
86
+ })
87
+
88
+ it('handles joining a single thread', () => {
89
+ const logsA = makeLogs({ en: 'e1', at: 'name', vl: 'Alice' })
90
+ const joined = joinThreads([threadFrom(logsA, 'A')])
91
+
92
+ expect(joined.size).toBe(1)
93
+ })
94
+
95
+ it('merges three threads', () => {
96
+ const a = threadFrom(makeLogs({ en: 'e1', at: 'x', vl: 1 }), 'A')
97
+ const b = threadFrom(makeLogs({ en: 'e2', at: 'x', vl: 2 }), 'B')
98
+ const c = threadFrom(makeLogs({ en: 'e3', at: 'x', vl: 3 }), 'C')
99
+ const joined = joinThreads([a, b, c])
100
+
101
+ expect(joined.size).toBe(3)
102
+ })
103
+ })
104
+
105
+ // ═════════════════════════════════════════════════════════════════
106
+ // Incremental updates (mapDelta — added)
107
+ // ═════════════════════════════════════════════════════════════════
108
+
109
+ describe('joinThreads — incremental adds', () => {
110
+ it('propagates new applog from one parent', () => {
111
+ const a = threadFrom(makeLogs({ en: 'e1', at: 'x', vl: 1 }), 'A')
112
+ const b = threadFrom([], 'B')
113
+ const joined = joinThreads([a, b])
114
+
115
+ expect(joined.size).toBe(1)
116
+
117
+ // Insert into B
118
+ const newLog = makeLog({ en: 'e2', at: 'x', vl: 2 })
119
+ b.insertRaw([newLog])
120
+
121
+ expect(joined.size).toBe(2)
122
+ expect(joined.applogs).toContain(newLog)
123
+ })
124
+
125
+ it('deduplicates when same log is added to second parent', () => {
126
+ const shared = makeLog({ en: 'e1', at: 'x', vl: 1 })
127
+ const a = threadFrom([shared], 'A')
128
+ const b = threadFrom([], 'B')
129
+ const joined = joinThreads([a, b])
130
+
131
+ expect(joined.size).toBe(1)
132
+
133
+ // Insert the same log into B — should not duplicate
134
+ b.insertRaw([shared])
135
+
136
+ expect(joined.size).toBe(1)
137
+ })
138
+
139
+ it('notifies subscribers on add', () => {
140
+ const a = threadFrom([], 'A')
141
+ const b = threadFrom([], 'B')
142
+ const joined = joinThreads([a, b])
143
+
144
+ const events: ThreadEvent[] = []
145
+ joined.subscribe(e => events.push(e))
146
+
147
+ const newLog = makeLog({ en: 'e1', at: 'x', vl: 1 })
148
+ a.insertRaw([newLog])
149
+
150
+ expect(events).toHaveLength(1)
151
+ expect(isInitEvent(events[0])).toBe(false)
152
+ if (!isInitEvent(events[0])) {
153
+ expect(events[0].added).toContain(newLog)
154
+ }
155
+ })
156
+
157
+ it('inserts maintain sort order', () => {
158
+ const early = makeLog({ en: 'e1', at: 'x', vl: 1, ts: '2020-01-01T00:00:00Z' })
159
+ const a = threadFrom([early], 'A')
160
+ const b = threadFrom([], 'B')
161
+ const joined = joinThreads([a, b])
162
+
163
+ const late = makeLog({ en: 'e2', at: 'x', vl: 2, ts: '2025-01-01T00:00:00Z' })
164
+ b.insertRaw([late])
165
+
166
+ expect(joined.applogs[0]).toBe(early)
167
+ expect(joined.applogs[1]).toBe(late)
168
+
169
+ // Now insert something in between
170
+ const mid = makeLog({ en: 'e3', at: 'x', vl: 3, ts: '2022-06-01T00:00:00Z' })
171
+ a.insertRaw([mid])
172
+
173
+ expect(joined.applogs[0]).toBe(early)
174
+ expect(joined.applogs[1]).toBe(mid)
175
+ expect(joined.applogs[2]).toBe(late)
176
+ })
177
+ })
178
+
179
+ // ═════════════════════════════════════════════════════════════════
180
+ // setParents — swap input threads without new result reference
181
+ // ═════════════════════════════════════════════════════════════════
182
+
183
+ describe('joinThreads — setParents', () => {
184
+ it('replaces parents and recomputes', () => {
185
+ const a = threadFrom(makeLogs({ en: 'e1', at: 'x', vl: 1 }), 'A')
186
+ const b = threadFrom(makeLogs({ en: 'e2', at: 'x', vl: 2 }), 'B')
187
+ const joined = joinThreads([a, b])
188
+
189
+ expect(joined.size).toBe(2)
190
+
191
+ // Swap to a completely different set of parents
192
+ const c = threadFrom(makeLogs({ en: 'e3', at: 'x', vl: 3 }), 'C')
193
+ joined.setParents([c])
194
+
195
+ expect(joined.size).toBe(1)
196
+ expect(joined.applogs[0].vl).toBe(3)
197
+ })
198
+
199
+ it('notifies subscribers with init event on setParents', () => {
200
+ const a = threadFrom(makeLogs({ en: 'e1', at: 'x', vl: 1 }), 'A')
201
+ const joined = joinThreads([a])
202
+
203
+ const events: ThreadEvent[] = []
204
+ joined.subscribe(e => events.push(e))
205
+
206
+ const b = threadFrom(makeLogs({ en: 'e2', at: 'x', vl: 2 }), 'B')
207
+ joined.setParents([b])
208
+
209
+ expect(events).toHaveLength(1)
210
+ expect(isInitEvent(events[0])).toBe(true)
211
+ })
212
+
213
+ it('subscribes to new parents after setParents', () => {
214
+ const a = threadFrom(makeLogs({ en: 'e1', at: 'x', vl: 1 }), 'A')
215
+ const joined = joinThreads([a])
216
+
217
+ const b = threadFrom([], 'B')
218
+ joined.setParents([b])
219
+
220
+ expect(joined.size).toBe(0)
221
+
222
+ // Insert into new parent — should propagate
223
+ const newLog = makeLog({ en: 'e2', at: 'x', vl: 2 })
224
+ b.insertRaw([newLog])
225
+
226
+ expect(joined.size).toBe(1)
227
+ expect(joined.applogs).toContain(newLog)
228
+ })
229
+
230
+ it('unsubscribes from old parents after setParents', () => {
231
+ const a = threadFrom([], 'A')
232
+ const joined = joinThreads([a])
233
+
234
+ const events: ThreadEvent[] = []
235
+ joined.subscribe(e => events.push(e))
236
+
237
+ const b = threadFrom([], 'B')
238
+ joined.setParents([b])
239
+
240
+ // Clear the init event from setParents
241
+ events.length = 0
242
+
243
+ // Insert into OLD parent — should NOT propagate
244
+ a.insertRaw([makeLog({ en: 'e1', at: 'x', vl: 1 })])
245
+
246
+ expect(events).toHaveLength(0)
247
+ expect(joined.size).toBe(0)
248
+ })
249
+ })
250
+
251
+ // ═════════════════════════════════════════════════════════════════
252
+ // triggerRemap — full recompute
253
+ // ═════════════════════════════════════════════════════════════════
254
+
255
+ describe('joinThreads — triggerRemap', () => {
256
+ it('recomputes from current parents', () => {
257
+ const a = threadFrom(makeLogs({ en: 'e1', at: 'x', vl: 1 }), 'A')
258
+ const b = threadFrom(makeLogs({ en: 'e2', at: 'x', vl: 2 }), 'B')
259
+ const joined = joinThreads([a, b])
260
+
261
+ expect(joined.size).toBe(2)
262
+
263
+ joined.triggerRemap()
264
+
265
+ expect(joined.size).toBe(2)
266
+ })
267
+
268
+ it('emits init event to subscribers', () => {
269
+ const a = threadFrom(makeLogs({ en: 'e1', at: 'x', vl: 1 }), 'A')
270
+ const joined = joinThreads([a])
271
+
272
+ const events: ThreadEvent[] = []
273
+ joined.subscribe(e => events.push(e))
274
+
275
+ joined.triggerRemap()
276
+
277
+ expect(events).toHaveLength(1)
278
+ expect(isInitEvent(events[0])).toBe(true)
279
+ if (isInitEvent(events[0])) {
280
+ expect(events[0].init).toHaveLength(1)
281
+ }
282
+ })
283
+ })
284
+
285
+ // ═════════════════════════════════════════════════════════════════
286
+ // dispose
287
+ // ═════════════════════════════════════════════════════════════════
288
+
289
+ describe('joinThreads — dispose', () => {
290
+ it('stops receiving parent updates after dispose', () => {
291
+ const a = threadFrom([], 'A')
292
+ const joined = joinThreads([a])
293
+
294
+ const events: ThreadEvent[] = []
295
+ joined.subscribe(e => events.push(e))
296
+
297
+ joined.dispose()
298
+
299
+ a.insertRaw([makeLog({ en: 'e1', at: 'x', vl: 1 })])
300
+
301
+ // No events should have been received
302
+ expect(events).toHaveLength(0)
303
+ })
304
+ })
@@ -0,0 +1,226 @@
1
+ import { Logger } from 'besonders-logger'
2
+ import { DebouncedFunc, pull, sortedIndexBy } from 'lodash-es'
3
+ import { Applog, ApplogForInsert } from '../applog/datom-types.ts'
4
+ import { arrayIfSingle } from '../types/typescript-utils.ts'
5
+ import { isInitEvent, Thread, ThreadEvent } from './basic.ts'
6
+
7
+ const { WARN, LOG, DEBUG, VERBOSE, ERROR } = Logger.setup(Logger.INFO) // eslint-disable-line no-unused-vars
8
+
9
+ // ── ThreadDerivation ────────────────────────────────────────────
10
+
11
+ /** Delta event — only incremental changes (no init) */
12
+ export type DeltaEvent = { added: readonly Applog[], removed: readonly Applog[] | null }
13
+
14
+ /** Context passed to mapDelta — explicit deps, no `this` needed */
15
+ export interface DeltaContext {
16
+ /** The parent thread that emitted the delta */
17
+ source: Thread
18
+ /** All current parent threads */
19
+ parents: readonly Thread[]
20
+ /** The mapped thread's output before this delta is applied */
21
+ state: readonly Applog[]
22
+ }
23
+
24
+ /**
25
+ * Derivation for a MappedThread — separates full recomputation from incremental updates.
26
+ *
27
+ * - `compute` produces full state from parents (construction, reset, parent reinit)
28
+ * - `mapDelta` transforms a parent's incremental delta (optional — falls back to compute)
29
+ */
30
+ export interface ThreadDerivation {
31
+ /** Compute full state from parents. Called at construction, triggerRemap, and parent reinit. */
32
+ compute(parents: readonly Thread[]): Applog[]
33
+
34
+ /**
35
+ * Transform an incremental delta from a parent.
36
+ * Return null to fall back to full recompute via compute().
37
+ * Optional — if omitted, every parent change triggers compute().
38
+ */
39
+ mapDelta?: (delta: DeltaEvent, ctx: DeltaContext) => DeltaEvent | null
40
+ }
41
+
42
+ // ── Write mapper (unchanged) ───────────────────────────────────
43
+
44
+ // export type ApplogWriteMapper<T extends Applog[] | ApplogForInsert[]> = (this: MappedThread, applogs: T) => T | null // FIXME: the TS generics don't actually work how I want them to
45
+ export type ApplogWriteMapper = (this: MappedThread, applogs: Applog[] | ApplogForInsert[]) => Applog[] | ApplogForInsert[] | null
46
+
47
+ // ── MappedThread ───────────────────────────────────────────────
48
+
49
+ export class MappedThread extends Thread {
50
+ static mapWrites(parent: Thread, name: string, mapper: ApplogWriteMapper) {
51
+ return new MappedThread(
52
+ `${name}<${parent.nameAndSizeUntracked}>`,
53
+ parent,
54
+ parent.filters,
55
+ null, // no derivation — share parent array
56
+ mapper,
57
+ )
58
+ }
59
+ static asReadOnly(parent: Thread) {
60
+ if (parent.readOnly) return parent // already read-only, no need to wrap
61
+ return new MappedThread(
62
+ `readOnly(${parent.name})`,
63
+ parent,
64
+ parent.filters,
65
+ null, // no derivation — share parent array
66
+ null,
67
+ true, // readOnly
68
+ )
69
+ }
70
+ private _parentSubscriptions: Map<Thread, (() => void)> = null // mapped to unsubscribe function
71
+
72
+ constructor(
73
+ readonly name: string,
74
+ parents: Thread | readonly Thread[],
75
+ filters: readonly string[],
76
+ private _derivation: ThreadDerivation | null,
77
+ private _writeMapper: ApplogWriteMapper = null,
78
+ private _readOnly?: boolean,
79
+ ) {
80
+ const parentArr = arrayIfSingle(parents) as readonly Thread[]
81
+
82
+ if (!_derivation && parentArr.length > 1) {
83
+ throw new Error(`MappedThread without derivation must have exactly one parent`)
84
+ }
85
+
86
+ const initialLogs = _derivation
87
+ ? _derivation.compute(parentArr)
88
+ : (parentArr[0] as any)._applogs as Applog[] // no derivation = share parent array
89
+
90
+ super(
91
+ name,
92
+ parents,
93
+ filters,
94
+ _derivation ? [...initialLogs] : initialLogs, // clone if derived, share if not
95
+ )
96
+
97
+ if (_derivation) {
98
+ this.subscribeToParents()
99
+ }
100
+ }
101
+
102
+ public insert(appLogsToInsert: ApplogForInsert[]) {
103
+ if (this.readOnly) throw ERROR(`[MappedThread] insert() called on read-only thread:`, this.nameAndSizeUntracked)
104
+ const mapped = this._writeMapper ? this._writeMapper(appLogsToInsert) : appLogsToInsert
105
+ if (this._writeMapper && !mapped) return
106
+ return this.parents.forEach(parent => parent.insert(mapped))
107
+ }
108
+ public insertRaw(appLogsToInsert: Applog[]) {
109
+ if (this.readOnly) throw ERROR(`[MappedThread] insertRaw() called on read-only thread:`, this.nameAndSizeUntracked)
110
+ const mapped = this._writeMapper ? this._writeMapper(appLogsToInsert) : appLogsToInsert
111
+ if (this._writeMapper && !mapped) return
112
+ return this.parents.forEach(parent => parent.insertRaw(mapped as Applog[]))
113
+ }
114
+
115
+ private subscribeToParents() {
116
+ this._parentSubscriptions = new Map()
117
+ if (!this.parents.length) {
118
+ WARN(`MappedThread has no parents`, this)
119
+ }
120
+ VERBOSE(`[MappedThread: ${this.name}] subscribing to parents:`, this.parents.map(p => p.name))
121
+
122
+ for (const p of this.parents) {
123
+ VERBOSE(`[MappedThread: ${this.name}] sub to parent`, p.nameAndSizeUntracked)
124
+ const sub = this.onParentUpdate.bind(this, p)
125
+ const unsubscribe = p.subscribe(sub, 'derived')
126
+ this._parentSubscriptions.set(p, unsubscribe)
127
+ }
128
+ }
129
+
130
+ /** Tear down parent subscriptions and clear internal state */
131
+ dispose() {
132
+ if (this._parentSubscriptions) {
133
+ for (const [, unsubscribe] of this._parentSubscriptions) {
134
+ unsubscribe()
135
+ }
136
+ this._parentSubscriptions = null
137
+ }
138
+ }
139
+
140
+ /** Swap parents at runtime — re-subscribes and recomputes applogs, notifying downstream */
141
+ setParents(newParents: readonly Thread[]) {
142
+ this.dispose()
143
+ ;(this as { parents: readonly Thread[] }).parents = newParents
144
+ this.subscribeToParents()
145
+ this.triggerRemap()
146
+ }
147
+
148
+ subscribe(callback: (event: ThreadEvent) => void, type?: 'derived' | 'reaction') {
149
+ if (this._derivation && !this._parentSubscriptions) {
150
+ this.subscribeToParents()
151
+ }
152
+ return super.subscribe(callback, type)
153
+ }
154
+
155
+ /** Recompute full state from parents via compute() */
156
+ triggerRemap() {
157
+ if (!this._derivation) throw ERROR(`triggerRemap on a thread without derivation`, this.nameAndSizeUntracked)
158
+ DEBUG(`MappedThread{${this.nameAndSizeUntracked}} triggerRemap`)
159
+ const newLogs = this._derivation.compute(this.parents as readonly Thread[])
160
+ this._applogs.length = 0
161
+ this._applogs.push(...newLogs)
162
+ this.notifySubscribers({ init: newLogs })
163
+ }
164
+
165
+ protected onParentUpdate(sourceThread: Thread, event: ThreadEvent) {
166
+ if (!this._derivation) return // no derivation = shared array, parent handles it
167
+
168
+ VERBOSE(`MappedThread{${this.nameAndSizeUntracked}} parentUpdate`, event)
169
+
170
+ let result: ThreadEvent
171
+
172
+ if (isInitEvent(event)) {
173
+ // Parent reinit → full recompute (ignore payload, read current parent state)
174
+ const newLogs = this._derivation.compute(this.parents as readonly Thread[])
175
+ result = { init: newLogs }
176
+ } else if (this._derivation.mapDelta) {
177
+ const ctx: DeltaContext = {
178
+ source: sourceThread,
179
+ parents: this.parents as readonly Thread[],
180
+ state: this._applogs,
181
+ }
182
+ const mapped = this._derivation.mapDelta(event, ctx)
183
+ if (mapped === null) {
184
+ // mapDelta can't handle it → fall back to full recompute
185
+ const newLogs = this._derivation.compute(this.parents as readonly Thread[])
186
+ result = { init: newLogs }
187
+ } else {
188
+ result = mapped
189
+ }
190
+ } else {
191
+ // No mapDelta → always full recompute
192
+ const newLogs = this._derivation.compute(this.parents as readonly Thread[])
193
+ result = { init: newLogs }
194
+ }
195
+
196
+ // Apply result to _applogs
197
+ if (isInitEvent(result)) {
198
+ this._applogs.length = 0
199
+ this._applogs.push(...result.init)
200
+ } else {
201
+ VERBOSE(`MappedThread{${this.nameAndSizeUntracked}} parentUpdate => mapped`, result)
202
+ for (const log of result.added) {
203
+ // insert at right location to maintain sort order
204
+ this._applogs.splice(sortedIndexBy(this._applogs, log, 'ts'), 0, log)
205
+ }
206
+ if (result.removed) {
207
+ for (const toRemove of result.removed) {
208
+ const idx = this._applogs.indexOf(toRemove)
209
+ if (idx >= 0) {
210
+ this._applogs.splice(idx, 1)
211
+ } else {
212
+ throw ERROR(`MappedThread{${this.name}} toRemove: log not found`, toRemove, {
213
+ thread: this,
214
+ event,
215
+ result,
216
+ })
217
+ }
218
+ }
219
+ }
220
+ }
221
+ this.notifySubscribers(result)
222
+ }
223
+ get readOnly() {
224
+ return this._readOnly ?? false
225
+ }
226
+ }
@@ -0,0 +1,144 @@
1
+ import { Logger } from 'besonders-logger'
2
+ import { curry, debounce, partial, pull, uniq, uniqBy, uniqWith } from 'lodash-es'
3
+
4
+ import { CID } from 'multiformats'
5
+ import { ensureTsPvAndFinalizeApplogs, joinThreads } from '../applog/applog-helpers.ts'
6
+ import { compareApplogsByEnAt, compareApplogsByTs, objEqualByKeys } from '../applog/applog-utils.ts'
7
+ import { Applog, ApplogForInsert, CidString, EntityID } from '../applog/datom-types.ts'
8
+ import { Thread } from './basic.ts'
9
+ import { MappedThread } from './mapped.ts'
10
+ import { ThreadInMemory, WriteableThread } from './writeable.ts'
11
+
12
+ const { WARN, LOG, DEBUG, VERBOSE, ERROR } = Logger.setup(Logger.INFO) // eslint-disable-line no-unused-vars
13
+
14
+ export function entityCount(thread: Thread) {
15
+ return allEntityIDs(thread).size // at some point: index size?
16
+ }
17
+ export function allEntityIDs(thread: Thread) {
18
+ return accumulateLogsToSet(thread, log => log.en)
19
+ }
20
+
21
+ function accumulateLogsToSet<ACC>(
22
+ threadOrLogs: Thread | Applog[],
23
+ callback: (log: Applog, acc: Set<ACC>) => ACC | undefined,
24
+ ) {
25
+ const logs = threadOrLogs instanceof Thread ? threadOrLogs.applogs : threadOrLogs
26
+ const set = new Set<ACC>()
27
+ for (const log of logs) {
28
+ set.add(callback(log, set))
29
+ }
30
+ return set
31
+ }
32
+
33
+ export function debounceWrites(
34
+ thread: Thread,
35
+ wait = 700,
36
+ removeDuplicatesWith = compareApplogsByEnAt,
37
+ ) {
38
+ if (thread.readOnly) throw ERROR(`[debounceWrites] but readOnly thread`, thread.name)
39
+ let insertQueue: Array<Applog | ApplogForInsert> = []
40
+
41
+ const debouncedCommit = debounce(() => {
42
+ WARN(`Debounce tail`, { thread, mappedThread, insertQueue })
43
+ const toInsert = ensureTsPvAndFinalizeApplogs(
44
+ // ? uniq, sure - but which one is used? (update: seems the first one, so reverse)
45
+ uniqWith(insertQueue.reverse(), removeDuplicatesWith),
46
+ thread,
47
+ )
48
+ thread.insertRaw(toInsert)
49
+ insertQueue.splice(0, insertQueue.length) // clear queue
50
+ }, wait)
51
+ const handleInsert = (applogs: Applog[] | ApplogForInsert[]) => {
52
+ DEBUG(`Debounce input:`, applogs, { thread, mappedThread, insertQueue })
53
+ insertQueue.push(...applogs)
54
+ debouncedCommit()
55
+ return null // don't insert anything
56
+ }
57
+
58
+ const mappedThread = MappedThread.mapWrites(
59
+ thread,
60
+ `Debounce(${wait})`,
61
+ handleInsert,
62
+ )
63
+ return mappedThread
64
+ }
65
+
66
+ /**
67
+ * @param deduplicateHoldItemsWith called with (heldLog, realLog), if it returns true, the held log is skipped
68
+ * @param onFirstWrite called with held logs about to be inserted, if it returns an array, those logs will be inserted instead
69
+ */
70
+ export function holdTillFirstWrite(
71
+ thread: Thread,
72
+ applogsToHold: ApplogForInsert[],
73
+ opts: {
74
+ deduplicateHoldItemsWith?: typeof compareApplogsByEnAt
75
+ onFirstWrite: (heldLogsToInsert: Applog[]) => Applog[] | undefined
76
+ },
77
+ ) {
78
+ DEBUG(`[holdTillFirstWrite] holding logs:`, { applogsToHold })
79
+ if (thread.readOnly) throw ERROR(`[holdTillFirstWrite] but readOnly thread`, thread.nameAndSizeUntracked)
80
+ const heldLogs = ensureTsPvAndFinalizeApplogs([...applogsToHold], thread)
81
+ const heldThread = ThreadInMemory.fromArray(heldLogs)
82
+
83
+ let hasInserted = false
84
+ const handleInsert = (realApplogs: Applog[] | ApplogForInsert[]) => {
85
+ if (hasInserted) return realApplogs
86
+ hasInserted = true
87
+
88
+ let toInsert = [...heldLogs]
89
+ // heldLogs.splice(0, heldLogs.length) // ? joinThread could take care of this
90
+ if (opts.deduplicateHoldItemsWith) {
91
+ toInsert = toInsert.filter(heldLog =>
92
+ // some duplicate? so don't insert
93
+ !realApplogs.some(realLog => opts.deduplicateHoldItemsWith(heldLog, realLog))
94
+ )
95
+ }
96
+ if (opts.onFirstWrite) {
97
+ const callbackResult = opts.onFirstWrite(toInsert)
98
+ if (callbackResult !== undefined) {
99
+ toInsert = callbackResult
100
+ }
101
+ }
102
+ DEBUG(`[holdTillFirstWrite] adding hold logs:`, { applogsToHold, heldLogs, toInsert, realApplogs })
103
+ return [...toInsert, ...realApplogs]
104
+ }
105
+ const joinedThread = joinThreads([thread, heldThread])
106
+ return new MappedThread(
107
+ `HoldTillFirstWrite[${applogsToHold.length}]<${thread.nameAndSizeUntracked}>`,
108
+ joinedThread,
109
+ thread.filters,
110
+ null, // no derivation — shares joinedThread's array
111
+ (applogs) => {
112
+ const logsToInsert = handleInsert(applogs)
113
+ thread.insert(logsToInsert)
114
+ return null
115
+ },
116
+ )
117
+ }
118
+
119
+ export function getAgents(thread: Thread) {
120
+ return uniq(thread.map(l => l.ag))
121
+ }
122
+
123
+ /**
124
+ * Returns applogs NOT contained in the exclusion set.
125
+ * Uses CID-based comparison (consistent with removeDuplicateAppLogs).
126
+ */
127
+ export const excludeApplogsContainedIn = (
128
+ applogs: readonly Applog[],
129
+ exclude: Thread | readonly Applog[] | Set<CID | CidString>,
130
+ ): Applog[] => {
131
+ const excludeCids: Set<string> = exclude instanceof Set
132
+ ? new Set([...exclude].map(c => c.toString()))
133
+ : exclude instanceof Thread
134
+ ? new Set(exclude.applogsCids)
135
+ : new Set(exclude.map(a => a.cid))
136
+
137
+ return applogs.filter(applog => {
138
+ if (!applog.cid) {
139
+ ERROR(`applog with missing CID`, applog)
140
+ throw new Error(`applog with missing CID`)
141
+ }
142
+ return !excludeCids.has(applog.cid)
143
+ })
144
+ }