@wovin/core 0.0.0-ciao-mobx-955482e8

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 (180) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +3 -0
  3. package/dist/applog/applog-helpers.d.ts +47 -0
  4. package/dist/applog/applog-helpers.d.ts.map +1 -0
  5. package/dist/applog/applog-utils.d.ts +57 -0
  6. package/dist/applog/applog-utils.d.ts.map +1 -0
  7. package/dist/applog/datom-types.d.ts +128 -0
  8. package/dist/applog/datom-types.d.ts.map +1 -0
  9. package/dist/applog.d.ts +4 -0
  10. package/dist/applog.d.ts.map +1 -0
  11. package/dist/applog.js +101 -0
  12. package/dist/applog.js.map +1 -0
  13. package/dist/blockstore/index.d.ts +21 -0
  14. package/dist/blockstore/index.d.ts.map +1 -0
  15. package/dist/blockstore.d.ts +2 -0
  16. package/dist/blockstore.d.ts.map +1 -0
  17. package/dist/blockstore.js +24 -0
  18. package/dist/blockstore.js.map +1 -0
  19. package/dist/chunk-6MQKRL6W.js +86 -0
  20. package/dist/chunk-6MQKRL6W.js.map +1 -0
  21. package/dist/chunk-7MW34UEO.js +40 -0
  22. package/dist/chunk-7MW34UEO.js.map +1 -0
  23. package/dist/chunk-7Z5YDQKK.js +1 -0
  24. package/dist/chunk-7Z5YDQKK.js.map +1 -0
  25. package/dist/chunk-CY4NLISM.js +144 -0
  26. package/dist/chunk-CY4NLISM.js.map +1 -0
  27. package/dist/chunk-E46VTKTZ.js +1 -0
  28. package/dist/chunk-E46VTKTZ.js.map +1 -0
  29. package/dist/chunk-O43W7UW6.js +434 -0
  30. package/dist/chunk-O43W7UW6.js.map +1 -0
  31. package/dist/chunk-XIQSYEV3.js +1604 -0
  32. package/dist/chunk-XIQSYEV3.js.map +1 -0
  33. package/dist/chunk-XVGW4QC3.js +55 -0
  34. package/dist/chunk-XVGW4QC3.js.map +1 -0
  35. package/dist/chunk-YDAKBU6Q.js +9 -0
  36. package/dist/chunk-YDAKBU6Q.js.map +1 -0
  37. package/dist/chunk-ZAADLBSB.js +36 -0
  38. package/dist/chunk-ZAADLBSB.js.map +1 -0
  39. package/dist/chunk-ZXCJRYD7.js +883 -0
  40. package/dist/chunk-ZXCJRYD7.js.map +1 -0
  41. package/dist/index.d.ts +8 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +354 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/ipfs/car.d.ts +59 -0
  46. package/dist/ipfs/car.d.ts.map +1 -0
  47. package/dist/ipfs/fetch-snapshot-chain.d.ts +32 -0
  48. package/dist/ipfs/fetch-snapshot-chain.d.ts.map +1 -0
  49. package/dist/ipfs/ipfs-utils.d.ts +35 -0
  50. package/dist/ipfs/ipfs-utils.d.ts.map +1 -0
  51. package/dist/ipfs.d.ts +4 -0
  52. package/dist/ipfs.d.ts.map +1 -0
  53. package/dist/ipfs.js +60 -0
  54. package/dist/ipfs.js.map +1 -0
  55. package/dist/ipns/ipns-record.d.ts +34 -0
  56. package/dist/ipns/ipns-record.d.ts.map +1 -0
  57. package/dist/ipns.d.ts +2 -0
  58. package/dist/ipns.d.ts.map +1 -0
  59. package/dist/ipns.js +64 -0
  60. package/dist/ipns.js.map +1 -0
  61. package/dist/pubsub/connector.d.ts +9 -0
  62. package/dist/pubsub/connector.d.ts.map +1 -0
  63. package/dist/pubsub/pub-pull.d.ts +14 -0
  64. package/dist/pubsub/pub-pull.d.ts.map +1 -0
  65. package/dist/pubsub/pubsub-types.d.ts +72 -0
  66. package/dist/pubsub/pubsub-types.d.ts.map +1 -0
  67. package/dist/pubsub/snap-push.d.ts +41 -0
  68. package/dist/pubsub/snap-push.d.ts.map +1 -0
  69. package/dist/pubsub/ucan-example.d.ts +3 -0
  70. package/dist/pubsub/ucan-example.d.ts.map +1 -0
  71. package/dist/pubsub/ucan.d.ts +16 -0
  72. package/dist/pubsub/ucan.d.ts.map +1 -0
  73. package/dist/pubsub.d.ts +5 -0
  74. package/dist/pubsub.d.ts.map +1 -0
  75. package/dist/pubsub.js +31 -0
  76. package/dist/pubsub.js.map +1 -0
  77. package/dist/query/basic.d.ts +105 -0
  78. package/dist/query/basic.d.ts.map +1 -0
  79. package/dist/query/divergences.d.ts +12 -0
  80. package/dist/query/divergences.d.ts.map +1 -0
  81. package/dist/query/matchers.d.ts +4 -0
  82. package/dist/query/matchers.d.ts.map +1 -0
  83. package/dist/query/memoized.d.ts +66 -0
  84. package/dist/query/memoized.d.ts.map +1 -0
  85. package/dist/query/query-steps.d.ts +4 -0
  86. package/dist/query/query-steps.d.ts.map +1 -0
  87. package/dist/query/situations.d.ts +80 -0
  88. package/dist/query/situations.d.ts.map +1 -0
  89. package/dist/query/subscribable.d.ts +102 -0
  90. package/dist/query/subscribable.d.ts.map +1 -0
  91. package/dist/query/types.d.ts +70 -0
  92. package/dist/query/types.d.ts.map +1 -0
  93. package/dist/query.d.ts +8 -0
  94. package/dist/query.d.ts.map +1 -0
  95. package/dist/query.js +108 -0
  96. package/dist/query.js.map +1 -0
  97. package/dist/retrieve/index.d.ts +2 -0
  98. package/dist/retrieve/index.d.ts.map +1 -0
  99. package/dist/retrieve/update-thread.d.ts +64 -0
  100. package/dist/retrieve/update-thread.d.ts.map +1 -0
  101. package/dist/retrieve.d.ts +2 -0
  102. package/dist/retrieve.d.ts.map +1 -0
  103. package/dist/retrieve.js +14 -0
  104. package/dist/retrieve.js.map +1 -0
  105. package/dist/thread/basic.d.ts +60 -0
  106. package/dist/thread/basic.d.ts.map +1 -0
  107. package/dist/thread/filters.d.ts +47 -0
  108. package/dist/thread/filters.d.ts.map +1 -0
  109. package/dist/thread/mapped.d.ts +31 -0
  110. package/dist/thread/mapped.d.ts.map +1 -0
  111. package/dist/thread/utils.d.ts +23 -0
  112. package/dist/thread/utils.d.ts.map +1 -0
  113. package/dist/thread/writeable.d.ts +41 -0
  114. package/dist/thread/writeable.d.ts.map +1 -0
  115. package/dist/thread.d.ts +6 -0
  116. package/dist/thread.d.ts.map +1 -0
  117. package/dist/thread.js +54 -0
  118. package/dist/thread.js.map +1 -0
  119. package/dist/types/typescript-utils.d.ts +34 -0
  120. package/dist/types/typescript-utils.d.ts.map +1 -0
  121. package/dist/types.d.ts +2 -0
  122. package/dist/types.d.ts.map +1 -0
  123. package/dist/types.js +26 -0
  124. package/dist/types.js.map +1 -0
  125. package/dist/utils/debug-name.d.ts +13 -0
  126. package/dist/utils/debug-name.d.ts.map +1 -0
  127. package/dist/utils.d.ts +4 -0
  128. package/dist/utils.d.ts.map +1 -0
  129. package/dist/utils.js +9 -0
  130. package/dist/utils.js.map +1 -0
  131. package/package.json +110 -0
  132. package/src/applog/applog-helpers.ts +150 -0
  133. package/src/applog/applog-utils.ts +398 -0
  134. package/src/applog/datom-types.ts +148 -0
  135. package/src/applog.ts +3 -0
  136. package/src/blockstore/index.ts +36 -0
  137. package/src/blockstore.ts +1 -0
  138. package/src/index.ts +8 -0
  139. package/src/ipfs/car.ts +291 -0
  140. package/src/ipfs/fetch-snapshot-chain.ts +135 -0
  141. package/src/ipfs/ipfs-utils.ts +132 -0
  142. package/src/ipfs.ts +3 -0
  143. package/src/ipns/ipns-record.ts +115 -0
  144. package/src/ipns.ts +1 -0
  145. package/src/pubsub/UCAN Specs Overview.md +217 -0
  146. package/src/pubsub/connector.ts +9 -0
  147. package/src/pubsub/pub-pull.ts +31 -0
  148. package/src/pubsub/pubsub-types.ts +90 -0
  149. package/src/pubsub/snap-push.ts +277 -0
  150. package/src/pubsub/ucan-example.ts +61 -0
  151. package/src/pubsub/ucan.ts +56 -0
  152. package/src/pubsub.ts +4 -0
  153. package/src/query/basic.ts +1061 -0
  154. package/src/query/divergences.ts +50 -0
  155. package/src/query/matchers.ts +8 -0
  156. package/src/query/memoized.test.ts +151 -0
  157. package/src/query/memoized.ts +180 -0
  158. package/src/query/query-steps.ts +4 -0
  159. package/src/query/query.test.ts +536 -0
  160. package/src/query/situations.ts +261 -0
  161. package/src/query/subscribable.test.ts +245 -0
  162. package/src/query/subscribable.ts +225 -0
  163. package/src/query/types.ts +155 -0
  164. package/src/query.ts +7 -0
  165. package/src/retrieve/index.ts +1 -0
  166. package/src/retrieve/update-thread.ts +248 -0
  167. package/src/retrieve.ts +1 -0
  168. package/src/test/perf/query.1m.perf.test.ts +94 -0
  169. package/src/test/perf/query.perf.test.ts +389 -0
  170. package/src/test/perf/query.realdata.perf.test.ts +175 -0
  171. package/src/thread/basic.ts +209 -0
  172. package/src/thread/filters.ts +234 -0
  173. package/src/thread/mapped.ts +166 -0
  174. package/src/thread/utils.ts +146 -0
  175. package/src/thread/writeable.ts +163 -0
  176. package/src/thread.ts +5 -0
  177. package/src/types/typescript-utils.ts +64 -0
  178. package/src/types.ts +1 -0
  179. package/src/utils/debug-name.ts +54 -0
  180. package/src/utils.ts +4 -0
