@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,536 @@
1
+ /**
2
+ * Query engine test suite — tests the push-based query system.
3
+ *
4
+ * Part 1: Non-reactive (snapshot) tests — verify query correctness without subscriptions
5
+ * Part 2: Reactive tests — verify subscribe, insert, event propagation, lazy activation
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8
+ import { sortApplogsByTs } from '../applog/applog-utils.ts'
9
+ import type { Applog, ApplogForInsert } from '../applog/datom-types.ts'
10
+ import { isInitEvent } from '../thread/basic.ts'
11
+ import { ThreadInMemory } from '../thread/writeable.ts'
12
+ import { liveQuery, liveQueryNot, query, queryNot } from './basic.ts'
13
+ import { isArrayInitEvent, type ArrayEvent } from './subscribable.ts'
14
+ import { QueryNode, QueryResult, LiveQueryResult } from './types.ts'
15
+
16
+ // ─── Test Data ───────────────────────────────────────────────────
17
+ // A small movie database — same schema as note3's test-applogs but
18
+ // with explicit CIDs and a smaller dataset for focused testing.
19
+
20
+ function makeApplogs(inputs: ApplogForInsert[]): Applog[] {
21
+ // Cast to Applog[] — ThreadInMemory.fromArray accepts this (CIDs are absent,
22
+ // same as note3's existing test pattern). Validation is loose for initial data.
23
+ const logs = inputs.map(input => ({
24
+ ts: new Date().toISOString(),
25
+ pv: null,
26
+ ag: 'testAgent',
27
+ ...input,
28
+ })) as Applog[]
29
+ sortApplogsByTs(logs)
30
+ return logs
31
+ }
32
+
33
+ // Base dataset
34
+ const baseData: ApplogForInsert[] = [
35
+ // People
36
+ { en: 'p1', at: 'person/name', vl: 'James Cameron', ag: 'testAgent' },
37
+ { en: 'p2', at: 'person/name', vl: 'Arnold Schwarzenegger', ag: 'testAgent' },
38
+ { en: 'p3', at: 'person/name', vl: 'Linda Hamilton', ag: 'testAgent' },
39
+ { en: 'p4', at: 'person/name', vl: 'John McTiernan', ag: 'testAgent' },
40
+ { en: 'p5', at: 'person/name', vl: 'Bruce Willis', ag: 'testAgent' },
41
+
42
+ // Movies
43
+ { en: 'm1', at: 'movie/title', vl: 'The Terminator', ag: 'testAgent' },
44
+ { en: 'm1', at: 'movie/year', vl: 1984, ag: 'testAgent' },
45
+ { en: 'm1', at: 'movie/director', vl: 'p1', ag: 'testAgent' },
46
+ { en: 'm1', at: 'movie/cast', vl: 'p2', ag: 'testAgent' },
47
+ { en: 'm1', at: 'movie/cast', vl: 'p3', ag: 'testAgent' },
48
+
49
+ { en: 'm2', at: 'movie/title', vl: 'Predator', ag: 'testAgent' },
50
+ { en: 'm2', at: 'movie/year', vl: 1987, ag: 'testAgent' },
51
+ { en: 'm2', at: 'movie/director', vl: 'p4', ag: 'testAgent' },
52
+ { en: 'm2', at: 'movie/cast', vl: 'p2', ag: 'testAgent' },
53
+
54
+ { en: 'm3', at: 'movie/title', vl: 'Die Hard', ag: 'testAgent' },
55
+ { en: 'm3', at: 'movie/year', vl: 1988, ag: 'testAgent' },
56
+ { en: 'm3', at: 'movie/director', vl: 'p4', ag: 'testAgent' },
57
+ { en: 'm3', at: 'movie/cast', vl: 'p5', ag: 'testAgent' },
58
+
59
+ { en: 'm4', at: 'movie/title', vl: 'T2: Judgment Day', ag: 'testAgent' },
60
+ { en: 'm4', at: 'movie/year', vl: 1991, ag: 'testAgent' },
61
+ { en: 'm4', at: 'movie/director', vl: 'p1', ag: 'testAgent' },
62
+ { en: 'm4', at: 'movie/cast', vl: 'p2', ag: 'testAgent' },
63
+ { en: 'm4', at: 'movie/cast', vl: 'p3', ag: 'testAgent' },
64
+ ]
65
+
66
+ // ─── Helpers ─────────────────────────────────────────────────────
67
+
68
+ let db: ReturnType<typeof ThreadInMemory.fromArray>
69
+
70
+ beforeEach(() => {
71
+ db = ThreadInMemory.fromArray(makeApplogs(baseData), 'test-movies')
72
+ })
73
+
74
+ afterEach(() => {
75
+ // Fresh db is created in beforeEach — no global state to reset
76
+ })
77
+
78
+ // ═════════════════════════════════════════════════════════════════
79
+ // PART 1: Non-reactive (snapshot) tests
80
+ // ═════════════════════════════════════════════════════════════════
81
+
82
+ describe('query engine — non-reactive', () => {
83
+ describe('single-step query', () => {
84
+ it('finds all movies by year', () => {
85
+ const result = query(db, [{ at: 'movie/year', vl: 1987 }])
86
+ expect(result.nodes).toHaveLength(1)
87
+ expect(result.nodes[0].variables).toMatchObject({ })
88
+ expect(result.nodes[0].logsOfThisNode.applogs[0].en).toBe('m2')
89
+ })
90
+
91
+ it('finds all movies with a specific attribute', () => {
92
+ const result = query(db, [{ at: 'movie/title' }])
93
+ expect(result.nodes).toHaveLength(4) // 4 movies
94
+ })
95
+
96
+ it('finds by entity and attribute', () => {
97
+ const result = query(db, [{ en: 'p1', at: 'person/name' }])
98
+ expect(result.nodes).toHaveLength(1)
99
+ expect(result.nodes[0].logsOfThisNode.applogs[0].vl).toBe('James Cameron')
100
+ })
101
+
102
+ it('returns empty for no matches', () => {
103
+ const result = query(db, [{ at: 'movie/year', vl: 2099 }])
104
+ expect(result.nodes).toHaveLength(0)
105
+ expect(result.isEmpty).toBe(true)
106
+ expect(result.size).toBe(0)
107
+ })
108
+ })
109
+
110
+ describe('multi-step query (variable binding)', () => {
111
+ it('resolves variable across two steps', () => {
112
+ // Find directors of 1987 movies
113
+ const result = query(db, [
114
+ { en: '?movieId', at: 'movie/year', vl: 1987 },
115
+ { en: '?movieId', at: 'movie/director', vl: '?directorId' },
116
+ ])
117
+ expect(result.records).toEqual([
118
+ { movieId: 'm2', directorId: 'p4' },
119
+ ])
120
+ })
121
+
122
+ it('resolves three-step query (movie → director → name)', () => {
123
+ // Find names of directors of 1984 movies
124
+ const result = query(db, [
125
+ { en: '?movieId', at: 'movie/year', vl: 1984 },
126
+ { en: '?movieId', at: 'movie/director', vl: '?directorId' },
127
+ { en: '?directorId', at: 'person/name', vl: '?directorName' },
128
+ ])
129
+ expect(result.records).toEqual([
130
+ { movieId: 'm1', directorId: 'p1', directorName: 'James Cameron' },
131
+ ])
132
+ })
133
+
134
+ it('fan-out: multiple results per step', () => {
135
+ // Find all cast of 1984 movies
136
+ const result = query(db, [
137
+ { en: '?movieId', at: 'movie/year', vl: 1984 },
138
+ { en: '?movieId', at: 'movie/cast', vl: '?actorId' },
139
+ ])
140
+ expect(result.records).toHaveLength(2) // p2, p3
141
+ const actorIds = result.records.map(r => r.actorId).sort()
142
+ expect(actorIds).toEqual(['p2', 'p3'])
143
+ })
144
+
145
+ it('fan-out across multiple input nodes', () => {
146
+ // Arnold's movies: find movies where p2 is cast, get titles
147
+ const result = query(db, [
148
+ { en: '?movieId', at: 'movie/cast', vl: 'p2' },
149
+ { en: '?movieId', at: 'movie/title', vl: '?title' },
150
+ ])
151
+ const titles = result.records.map(r => r.title).sort()
152
+ expect(titles).toEqual(['Predator', 'T2: Judgment Day', 'The Terminator'])
153
+ })
154
+
155
+ it('four-step query', () => {
156
+ // Actor name → movies → director → director name
157
+ const result = query(db, [
158
+ { en: '?actorId', at: 'person/name', vl: 'Arnold Schwarzenegger' },
159
+ { en: '?movieId', at: 'movie/cast', vl: '?actorId' },
160
+ { en: '?movieId', at: 'movie/director', vl: '?directorId' },
161
+ { en: '?directorId', at: 'person/name', vl: '?directorName' },
162
+ ])
163
+ const directors = result.records.map(r => r.directorName).sort()
164
+ expect(directors).toEqual(['James Cameron', 'James Cameron', 'John McTiernan'])
165
+ })
166
+ })
167
+
168
+ describe('QueryResult derived getters', () => {
169
+ it('.records returns variable maps', () => {
170
+ const result = query(db, [{ en: '?id', at: 'movie/title', vl: '?title' }])
171
+ expect(result.records).toHaveLength(4)
172
+ expect(result.records[0]).toHaveProperty('id')
173
+ expect(result.records[0]).toHaveProperty('title')
174
+ })
175
+
176
+ it('.size and .isEmpty', () => {
177
+ const result = query(db, [{ at: 'movie/title' }])
178
+ expect(result.size).toBe(4)
179
+ expect(result.isEmpty).toBe(false)
180
+
181
+ const empty = query(db, [{ at: 'nonexistent' }])
182
+ expect(empty.size).toBe(0)
183
+ expect(empty.isEmpty).toBe(true)
184
+ })
185
+
186
+ it('.leafNodeLogs returns applogs of leaf nodes', () => {
187
+ const result = query(db, [{ en: 'm1', at: 'movie/title' }])
188
+ expect(result.leafNodeLogs).toHaveLength(1)
189
+ expect(result.leafNodeLogs[0].vl).toBe('The Terminator')
190
+ })
191
+
192
+ it.skip('.threadOfAllTrails joins all trail threads (requires CID-bearing applogs)', () => {
193
+ // Skipped: joinThreads → removeDuplicateAppLogs requires CIDs on applogs.
194
+ // Our test data omits CIDs (same as note3's test pattern).
195
+ // This getter delegates to joinThreads which is tested separately.
196
+ })
197
+ })
198
+
199
+ describe('one-off use (zero overhead)', () => {
200
+ it('query result is immediately available without subscribe', () => {
201
+ const result = query(db, [{ at: 'movie/title' }])
202
+ // No subscribe() call — items are available immediately
203
+ expect(result.nodes).toHaveLength(4)
204
+ expect(result.records).toHaveLength(4)
205
+ })
206
+
207
+ it('query returns QueryResult without subscribe/dispose', () => {
208
+ const result = query(db, [{ at: 'movie/title' }])
209
+ expect(result).toBeInstanceOf(QueryResult)
210
+ expect(result).not.toHaveProperty('subscribe')
211
+ expect(result).not.toHaveProperty('dispose')
212
+ })
213
+ })
214
+ })
215
+
216
+ // ═════════════════════════════════════════════════════════════════
217
+ // PART 2: Reactive tests
218
+ // ═════════════════════════════════════════════════════════════════
219
+
220
+ describe('liveQuery — reactive', () => {
221
+ describe('subscribe basics', () => {
222
+ it('liveQuery returns LiveQueryResult', () => {
223
+ const result = liveQuery(db, [{ at: 'movie/title' }])
224
+ expect(result).toBeInstanceOf(LiveQueryResult)
225
+ expect(result.nodes).toHaveLength(4)
226
+ result.dispose()
227
+ })
228
+
229
+ it('subscribe does NOT send init event — read .nodes for current state', () => {
230
+ const result = liveQuery(db, [{ at: 'movie/title' }])
231
+ const events: ArrayEvent<QueryNode>[] = []
232
+ const unsub = result.subscribe(e => events.push(e))
233
+
234
+ // No init event fired
235
+ expect(events).toHaveLength(0)
236
+ // Current state available via .nodes
237
+ expect(result.nodes).toHaveLength(4)
238
+ unsub()
239
+ result.dispose()
240
+ })
241
+
242
+ it('unsubscribe cleans up without error', () => {
243
+ const result = liveQuery(db, [{ at: 'movie/title' }])
244
+ const unsub = result.subscribe(() => {})
245
+ expect(() => unsub()).not.toThrow()
246
+ result.dispose()
247
+ })
248
+ })
249
+
250
+ describe('single-step reactive updates', () => {
251
+ it('receives added event after insert', () => {
252
+ const result = liveQuery(db, [{ at: 'movie/year', vl: 1987 }])
253
+ const events: ArrayEvent<QueryNode>[] = []
254
+ const unsub = result.subscribe(e => events.push(e))
255
+
256
+ expect(events).toHaveLength(0)
257
+ expect(result.nodes).toHaveLength(1)
258
+
259
+ db.insert([
260
+ { en: 'm5', at: 'movie/year', vl: 1987, ag: 'testAgent' },
261
+ ])
262
+
263
+ expect(events.length).toBeGreaterThan(0)
264
+ const addedEvents = events.filter(e => !isArrayInitEvent(e))
265
+ expect(addedEvents.length).toBeGreaterThan(0)
266
+ expect(result.nodes).toHaveLength(2)
267
+
268
+ unsub()
269
+ result.dispose()
270
+ })
271
+
272
+ it('items update live after insert', () => {
273
+ const result = liveQuery(db, [{ en: '?id', at: 'movie/title', vl: '?title' }])
274
+
275
+ const titlesBefore = result.records.map(r => r.title).sort()
276
+ expect(titlesBefore).toEqual(['Die Hard', 'Predator', 'T2: Judgment Day', 'The Terminator'])
277
+
278
+ db.insert([
279
+ { en: 'm5', at: 'movie/title', vl: 'Aliens', ag: 'testAgent' },
280
+ ])
281
+
282
+ const titlesAfter = result.records.map(r => r.title).sort()
283
+ expect(titlesAfter).toEqual(['Aliens', 'Die Hard', 'Predator', 'T2: Judgment Day', 'The Terminator'])
284
+
285
+ result.dispose()
286
+ })
287
+
288
+ it('does not receive events after unsubscribe', () => {
289
+ const result = liveQuery(db, [{ at: 'movie/year', vl: 1987 }])
290
+ const cb = vi.fn()
291
+ const unsub = result.subscribe(cb)
292
+
293
+ expect(cb).toHaveBeenCalledTimes(0)
294
+ unsub()
295
+
296
+ db.insert([
297
+ { en: 'm5', at: 'movie/year', vl: 1987, ag: 'testAgent' },
298
+ ])
299
+
300
+ // Activation unsub still holds, but user callback is gone
301
+ expect(cb).toHaveBeenCalledTimes(0)
302
+ result.dispose()
303
+ })
304
+ })
305
+
306
+ describe('multi-step reactive updates', () => {
307
+ it('propagates insert through multi-step query', () => {
308
+ const result = liveQuery(db, [
309
+ { en: '?movieId', at: 'movie/year', vl: 1984 },
310
+ { en: '?movieId', at: 'movie/director', vl: '?directorId' },
311
+ { en: '?directorId', at: 'person/name', vl: '?directorName' },
312
+ ])
313
+
314
+ expect(result.records).toEqual([
315
+ { movieId: 'm1', directorId: 'p1', directorName: 'James Cameron' },
316
+ ])
317
+
318
+ db.insert([
319
+ { en: 'm5', at: 'movie/year', vl: 1984, ag: 'testAgent' },
320
+ { en: 'm5', at: 'movie/director', vl: 'p4', ag: 'testAgent' },
321
+ ])
322
+
323
+ const directorNames = result.records.map(r => r.directorName).sort()
324
+ expect(directorNames).toContain('James Cameron')
325
+ expect(directorNames).toContain('John McTiernan')
326
+
327
+ result.dispose()
328
+ })
329
+
330
+ it('new data in step 2 propagates to existing step 1 results', () => {
331
+ const result = liveQuery(db, [
332
+ { en: '?movieId', at: 'movie/director', vl: 'p1' },
333
+ { en: '?movieId', at: 'movie/cast', vl: '?actorId' },
334
+ ])
335
+
336
+ const actorsBefore = result.records.map(r => r.actorId).sort()
337
+ expect(actorsBefore).toEqual(['p2', 'p2', 'p3', 'p3'])
338
+
339
+ db.insert([
340
+ { en: 'm1', at: 'movie/cast', vl: 'p5', ag: 'testAgent' },
341
+ ])
342
+
343
+ const actorsAfter = result.records.map(r => r.actorId).sort()
344
+ expect(actorsAfter).toContain('p5')
345
+ expect(actorsAfter.length).toBe(5)
346
+
347
+ result.dispose()
348
+ })
349
+ })
350
+
351
+ describe('eagerly activated — always up-to-date', () => {
352
+ it('nodes update without explicit subscribe', () => {
353
+ const result = liveQuery(db, [{ at: 'movie/year', vl: 1987 }])
354
+ expect(result.nodes).toHaveLength(1)
355
+
356
+ // Insert — liveQuery is eagerly activated, so it tracks changes
357
+ db.insert([
358
+ { en: 'm5', at: 'movie/year', vl: 1987, ag: 'testAgent' },
359
+ ])
360
+
361
+ expect(result.nodes).toHaveLength(2)
362
+ result.dispose()
363
+ })
364
+ })
365
+
366
+ describe('dispose', () => {
367
+ it('dispose tears down subscriptions', () => {
368
+ const result = liveQuery(db, [{ at: 'movie/title' }])
369
+ const cb = vi.fn()
370
+ result.subscribe(cb)
371
+
372
+ expect(cb).toHaveBeenCalledTimes(0)
373
+
374
+ db.insert([
375
+ { en: 'm5', at: 'movie/title', vl: 'Aliens', ag: 'testAgent' },
376
+ ])
377
+ expect(cb).toHaveBeenCalledTimes(1)
378
+
379
+ result.dispose()
380
+
381
+ db.insert([
382
+ { en: 'm6', at: 'movie/title', vl: 'Alien 3', ag: 'testAgent' },
383
+ ])
384
+
385
+ expect(cb).toHaveBeenCalledTimes(1)
386
+ })
387
+ })
388
+
389
+ describe('event batching', () => {
390
+ it('multi-step fires single event per upstream change', () => {
391
+ const result = liveQuery(db, [
392
+ { en: '?movieId', at: 'movie/year', vl: 1984 },
393
+ { en: '?movieId', at: 'movie/cast', vl: '?actorId' },
394
+ ])
395
+ const events: ArrayEvent<QueryNode>[] = []
396
+ const unsub = result.subscribe(e => events.push(e))
397
+
398
+ db.insert([
399
+ { en: 'm5', at: 'movie/year', vl: 1984, ag: 'testAgent' },
400
+ ])
401
+ db.insert([
402
+ { en: 'm5', at: 'movie/cast', vl: 'p4', ag: 'testAgent' },
403
+ ])
404
+ db.insert([
405
+ { en: 'm5', at: 'movie/cast', vl: 'p5', ag: 'testAgent' },
406
+ ])
407
+
408
+ expect(events.length).toBeLessThanOrEqual(6)
409
+
410
+ unsub()
411
+ result.dispose()
412
+ })
413
+ })
414
+
415
+ describe('memoization', () => {
416
+ it('same args return same LiveQueryResult instance', () => {
417
+ const r1 = liveQuery(db, [{ at: 'movie/title' }])
418
+ const r2 = liveQuery(db, [{ at: 'movie/title' }])
419
+ expect(r1).toBe(r2)
420
+ r1.dispose()
421
+ })
422
+
423
+ it('different args return different instances', () => {
424
+ const r1 = liveQuery(db, [{ at: 'movie/title' }])
425
+ const r2 = liveQuery(db, [{ at: 'movie/year' }])
426
+ expect(r1).not.toBe(r2)
427
+ r1.dispose()
428
+ r2.dispose()
429
+ })
430
+ })
431
+ })
432
+
433
+ // ═════════════════════════════════════════════════════════════════
434
+ // PART 3: liveQueryNot — incremental NOT-filter
435
+ // ═════════════════════════════════════════════════════════════════
436
+
437
+ describe('liveQueryNot', () => {
438
+ // Schema: movies have directors. queryNot finds movies WITHOUT a director.
439
+ let db: ReturnType<typeof ThreadInMemory.fromArray>
440
+
441
+ beforeEach(() => {
442
+ db = ThreadInMemory.fromArray(makeApplogs([
443
+ { en: 'm1', at: 'movie/title', vl: 'The Terminator', ag: 'a' },
444
+ { en: 'm2', at: 'movie/title', vl: 'Predator', ag: 'a' },
445
+ { en: 'm3', at: 'movie/title', vl: 'Die Hard', ag: 'a' },
446
+ // m1 and m2 have directors, m3 does not
447
+ { en: 'm1', at: 'movie/director', vl: 'James Cameron', ag: 'a' },
448
+ { en: 'm2', at: 'movie/director', vl: 'John McTiernan', ag: 'a' },
449
+ ]), 'test-qnot')
450
+ })
451
+
452
+ it('snapshot queryNot: excludes nodes matching the pattern', () => {
453
+ const movies = query(db, [{ en: '?id', at: 'movie/title' }])
454
+ const noDirector = queryNot(db, movies, [{ en: '?id', at: 'movie/director' }])
455
+ expect(noDirector.records.map(r => r.id)).toEqual(['m3'])
456
+ })
457
+
458
+ it('initial state matches snapshot queryNot', () => {
459
+ const upstream = liveQuery(db, [{ en: '?id', at: 'movie/title' }])
460
+ const result = liveQueryNot(db, upstream, [{ en: '?id', at: 'movie/director' }])
461
+
462
+ expect(result.nodes).toHaveLength(1)
463
+ expect(result.nodes[0].variables.id).toBe('m3')
464
+
465
+ result.dispose()
466
+ upstream.dispose()
467
+ })
468
+
469
+ it('incrementally removes node when new applog matches NOT pattern', () => {
470
+ const upstream = liveQuery(db, [{ en: '?id', at: 'movie/title' }])
471
+ const result = liveQueryNot(db, upstream, [{ en: '?id', at: 'movie/director' }])
472
+
473
+ expect(result.nodes).toHaveLength(1) // m3 has no director
474
+
475
+ // Give m3 a director — should be excluded now
476
+ db.insert([{ en: 'm3', at: 'movie/director', vl: 'John McTiernan', ag: 'a' }])
477
+
478
+ expect(result.nodes).toHaveLength(0)
479
+
480
+ result.dispose()
481
+ upstream.dispose()
482
+ })
483
+
484
+ it('includes new upstream node that passes NOT filter', () => {
485
+ const upstream = liveQuery(db, [{ en: '?id', at: 'movie/title' }])
486
+ const result = liveQueryNot(db, upstream, [{ en: '?id', at: 'movie/director' }])
487
+
488
+ expect(result.nodes).toHaveLength(1) // m3
489
+
490
+ // Add a new movie WITHOUT a director
491
+ db.insert([{ en: 'm4', at: 'movie/title', vl: 'Aliens', ag: 'a' }])
492
+
493
+ expect(result.nodes).toHaveLength(2)
494
+ expect(result.records.map(r => r.id).sort()).toEqual(['m3', 'm4'])
495
+
496
+ result.dispose()
497
+ upstream.dispose()
498
+ })
499
+
500
+ it('excludes new upstream node that fails NOT filter', () => {
501
+ const upstream = liveQuery(db, [{ en: '?id', at: 'movie/title' }])
502
+ const result = liveQueryNot(db, upstream, [{ en: '?id', at: 'movie/director' }])
503
+
504
+ expect(result.nodes).toHaveLength(1)
505
+
506
+ // Add a new movie WITH a director (both in same batch)
507
+ db.insert([
508
+ { en: 'm4', at: 'movie/title', vl: 'Aliens', ag: 'a' },
509
+ { en: 'm4', at: 'movie/director', vl: 'James Cameron', ag: 'a' },
510
+ ])
511
+
512
+ // m4 should NOT appear (has director)
513
+ expect(result.records.map(r => r.id)).toEqual(['m3'])
514
+
515
+ result.dispose()
516
+ upstream.dispose()
517
+ })
518
+
519
+ it('fires subscribe events on changes', () => {
520
+ const upstream = liveQuery(db, [{ en: '?id', at: 'movie/title' }])
521
+ const result = liveQueryNot(db, upstream, [{ en: '?id', at: 'movie/director' }])
522
+ const events: ArrayEvent<QueryNode>[] = []
523
+ const unsub = result.subscribe(e => events.push(e))
524
+
525
+ expect(events).toHaveLength(0) // no init on subscribe
526
+
527
+ // Adding director to m3 removes it
528
+ db.insert([{ en: 'm3', at: 'movie/director', vl: 'Someone', ag: 'a' }])
529
+
530
+ expect(events.length).toBeGreaterThan(0)
531
+
532
+ unsub()
533
+ result.dispose()
534
+ upstream.dispose()
535
+ })
536
+ })