@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,1245 @@
1
+ import { AgentHash, Applog, ApplogValue, CidString, DatalogQueryPattern, EntityID, SearchContext, ValueOrMatcher } from '../applog/datom-types.ts'
2
+
3
+ import { Logger } from 'besonders-logger'
4
+
5
+ import { isEmpty } from 'lodash-es'
6
+ import stringify from 'safe-stable-stringify'
7
+ import { isLaterByTsAndPv, isoDateStrCompare, isVariable, resolveOrRemoveVariables, sortApplogsByTs } from '../applog/applog-utils.ts'
8
+ import { createDebugName } from '../utils/debug-name.ts'
9
+ import { isInitEvent, StaticThread, Thread, ThreadEvent } from '../thread/basic.ts'
10
+ import { hasFilter, makeFilter, rollingFilter, rollingMapper, ThreadOnlyCurrent } from '../thread/filters.ts'
11
+ import { applogsByEntity } from '../thread/indexes.ts'
12
+ import { MappedThread, type ThreadDerivation } from '../thread/mapped.ts'
13
+ import { ThreadInMemory } from '../thread/writeable.ts'
14
+ import { memoizedFn } from './memoized.ts'
15
+ import { isArrayInitEvent, SubscribableArray, SubscribableArrayImpl, SubscribableImpl, Unsubscribe } from './subscribable.ts'
16
+ import { LiveQueryResult, QueryNode, QueryResult } from './types.ts'
17
+
18
+ const { WARN, LOG, DEBUG, VERBOSE, ERROR } = Logger.setup(Logger.INFO, { prefix: '[q]' }) // eslint-disable-line no-unused-vars
19
+
20
+ function assertLWW(thread: Thread) {
21
+ if (!hasFilter(thread, 'lastWriteWins')) {
22
+ throw ERROR(`requires lastWriteWins-filtered thread, got filters:`, thread.filters, { name: thread.name })
23
+ }
24
+ }
25
+
26
+ let globalQueryTimeoutTime = null
27
+
28
+ // util.inspect.defaultOptions.depth = 5;
29
+
30
+ // export interface QueryExecutorArguments {
31
+ // db: Thread
32
+ // // applogs: AppLog[]
33
+ // nodes: SearchContextWithLog[]
34
+ // }
35
+ // export interface QueryExecutorResult {
36
+ // // applogs: AppLog[]
37
+ // nodes: SearchContextWithLog[]
38
+ // }
39
+ // export type QueryExecutor = (args: QueryExecutorArguments) => QueryExecutorResult
40
+
41
+ /////////////
42
+ // QUERIES //
43
+ /////////////
44
+
45
+ /**
46
+ * Keep only the latest logs for each en&at (= last write wins)
47
+ */
48
+ export const lastWriteWins = memoizedFn('lastWriteWins', function lastWriteWins(
49
+ thread: Thread,
50
+ { inverseToOnlyReturnFirstLogs, tolerateAlreadyFiltered }: {
51
+ inverseToOnlyReturnFirstLogs?: boolean
52
+ tolerateAlreadyFiltered?: boolean
53
+ } = {},
54
+ ): ThreadOnlyCurrent {
55
+ VERBOSE(`lastWriteWins${inverseToOnlyReturnFirstLogs ? '.inversed' : ''} < ${thread.nameAndSizeUntracked} > initializing`)
56
+ if (thread.filters.includes('lastWriteWins')) {
57
+ if (tolerateAlreadyFiltered) {
58
+ DEBUG(`[lastWriteWins] already filtered, but tolerateAlreadyFiltered=true, so returning`)
59
+ return thread as ThreadOnlyCurrent
60
+ }
61
+ throw ERROR(`thread already filtered lastWriteWins:`, thread.filters, { name: thread.name })
62
+ }
63
+
64
+ let rollingMap: Map<string, Applog>
65
+
66
+ /**
67
+ * Iterate `newLogs` (already chain-aware-sorted by `sortApplogsByTs`) updating
68
+ * `rollingMap` to hold the LWW winner per (en|at) key. Uses `isLaterByTsAndPv`
69
+ * — pairwise pv-aware predicate — so cross-batch comparisons in mapDelta also
70
+ * stay deterministic when same-ts chain links collide.
71
+ */
72
+ const processLogs = (newLogs: readonly Applog[], toRemove: Applog[] | null): Applog[] => {
73
+ const toAdd = [] as Applog[]
74
+ let prevTs: string | undefined
75
+ for (
76
+ let i = inverseToOnlyReturnFirstLogs ? 0 : newLogs.length - 1;
77
+ inverseToOnlyReturnFirstLogs ? i < newLogs.length : i >= 0;
78
+ inverseToOnlyReturnFirstLogs ? i++ : i--
79
+ ) {
80
+ const log = newLogs[i]
81
+ const key = log.en + '|' + log.at
82
+
83
+ if (prevTs !== undefined) {
84
+ const cmp = isoDateStrCompare(prevTs, log.ts)
85
+ if (inverseToOnlyReturnFirstLogs ? cmp > 0 : cmp < 0) {
86
+ throw ERROR(`lastWriteWins.processLogs logs not ts-sorted:`, prevTs, inverseToOnlyReturnFirstLogs ? '>' : '<', log.ts, {
87
+ log,
88
+ i,
89
+ newLogs,
90
+ inverseToOnlyReturnFirstLogs,
91
+ })
92
+ }
93
+ }
94
+ prevTs = log.ts
95
+
96
+ const existing = rollingMap.get(key)
97
+ const replaces = !existing || (inverseToOnlyReturnFirstLogs
98
+ ? isLaterByTsAndPv(existing, log)
99
+ : isLaterByTsAndPv(log, existing))
100
+ if (replaces) {
101
+ if (existing && toRemove) toRemove.push(existing)
102
+ toAdd.push(log)
103
+ rollingMap.set(key, log)
104
+ }
105
+ }
106
+ return toAdd
107
+ }
108
+
109
+ const lwwName = `lastWriteWins${inverseToOnlyReturnFirstLogs ? '.inversed' : ''}`
110
+ const derivation: ThreadDerivation = {
111
+ compute(parents) {
112
+ if (parents.length !== 1) {
113
+ throw ERROR(`${lwwName} requires exactly one parent`, { parents: parents.length })
114
+ }
115
+ const [parent] = parents
116
+ rollingMap = new Map()
117
+ const toAdd = processLogs(parent.applogs, null)
118
+ sortApplogsByTs(toAdd)
119
+ VERBOSE.isDisabled || VERBOSE(`${lwwName}<${thread.nameAndSizeUntracked}> compute`, { toAdd: toAdd.length })
120
+ return toAdd
121
+ },
122
+ mapDelta(delta) {
123
+ const toRemove = [] as Applog[]
124
+ const toAdd = processLogs(delta.added, toRemove)
125
+ sortApplogsByTs(toAdd)
126
+ VERBOSE.isDisabled || VERBOSE(`${lwwName}<${thread.nameAndSizeUntracked}> mapDelta`, { ...delta, toAdd, toRemove })
127
+ return { added: toAdd, removed: toRemove }
128
+ },
129
+ }
130
+ const mappedThread = rollingMapper(thread, derivation, { name: lwwName, extraFilterName: 'lastWriteWins' })
131
+ VERBOSE.isDisabled || VERBOSE(`lastWriteWins<${thread.nameAndSizeUntracked}> filtered down to`, mappedThread.applogs.length)
132
+ return mappedThread as ThreadOnlyCurrent
133
+ }, { argsDebugName: (thread) => createDebugName({ caller: 'lastWriteWins', thread }) })
134
+
135
+ const isDeletedAttrs = ['isDeleted', 'relation/isDeleted', 'block/isDeleted']
136
+ function isDeletionLog(log: Applog) {
137
+ return log.vl === true && isDeletedAttrs.includes(log.at)
138
+ }
139
+
140
+ /**
141
+ * Remove all applogs for entities that have an applog `{ at: 'isDeleted' | 'relation/isDeleted' | 'block/isDeleted', vl: true }`.
142
+ *
143
+ * Emits synthetic `removed` events for the entity's pre-existing applogs when an
144
+ * entity becomes deleted (and synthetic `added` events for un-deletion).
145
+ *
146
+ * Un-deletion: canonical input is appending `{ at: 'isDeleted', vl: false }`. Requires
147
+ * `lastWriteWins` upstream — without it, the `vl: true` log isn't superseded so we
148
+ * can't observe a transition. Soft-warned at runtime.
149
+ */
150
+ export const withoutDeleted = memoizedFn('withoutDeleted', function withoutDeleted(
151
+ thread: Thread,
152
+ ) {
153
+ if (VERBOSE.isEnabled) VERBOSE(`withoutDeleted<${thread.nameAndSizeUntracked}>`)
154
+ if (thread.filters.includes('withoutDeleted')) {
155
+ throw ERROR(`thread already filtered withoutDeleted:`, thread.filters, { name: thread.name })
156
+ }
157
+ if (!thread.filters.includes('lastWriteWins')) {
158
+ WARN(`withoutDeleted on non-lastWriteWins thread: un-deletion (isDeleted: false) won't take effect`, { thread: thread.name })
159
+ }
160
+
161
+ // FIFO contract: byEntity must subscribe to `thread` BEFORE `result` does, so the index
162
+ // is up-to-date when our mapper runs. Calling applogsByEntity here forces that order;
163
+ // memoization makes it idempotent if other code already subscribed.
164
+ const byEntity = applogsByEntity(thread)
165
+
166
+ // Per-entity count of currently-active isDeleted-class logs. 0→1 = entity becomes hidden;
167
+ // 1→0 = entity becomes visible. Handles multiple isDeleted-class attrs per entity.
168
+ const activeDeletionMarkers = new Map<EntityID, number>()
169
+ for (const log of thread.applogs) {
170
+ if (isDeletionLog(log)) {
171
+ activeDeletionMarkers.set(log.en, (activeDeletionMarkers.get(log.en) ?? 0) + 1)
172
+ }
173
+ }
174
+ const isHidden = (en: EntityID) => activeDeletionMarkers.has(en)
175
+
176
+ const derivation: ThreadDerivation = {
177
+ compute(parents) {
178
+ if (parents.length !== 1) throw ERROR(`withoutDeleted requires exactly one parent`, { parents: parents.length })
179
+ const [parent] = parents
180
+ activeDeletionMarkers.clear()
181
+ for (const log of parent.applogs) {
182
+ if (isDeletionLog(log)) {
183
+ activeDeletionMarkers.set(log.en, (activeDeletionMarkers.get(log.en) ?? 0) + 1)
184
+ }
185
+ }
186
+ return parent.applogs.filter(log => !isHidden(log.en))
187
+ },
188
+ mapDelta: (delta) => {
189
+ // Snapshot of which entities were hidden BEFORE this delta. Required because
190
+ // pass-through correctness depends on prior visibility (logs of pre-hidden
191
+ // entities were never in result, so they must not appear in `removed`),
192
+ // while the post-mutation `isHidden` state determines whether new content
193
+ // should be admitted. Filtering only on post-mutation state breaks both
194
+ // W→N (stale isDeleted=true log slips into removed → MappedThread crash)
195
+ // and F→D-with-content-removal (legitimate content removal gets filtered).
196
+ const hiddenBefore = new Set(activeDeletionMarkers.keys())
197
+ const entitiesNowHidden: EntityID[] = []
198
+ const entitiesNowVisible: EntityID[] = []
199
+
200
+ for (const log of delta.added) {
201
+ if (!isDeletionLog(log)) continue
202
+ const prev = activeDeletionMarkers.get(log.en) ?? 0
203
+ activeDeletionMarkers.set(log.en, prev + 1)
204
+ if (prev === 0) entitiesNowHidden.push(log.en)
205
+ }
206
+ if (delta.removed) {
207
+ for (const log of delta.removed) {
208
+ if (!isDeletionLog(log)) continue
209
+ const prev = activeDeletionMarkers.get(log.en) ?? 0
210
+ const next = prev - 1
211
+ if (next > 0) activeDeletionMarkers.set(log.en, next)
212
+ else {
213
+ activeDeletionMarkers.delete(log.en)
214
+ entitiesNowVisible.push(log.en)
215
+ }
216
+ }
217
+ }
218
+
219
+ // Synthetic removals: applogs of newly-hidden entities currently in result.
220
+ // byEntity index is updated for this tick already (FIFO), so we filter out
221
+ // applogs added in this very tick (they were never in result).
222
+ const newAddedSet = new Set(delta.added)
223
+ const syntheticRemovals: Applog[] = []
224
+ for (const en of entitiesNowHidden) {
225
+ const applogs = byEntity.get(en)
226
+ if (!applogs) continue
227
+ for (const log of applogs) {
228
+ if (!newAddedSet.has(log)) syntheticRemovals.push(log)
229
+ }
230
+ }
231
+
232
+ // Synthetic additions: all current parent applogs of newly-visible entities.
233
+ const syntheticAdditions: Applog[] = []
234
+ for (const en of entitiesNowVisible) {
235
+ const applogs = byEntity.get(en)
236
+ if (applogs) syntheticAdditions.push(...applogs)
237
+ }
238
+
239
+ // Pass-through: a parent log goes through to subscribers iff the entity
240
+ // wasn't hidden before AND isn't hidden now. Transitions are owned by the
241
+ // 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)) ?? []
246
+
247
+ return {
248
+ added: [...passAdded, ...syntheticAdditions],
249
+ removed: [...passRemoved, ...syntheticRemovals],
250
+ }
251
+ },
252
+ }
253
+ const result = rollingMapper(thread, derivation, { name: 'withoutDeleted', extraFilterName: 'withoutDeleted' })
254
+
255
+ return result
256
+ })
257
+
258
+ ///////////////////////////
259
+ // ONE-OFF QUERY (snapshot) //
260
+ ///////////////////////////
261
+
262
+ /** Shared helper: create a QueryNode from a log and its context */
263
+ function makeQueryNode(
264
+ log: Applog,
265
+ parentNode: QueryNode | null,
266
+ varMapper: (log: Applog) => SearchContext,
267
+ threadName: string,
268
+ ): QueryNode {
269
+ const nodeVars = Object.assign({}, parentNode?.variables, varMapper(log))
270
+ return new QueryNode(
271
+ StaticThread.fromArray([log], threadName),
272
+ nodeVars,
273
+ parentNode,
274
+ )
275
+ }
276
+
277
+ /**
278
+ * One-off query — returns a plain snapshot. No subscriptions, no stale-data risk.
279
+ */
280
+ export const query = memoizedFn('query', function query(
281
+ threadOrLogs: Thread | Applog[],
282
+ patternOrPatterns: DatalogQueryPattern | DatalogQueryPattern[],
283
+ startVariables: SearchContext = {},
284
+ opts: { debug?: boolean } = {},
285
+ ): QueryResult {
286
+ throwOnTimeout()
287
+ const thread = threadFromMaybeArray(threadOrLogs)
288
+ DEBUG(`query<${thread.nameAndSizeUntracked}>:`, patternOrPatterns)
289
+ const patterns = (Array.isArray(patternOrPatterns) ? patternOrPatterns : [patternOrPatterns]) as DatalogQueryPattern[]
290
+ warnIfDisjointQuerySteps(patterns)
291
+
292
+ let prevNodes: readonly QueryNode[] | null
293
+ if (patterns.length === 1) {
294
+ prevNodes = null
295
+ } else {
296
+ const patternsExceptLast = patterns.slice(0, -1)
297
+ prevNodes = query(thread, patternsExceptLast, startVariables, opts).nodes
298
+ }
299
+ const lastPattern = patterns[patterns.length - 1]
300
+ const stepResult = queryStepOnce(thread, prevNodes, lastPattern, opts)
301
+ VERBOSE.isDisabled || VERBOSE(`query result:`, stepResult.nodes)
302
+ return stepResult
303
+ }, {
304
+ argsDebugName: (thread, pattern, startVars) =>
305
+ createDebugName({ caller: 'query', thread, args: startVars ? { pattern, startVars } : pattern }),
306
+ })
307
+
308
+ /**
309
+ * One-off query step — pure filtering via makeFilter, no subscriptions.
310
+ */
311
+ export function queryStepOnce(
312
+ thread: Thread,
313
+ prevNodes: readonly QueryNode[] | null,
314
+ pattern: DatalogQueryPattern,
315
+ opts: { debug?: boolean } = {},
316
+ ): QueryResult {
317
+ DEBUG(`queryStepOnce<${thread.nameAndSizeUntracked}> with`, prevNodes?.length ?? 'all', 'nodes, pattern:', pattern)
318
+ if (!Object.entries(pattern).length) throw new Error(`Pattern is empty`)
319
+
320
+ function doQueryOnce(node: QueryNode | null): QueryNode[] {
321
+ const [patternWithResolvedVars, variablesToFill] = resolveOrRemoveVariables(pattern, node?.variables ?? {})
322
+ VERBOSE(`[queryStepOnce.doQuery] patternWithoutVars: `, patternWithResolvedVars)
323
+ const filter = makeFilter(patternWithResolvedVars)
324
+ const matchingLogs = filter(thread.applogs)
325
+ const varMapper = createObjMapper(variablesToFill)
326
+
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
+ ))
335
+
336
+ if (VERBOSE.isEnabled) VERBOSE(`[queryStepOnce.doQuery] nodes:`, nodes.map(n => n.variables))
337
+ if (opts.debug) {
338
+ LOG(`[queryStepOnce] step result:`, nodes.map(({ variables, logsOfThisNode: thread }) => ({
339
+ variables,
340
+ thread,
341
+ })))
342
+ }
343
+
344
+ return nodes
345
+ }
346
+
347
+ if (!prevNodes) {
348
+ return new QueryResult(doQueryOnce(null))
349
+ }
350
+
351
+ const allNodes = prevNodes.flatMap(inputNode => doQueryOnce(inputNode))
352
+ return new QueryResult(allNodes)
353
+ }
354
+
355
+ ///////////////////////////
356
+ // LIVE QUERY (reactive) //
357
+ ///////////////////////////
358
+
359
+ /**
360
+ * Live query — eagerly activated, always up-to-date.
361
+ * Returns LiveQueryResult with subscribe + dispose.
362
+ */
363
+ export const liveQuery = memoizedFn('liveQuery', function liveQuery(
364
+ threadOrLogs: Thread | Applog[],
365
+ patternOrPatterns: DatalogQueryPattern | DatalogQueryPattern[],
366
+ startVariables: SearchContext = {},
367
+ opts: { debug?: boolean } = {},
368
+ ): LiveQueryResult {
369
+ throwOnTimeout()
370
+ const thread = threadFromMaybeArray(threadOrLogs)
371
+ DEBUG(`liveQuery<${thread.nameAndSizeUntracked}>:`, patternOrPatterns)
372
+ const patterns = (Array.isArray(patternOrPatterns) ? patternOrPatterns : [patternOrPatterns]) as DatalogQueryPattern[]
373
+
374
+ let prevResult: LiveQueryResult | null
375
+ if (patterns.length === 1) {
376
+ prevResult = null
377
+ } else {
378
+ const patternsExceptLast = patterns.slice(0, -1)
379
+ prevResult = liveQuery(thread, patternsExceptLast, startVariables, opts)
380
+ }
381
+ const lastPattern = patterns[patterns.length - 1]
382
+ const stepResult = liveQueryStep(thread, prevResult, lastPattern, opts)
383
+ VERBOSE.isDisabled || VERBOSE(`liveQuery result:`, stepResult.nodes)
384
+ return stepResult
385
+ }, {
386
+ argsDebugName: (thread, pattern, startVars) =>
387
+ createDebugName({ caller: 'liveQuery', thread, args: startVars ? { pattern, startVars } : pattern }),
388
+ })
389
+
390
+ export const liveQueryStep = memoizedFn('liveQueryStep', function liveQueryStep(
391
+ thread: Thread,
392
+ nodeSet: LiveQueryResult | null,
393
+ pattern: DatalogQueryPattern,
394
+ opts: { debug?: boolean } = {},
395
+ ): LiveQueryResult {
396
+ DEBUG(`liveQueryStep<${thread.nameAndSizeUntracked}> with`, nodeSet?.untrackedSize ?? 'all', 'nodes, pattern:', pattern)
397
+ if (!Object.entries(pattern).length) throw new Error(`Pattern is empty`)
398
+
399
+ function doQuery(node: QueryNode | null): SubscribableArray<QueryNode> {
400
+ const [patternWithResolvedVars, variablesToFill] = resolveOrRemoveVariables(pattern, node?.variables ?? {})
401
+ VERBOSE(`[liveQueryStep.doQuery] patternWithoutVars: `, patternWithResolvedVars)
402
+ const applogsMatchingStatic = rollingFilter(thread, patternWithResolvedVars)
403
+ const varMapper = createObjMapper(variablesToFill)
404
+
405
+ function makeNode(log: Applog): QueryNode {
406
+ return makeQueryNode(
407
+ log, node, varMapper,
408
+ createDebugName({
409
+ caller: 'QueryNode',
410
+ thread: applogsMatchingStatic,
411
+ pattern: `${stringify(Object.assign({}, node?.variables, varMapper(log)))}@${stringify(patternWithResolvedVars)}`,
412
+ }),
413
+ )
414
+ }
415
+
416
+ // Compute initial result synchronously
417
+ const initialNodes = applogsMatchingStatic.applogs.map(makeNode)
418
+
419
+ if (VERBOSE.isEnabled) VERBOSE(`[liveQueryStep.doQuery] initial nodes:`, initialNodes.map(n => n.variables))
420
+ if (opts.debug) {
421
+ LOG(`[liveQueryStep] step result:`, initialNodes.map(({ variables, logsOfThisNode: thread }) => ({
422
+ variables,
423
+ thread,
424
+ })))
425
+ }
426
+
427
+ // Upstream subscription activates lazily — only when someone subscribes to us
428
+ const result = new SubscribableArrayImpl<QueryNode>(
429
+ 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)
443
+ }
444
+ }
445
+ }, 'derived'),
446
+ )
447
+ return result
448
+ }
449
+
450
+ // ── Single-step query (nodeSet === null) ──────────────────────
451
+ if (!nodeSet) {
452
+ return new LiveQueryResult(doQuery(null))
453
+ }
454
+
455
+ // ── Multi-step query (nodeSet !== null) ────────────────────────
456
+
457
+ // Compute initial result synchronously
458
+ const initialInners = nodeSet.nodes.map(inputNode => ({
459
+ inputNode,
460
+ inner: doQuery(inputNode),
461
+ }))
462
+ const initialItems = initialInners.flatMap(({ inner }) => [...inner.items])
463
+
464
+ // Lazy activation: upstream subscriptions only created when someone subscribes
465
+ const aggregated = new SubscribableArrayImpl<QueryNode>(
466
+ initialItems,
467
+ () => {
468
+ const subsByInputNode = new Map<QueryNode, {
469
+ inner: SubscribableArray<QueryNode>,
470
+ unsub: Unsubscribe,
471
+ nodes: QueryNode[],
472
+ }>()
473
+
474
+ function wireInner(inputNode: QueryNode, inner: SubscribableArray<QueryNode>): QueryNode[] {
475
+ const entry = { inner, unsub: null! as Unsubscribe, nodes: [...inner.items] }
476
+
477
+ entry.unsub = inner.subscribe((event) => {
478
+ if (isArrayInitEvent(event)) {
479
+ if (entry.nodes.length) aggregated._remove(entry.nodes)
480
+ entry.nodes = [...event.init]
481
+ if (entry.nodes.length) aggregated._push(...entry.nodes)
482
+ } else {
483
+ if (event.added.length) {
484
+ entry.nodes.push(...event.added)
485
+ aggregated._push(...event.added)
486
+ }
487
+ if (event.removed?.length) {
488
+ for (const r of event.removed) {
489
+ const idx = entry.nodes.indexOf(r)
490
+ if (idx >= 0) entry.nodes.splice(idx, 1)
491
+ }
492
+ aggregated._remove(event.removed)
493
+ }
494
+ }
495
+ }, 'derived')
496
+
497
+ subsByInputNode.set(inputNode, entry)
498
+ return entry.nodes
499
+ }
500
+
501
+ function addInputNode(inputNode: QueryNode): QueryNode[] {
502
+ return wireInner(inputNode, doQuery(inputNode))
503
+ }
504
+
505
+ function removeInputNode(inputNode: QueryNode): QueryNode[] {
506
+ const entry = subsByInputNode.get(inputNode)
507
+ if (!entry) return []
508
+ entry.unsub()
509
+ entry.inner.dispose()
510
+ const removed = entry.nodes
511
+ subsByInputNode.delete(inputNode)
512
+ return removed
513
+ }
514
+
515
+ // Reuse pre-computed inners (no re-creation of sub-queries)
516
+ for (const { inputNode, inner } of initialInners) {
517
+ wireInner(inputNode, inner)
518
+ }
519
+
520
+ // Subscribe to previous step for FUTURE changes only (no init)
521
+ const prevUnsub = nodeSet.subscribe((event) => {
522
+ if (isArrayInitEvent(event)) {
523
+ for (const [, entry] of subsByInputNode) {
524
+ entry.unsub(); entry.inner.dispose()
525
+ }
526
+ subsByInputNode.clear()
527
+ const allNodes: QueryNode[] = []
528
+ for (const node of event.init) {
529
+ allNodes.push(...addInputNode(node))
530
+ }
531
+ aggregated._reset(allNodes)
532
+ } else {
533
+ if (event.added.length) {
534
+ const allAdded: QueryNode[] = []
535
+ for (const node of event.added) {
536
+ allAdded.push(...addInputNode(node))
537
+ }
538
+ if (allAdded.length) aggregated._push(...allAdded)
539
+ }
540
+ if (event.removed?.length) {
541
+ const allRemoved: QueryNode[] = []
542
+ for (const node of event.removed) {
543
+ allRemoved.push(...removeInputNode(node))
544
+ }
545
+ if (allRemoved.length) aggregated._remove(allRemoved)
546
+ }
547
+ }
548
+ }, 'derived')
549
+
550
+ return () => {
551
+ prevUnsub()
552
+ for (const [, entry] of subsByInputNode) {
553
+ entry.unsub(); entry.inner.dispose()
554
+ }
555
+ subsByInputNode.clear()
556
+ }
557
+ },
558
+ )
559
+
560
+ if (VERBOSE.isEnabled) VERBOSE(`[liveQueryStep] aggregated initial:`, [...aggregated.items])
561
+ return new LiveQueryResult(aggregated)
562
+ }, { argsDebugName: (thread, _nodes, pattern) => createDebugName({ caller: 'liveQueryStep', thread, pattern }) })
563
+
564
+ export const queryNot = memoizedFn('queryNot', function queryNot(
565
+ thread: Thread,
566
+ startNodes: QueryResult,
567
+ patternOrPatterns: DatalogQueryPattern | DatalogQueryPattern[],
568
+ opts: { debug?: boolean } = {},
569
+ ) {
570
+ const nodes = startNodes.nodes
571
+ DEBUG(`queryNot<${thread.nameAndSizeUntracked}> from: ${nodes.length} nodes`)
572
+ const patterns = (Array.isArray(patternOrPatterns) ? patternOrPatterns : [patternOrPatterns]) as DatalogQueryPattern[]
573
+
574
+ // For each node, run all patterns as a joined multi-step query.
575
+ // Exclude the node if ANY complete binding exists across all steps.
576
+ const filtered = nodes.filter(function innerNodeFilter({ variables }) {
577
+ // Start with a single binding from the node's variables
578
+ let bindings: Record<string, any>[] = [variables ?? {}]
579
+
580
+ for (const pattern of patterns) {
581
+ if (!Object.entries(pattern).length) throw new Error(`Pattern is empty`)
582
+ const nextBindings: Record<string, any>[] = []
583
+
584
+ for (const binding of bindings) {
585
+ const [resolved, varsToFill] = resolveOrRemoveVariables(pattern, binding)
586
+ const filter = makeFilter(resolved)
587
+ const matchingLogs = filter(thread.applogs)
588
+ const varMapper = createObjMapper(varsToFill)
589
+
590
+ for (const log of matchingLogs) {
591
+ nextBindings.push({ ...binding, ...varMapper(log) })
592
+ }
593
+ }
594
+
595
+ bindings = nextBindings
596
+ if (bindings.length === 0) break // no matches — node is safe, skip remaining patterns
597
+ }
598
+
599
+ VERBOSE(`[queryNot] node:`, variables, '=> bindings:', bindings.length)
600
+ if (opts.debug) LOG(`[queryNot] node result:`, variables, '=>', bindings)
601
+ return bindings.length === 0 // keep node if no complete match found
602
+ })
603
+ return new QueryResult([...filtered])
604
+ }, { argsDebugName: (thread, nodes, pattern) => createDebugName({ caller: 'queryNot', thread, pattern }) })
605
+
606
+ /** Live variant: queryNot with incremental updates.
607
+ * - Thread additions: O(new_applogs × included_nodes) — only checks new applogs
608
+ * - Thread removals/resets: full recompute (rare for append-mostly logs)
609
+ * - Upstream node additions: O(new_nodes × applogs)
610
+ * - Upstream node removals: removed from output
611
+ */
612
+ export const liveQueryNot = memoizedFn('liveQueryNot', function liveQueryNot(
613
+ thread: Thread,
614
+ upstream: LiveQueryResult,
615
+ patternOrPatterns: DatalogQueryPattern | DatalogQueryPattern[],
616
+ opts: { debug?: boolean } = {},
617
+ ) {
618
+ const patterns = (Array.isArray(patternOrPatterns) ? patternOrPatterns : [patternOrPatterns]) as DatalogQueryPattern[]
619
+
620
+ /** Check if a node should be excluded (matches the NOT patterns as a joined multi-step query) */
621
+ function nodeMatchesNot(node: QueryNode, applogs: readonly Applog[]): boolean {
622
+ let bindings: Record<string, any>[] = [node.variables ?? {}]
623
+ for (const pattern of patterns) {
624
+ const nextBindings: Record<string, any>[] = []
625
+ for (const binding of bindings) {
626
+ const [resolved, varsToFill] = resolveOrRemoveVariables(pattern, binding)
627
+ const filter = makeFilter(resolved)
628
+ const varMapper = createObjMapper(varsToFill)
629
+ for (const log of filter(applogs)) {
630
+ nextBindings.push({ ...binding, ...varMapper(log) })
631
+ }
632
+ }
633
+ bindings = nextBindings
634
+ if (bindings.length === 0) return false // no matches — node passes
635
+ }
636
+ return bindings.length > 0 // excluded if any complete binding exists
637
+ }
638
+
639
+ /** Full recompute: filter all upstream nodes against all thread applogs */
640
+ function computeAll(): QueryNode[] {
641
+ return upstream.nodes.filter(node => !nodeMatchesNot(node, thread.applogs))
642
+ }
643
+
644
+ const result = new SubscribableArrayImpl<QueryNode>(
645
+ computeAll(),
646
+ () => {
647
+ // Subscribe to thread changes
648
+ const threadUnsub = thread.subscribe((event) => {
649
+ if (isInitEvent(event)) {
650
+ // Full reset — recompute everything
651
+ result._reset(computeAll())
652
+ return
653
+ }
654
+
655
+ if (event.removed?.length) {
656
+ // Removals: a previously-excluded node might now pass — full recompute
657
+ result._reset(computeAll())
658
+ return
659
+ }
660
+
661
+ if (event.added.length) {
662
+ // Additions: only check new applogs against currently-included nodes
663
+ const toRemove = result.items.filter(node => nodeMatchesNot(node, event.added))
664
+ if (toRemove.length > 0) {
665
+ result._remove(toRemove)
666
+ }
667
+ }
668
+ }, 'derived')
669
+
670
+ // Subscribe to upstream node changes
671
+ const upstreamUnsub = upstream.subscribe((event) => {
672
+ if (isArrayInitEvent(event)) {
673
+ result._reset(computeAll())
674
+ return
675
+ }
676
+
677
+ // New upstream nodes: check each against full thread
678
+ if (event.added.length) {
679
+ const passing = event.added.filter(node => !nodeMatchesNot(node, thread.applogs))
680
+ if (passing.length > 0) result._push(...passing)
681
+ }
682
+
683
+ // Removed upstream nodes: remove from our output
684
+ if (event.removed?.length) {
685
+ const removedSet = new Set(event.removed)
686
+ const toRemove = result.items.filter(node => removedSet.has(node))
687
+ if (toRemove.length > 0) result._remove(toRemove)
688
+ }
689
+ }, 'derived')
690
+
691
+ return () => { threadUnsub(); upstreamUnsub() }
692
+ },
693
+ )
694
+
695
+ return new LiveQueryResult(result)
696
+ }, { argsDebugName: (thread, _nodes, pattern) => createDebugName({ caller: 'liveQueryNot', thread, pattern }) })
697
+
698
+ // export function or(queries: QueryExecutor[]) {
699
+ // return tagged(
700
+ // `or{${stringify(queries)} } `,
701
+ // function orExecutor(args: QueryExecutorArguments) {
702
+ // const { db, nodes: contexts } = args
703
+ // VERBOSE('[or]', { queries, contexts })
704
+ // let results = []
705
+ // for (const query of queries) {
706
+ // const res = query(args)
707
+ // VERBOSE('[or] query', query, 'result =>', res)
708
+ // results.push(...res.nodes)
709
+ // }
710
+ // return { contexts: results }
711
+ // }
712
+ // )
713
+ // }
714
+
715
+ // export type Tagged<T> = T & { tag: string }
716
+ // export function tagged<T>(tag: string, thing: T): Tagged<T> {
717
+ // const e = thing as (T & { tag: string })
718
+ // e.tag = tag
719
+ // return e
720
+ // }
721
+
722
+ //////////////////////
723
+ // COMPOSED QUERIES //
724
+ //////////////////////
725
+
726
+ /** One-off: filter thread by pattern, map to values. Returns plain array. */
727
+ export const filterAndMap = memoizedFn('filterAndMap', function filterAndMap<R>(
728
+ thread: Thread,
729
+ pattern: DatalogQueryPattern,
730
+ mapper: (keyof Applog) | (Partial<{ [key in keyof Applog]: string }>) | ((applog: Applog) => R),
731
+ ) {
732
+ DEBUG(`filterAndMap<${thread.nameAndSizeUntracked}>`, pattern)
733
+ const filter = makeFilter(pattern)
734
+ const filtered = filter(thread.applogs)
735
+ return mapApplogsWith(filtered, mapper)
736
+ }, { argsDebugName: (thread, pattern) => createDebugName({ caller: 'filterAndMap', thread, pattern }) })
737
+
738
+ /** Live variant: returns SubscribableArray that updates when thread changes. */
739
+ export const liveFilterAndMap = memoizedFn('liveFilterAndMap', function liveFilterAndMap<R>(
740
+ thread: Thread,
741
+ pattern: DatalogQueryPattern,
742
+ mapper: (keyof Applog) | (Partial<{ [key in keyof Applog]: string }>) | ((applog: Applog) => R),
743
+ ) {
744
+ DEBUG(`liveFilterAndMap<${thread.nameAndSizeUntracked}>`, pattern)
745
+ const filtered = rollingFilter(thread, pattern)
746
+ const mapFn = makeApplogMapper(mapper)
747
+
748
+ const cidToMapped = new Map<CidString, R>()
749
+ const mapAndTrack = (log: Applog): R => {
750
+ const r = mapFn(log)
751
+ cidToMapped.set(log.cid, r)
752
+ return r
753
+ }
754
+
755
+ const initial = filtered.applogs.map(mapAndTrack)
756
+ const result = new SubscribableArrayImpl<R>(
757
+ 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
771
+ }
772
+ cidToMapped.delete(log.cid)
773
+ toRemove.push(r)
774
+ }
775
+ if (toRemove.length) result._remove(toRemove)
776
+ }
777
+ }
778
+ }, 'derived'),
779
+ )
780
+ return result
781
+ }, { argsDebugName: (thread, pattern) => createDebugName({ caller: 'liveFilterAndMap', thread, pattern }) })
782
+
783
+ /** One-off: query and map results. Returns plain array. */
784
+ export const queryAndMap = memoizedFn('queryAndMap', function queryAndMap<R>(
785
+ threadOrLogs: Thread | Applog[],
786
+ patternOrPatterns: Parameters<typeof query>[1],
787
+ mapDef: string | (Partial<{ [key in keyof SearchContext]: string }>) | ((record: SearchContext) => R),
788
+ variables: SearchContext = {},
789
+ ) {
790
+ const thread = threadFromMaybeArray(threadOrLogs)
791
+ DEBUG(`queryAndMap<${thread.nameAndSizeUntracked}>`, { patternOrPatterns, variables, map: mapDef })
792
+ const queryResult = query(thread, patternOrPatterns)
793
+ return mapQueryResultWith(queryResult, mapDef)
794
+ }, { argsDebugName: (thread, pattern) => createDebugName({ caller: 'queryAndMap', thread, pattern }) })
795
+
796
+ /** Live variant: query and map results, returns SubscribableArray that updates reactively. */
797
+ export const liveQueryAndMap = memoizedFn('liveQueryAndMap', function liveQueryAndMap<R>(
798
+ thread: Thread,
799
+ patternOrPatterns: Parameters<typeof liveQuery>[1],
800
+ mapDef: string | (Partial<{ [key in keyof SearchContext]: string }>) | ((record: SearchContext) => R),
801
+ ) {
802
+ DEBUG(`liveQueryAndMap<${thread.nameAndSizeUntracked}>`, { patternOrPatterns, map: mapDef })
803
+ const live = liveQuery(thread, patternOrPatterns)
804
+
805
+ function computeAll(): R[] {
806
+ const snapshot = new QueryResult(live.nodes)
807
+ return mapQueryResultWith(snapshot, mapDef) as R[]
808
+ }
809
+
810
+ const result = new SubscribableArrayImpl<R>(
811
+ computeAll(),
812
+ () => live.subscribe(() => {
813
+ result._reset(computeAll())
814
+ }, 'derived'),
815
+ )
816
+ return result
817
+ }, { argsDebugName: (thread, pattern) => createDebugName({ caller: 'liveQueryAndMap', thread, pattern }) })
818
+
819
+ /** One-off: query entity attributes. Returns Record or null. Requires current-state thread (LWW). */
820
+ export const queryEntity = memoizedFn('queryEntity', function queryEntity(
821
+ thread: Thread,
822
+ name: string,
823
+ entityID: EntityID,
824
+ attributes: readonly string[],
825
+ ) {
826
+ assertLWW(thread)
827
+ DEBUG(`queryEntity<${thread.nameAndSizeUntracked}>`, entityID, name)
828
+ const filter = makeFilter({ en: entityID, at: prefixAttrs(name, attributes) })
829
+ const filtered = filter(thread.applogs)
830
+ VERBOSE(`queryEntity applogs:`, filtered)
831
+ if (filtered.length === 0) return null
832
+ return Object.fromEntries(
833
+ filtered.map(({ at, vl }) => [at.slice(name.length + 1), vl]),
834
+ )
835
+ }, {
836
+ argsDebugName: (thread, name, entityID) => createDebugName({ caller: 'queryEntity', thread, args: { name, entityID } }),
837
+ })
838
+
839
+ /** Live variant: returns Subscribable that updates when entity attributes change. Requires current-state thread (LWW). */
840
+ export const liveQueryEntity = memoizedFn('liveQueryEntity', function liveQueryEntity(
841
+ thread: Thread,
842
+ name: string,
843
+ entityID: EntityID,
844
+ attributes: readonly string[],
845
+ ) {
846
+ assertLWW(thread)
847
+ DEBUG(`liveQueryEntity<${thread.nameAndSizeUntracked}>`, entityID, name)
848
+ const filtered = rollingFilter(thread, { en: entityID, at: prefixAttrs(name, attributes) })
849
+
850
+ function compute() {
851
+ if (filtered.isEmpty) return null
852
+ return Object.fromEntries(
853
+ filtered.map(({ at, vl }) => [at.slice(name.length + 1), vl]),
854
+ )
855
+ }
856
+
857
+ const result = new SubscribableImpl<Record<string, ApplogValue> | null>(
858
+ compute(),
859
+ () => filtered.subscribe(() => {
860
+ result._set(compute())
861
+ }, 'derived'),
862
+ )
863
+ return result
864
+ }, {
865
+ argsDebugName: (thread, name, entityID) => createDebugName({ caller: 'liveQueryEntity', thread, args: { name, entityID } }),
866
+ })
867
+
868
+ /** Live single-attribute query. Requires current-state thread (LWW). Returns Subscribable<T | null>. */
869
+ export const liveEntityAt = memoizedFn('liveEntityAt', function liveEntityAt<T extends ApplogValue>(
870
+ thread: Thread,
871
+ entityID: EntityID,
872
+ at: string,
873
+ ) {
874
+ assertLWW(thread)
875
+ DEBUG(`liveEntityAt<${thread.nameAndSizeUntracked}>`, entityID, at)
876
+ const filtered = rollingFilter(thread, { en: entityID, at })
877
+
878
+ function compute(): T | null {
879
+ if (filtered.isEmpty) return null
880
+ return filtered.applogs[filtered.applogs.length - 1].vl as T
881
+ }
882
+
883
+ const result = new SubscribableImpl<T | null>(
884
+ compute(),
885
+ () => filtered.subscribe(() => {
886
+ result._set(compute())
887
+ }, 'derived'),
888
+ )
889
+ return result
890
+ }, {
891
+ argsDebugName: (thread, entityID, at) => createDebugName({ caller: 'liveEntityAt', thread, args: { entityID, at } }),
892
+ })
893
+
894
+ export const agentsOfThread = memoizedFn('agentsOfThread', function agentsOfThread(
895
+ thread: Thread,
896
+ ) {
897
+ DEBUG(`agentsOfThread<${thread.nameAndSizeUntracked}>`)
898
+
899
+ const mapped = new Map<string, number>()
900
+ const onEvent = (event: ThreadEvent) => {
901
+ for (const log of (isInitEvent(event) ? event.init : event.added)) {
902
+ const prev = mapped.get(log.ag) ?? 0
903
+ mapped.set(log.ag, prev + 1)
904
+ }
905
+ for (const log of (!isInitEvent(event) && event.removed || [])) {
906
+ const prev = mapped.get(log.ag)
907
+ if (!prev || prev < 1) throw ERROR(`[agentsOfThread] number is now negative`, { log, event, mapped, prev })
908
+ mapped.set(log.ag, prev - 1)
909
+ }
910
+ LOG(`agentsOfThread<${thread.nameAndSizeUntracked}> processed event`, { event, mapped })
911
+ }
912
+
913
+ onEvent({ init: thread.applogs })
914
+ thread.subscribe(onEvent, 'derived')
915
+ // TODO: cleanup via ref-counted disposal when no longer needed
916
+
917
+ return mapped
918
+ })
919
+
920
+ export const entityOverlap = memoizedFn('entityOverlap', function entityOverlapCount(
921
+ threadA: Thread,
922
+ threadB: Thread,
923
+ ) {
924
+ LOG(`entityOverlap<${threadA.nameAndSizeUntracked}, ${threadB.nameAndSizeUntracked}>`)
925
+
926
+ // Compute once — snapshot, not reactive (TODO: migrate to Subscribable)
927
+ const entitiesA = new Set(threadA.map(log => log.en))
928
+ const entitiesB = new Set(threadB.map(log => log.en))
929
+ return [...entitiesA].filter(en => entitiesB.has(en))
930
+ })
931
+
932
+ export const entityOverlapMap = function entityOverlapMap(
933
+ threadA: Thread,
934
+ threadB: Thread,
935
+ threadAName = 'incoming',
936
+ threadBName = 'current',
937
+ ) {
938
+ const useInferredVM = (en, thread: Thread) => en
939
+ const overlapping = entityOverlap(threadA, threadB)
940
+ const mapped = new Map()
941
+ overlapping.forEach(eachEntityID => (
942
+ mapped.set(eachEntityID, {
943
+ [threadAName]: useInferredVM(eachEntityID, threadA),
944
+ [threadBName]: useInferredVM(eachEntityID, threadB),
945
+ })
946
+ ))
947
+ }
948
+
949
+ export const entityOverlapCount = memoizedFn(
950
+ 'entityOverlapCount',
951
+ function entityOverlapCount(threadA: Thread, threadB: Thread) {
952
+ return entityOverlap(threadA, threadB).length
953
+ },
954
+ )
955
+
956
+ /** Live variant: entity overlap count as Subscribable<number>. */
957
+ export const liveEntityOverlapCount = memoizedFn(
958
+ 'liveEntityOverlapCount',
959
+ function liveEntityOverlapCount(threadA: Thread, threadB: Thread) {
960
+ function compute() {
961
+ const entitiesA = new Set(threadA.map(log => log.en))
962
+ const entitiesB = new Set(threadB.map(log => log.en))
963
+ return [...entitiesA].filter(en => entitiesB.has(en)).length
964
+ }
965
+
966
+ const result = new SubscribableImpl<number>(
967
+ compute(),
968
+ () => {
969
+ const unsub1 = threadA.subscribe(() => result._set(compute()), 'derived')
970
+ const unsub2 = threadB.subscribe(() => result._set(compute()), 'derived')
971
+ return () => { unsub1(); unsub2() }
972
+ },
973
+ )
974
+ return result
975
+ },
976
+ )
977
+
978
+ export const querySingle = memoizedFn('querySingle', function querySingle(
979
+ threadOrLogs: Thread | Applog[],
980
+ patternOrPatterns: Parameters<typeof query>[1],
981
+ variables: SearchContext = {},
982
+ ) {
983
+ const result = query(threadOrLogs, patternOrPatterns, variables)
984
+ // Snapshot — not reactive (TODO: migrate to Subscribable<Applog | null>)
985
+ if (result.isEmpty) return null
986
+ if (result.size > 1) throw ERROR(`[querySingle] got`, result.size, `results:`, result)
987
+ const logsOfThisNode = result.nodes[0].logsOfThisNode
988
+ if (logsOfThisNode.size != 1) throw ERROR(`[querySingle] single result, but got`, logsOfThisNode.size, `logs:`, logsOfThisNode.applogs)
989
+ return logsOfThisNode.applogs[0]
990
+ }, {
991
+ argsDebugName: (thread, pattern) => createDebugName({ caller: 'querySingle', thread, pattern }),
992
+ })
993
+
994
+ export const querySingleAndMap = memoizedFn(
995
+ 'querySingleAndMap',
996
+ function querySingleAndMap<MAP extends (keyof Applog | (Partial<{ [key in keyof Applog]: string }>))>(
997
+ threadOrLogs: Thread | Applog[],
998
+ patternOrPatterns: Parameters<typeof query>[1],
999
+ mapDef: MAP,
1000
+ variables: SearchContext = {},
1001
+ ) {
1002
+ const log = querySingle(threadOrLogs, patternOrPatterns, variables)
1003
+ // Snapshot — not reactive (TODO: migrate to Subscribable<T>)
1004
+ if (!log) return undefined
1005
+ if (typeof mapDef === 'string') {
1006
+ return log[mapDef as string]
1007
+ } else {
1008
+ return createObjMapper(mapDef)(log)
1009
+ }
1010
+ },
1011
+ {
1012
+ argsDebugName: (thread, pattern) => createDebugName({ caller: 'querySingleAndMap', thread, pattern }),
1013
+ },
1014
+ )
1015
+
1016
+ /** Live variant: querySingle returning Subscribable<Applog | null>. */
1017
+ export const liveQuerySingle = memoizedFn('liveQuerySingle', function liveQuerySingle(
1018
+ thread: Thread,
1019
+ patternOrPatterns: Parameters<typeof liveQuery>[1],
1020
+ ) {
1021
+ DEBUG(`liveQuerySingle<${thread.nameAndSizeUntracked}>`)
1022
+ const live = liveQuery(thread, patternOrPatterns)
1023
+
1024
+ function compute(): Applog | null {
1025
+ if (live.isEmpty) return null
1026
+ if (live.size > 1) throw ERROR(`[liveQuerySingle] got`, live.size, `results`)
1027
+ const logsOfThisNode = live.nodes[0].logsOfThisNode
1028
+ if (logsOfThisNode.size !== 1) throw ERROR(`[liveQuerySingle] single result, but got`, logsOfThisNode.size, `logs`)
1029
+ return logsOfThisNode.applogs[0]
1030
+ }
1031
+
1032
+ const result = new SubscribableImpl<Applog | null>(
1033
+ compute(),
1034
+ () => live.subscribe(() => {
1035
+ result._set(compute())
1036
+ }),
1037
+ )
1038
+ return result
1039
+ }, {
1040
+ argsDebugName: (thread, pattern) => createDebugName({ caller: 'liveQuerySingle', thread, pattern }),
1041
+ })
1042
+
1043
+ /** Live variant: querySingleAndMap returning Subscribable<T | undefined>. */
1044
+ export const liveQuerySingleAndMap = memoizedFn(
1045
+ 'liveQuerySingleAndMap',
1046
+ function liveQuerySingleAndMap<MAP extends (keyof Applog | (Partial<{ [key in keyof Applog]: string }>))>(
1047
+ thread: Thread,
1048
+ patternOrPatterns: Parameters<typeof liveQuery>[1],
1049
+ mapDef: MAP,
1050
+ ) {
1051
+ DEBUG(`liveQuerySingleAndMap<${thread.nameAndSizeUntracked}>`)
1052
+ const liveSingle = liveQuerySingle(thread, patternOrPatterns)
1053
+
1054
+ function compute() {
1055
+ const log = liveSingle.value
1056
+ if (!log) return undefined
1057
+ if (typeof mapDef === 'string') {
1058
+ return log[mapDef as string]
1059
+ } else {
1060
+ return createObjMapper(mapDef)(log)
1061
+ }
1062
+ }
1063
+
1064
+ const result = new SubscribableImpl<any>(
1065
+ compute(),
1066
+ () => liveSingle.subscribe(() => {
1067
+ result._set(compute())
1068
+ }),
1069
+ )
1070
+ return result
1071
+ },
1072
+ {
1073
+ argsDebugName: (thread, pattern) => createDebugName({ caller: 'liveQuerySingleAndMap', thread, pattern }),
1074
+ },
1075
+ )
1076
+
1077
+ /////////////
1078
+ // HELPERS //
1079
+ /////////////
1080
+
1081
+ /** Create a single-applog mapper function from a mapDef */
1082
+ export function makeApplogMapper<R>(
1083
+ mapDef: (keyof Applog) | (Partial<{ [key in keyof Applog]: string }>) | ((applog: Applog) => R),
1084
+ ): (applog: Applog) => R {
1085
+ if (typeof mapDef === 'function') {
1086
+ return mapDef as (applog: Applog) => R
1087
+ } else if (typeof mapDef === 'string') {
1088
+ return (log: Applog) => log[mapDef] as R
1089
+ } else {
1090
+ return createObjMapper(mapDef) as (applog: Applog) => R
1091
+ }
1092
+ }
1093
+
1094
+ /** Map an array of applogs using a mapDef */
1095
+ export function mapApplogsWith<R>(
1096
+ applogs: readonly Applog[],
1097
+ mapDef: (keyof Applog) | (Partial<{ [key in keyof Applog]: string }>) | ((applog: Applog) => R),
1098
+ ) {
1099
+ return applogs.map(makeApplogMapper(mapDef))
1100
+ }
1101
+
1102
+ export const mapThreadWith = function filterAndMapGetterFx<R>(
1103
+ thread: Thread,
1104
+ mapDef: (keyof Applog) | (Partial<{ [key in keyof Applog]: string }>) | ((applog: Applog) => R),
1105
+ ) {
1106
+ return mapApplogsWith(thread.applogs, mapDef)
1107
+ }
1108
+ export const mapQueryResultWith = function filterAndMapGetterFx<R>(
1109
+ queryResult: QueryResult,
1110
+ mapDef: string | (Partial<{ [key in keyof SearchContext]: string }>) | ((record: SearchContext) => R),
1111
+ ) {
1112
+ if (typeof mapDef === 'function') {
1113
+ return queryResult.records.map(mapDef)
1114
+ } else if (typeof mapDef === 'string') {
1115
+ return queryResult.nodes.map((node) => {
1116
+ if (!Object.hasOwn(node.record, mapDef)) {
1117
+ if (node.logsOfThisNode.size !== 1) {
1118
+ throw ERROR(`not sure what to map (it's not a var and a result node log count of ${node.logsOfThisNode.size})`)
1119
+ }
1120
+ return node.logsOfThisNode.firstLog[mapDef]
1121
+ }
1122
+ return node.record[mapDef]
1123
+ })
1124
+ } else {
1125
+ return queryResult.nodes.map((node) => {
1126
+ return createObjMapper(mapDef)(node.record)
1127
+ })
1128
+ }
1129
+ }
1130
+ /**
1131
+ * Map Applog to custom named record, e.g.:
1132
+ * { en: 'movieID', vl: 'movieName' }
1133
+ * will map the applog to { movieID: .., movieName: .. }
1134
+ */
1135
+ export function createObjMapper<FROM extends string, TO extends string>(applogFieldMap: Partial<{ [key in FROM]: TO }>) {
1136
+ return (applog: { [key in FROM]: any }) => {
1137
+ return Object.entries(applogFieldMap).reduce((acc, [key, value]) => {
1138
+ acc[value as TO] = applog[key]
1139
+ return acc
1140
+ }, {} as Partial<{ [key in TO]: ApplogValue }>)
1141
+ }
1142
+ }
1143
+
1144
+ export function startsWith(str: string) {
1145
+ return (value) => value.startsWith(str)
1146
+ }
1147
+
1148
+ export function prefixAttrs(prefix: string, attrs: readonly string[]) {
1149
+ return attrs.map(at => prefixAt(prefix, at))
1150
+ }
1151
+ export function prefixAt(prefix: string, attr: string) {
1152
+ return `${prefix}/${attr}`
1153
+ }
1154
+
1155
+ /** Inverse of prefixAt — strips everything up to and including the first `/` */
1156
+ export function stripAtPrefix(attr: string): string {
1157
+ const idx = attr.indexOf('/')
1158
+ return idx >= 0 ? attr.slice(idx + 1) : attr
1159
+ }
1160
+
1161
+ /** Create a key mapper from an explicit attribute→key record */
1162
+ export function mapAttributes<A extends string>(mapping: Record<A, string>): (attr: A) => string {
1163
+ return (attr) => mapping[attr] ?? attr
1164
+ }
1165
+
1166
+ /** Resolve key mapping options to a concrete mapper function */
1167
+ export function resolveKeyMapper(opts?: { stripAtPrefix?: true | string; mapKeys?: (attr: string) => string }): (attr: string) => string {
1168
+ if (!opts) return (attr) => attr
1169
+ if (opts.mapKeys) return opts.mapKeys
1170
+ if (opts.stripAtPrefix === true) return stripAtPrefix
1171
+ if (typeof opts.stripAtPrefix === 'string') {
1172
+ const prefix = opts.stripAtPrefix + '/'
1173
+ return (attr) => attr.startsWith(prefix) ? attr.slice(prefix.length) : attr
1174
+ }
1175
+ return (attr) => attr
1176
+ }
1177
+ export function threadFromMaybeArray(threadOrLogs: Thread | Applog[], name?: string) {
1178
+ if (!Array.isArray(threadOrLogs)) {
1179
+ return threadOrLogs
1180
+ }
1181
+ return ThreadInMemory.fromArray(threadOrLogs, name || `threadFromArray[${threadOrLogs.length}]`, true)
1182
+ }
1183
+ export function withTimeout<R>(timeoutMilliseconds: number, func: () => R) {
1184
+ if (globalQueryTimeoutTime) throw ERROR(`Nested timeout not supported`)
1185
+ globalQueryTimeoutTime = performance.now() + timeoutMilliseconds
1186
+ try {
1187
+ return func()
1188
+ } finally {
1189
+ globalQueryTimeoutTime = null
1190
+ }
1191
+ }
1192
+ function getPatternVariableNames(pattern: DatalogQueryPattern): Set<string> {
1193
+ const vars = new Set<string>()
1194
+ for (const value of Object.values(pattern)) {
1195
+ if (isVariable(value)) {
1196
+ vars.add((value as string).slice(1))
1197
+ }
1198
+ }
1199
+ return vars
1200
+ }
1201
+
1202
+ /**
1203
+ * Warn if a multi-step query has steps that are not connected via shared variables.
1204
+ * Disconnected steps produce a cartesian product instead of a join.
1205
+ */
1206
+ function warnIfDisjointQuerySteps(patterns: DatalogQueryPattern[]) {
1207
+ if (patterns.length < 2) return
1208
+
1209
+ const varSets = patterns.map(getPatternVariableNames)
1210
+ const reachable = new Set(varSets[0])
1211
+
1212
+ for (let i = 1; i < varSets.length; i++) {
1213
+ const stepVars = varSets[i]
1214
+ if (stepVars.size === 0) {
1215
+ WARN(
1216
+ `[query] Step ${i} has no variables — it produces identical results regardless of previous steps (cartesian product).`,
1217
+ `Patterns:`, patterns,
1218
+ )
1219
+ continue
1220
+ }
1221
+ const connected = [...stepVars].some(v => reachable.has(v))
1222
+ if (!connected) {
1223
+ WARN(
1224
+ `[query] Step ${i} is disconnected from previous steps — no shared variable.`,
1225
+ `This produces a cartesian product instead of a join.`,
1226
+ `Step ${i} variables: {${[...stepVars].join(', ')}}`,
1227
+ `Reachable from prior steps: {${[...reachable].join(', ')}}`,
1228
+ `Patterns:`, patterns,
1229
+ )
1230
+ }
1231
+ for (const v of stepVars) reachable.add(v)
1232
+ }
1233
+ }
1234
+
1235
+ export function throwOnTimeout() {
1236
+ if (globalQueryTimeoutTime == null) return
1237
+ if (performance.now() >= globalQueryTimeoutTime) {
1238
+ throw new QueryTimeoutError(globalQueryTimeoutTime)
1239
+ }
1240
+ }
1241
+ class QueryTimeoutError extends Error {
1242
+ constructor(message: string) {
1243
+ super(message)
1244
+ }
1245
+ }