@wovin/core 0.1.35 → 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,50 @@
1
+ import { Logger } from 'besonders-logger'
2
+ import stringify from 'safe-stable-stringify'
3
+ import { Applog, CidString } from '../applog/datom-types.ts'
4
+ import { createDebugName } from '../utils/debug-name.ts'
5
+ import { Thread } from '../thread/basic.ts'
6
+ import { ThreadInMemory } from '../thread/writeable.ts'
7
+ import { memoizedFn } from './memoized.ts'
8
+
9
+ const { WARN, LOG, DEBUG, VERBOSE, ERROR } = Logger.setup(Logger.INFO) // eslint-disable-line no-unused-vars
10
+
11
+ export interface DivergenceLeaf {
12
+ log: Applog
13
+ thread: Thread
14
+ }
15
+
16
+ export const queryDivergencesByPrev = memoizedFn('queryDivergencesByPrev', function queryConflictingByPrev(
17
+ sourceThread: Thread,
18
+ ) {
19
+ DEBUG(`queryDivergencesByPrev<${sourceThread.nameAndSizeUntracked}>`)
20
+ if (sourceThread.filters.includes('lastWriteWins')) WARN(`queryDivergencesByPrev on thread lastWriteWins`, sourceThread)
21
+
22
+ const logsForNode = new Map<CidString, Applog[]>()
23
+ const leafs = new Set<CidString>()
24
+ VERBOSE('all applogs:', sourceThread.applogs)
25
+ for (const log of sourceThread.applogs) {
26
+ let prevLogs
27
+ if (log.pv) {
28
+ prevLogs = log.pv && logsForNode.get(log.pv.toString())
29
+ leafs.delete(log.pv.toString())
30
+ }
31
+ VERBOSE('traversing log', { log, prevLogs, leafs: Array.from(leafs) })
32
+ logsForNode.set(log.cid, prevLogs ? [...prevLogs, log] : [log])
33
+ leafs.add(log.cid)
34
+ }
35
+ const divergences = Array.from(leafs).map(leafID => {
36
+ const thread = new ThreadInMemory(
37
+ createDebugName({
38
+ caller: 'DivergenceLeaf',
39
+ thread: sourceThread,
40
+ pattern: `leaf: ${leafID}`,
41
+ }),
42
+ logsForNode.get(leafID),
43
+ sourceThread.filters,
44
+ true,
45
+ )
46
+ return ({ log: thread.latestLog, thread })
47
+ })
48
+ // TODO: migrate to SubscribableArray for reactive updates
49
+ return divergences
50
+ })
@@ -0,0 +1,131 @@
1
+ import { Logger } from 'besonders-logger'
2
+ import { Applog, ApplogValue, DatalogQueryPattern, EntityID } from '../applog/datom-types.ts'
3
+ import { isInitEvent, Thread } from '../thread/basic.ts'
4
+ import { makeFilter, rollingFilter } from '../thread/filters.ts'
5
+ import { resolveKeyMapper } from './basic.ts'
6
+ import { memoizedFn } from './memoized.ts'
7
+ import { SubscribableImpl } from './subscribable.ts'
8
+ import type { Subscribable } from './subscribable.ts'
9
+ import type { StripExplicitPrefix, StripFirstPrefix } from './attr-helpers.ts'
10
+
11
+ const { DEBUG } = Logger.setup(Logger.INFO) // eslint-disable-line no-unused-vars
12
+
13
+ export function liveEntityCollection<A extends string>(
14
+ thread: Thread, discoveryPattern: DatalogQueryPattern, liveAttributes: readonly A[],
15
+ ): Subscribable<ReadonlyMap<EntityID, Record<A, ApplogValue | null>>>
16
+ export function liveEntityCollection<A extends string>(
17
+ thread: Thread, discoveryPattern: DatalogQueryPattern, liveAttributes: readonly A[],
18
+ opts: { stripAtPrefix: true },
19
+ ): Subscribable<ReadonlyMap<EntityID, Record<StripFirstPrefix<A>, ApplogValue | null>>>
20
+ export function liveEntityCollection<A extends string, P extends string>(
21
+ thread: Thread, discoveryPattern: DatalogQueryPattern, liveAttributes: readonly A[],
22
+ opts: { stripAtPrefix: P },
23
+ ): Subscribable<ReadonlyMap<EntityID, Record<StripExplicitPrefix<A, P>, ApplogValue | null>>>
24
+ export function liveEntityCollection<A extends string>(
25
+ thread: Thread, discoveryPattern: DatalogQueryPattern, liveAttributes: readonly A[],
26
+ opts: { mapKeys: (attr: A) => string },
27
+ ): Subscribable<ReadonlyMap<EntityID, Record<string, ApplogValue | null>>>
28
+ export function liveEntityCollection<A extends string>(
29
+ thread: Thread, discoveryPattern: DatalogQueryPattern, liveAttributes: readonly A[],
30
+ opts: { stripAtPrefix?: true | string; mapKeys?: (attr: A) => string },
31
+ ): Subscribable<ReadonlyMap<EntityID, Record<string, ApplogValue | null>>>
32
+ export function liveEntityCollection<A extends string>(
33
+ thread: Thread,
34
+ discoveryPattern: DatalogQueryPattern,
35
+ liveAttributes: readonly A[],
36
+ opts?: { stripAtPrefix?: true | string; mapKeys?: (attr: A) => string },
37
+ ): Subscribable<ReadonlyMap<EntityID, Record<string, ApplogValue | null>>> {
38
+ return _liveEntityCollection(thread, discoveryPattern, liveAttributes,
39
+ opts as { stripAtPrefix?: true | string; mapKeys?: (attr: string) => string })
40
+ }
41
+
42
+ const _liveEntityCollection = memoizedFn('liveEntityCollection',
43
+ function liveEntityCollection<A extends string>(
44
+ thread: Thread,
45
+ discoveryPattern: DatalogQueryPattern,
46
+ liveAttributes: readonly A[],
47
+ opts?: { stripAtPrefix?: true | string; mapKeys?: (attr: string) => string },
48
+ ): Subscribable<ReadonlyMap<EntityID, Record<string, ApplogValue | null>>> {
49
+ DEBUG('liveEntityCollection', discoveryPattern, liveAttributes)
50
+ const discoveryAttr = discoveryPattern.at as string
51
+ const allAttrs = new Set([discoveryAttr, ...liveAttributes])
52
+ const filtered = rollingFilter(thread, { at: [...allAttrs] })
53
+ const isDiscoveryMatch = makeFilter(discoveryPattern)
54
+ const attrSet = new Set<string>(liveAttributes)
55
+ const key = resolveKeyMapper(opts)
56
+
57
+ const map = new Map<EntityID, Record<string, ApplogValue | null>>()
58
+
59
+ function makeRecord(entityId: EntityID): Record<string, ApplogValue | null> {
60
+ const record = {} as Record<string, ApplogValue | null>
61
+ for (const attr of liveAttributes) record[key(attr)] = null
62
+ // Backfill from current filtered state
63
+ for (const log of filtered.applogs) {
64
+ if (log.en === entityId && attrSet.has(log.at)) {
65
+ record[key(log.at)] = log.vl
66
+ }
67
+ }
68
+ return record
69
+ }
70
+
71
+ function buildFull(applogs: readonly Applog[]) {
72
+ map.clear()
73
+ for (const log of isDiscoveryMatch(applogs)) {
74
+ if (!map.has(log.en)) map.set(log.en, makeRecord(log.en))
75
+ }
76
+ }
77
+
78
+ function addLog(log: Applog) {
79
+ // Discovery match → ensure entity exists
80
+ if (isDiscoveryMatch([log]).length > 0 && !map.has(log.en)) {
81
+ map.set(log.en, makeRecord(log.en))
82
+ return // makeRecord already backfilled attrs
83
+ }
84
+ // Attribute match → update value
85
+ if (attrSet.has(log.at)) {
86
+ const record = map.get(log.en)
87
+ if (record) record[key(log.at)] = log.vl
88
+ }
89
+ }
90
+
91
+ function removeLog(log: Applog) {
92
+ if (isDiscoveryMatch([log]).length > 0) {
93
+ // Check if entity still has another discovery match
94
+ const stillDiscovered = filtered.applogs.some(
95
+ l => l.en === log.en && isDiscoveryMatch([l]).length > 0,
96
+ )
97
+ if (!stillDiscovered) {
98
+ map.delete(log.en)
99
+ return
100
+ }
101
+ }
102
+ if (attrSet.has(log.at)) {
103
+ const record = map.get(log.en)
104
+ if (record) {
105
+ // Find current value from remaining applogs
106
+ const current = filtered.applogs.find(l => l.en === log.en && l.at === log.at)
107
+ record[key(log.at)] = current?.vl ?? null
108
+ }
109
+ }
110
+ }
111
+
112
+ // Initial build
113
+ buildFull(filtered.applogs)
114
+
115
+ const result = new SubscribableImpl<ReadonlyMap<EntityID, Record<string, ApplogValue | null>>>(
116
+ map,
117
+ () => filtered.subscribe((event) => {
118
+ if (isInitEvent(event)) {
119
+ buildFull(event.init)
120
+ } else {
121
+ // Process removes before adds — LWW updates appear as remove+add in same delta
122
+ if (event.removed) for (const log of event.removed) removeLog(log)
123
+ for (const log of event.added) addLog(log)
124
+ }
125
+ result._set(map)
126
+ }, 'derived'),
127
+ { equals: false },
128
+ )
129
+ return result
130
+ },
131
+ )
@@ -0,0 +1,102 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest'
2
+ import { finalizeApplogForInsert } from '../applog/applog-helpers.ts'
3
+ import { sortApplogsByTs } from '../applog/applog-utils.ts'
4
+ import type { Applog, ApplogForInsert } from '../applog/datom-types.ts'
5
+ import { ThreadInMemory } from '../thread/writeable.ts'
6
+ import { lastWriteWins, liveFilterAndMap } from './basic.ts'
7
+
8
+ let tsCounter = 0
9
+ function makeApplogs(inputs: ApplogForInsert[]): Applog[] {
10
+ const logs = inputs.map(input =>
11
+ finalizeApplogForInsert({
12
+ ts: new Date(1700000000000 + ++tsCounter * 1000).toISOString(),
13
+ pv: null,
14
+ ag: 'testAgent',
15
+ ...input,
16
+ } as ApplogForInsert, {}),
17
+ )
18
+ sortApplogsByTs(logs)
19
+ return logs
20
+ }
21
+
22
+ let db: ReturnType<typeof ThreadInMemory.fromArray>
23
+
24
+ beforeEach(() => {
25
+ db = ThreadInMemory.fromArray(
26
+ makeApplogs([
27
+ { en: 'p1', at: 'person/name', vl: 'Alice', ag: 'testAgent' },
28
+ { en: 'p2', at: 'person/name', vl: 'Bob', ag: 'testAgent' },
29
+ ]),
30
+ 'test-people',
31
+ )
32
+ })
33
+
34
+ describe('liveFilterAndMap — identity tracking', () => {
35
+ it('reflects added items via _push', () => {
36
+ const lww = lastWriteWins(db)
37
+ const result = liveFilterAndMap(lww, { at: 'person/name' }, 'vl')
38
+ const unsub = result.subscribe(() => {})
39
+
40
+ expect([...result.items].sort()).toEqual(['Alice', 'Bob'])
41
+
42
+ db.insert([{ en: 'p3', at: 'person/name', vl: 'Carol', ag: 'testAgent' }])
43
+
44
+ expect([...result.items].sort()).toEqual(['Alice', 'Bob', 'Carol'])
45
+ unsub()
46
+ })
47
+
48
+ it('reflects removed items via _remove (LWW supersedes)', () => {
49
+ const lww = lastWriteWins(db)
50
+ const result = liveFilterAndMap(lww, { at: 'person/name' }, 'vl')
51
+ const unsub = result.subscribe(() => {})
52
+
53
+ expect([...result.items].sort()).toEqual(['Alice', 'Bob'])
54
+
55
+ // Supersede p1's name — LWW emits removed: [oldP1], added: [newP1]
56
+ db.insert([{ en: 'p1', at: 'person/name', vl: 'Alicia', ag: 'testAgent' }])
57
+
58
+ expect([...result.items].sort()).toEqual(['Alicia', 'Bob'])
59
+ unsub()
60
+ })
61
+
62
+ it('handles successive supersedes (no leak in identity index)', () => {
63
+ const lww = lastWriteWins(db)
64
+ const result = liveFilterAndMap(lww, { at: 'person/name' }, 'vl')
65
+ const unsub = result.subscribe(() => {})
66
+
67
+ db.insert([{ en: 'p1', at: 'person/name', vl: 'Alicia', ag: 'testAgent' }])
68
+ db.insert([{ en: 'p1', at: 'person/name', vl: 'Alex', ag: 'testAgent' }])
69
+ db.insert([{ en: 'p2', at: 'person/name', vl: 'Bobby', ag: 'testAgent' }])
70
+
71
+ expect([...result.items].sort()).toEqual(['Alex', 'Bobby'])
72
+ unsub()
73
+ })
74
+
75
+ it('mapper as object form — removed routes through cidToMapped', () => {
76
+ const lww = lastWriteWins(db)
77
+ const result = liveFilterAndMap(lww, { at: 'person/name' }, { en: 'id', vl: 'name' })
78
+ const unsub = result.subscribe(() => {})
79
+
80
+ expect(result.items).toHaveLength(2)
81
+ expect(result.items.find(r => r.id === 'p1')?.name).toBe('Alice')
82
+
83
+ db.insert([{ en: 'p1', at: 'person/name', vl: 'Alicia', ag: 'testAgent' }])
84
+
85
+ expect(result.items).toHaveLength(2)
86
+ expect(result.items.find(r => r.id === 'p1')?.name).toBe('Alicia')
87
+ unsub()
88
+ })
89
+
90
+ it('mapper as function form — removed routes through cidToMapped', () => {
91
+ const lww = lastWriteWins(db)
92
+ const result = liveFilterAndMap(lww, { at: 'person/name' }, log => `${log.en}=${log.vl}`)
93
+ const unsub = result.subscribe(() => {})
94
+
95
+ expect([...result.items].sort()).toEqual(['p1=Alice', 'p2=Bob'])
96
+
97
+ db.insert([{ en: 'p1', at: 'person/name', vl: 'Alicia', ag: 'testAgent' }])
98
+
99
+ expect([...result.items].sort()).toEqual(['p1=Alicia', 'p2=Bob'])
100
+ unsub()
101
+ })
102
+ })
@@ -0,0 +1,8 @@
1
+ import { DatomPart } from '../applog/datom-types.ts'
2
+
3
+ export function includes(str: string) {
4
+ return (vl: DatomPart) => vl?.includes?.(str)
5
+ }
6
+ export function includedIn(arr: string[]) {
7
+ return (vl: DatomPart) => arr?.includes?.(vl)
8
+ }
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { memoizedFn, refCountedMemoizedFn } from './memoized.ts'
3
+
4
+ describe('memoizedFn', () => {
5
+ it('returns cached result for same args', () => {
6
+ const fn = vi.fn((a: number, b: number) => a + b)
7
+ const memo = memoizedFn('add', fn)
8
+
9
+ expect(memo(1, 2)).toBe(3)
10
+ expect(memo(1, 2)).toBe(3)
11
+ expect(fn).toHaveBeenCalledTimes(1)
12
+ })
13
+
14
+ it('computes fresh result for different args', () => {
15
+ const fn = vi.fn((a: number) => a * 2)
16
+ const memo = memoizedFn('double', fn)
17
+
18
+ expect(memo(3)).toBe(6)
19
+ expect(memo(5)).toBe(10)
20
+ expect(fn).toHaveBeenCalledTimes(2)
21
+ })
22
+
23
+ it('deep-compares non-Thread args', () => {
24
+ const fn = vi.fn((obj: { x: number }) => obj.x * 2)
25
+ const memo = memoizedFn('deep', fn)
26
+
27
+ expect(memo({ x: 3 })).toBe(6)
28
+ expect(memo({ x: 3 })).toBe(6) // new object, same shape
29
+ expect(fn).toHaveBeenCalledTimes(1)
30
+ })
31
+
32
+ it('evicts oldest entry when maxSize exceeded', () => {
33
+ const fn = vi.fn((n: number) => n * 10)
34
+ const memo = memoizedFn('lru', fn, { maxSize: 2 })
35
+
36
+ memo(1) // cache: [1]
37
+ memo(2) // cache: [1, 2]
38
+ memo(3) // cache: [2, 3] — evicts 1
39
+
40
+ expect(fn).toHaveBeenCalledTimes(3)
41
+
42
+ // 2 and 3 are cached
43
+ memo(2)
44
+ memo(3)
45
+ expect(fn).toHaveBeenCalledTimes(3)
46
+
47
+ // 1 was evicted, needs recomputation
48
+ memo(1)
49
+ expect(fn).toHaveBeenCalledTimes(4)
50
+ })
51
+ })
52
+
53
+ describe('refCountedMemoizedFn', () => {
54
+ it('returns same result for same args, increments refCount', () => {
55
+ const fn = vi.fn((n: number) => ({ value: n * 2 }))
56
+ const memo = refCountedMemoizedFn('rc', fn)
57
+
58
+ const ref1 = memo(5)
59
+ const ref2 = memo(5)
60
+ expect(ref1.value).toBe(ref2.value) // same object
61
+ expect(fn).toHaveBeenCalledTimes(1)
62
+ })
63
+
64
+ it('calls onCleanup when last ref released', () => {
65
+ const cleanup = vi.fn()
66
+ const fn = vi.fn((n: number) => n * 2)
67
+ const memo = refCountedMemoizedFn('rc', fn, { onCleanup: cleanup })
68
+
69
+ const ref1 = memo(5)
70
+ const ref2 = memo(5)
71
+
72
+ ref1.release()
73
+ expect(cleanup).not.toHaveBeenCalled()
74
+
75
+ ref2.release()
76
+ expect(cleanup).toHaveBeenCalledOnce()
77
+ expect(cleanup).toHaveBeenCalledWith(10, 5) // result, ...args
78
+ })
79
+
80
+ it('recomputes after full cleanup', () => {
81
+ const fn = vi.fn((n: number) => n * 2)
82
+ const memo = refCountedMemoizedFn('rc', fn, { onCleanup: () => {} })
83
+
84
+ const ref1 = memo(3)
85
+ ref1.release()
86
+ expect(fn).toHaveBeenCalledTimes(1)
87
+
88
+ const ref2 = memo(3)
89
+ expect(fn).toHaveBeenCalledTimes(2)
90
+ ref2.release()
91
+ })
92
+
93
+ it('release is idempotent', () => {
94
+ const cleanup = vi.fn()
95
+ const fn = vi.fn((n: number) => n * 2)
96
+ const memo = refCountedMemoizedFn('rc', fn, { onCleanup: cleanup })
97
+
98
+ const ref = memo(1)
99
+ ref.release()
100
+ ref.release() // second release is a no-op
101
+ expect(cleanup).toHaveBeenCalledOnce()
102
+ })
103
+
104
+ it('grace period delays cleanup', () => {
105
+ vi.useFakeTimers()
106
+ const cleanup = vi.fn()
107
+ const fn = vi.fn((n: number) => n * 2)
108
+ const memo = refCountedMemoizedFn('rc', fn, {
109
+ onCleanup: cleanup,
110
+ gracePeriodMs: 100,
111
+ })
112
+
113
+ const ref = memo(5)
114
+ ref.release()
115
+
116
+ // Not cleaned up yet
117
+ expect(cleanup).not.toHaveBeenCalled()
118
+
119
+ // Re-acquire within grace period
120
+ const ref2 = memo(5)
121
+ expect(fn).toHaveBeenCalledTimes(1) // reused, not recomputed
122
+
123
+ vi.advanceTimersByTime(200)
124
+ expect(cleanup).not.toHaveBeenCalled() // still held by ref2
125
+
126
+ ref2.release()
127
+ vi.advanceTimersByTime(50)
128
+ expect(cleanup).not.toHaveBeenCalled() // still in grace
129
+
130
+ vi.advanceTimersByTime(60)
131
+ expect(cleanup).toHaveBeenCalledOnce()
132
+
133
+ vi.useRealTimers()
134
+ })
135
+
136
+ it('separate entries for different args', () => {
137
+ const cleanup = vi.fn()
138
+ const fn = vi.fn((n: number) => n * 2)
139
+ const memo = refCountedMemoizedFn('rc', fn, { onCleanup: cleanup })
140
+
141
+ const refA = memo(1)
142
+ const refB = memo(2)
143
+ expect(fn).toHaveBeenCalledTimes(2)
144
+
145
+ refA.release()
146
+ expect(cleanup).toHaveBeenCalledWith(2, 1)
147
+
148
+ refB.release()
149
+ expect(cleanup).toHaveBeenCalledWith(4, 2)
150
+ })
151
+ })
@@ -0,0 +1,180 @@
1
+ /**
2
+ * memoizedFn — Simple argument memoization without MobX.
3
+ *
4
+ * Replaces computedFnDeepCompare for query functions.
5
+ * With lazy subscribe (SubscribableArray), there's no urgency to clean up
6
+ * cached entries — unsubscribed entries are just plain arrays in memory.
7
+ *
8
+ * refCountedMemoizedFn — Ref-counted memoization for reactive results.
9
+ * Returns a wrapper that increments a ref count on each call and decrements
10
+ * on dispose(). When refCount hits 0, the entry is cleaned up (with optional
11
+ * grace period).
12
+ */
13
+
14
+ import { isEqual } from 'lodash-es'
15
+ import { Thread } from '../thread/basic.ts'
16
+
17
+ /** Deep-compare args, but Thread instances by identity (same as MobX version) */
18
+ function compareStructural(argsA: any[], argsB: any[], versionsA?: number[]): boolean {
19
+ if (argsA.length !== argsB.length) return false
20
+ for (let i = 0; i < argsA.length; i++) {
21
+ if (argsA[i] instanceof Thread) {
22
+ if (argsB[i] !== argsA[i]) return false
23
+ // Check if thread was mutated since the cache entry was created
24
+ if (versionsA && versionsA[i] !== undefined && argsB[i]._version !== versionsA[i]) return false
25
+ } else {
26
+ if (!isEqual(argsA[i], argsB[i])) return false
27
+ }
28
+ }
29
+ return true
30
+ }
31
+
32
+ /** Snapshot Thread._version for each Thread arg */
33
+ function snapshotVersions(args: any[]): number[] {
34
+ return args.map(a => a instanceof Thread ? a._version : undefined)
35
+ }
36
+
37
+ interface MemoizedFnOptions {
38
+ argsEqual?: (a: any[], b: any[]) => boolean
39
+ argsDebugName?: (...args: any[]) => string
40
+ /** Max cache entries. Oldest evicted when exceeded. Default: unlimited. */
41
+ maxSize?: number
42
+ }
43
+
44
+ export function memoizedFn<T extends (...args: any[]) => any>(
45
+ name: string,
46
+ fn: T,
47
+ opts?: MemoizedFnOptions,
48
+ ): T {
49
+ // TODO: cache grows unboundedly by default — see todo/2026-04-17_ciao-mobx/memoized-cache-cleanup.md for analysis
50
+ const cache: Array<{ args: any[]; versions: number[]; result: any }> = []
51
+ const argsEqual = opts?.argsEqual ?? compareStructural
52
+ const maxSize = opts?.maxSize ?? Infinity
53
+
54
+ return function (this: any, ...args: any[]) {
55
+ const existing = cache.find(entry => argsEqual(entry.args, args, entry.versions))
56
+ if (existing) return existing.result
57
+
58
+ const result = fn.apply(this, args)
59
+
60
+ if (cache.length >= maxSize) {
61
+ cache.shift() // evict oldest
62
+ }
63
+ cache.push({ args, versions: snapshotVersions(args), result })
64
+ return result
65
+ } as any
66
+ }
67
+
68
+ // ═══════════════════════════════════════════════════════════════
69
+ // refCountedMemoizedFn — ref-counted cache with disposal
70
+ // ═══════════════════════════════════════════════════════════════
71
+
72
+ /** A result wrapper returned by refCountedMemoizedFn. Call release() when done. */
73
+ export interface RefCounted<T> {
74
+ readonly value: T
75
+ /** Decrement ref count. When it reaches 0, the cache entry is cleaned up. */
76
+ release(): void
77
+ }
78
+
79
+ interface RefCountedOptions<T extends (...args: any[]) => any> {
80
+ argsEqual?: (a: any[], b: any[]) => boolean
81
+ argsDebugName?: (...args: any[]) => string
82
+ /**
83
+ * Called when the last reference is released and the entry is evicted.
84
+ * Use to tear down subscriptions, dispose SubscribableArrays, etc.
85
+ */
86
+ onCleanup?: (result: ReturnType<T>, ...args: Parameters<T>) => void
87
+ /**
88
+ * Grace period in ms before actually cleaning up after refCount hits 0.
89
+ * If someone re-acquires within this window, the entry is reused.
90
+ * Default: 0 (immediate cleanup).
91
+ */
92
+ gracePeriodMs?: number
93
+ }
94
+
95
+ interface CacheEntry<T> {
96
+ args: any[]
97
+ result: T
98
+ refCount: number
99
+ graceTimer: ReturnType<typeof setTimeout> | null
100
+ }
101
+
102
+ /**
103
+ * Creates a memoized function whose results are ref-counted.
104
+ *
105
+ * Currently unused — doesn't fit liveQuery's recursive multi-step structure
106
+ * (API break, cache loss on pattern change without grace period).
107
+ * See todo/2026-04-17_ciao-mobx/memoized-cache-cleanup.md for full analysis.
108
+ *
109
+ * Each call returns `RefCounted<ReturnType<fn>>`. Multiple calls with the same
110
+ * args return the same cached result with an incremented ref count.
111
+ * When all holders call `release()`, the cache entry is cleaned up
112
+ * (subject to optional grace period).
113
+ *
114
+ * Usage:
115
+ * ```
116
+ * const getFiltered = refCountedMemoizedFn('rollingFilter', (thread, pattern) => {
117
+ * // ... expensive setup, returns MappedThread
118
+ * }, { onCleanup: (result) => result.dispose() })
119
+ *
120
+ * const ref = getFiltered(myThread, myPattern)
121
+ * // use ref.value (the MappedThread)
122
+ * ref.release() // decrement; when last holder releases, onCleanup fires
123
+ * ```
124
+ */
125
+ export function refCountedMemoizedFn<T extends (...args: any[]) => any>(
126
+ name: string,
127
+ fn: T,
128
+ opts?: RefCountedOptions<T>,
129
+ ): (...args: Parameters<T>) => RefCounted<ReturnType<T>> {
130
+ const cache: CacheEntry<ReturnType<T>>[] = []
131
+ const argsEqual = opts?.argsEqual ?? compareStructural
132
+ const gracePeriodMs = opts?.gracePeriodMs ?? 0
133
+
134
+ function evict(entry: CacheEntry<ReturnType<T>>, args: any[]) {
135
+ const idx = cache.indexOf(entry)
136
+ if (idx >= 0) cache.splice(idx, 1)
137
+ opts?.onCleanup?.(entry.result, ...args as any)
138
+ }
139
+
140
+ return function (this: any, ...args: Parameters<T>): RefCounted<ReturnType<T>> {
141
+ let entry = cache.find(e => argsEqual(e.args, args))
142
+
143
+ if (entry) {
144
+ // Cancel pending grace-period cleanup
145
+ if (entry.graceTimer !== null) {
146
+ clearTimeout(entry.graceTimer)
147
+ entry.graceTimer = null
148
+ }
149
+ entry.refCount++
150
+ } else {
151
+ const result = fn.apply(this, args)
152
+ entry = { args, result, refCount: 1, graceTimer: null }
153
+ cache.push(entry)
154
+ }
155
+
156
+ const capturedEntry = entry
157
+ let released = false
158
+
159
+ return {
160
+ get value() { return capturedEntry.result },
161
+ release() {
162
+ if (released) return // idempotent
163
+ released = true
164
+ capturedEntry.refCount--
165
+
166
+ if (capturedEntry.refCount <= 0) {
167
+ if (gracePeriodMs > 0) {
168
+ capturedEntry.graceTimer = setTimeout(() => {
169
+ if (capturedEntry.refCount <= 0) {
170
+ evict(capturedEntry, args)
171
+ }
172
+ }, gracePeriodMs)
173
+ } else {
174
+ evict(capturedEntry, args)
175
+ }
176
+ }
177
+ },
178
+ }
179
+ }
180
+ }
@@ -0,0 +1,4 @@
1
+ export const q = {
2
+ not: () => {
3
+ },
4
+ }