@@ -0,0 +1,261 @@
1
+ import { Applog } from '../applog/datom-types.ts'
2
+ import { query } from './basic.ts'
3
+ import { QueryNode } from './types.ts'
4
+ import { Logger } from 'besonders-logger'
5
+
6
+ const { WARN, LOG, DEBUG, VERBOSE, ERROR } = Logger.setup(Logger.INFO) // eslint-disable-line no-unused-vars
7
+
8
+ /**
9
+ * Situations are meant to flag and possibly autocorrect or autosuggest solutions
10
+ *
11
+ * Types of Situations:
12
+ * Divergences = leaf nodes that are based on different previous versions/contexts
13
+ * Conflicts/Disagreement = different agents setting the same attribute (based on same previous)
14
+ * Overwrite = one agent's action implicitly overrides another's without explicit confirmation
15
+ * Conscious Disagreement = agents explicitly acknowledge and accept conflicting changes
16
+ * Suggestions = recommendations for improvements or alternatives
17
+ */
18
+ interface Situation {
19
+ name: string
20
+ desc: string
21
+ intensity: number
22
+ type: typeof SituationTypes[keyof typeof SituationTypes]
23
+ query: typeof query // wovin can export common examples
24
+ resolutionOptions: Resolution[]
25
+ metadata?: {
26
+ agentId?: string
27
+ timestamp?: number
28
+ context?: Record<string, any>
29
+ }
30
+ }
31
+
32
+ interface QueryResultWithSituations {
33
+ result: any
34
+ situations: Situation[]
35
+ threadView?: ThreadView
36
+ }
37
+
38
+ interface ThreadView {
39
+ id: string
40
+ situations: SituatedMessage[]
41
+ resolutionState: ResolutionState
42
+ }
43
+
44
+ interface SituatedMessage {
45
+ id: string
46
+ situation: Situation
47
+ message: string
48
+ resolutions: ResolutionAction[]
49
+ timestamp: number
50
+ agentId?: string
51
+ }
52
+
53
+ interface ResolutionState {
54
+ pending: string[] // situation IDs
55
+ resolved: string[] // situation IDs
56
+ ignored: string[] // situation IDs
57
+ }
58
+
59
+ interface ResolutionAction {
60
+ name: string
61
+ applied: boolean
62
+ appliedAt?: number
63
+ appliedBy?: string
64
+ }
65
+
66
+ const SituationTypes = {
67
+ divergence: 'divergence',
68
+ disagreement: 'disagreement',
69
+ overwrite: 'overwrite',
70
+ consciousDisagreement: 'consciousDisagreement',
71
+ suggestion: 'suggestion',
72
+ } as const
73
+ interface Resolution {
74
+ name: string
75
+ desc: string
76
+ assertion: () => Applog[]
77
+ }
78
+ const ContentDivergence = {
79
+ name: 'ContentDivergence',
80
+ desc: 'block/content changes from different agents based on different pv logs',
81
+ intensity: 10,
82
+ type: SituationTypes.divergence,
83
+ query: QueryNode,
84
+ resolutionOptions: [],
85
+ }
86
+
87
+ const AttributeOverwrite = {
88
+ name: 'AttributeOverwrite',
89
+ desc: 'one agent implicitly overwrites another agent\'s attribute change without confirmation',
90
+ intensity: 8,
91
+ type: SituationTypes.overwrite,
92
+ query: QueryNode,
93
+ resolutionOptions: [
94
+ {
95
+ name: 'restore',
96
+ desc: 'restore the previous value',
97
+ assertion: () => [] // TODO: implement restoration logic
98
+ },
99
+ {
100
+ name: 'confirm',
101
+ desc: 'confirm the overwrite as intentional',
102
+ assertion: () => [] // TODO: implement confirmation logic
103
+ }
104
+ ],
105
+ }
106
+
107
+ const ConsciousDisagreement = {
108
+ name: 'ConsciousDisagreement',
109
+ desc: 'agents explicitly acknowledge and accept conflicting changes to the same attribute',
110
+ intensity: 5,
111
+ type: SituationTypes.consciousDisagreement,
112
+ query: QueryNode,
113
+ resolutionOptions: [
114
+ {
115
+ name: 'merge',
116
+ desc: 'create a merged version that incorporates both perspectives',
117
+ assertion: () => [] // TODO: implement merge logic
118
+ },
119
+ {
120
+ name: 'branch',
121
+ desc: 'create separate branches for each perspective',
122
+ assertion: () => [] // TODO: implement branching logic
123
+ }
124
+ ],
125
+ }
126
+
127
+ const ContentSuggestion = {
128
+ name: 'ContentSuggestion',
129
+ desc: 'AI or agent suggests improvements to existing content',
130
+ intensity: 3,
131
+ type: SituationTypes.suggestion,
132
+ query: QueryNode,
133
+ resolutionOptions: [
134
+ {
135
+ name: 'accept',
136
+ desc: 'accept the suggestion and apply changes',
137
+ assertion: () => [] // TODO: implement acceptance logic
138
+ },
139
+ {
140
+ name: 'reject',
141
+ desc: 'reject the suggestion',
142
+ assertion: () => [] // TODO: implement rejection logic
143
+ },
144
+ {
145
+ name: 'modify',
146
+ desc: 'accept with modifications',
147
+ assertion: () => [] // TODO: implement modification logic
148
+ }
149
+ ],
150
+ }
151
+
152
+ /**
153
+ * Creates a thread view for situations detected in query results
154
+ */
155
+ export function createThreadForSituations(
156
+ result: any,
157
+ situations: Situation[],
158
+ options: {
159
+ threadId?: string
160
+ groupByType?: boolean
161
+ sortByIntensity?: boolean
162
+ } = {}
163
+ ): QueryResultWithSituations {
164
+ const threadId = options.threadId || `thread_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
165
+
166
+ // Sort situations if requested
167
+ let sortedSituations = [...situations]
168
+ if (options.sortByIntensity) {
169
+ sortedSituations.sort((a, b) => b.intensity - a.intensity)
170
+ }
171
+
172
+ // Group by type if requested
173
+ const threadMessages: SituatedMessage[] = sortedSituations.map((situation, index) => ({
174
+ id: `${threadId}_msg_${index}`,
175
+ situation,
176
+ message: formatSituationMessage(situation),
177
+ resolutions: situation.resolutionOptions.map(option => ({
178
+ name: option.name,
179
+ applied: false,
180
+ })),
181
+ timestamp: Date.now(),
182
+ agentId: situation.metadata?.agentId,
183
+ }))
184
+
185
+ const threadView: ThreadView = {
186
+ id: threadId,
187
+ situations: threadMessages,
188
+ resolutionState: {
189
+ pending: threadMessages.map(msg => msg.id),
190
+ resolved: [],
191
+ ignored: [],
192
+ },
193
+ }
194
+
195
+ return {
196
+ result,
197
+ situations,
198
+ threadView,
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Formats a situation into a human-readable message for the thread
204
+ */
205
+ function formatSituationMessage(situation: Situation): string {
206
+ const typeEmoji = {
207
+ [SituationTypes.divergence]: '🔀',
208
+ [SituationTypes.disagreement]: '⚠️',
209
+ [SituationTypes.overwrite]: '🔄',
210
+ [SituationTypes.consciousDisagreement]: '💭',
211
+ [SituationTypes.suggestion]: '💡',
212
+ }
213
+
214
+ const emoji = typeEmoji[situation.type] || '❓'
215
+ return `${emoji} **${situation.name}**: ${situation.desc} (intensity: ${situation.intensity})`
216
+ }
217
+
218
+ /**
219
+ * Applies a resolution action to a situation in a thread
220
+ */
221
+ export function applyResolution(
222
+ threadView: ThreadView,
223
+ messageId: string,
224
+ resolutionName: string,
225
+ appliedBy: string
226
+ ): ThreadView {
227
+ const updatedMessages = threadView.situations.map(msg => {
228
+ if (msg.id === messageId) {
229
+ const updatedResolutions = msg.resolutions.map(res =>
230
+ res.name === resolutionName
231
+ ? { ...res, applied: true, appliedAt: Date.now(), appliedBy }
232
+ : res
233
+ )
234
+
235
+ return {
236
+ ...msg,
237
+ resolutions: updatedResolutions,
238
+ }
239
+ }
240
+ return msg
241
+ })
242
+
243
+ const message = updatedMessages.find(msg => msg.id === messageId)
244
+ const hasAppliedResolution = message?.resolutions.some(res => res.applied)
245
+
246
+ const resolutionState = {
247
+ ...threadView.resolutionState,
248
+ pending: hasAppliedResolution
249
+ ? threadView.resolutionState.pending.filter(id => id !== messageId)
250
+ : threadView.resolutionState.pending,
251
+ resolved: hasAppliedResolution
252
+ ? [...threadView.resolutionState.resolved, messageId]
253
+ : threadView.resolutionState.resolved,
254
+ }
255
+
256
+ return {
257
+ ...threadView,
258
+ situations: updatedMessages,
259
+ resolutionState,
260
+ }
261
+ }
@@ -0,0 +1,245 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { ArrayEvent, isArrayInitEvent, SubscribableArrayImpl, SubscribableImpl } from './subscribable.ts'
3
+
4
+ describe('SubscribableImpl', () => {
5
+ it('provides value synchronously', () => {
6
+ const s = new SubscribableImpl(42)
7
+ expect(s.value).toBe(42)
8
+ })
9
+
10
+ it('does NOT fire callback on subscribe', () => {
11
+ const s = new SubscribableImpl('hello')
12
+ const calls: string[] = []
13
+ s.subscribe(() => calls.push(s.value))
14
+ expect(calls).toEqual([])
15
+ })
16
+
17
+ it('notifies on _set', () => {
18
+ const s = new SubscribableImpl(1)
19
+ const values: number[] = []
20
+ s.subscribe(() => values.push(s.value))
21
+ s._set(2)
22
+ s._set(3)
23
+ expect(values).toEqual([2, 3])
24
+ expect(s.value).toBe(3)
25
+ })
26
+
27
+ it('activates upstream lazily on first subscribe', () => {
28
+ const deactivate = vi.fn()
29
+ const activate = vi.fn(() => deactivate)
30
+ const s = new SubscribableImpl(0, activate)
31
+
32
+ expect(activate).not.toHaveBeenCalled()
33
+ const unsub = s.subscribe(() => {})
34
+ expect(activate).toHaveBeenCalledOnce()
35
+
36
+ // Second subscriber doesn't re-activate
37
+ const unsub2 = s.subscribe(() => {})
38
+ expect(activate).toHaveBeenCalledOnce()
39
+
40
+ // Unsubscribe one — upstream stays active
41
+ unsub2()
42
+ expect(deactivate).not.toHaveBeenCalled()
43
+
44
+ // Unsubscribe last — upstream deactivates
45
+ unsub()
46
+ expect(deactivate).toHaveBeenCalledOnce()
47
+ })
48
+
49
+ it('re-activates upstream after full unsubscribe + re-subscribe', () => {
50
+ const deactivate = vi.fn()
51
+ const activate = vi.fn(() => deactivate)
52
+ const s = new SubscribableImpl(0, activate)
53
+
54
+ const unsub1 = s.subscribe(() => {})
55
+ unsub1()
56
+ expect(activate).toHaveBeenCalledTimes(1)
57
+ expect(deactivate).toHaveBeenCalledTimes(1)
58
+
59
+ const unsub2 = s.subscribe(() => {})
60
+ expect(activate).toHaveBeenCalledTimes(2)
61
+ unsub2()
62
+ expect(deactivate).toHaveBeenCalledTimes(2)
63
+ })
64
+
65
+ it('snapshots subscribers to handle unsubscribe during notification', () => {
66
+ const s = new SubscribableImpl(0)
67
+ const events: string[] = []
68
+ let unsub2: (() => void) | null = null
69
+
70
+ s.subscribe(() => {
71
+ events.push('sub1')
72
+ unsub2?.()
73
+ })
74
+ unsub2 = s.subscribe(() => {
75
+ events.push('sub2')
76
+ })
77
+
78
+ // No immediate callbacks — nothing fired yet
79
+ expect(events).toEqual([])
80
+
81
+ // _set — sub1 runs and unsubscribes sub2, but sub2 still fires (snapshot)
82
+ s._set(1)
83
+ expect(events).toEqual(['sub1', 'sub2'])
84
+ })
85
+
86
+ it('dispose tears down upstream and clears subscribers', () => {
87
+ const deactivate = vi.fn()
88
+ const activate = vi.fn(() => deactivate)
89
+ const s = new SubscribableImpl(0, activate)
90
+
91
+ s.subscribe(() => {})
92
+ expect(activate).toHaveBeenCalledOnce()
93
+
94
+ s.dispose()
95
+ expect(deactivate).toHaveBeenCalledOnce()
96
+
97
+ // _set after dispose — no error, no subscribers
98
+ s._set(99)
99
+ expect(s.value).toBe(99)
100
+ })
101
+
102
+ it('works with complex types', () => {
103
+ const s = new SubscribableImpl<{ name: string } | null>(null)
104
+ const values: ({ name: string } | null)[] = []
105
+ s.subscribe(() => values.push(s.value))
106
+ s._set({ name: 'Alice' })
107
+ s._set(null)
108
+ expect(values).toEqual([{ name: 'Alice' }, null])
109
+ })
110
+ })
111
+
112
+ describe('SubscribableArrayImpl', () => {
113
+ it('provides items synchronously', () => {
114
+ const arr = new SubscribableArrayImpl([1, 2, 3])
115
+ expect(arr.items).toEqual([1, 2, 3])
116
+ expect(arr.length).toBe(3)
117
+ })
118
+
119
+ it('does NOT send init event on subscribe', () => {
120
+ const arr = new SubscribableArrayImpl([1, 2, 3])
121
+ const events: ArrayEvent<number>[] = []
122
+ arr.subscribe(e => events.push(e))
123
+ expect(events).toEqual([])
124
+ // Current state is available via .items
125
+ expect(arr.items).toEqual([1, 2, 3])
126
+ })
127
+
128
+ it('notifies on _push', () => {
129
+ const arr = new SubscribableArrayImpl([1])
130
+ const events: ArrayEvent<number>[] = []
131
+ arr.subscribe(e => events.push(e))
132
+ arr._push(2, 3)
133
+ expect(arr.items).toEqual([1, 2, 3])
134
+ expect(events).toEqual([
135
+ { added: [2, 3], removed: null },
136
+ ])
137
+ })
138
+
139
+ it('notifies on _remove', () => {
140
+ const arr = new SubscribableArrayImpl([1, 2, 3])
141
+ const events: ArrayEvent<number>[] = []
142
+ arr.subscribe(e => events.push(e))
143
+ arr._remove([2])
144
+ expect(arr.items).toEqual([1, 3])
145
+ expect(events).toEqual([
146
+ { added: [], removed: [2] },
147
+ ])
148
+ })
149
+
150
+ it('notifies on _reset', () => {
151
+ const arr = new SubscribableArrayImpl([1, 2])
152
+ const events: ArrayEvent<number>[] = []
153
+ arr.subscribe(e => events.push(e))
154
+ arr._reset([10, 20])
155
+ expect(arr.items).toEqual([10, 20])
156
+ expect(events).toEqual([
157
+ { init: [10, 20] },
158
+ ])
159
+ })
160
+
161
+ it('activates upstream lazily on first subscribe', () => {
162
+ const deactivate = vi.fn()
163
+ const activate = vi.fn(() => deactivate)
164
+ const arr = new SubscribableArrayImpl([1], activate)
165
+
166
+ expect(activate).not.toHaveBeenCalled()
167
+ const unsub = arr.subscribe(() => {})
168
+ expect(activate).toHaveBeenCalledOnce()
169
+
170
+ // Second subscriber doesn't re-activate
171
+ const unsub2 = arr.subscribe(() => {})
172
+ expect(activate).toHaveBeenCalledOnce()
173
+
174
+ // Unsubscribe one — upstream stays active
175
+ unsub2()
176
+ expect(deactivate).not.toHaveBeenCalled()
177
+
178
+ // Unsubscribe last — upstream deactivates
179
+ unsub()
180
+ expect(deactivate).toHaveBeenCalledOnce()
181
+ })
182
+
183
+ it('re-activates upstream after full unsubscribe + re-subscribe', () => {
184
+ const deactivate = vi.fn()
185
+ const activate = vi.fn(() => deactivate)
186
+ const arr = new SubscribableArrayImpl([1], activate)
187
+
188
+ const unsub1 = arr.subscribe(() => {})
189
+ unsub1()
190
+ expect(activate).toHaveBeenCalledTimes(1)
191
+ expect(deactivate).toHaveBeenCalledTimes(1)
192
+
193
+ // Re-subscribe should re-activate
194
+ const unsub2 = arr.subscribe(() => {})
195
+ expect(activate).toHaveBeenCalledTimes(2)
196
+ unsub2()
197
+ expect(deactivate).toHaveBeenCalledTimes(2)
198
+ })
199
+
200
+ it('snapshots subscribers to handle unsubscribe during notification', () => {
201
+ const arr = new SubscribableArrayImpl<number>([])
202
+ const events: string[] = []
203
+ let unsub2: (() => void) | null = null
204
+
205
+ arr.subscribe(() => {
206
+ events.push('sub1')
207
+ // Unsubscribe sub2 during notification
208
+ unsub2?.()
209
+ })
210
+ unsub2 = arr.subscribe(() => {
211
+ events.push('sub2')
212
+ })
213
+
214
+ // No init events — nothing fired yet
215
+ expect(events).toEqual([])
216
+
217
+ // Push — sub1 runs and unsubscribes sub2, but sub2 should still receive this event
218
+ // (because we snapshot subscribers before iterating)
219
+ arr._push(1)
220
+ expect(events).toEqual(['sub1', 'sub2'])
221
+ })
222
+
223
+ it('dispose tears down upstream and clears subscribers', () => {
224
+ const deactivate = vi.fn()
225
+ const activate = vi.fn(() => deactivate)
226
+ const arr = new SubscribableArrayImpl([1], activate)
227
+
228
+ arr.subscribe(() => {})
229
+ expect(activate).toHaveBeenCalledOnce()
230
+
231
+ arr.dispose()
232
+ expect(deactivate).toHaveBeenCalledOnce()
233
+
234
+ // Push after dispose — no subscribers to notify (no error)
235
+ arr._push(2)
236
+ expect(arr.items).toEqual([1, 2])
237
+ })
238
+ })
239
+
240
+ describe('isArrayInitEvent', () => {
241
+ it('identifies init events', () => {
242
+ expect(isArrayInitEvent({ init: [1, 2] })).toBe(true)
243
+ expect(isArrayInitEvent({ added: [1], removed: null })).toBe(false)
244
+ })
245
+ })