@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.
- package/README.md +0 -12
- package/dist/applog/applog-helpers.d.ts +12 -12
- package/dist/applog/applog-helpers.d.ts.map +1 -1
- package/dist/applog/applog-utils.d.ts +25 -6
- package/dist/applog/applog-utils.d.ts.map +1 -1
- package/dist/applog/datom-types.d.ts +4 -5
- package/dist/applog/datom-types.d.ts.map +1 -1
- package/dist/applog.d.ts +3 -3
- package/dist/applog.d.ts.map +1 -1
- package/dist/{applog.min.js → applog.js} +6 -7
- package/dist/blockstore.d.ts +1 -1
- package/dist/blockstore.d.ts.map +1 -1
- package/dist/{blockstore.min.js → blockstore.js} +1 -3
- package/dist/{blockstore.min.js.map → blockstore.js.map} +1 -1
- package/dist/{chunk-KXMTKPF4.min.js → chunk-3JZMOEOD.js} +8 -8
- package/dist/chunk-3JZMOEOD.js.map +1 -0
- package/dist/chunk-3WZVG277.js +434 -0
- package/dist/chunk-3WZVG277.js.map +1 -0
- package/dist/chunk-7Z5YDQKK.js +1 -0
- package/dist/chunk-CPSDKFBG.js +147 -0
- package/dist/chunk-CPSDKFBG.js.map +1 -0
- package/dist/chunk-E46VTKTZ.js +1 -0
- package/dist/{chunk-H3VQJP56.min.js → chunk-J2FDHGOZ.js} +9 -9
- package/dist/chunk-J2FDHGOZ.js.map +1 -0
- package/dist/chunk-L5EEEGE6.js +1862 -0
- package/dist/chunk-L5EEEGE6.js.map +1 -0
- package/dist/{chunk-BRC7LSM6.min.js → chunk-PD3C7XUM.js} +5 -5
- package/dist/chunk-PD3C7XUM.js.map +1 -0
- package/dist/chunk-QZXKQCAY.js +1026 -0
- package/dist/chunk-QZXKQCAY.js.map +1 -0
- package/dist/{chunk-QPGEBDMJ.min.js → chunk-YDAKBU6Q.js} +1 -1
- package/dist/chunk-YDAKBU6Q.js.map +1 -0
- package/dist/chunk-ZAADLBSB.js +36 -0
- package/dist/chunk-ZAADLBSB.js.map +1 -0
- package/dist/index.d.ts +7 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/{index.min.js → index.js} +73 -46
- package/dist/ipfs/car.d.ts +11 -11
- package/dist/ipfs/car.d.ts.map +1 -1
- package/dist/ipfs/ipfs-utils.d.ts +2 -2
- package/dist/ipfs/ipfs-utils.d.ts.map +1 -1
- package/dist/ipfs.d.ts +3 -3
- package/dist/ipfs.d.ts.map +1 -1
- package/dist/{ipfs.min.js → ipfs.js} +7 -10
- package/dist/ipns.d.ts +1 -1
- package/dist/ipns.d.ts.map +1 -1
- package/dist/ipns.js +64 -0
- package/dist/ipns.js.map +1 -0
- package/dist/pubsub/pub-pull.d.ts +3 -3
- package/dist/pubsub/pub-pull.d.ts.map +1 -1
- package/dist/pubsub/pubsub-types.d.ts +3 -3
- package/dist/pubsub/pubsub-types.d.ts.map +1 -1
- package/dist/pubsub/snap-push.d.ts +4 -4
- package/dist/pubsub/snap-push.d.ts.map +1 -1
- package/dist/pubsub/ucan.d.ts +1 -1
- package/dist/pubsub/ucan.d.ts.map +1 -1
- package/dist/pubsub.d.ts +4 -4
- package/dist/pubsub.d.ts.map +1 -1
- package/dist/{pubsub.min.js → pubsub.js} +7 -10
- package/dist/query/attr-helpers.d.ts +5 -0
- package/dist/query/attr-helpers.d.ts.map +1 -0
- package/dist/query/basic.d.ts +85 -21
- package/dist/query/basic.d.ts.map +1 -1
- package/dist/query/divergences.d.ts +5 -5
- package/dist/query/divergences.d.ts.map +1 -1
- package/dist/query/entity-collection.d.ts +19 -0
- package/dist/query/entity-collection.d.ts.map +1 -0
- package/dist/query/matchers.d.ts +1 -1
- package/dist/query/matchers.d.ts.map +1 -1
- package/dist/query/memoized.d.ts +66 -0
- package/dist/query/memoized.d.ts.map +1 -0
- package/dist/query/situations.d.ts +2 -1
- package/dist/query/situations.d.ts.map +1 -1
- package/dist/query/subscribable.d.ts +111 -0
- package/dist/query/subscribable.d.ts.map +1 -0
- package/dist/query/types.d.ts +54 -14
- package/dist/query/types.d.ts.map +1 -1
- package/dist/query.d.ts +9 -5
- package/dist/query.d.ts.map +1 -1
- package/dist/{query.min.js → query.js} +51 -32
- package/dist/retrieve/index.d.ts +1 -1
- package/dist/retrieve/index.d.ts.map +1 -1
- package/dist/retrieve/update-thread.d.ts +3 -3
- package/dist/retrieve/update-thread.d.ts.map +1 -1
- package/dist/retrieve.d.ts +1 -1
- package/dist/retrieve.d.ts.map +1 -1
- package/dist/retrieve.js +14 -0
- package/dist/thread/basic.d.ts +15 -19
- package/dist/thread/basic.d.ts.map +1 -1
- package/dist/thread/filters.d.ts +8 -10
- package/dist/thread/filters.d.ts.map +1 -1
- package/dist/thread/indexes.d.ts +56 -0
- package/dist/thread/indexes.d.ts.map +1 -0
- package/dist/thread/mapped.d.ts +40 -11
- package/dist/thread/mapped.d.ts.map +1 -1
- package/dist/thread/utils.d.ts +5 -5
- package/dist/thread/utils.d.ts.map +1 -1
- package/dist/thread/writeable.d.ts +2 -2
- package/dist/thread/writeable.d.ts.map +1 -1
- package/dist/thread.d.ts +6 -5
- package/dist/thread.d.ts.map +1 -1
- package/dist/{thread.min.js → thread.js} +9 -6
- package/dist/types/typescript-utils.d.ts +6 -5
- package/dist/types/typescript-utils.d.ts.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/{types.min.js → types.js} +3 -4
- package/dist/utils/debug-name.d.ts +13 -0
- package/dist/utils/debug-name.d.ts.map +1 -0
- package/dist/utils.d.ts +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +9 -0
- package/package.json +32 -23
- package/src/applog/applog-helpers.ts +155 -0
- package/src/applog/applog-utils.test.ts +108 -0
- package/src/applog/applog-utils.ts +507 -0
- package/src/applog/datom-types.ts +148 -0
- package/src/applog.ts +3 -0
- package/src/blockstore/index.ts +36 -0
- package/src/blockstore.ts +1 -0
- package/src/index.ts +8 -0
- package/src/ipfs/car.ts +291 -0
- package/src/ipfs/fetch-snapshot-chain.ts +135 -0
- package/src/ipfs/ipfs-utils.ts +132 -0
- package/src/ipfs.ts +3 -0
- package/src/ipns/ipns-record.ts +115 -0
- package/src/ipns.ts +1 -0
- package/src/pubsub/UCAN Specs Overview.md +217 -0
- package/src/pubsub/connector.ts +9 -0
- package/src/pubsub/pub-pull.ts +31 -0
- package/src/pubsub/pubsub-types.ts +90 -0
- package/src/pubsub/snap-push.ts +277 -0
- package/src/pubsub/ucan-example.ts +61 -0
- package/src/pubsub/ucan.ts +56 -0
- package/src/pubsub.ts +4 -0
- package/src/query/attr-helpers.ts +5 -0
- package/src/query/basic.ts +1245 -0
- package/src/query/divergences.ts +50 -0
- package/src/query/entity-collection.ts +131 -0
- package/src/query/liveFilterAndMap.test.ts +102 -0
- package/src/query/matchers.ts +8 -0
- package/src/query/memoized.test.ts +151 -0
- package/src/query/memoized.ts +180 -0
- package/src/query/query-steps.ts +4 -0
- package/src/query/query.test.ts +538 -0
- package/src/query/situations.ts +261 -0
- package/src/query/subscribable.test.ts +245 -0
- package/src/query/subscribable.ts +234 -0
- package/src/query/types.ts +155 -0
- package/src/query/withoutDeleted.test.ts +204 -0
- package/src/query.ts +9 -0
- package/src/retrieve/index.ts +1 -0
- package/src/retrieve/update-thread.ts +248 -0
- package/src/retrieve.ts +1 -0
- package/src/test/perf/query.1m.perf.test.ts +94 -0
- package/src/test/perf/query.perf.test.ts +389 -0
- package/src/test/perf/query.realdata.perf.test.ts +182 -0
- package/src/thread/basic.ts +209 -0
- package/src/thread/filters.ts +227 -0
- package/src/thread/indexes.ts +250 -0
- package/src/thread/joinThreads.test.ts +304 -0
- package/src/thread/mapped.ts +226 -0
- package/src/thread/utils.ts +144 -0
- package/src/thread/writeable.ts +163 -0
- package/src/thread.ts +6 -0
- package/src/types/typescript-utils.ts +64 -0
- package/src/types.ts +1 -0
- package/src/utils/debug-name.ts +54 -0
- package/src/utils.ts +4 -0
- package/dist/chunk-2Y2PYHGR.min.js +0 -65
- package/dist/chunk-2Y2PYHGR.min.js.map +0 -1
- package/dist/chunk-5MMGBK2U.min.js +0 -1
- package/dist/chunk-7IDQIMQO.min.js +0 -1
- package/dist/chunk-BRC7LSM6.min.js.map +0 -1
- package/dist/chunk-COXXILXC.min.js +0 -512
- package/dist/chunk-COXXILXC.min.js.map +0 -1
- package/dist/chunk-GDX2OO7L.min.js +0 -9080
- package/dist/chunk-GDX2OO7L.min.js.map +0 -1
- package/dist/chunk-H3VQJP56.min.js.map +0 -1
- package/dist/chunk-HYMC7W6S.min.js +0 -1549
- package/dist/chunk-HYMC7W6S.min.js.map +0 -1
- package/dist/chunk-KEHU7HGZ.min.js +0 -5216
- package/dist/chunk-KEHU7HGZ.min.js.map +0 -1
- package/dist/chunk-KXMTKPF4.min.js.map +0 -1
- package/dist/chunk-PHITDXZT.min.js +0 -36
- package/dist/chunk-QO2KMGDN.min.js +0 -3771
- package/dist/chunk-QO2KMGDN.min.js.map +0 -1
- package/dist/chunk-QPGEBDMJ.min.js.map +0 -1
- package/dist/chunk-WXLCBTHX.min.js +0 -1606
- package/dist/chunk-WXLCBTHX.min.js.map +0 -1
- package/dist/ipns.min.js +0 -6419
- package/dist/ipns.min.js.map +0 -1
- package/dist/mobx/mobx-utils.d.ts +0 -82
- package/dist/mobx/mobx-utils.d.ts.map +0 -1
- package/dist/mobx.d.ts +0 -2
- package/dist/mobx.d.ts.map +0 -1
- package/dist/mobx.min.js +0 -141
- package/dist/retrieve.min.js +0 -17
- package/dist/types.min.js.map +0 -1
- package/dist/utils.min.js +0 -10
- package/dist/utils.min.js.map +0 -1
- /package/dist/{applog.min.js.map → applog.js.map} +0 -0
- /package/dist/{chunk-5MMGBK2U.min.js.map → chunk-7Z5YDQKK.js.map} +0 -0
- /package/dist/{chunk-7IDQIMQO.min.js.map → chunk-E46VTKTZ.js.map} +0 -0
- /package/dist/{chunk-PHITDXZT.min.js.map → index.js.map} +0 -0
- /package/dist/{index.min.js.map → ipfs.js.map} +0 -0
- /package/dist/{ipfs.min.js.map → pubsub.js.map} +0 -0
- /package/dist/{mobx.min.js.map → query.js.map} +0 -0
- /package/dist/{pubsub.min.js.map → retrieve.js.map} +0 -0
- /package/dist/{query.min.js.map → thread.js.map} +0 -0
- /package/dist/{retrieve.min.js.map → types.js.map} +0 -0
- /package/dist/{thread.min.js.map → utils.js.map} +0 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push-based subscribable primitives for the query system.
|
|
3
|
+
*
|
|
4
|
+
* Two primitives:
|
|
5
|
+
* - Subscribable<T> — any value + "changed" notifications
|
|
6
|
+
* - SubscribableArray<T> — array + incremental delta events (added/removed)
|
|
7
|
+
*
|
|
8
|
+
* Key property: **lazy subscribe** — upstream subscriptions only activate
|
|
9
|
+
* when the first subscriber attaches, and deactivate when the last leaves.
|
|
10
|
+
* This means one-off reads (.value / .items) have zero subscription overhead.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type Unsubscribe = () => void
|
|
14
|
+
|
|
15
|
+
// ═══════════════════════════════════════════════════════════════
|
|
16
|
+
// Subscribable<T> — generic single-value
|
|
17
|
+
// ═══════════════════════════════════════════════════════════════
|
|
18
|
+
|
|
19
|
+
export interface Subscribable<T> {
|
|
20
|
+
/** Current value — plain read, no side effects */
|
|
21
|
+
readonly value: T
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Subscribe to change notifications.
|
|
25
|
+
* - First call activates upstream subscriptions (lazy).
|
|
26
|
+
* - Callback does NOT fire immediately — read .value for current state.
|
|
27
|
+
* - Callback fires whenever .value changes.
|
|
28
|
+
* - Last unsubscribe deactivates upstream.
|
|
29
|
+
*/
|
|
30
|
+
subscribe(cb: () => void, type?: 'derived' | 'reaction'): Unsubscribe
|
|
31
|
+
|
|
32
|
+
/** Tear down all internal subscriptions */
|
|
33
|
+
dispose(): void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Implementation of Subscribable<T> with lazy upstream activation.
|
|
38
|
+
*/
|
|
39
|
+
export class SubscribableImpl<T> implements Subscribable<T> {
|
|
40
|
+
private _value: T
|
|
41
|
+
private _derivedSubscribers: (() => void)[] = []
|
|
42
|
+
private _subscribers: (() => void)[] = []
|
|
43
|
+
private _upstreamActive = false
|
|
44
|
+
private _activateUpstream: (() => Unsubscribe) | null
|
|
45
|
+
private _deactivateUpstream: Unsubscribe | null = null
|
|
46
|
+
private _equals: (a: T, b: T) => boolean
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
initialValue: T,
|
|
50
|
+
activateUpstream?: () => Unsubscribe,
|
|
51
|
+
opts?: { equals?: false | ((a: T, b: T) => boolean) },
|
|
52
|
+
) {
|
|
53
|
+
this._value = initialValue
|
|
54
|
+
this._activateUpstream = activateUpstream ?? null
|
|
55
|
+
this._equals = opts?.equals === false ? () => false : (opts?.equals ?? ((a, b) => a === b))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get value(): T { return this._value }
|
|
59
|
+
|
|
60
|
+
subscribe(cb: () => void, type?: 'derived' | 'reaction'): Unsubscribe {
|
|
61
|
+
if (!this._upstreamActive && this._activateUpstream) {
|
|
62
|
+
this._deactivateUpstream = this._activateUpstream()
|
|
63
|
+
this._upstreamActive = true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const list = type === 'derived' ? this._derivedSubscribers : this._subscribers
|
|
67
|
+
list.push(cb)
|
|
68
|
+
// No immediate callback — subscriber reads .value for current state
|
|
69
|
+
|
|
70
|
+
return () => {
|
|
71
|
+
const idx = list.indexOf(cb)
|
|
72
|
+
if (idx >= 0) list.splice(idx, 1)
|
|
73
|
+
|
|
74
|
+
if (this._derivedSubscribers.length === 0 && this._subscribers.length === 0 && this._upstreamActive) {
|
|
75
|
+
this._deactivateUpstream?.()
|
|
76
|
+
this._deactivateUpstream = null
|
|
77
|
+
this._upstreamActive = false
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Update value and notify subscribers (skips if equals check passes) */
|
|
83
|
+
_set(value: T) {
|
|
84
|
+
if (this._equals(value, this._value)) return
|
|
85
|
+
this._value = value
|
|
86
|
+
this._notify()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private _notify() {
|
|
90
|
+
const derived = [...this._derivedSubscribers]
|
|
91
|
+
for (const sub of derived) sub()
|
|
92
|
+
const subs = [...this._subscribers]
|
|
93
|
+
for (const sub of subs) sub()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
dispose() {
|
|
97
|
+
this._deactivateUpstream?.()
|
|
98
|
+
this._deactivateUpstream = null
|
|
99
|
+
this._derivedSubscribers.length = 0
|
|
100
|
+
this._subscribers.length = 0
|
|
101
|
+
this._upstreamActive = false
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ═══════════════════════════════════════════════════════════════
|
|
106
|
+
// SubscribableArray<T> — array with delta events
|
|
107
|
+
// ═══════════════════════════════════════════════════════════════
|
|
108
|
+
|
|
109
|
+
/** Delta events — mirrors ThreadEvent shape */
|
|
110
|
+
export type ArrayEvent<T> =
|
|
111
|
+
| { init: readonly T[] }
|
|
112
|
+
| { added: readonly T[]; removed: readonly T[] | null }
|
|
113
|
+
|
|
114
|
+
/** Type guard for init events. Same logic as thread's isInitEvent. */
|
|
115
|
+
export function isArrayInitEvent<T>(event: ArrayEvent<T>): event is { init: readonly T[] } {
|
|
116
|
+
return (event as any).init !== undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface SubscribableArray<T> {
|
|
120
|
+
/**
|
|
121
|
+
* Current snapshot — plain readonly array.
|
|
122
|
+
*
|
|
123
|
+
* NOTE: only stays current while at least one subscriber is active
|
|
124
|
+
* (upstream is lazily activated on first `.subscribe()`). With no
|
|
125
|
+
* subscribers this returns the initial snapshot from construction
|
|
126
|
+
* and does NOT reflect later mutations. Tests/consumers that want
|
|
127
|
+
* to observe updates must hold a subscription (`subscribe(() => {})`
|
|
128
|
+
* is enough).
|
|
129
|
+
*/
|
|
130
|
+
readonly items: readonly T[]
|
|
131
|
+
|
|
132
|
+
/** Length shortcut */
|
|
133
|
+
readonly length: number
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Subscribe to delta events.
|
|
137
|
+
* - First call activates upstream subscriptions (lazy).
|
|
138
|
+
* - No init event on subscribe — read .items for current state.
|
|
139
|
+
* - Receives `{ init }` only on genuine resets (triggerRemap).
|
|
140
|
+
* - Last unsubscribe deactivates upstream.
|
|
141
|
+
*/
|
|
142
|
+
subscribe(cb: (event: ArrayEvent<T>) => void, type?: 'derived' | 'reaction'): Unsubscribe
|
|
143
|
+
|
|
144
|
+
/** Tear down all internal subscriptions */
|
|
145
|
+
dispose(): void
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Implementation of SubscribableArray with lazy upstream activation.
|
|
150
|
+
*
|
|
151
|
+
* Constructor takes initial items (computed synchronously at query time)
|
|
152
|
+
* and an optional activation function that sets up upstream subscriptions.
|
|
153
|
+
* The activation function is only called on first `.subscribe()`.
|
|
154
|
+
*/
|
|
155
|
+
export class SubscribableArrayImpl<T> implements SubscribableArray<T> {
|
|
156
|
+
private _items: T[]
|
|
157
|
+
private _derivedSubscribers: ((event: ArrayEvent<T>) => void)[] = []
|
|
158
|
+
private _subscribers: ((event: ArrayEvent<T>) => void)[] = []
|
|
159
|
+
private _upstreamActive = false
|
|
160
|
+
private _activateUpstream: (() => Unsubscribe) | null
|
|
161
|
+
private _deactivateUpstream: Unsubscribe | null = null
|
|
162
|
+
|
|
163
|
+
constructor(
|
|
164
|
+
initialItems: T[],
|
|
165
|
+
activateUpstream?: () => Unsubscribe,
|
|
166
|
+
) {
|
|
167
|
+
this._items = initialItems
|
|
168
|
+
this._activateUpstream = activateUpstream ?? null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
get items(): readonly T[] { return this._items }
|
|
172
|
+
get length(): number { return this._items.length }
|
|
173
|
+
|
|
174
|
+
subscribe(cb: (event: ArrayEvent<T>) => void, type?: 'derived' | 'reaction'): Unsubscribe {
|
|
175
|
+
// Activate upstream on first subscriber (lazy)
|
|
176
|
+
if (!this._upstreamActive && this._activateUpstream) {
|
|
177
|
+
this._deactivateUpstream = this._activateUpstream()
|
|
178
|
+
this._upstreamActive = true
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const list = type === 'derived' ? this._derivedSubscribers : this._subscribers
|
|
182
|
+
list.push(cb)
|
|
183
|
+
// No init event — subscriber reads .items for current state
|
|
184
|
+
|
|
185
|
+
return () => {
|
|
186
|
+
const idx = list.indexOf(cb)
|
|
187
|
+
if (idx >= 0) list.splice(idx, 1)
|
|
188
|
+
|
|
189
|
+
// Deactivate upstream when last subscriber leaves
|
|
190
|
+
if (this._derivedSubscribers.length === 0 && this._subscribers.length === 0 && this._upstreamActive) {
|
|
191
|
+
this._deactivateUpstream?.()
|
|
192
|
+
this._deactivateUpstream = null
|
|
193
|
+
this._upstreamActive = false
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Push items and notify subscribers */
|
|
199
|
+
_push(...items: T[]) {
|
|
200
|
+
this._items.push(...items)
|
|
201
|
+
this._notify({ added: items, removed: null })
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Remove items and notify subscribers */
|
|
205
|
+
_remove(items: readonly T[]) {
|
|
206
|
+
for (const item of items) {
|
|
207
|
+
const idx = this._items.indexOf(item)
|
|
208
|
+
if (idx >= 0) this._items.splice(idx, 1)
|
|
209
|
+
}
|
|
210
|
+
this._notify({ added: [], removed: items })
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Full reset — replace all items */
|
|
214
|
+
_reset(items: T[]) {
|
|
215
|
+
this._items = items
|
|
216
|
+
this._notify({ init: [...this._items] })
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private _notify(event: ArrayEvent<T>) {
|
|
220
|
+
// Snapshot: subscriber callbacks may synchronously unsubscribe during iteration
|
|
221
|
+
const derived = [...this._derivedSubscribers]
|
|
222
|
+
for (const sub of derived) sub(event)
|
|
223
|
+
const subs = [...this._subscribers]
|
|
224
|
+
for (const sub of subs) sub(event)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
dispose() {
|
|
228
|
+
this._deactivateUpstream?.()
|
|
229
|
+
this._deactivateUpstream = null
|
|
230
|
+
this._derivedSubscribers.length = 0
|
|
231
|
+
this._subscribers.length = 0
|
|
232
|
+
this._upstreamActive = false
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { joinThreads } from '../applog/applog-helpers.ts'
|
|
2
|
+
import { SearchContext } from '../applog/datom-types.ts'
|
|
3
|
+
import type { Thread } from '../thread/basic.ts'
|
|
4
|
+
import { ArrayEvent, SubscribableArray, SubscribableArrayImpl, Unsubscribe } from './subscribable.ts'
|
|
5
|
+
|
|
6
|
+
export class QueryNode {
|
|
7
|
+
constructor(
|
|
8
|
+
readonly logsOfThisNode: Thread,
|
|
9
|
+
readonly variables: SearchContext,
|
|
10
|
+
readonly prevNode: QueryNode | null = null,
|
|
11
|
+
) {}
|
|
12
|
+
get record() {
|
|
13
|
+
return this.variables // alias for end-user consumption
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get threadOfTrail() {
|
|
17
|
+
if (!this.prevNode) return this.logsOfThisNode
|
|
18
|
+
return joinThreads([
|
|
19
|
+
this.logsOfThisNode,
|
|
20
|
+
this.prevNode.threadOfTrail,
|
|
21
|
+
])
|
|
22
|
+
}
|
|
23
|
+
get trailLogs() {
|
|
24
|
+
return this.threadOfTrail.applogs
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Shared interface for query results (one-off and live) */
|
|
29
|
+
export interface IQueryResult {
|
|
30
|
+
readonly nodes: readonly QueryNode[]
|
|
31
|
+
readonly size: number
|
|
32
|
+
readonly isEmpty: boolean
|
|
33
|
+
readonly records: readonly SearchContext[]
|
|
34
|
+
readonly leafNodeLogs: readonly import('../applog/datom-types').Applog[]
|
|
35
|
+
readonly leafNodeThread: Thread
|
|
36
|
+
readonly threadOfAllTrails: Thread
|
|
37
|
+
readonly thread: Thread
|
|
38
|
+
readonly allApplogs: readonly import('../applog/datom-types').Applog[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One-off query result — plain frozen snapshot.
|
|
43
|
+
* No subscribe method. No stale-data risk.
|
|
44
|
+
*/
|
|
45
|
+
export class QueryResult implements IQueryResult {
|
|
46
|
+
constructor(
|
|
47
|
+
readonly nodes: readonly QueryNode[],
|
|
48
|
+
) {}
|
|
49
|
+
|
|
50
|
+
get size() {
|
|
51
|
+
return this.nodes.length
|
|
52
|
+
}
|
|
53
|
+
get isEmpty() {
|
|
54
|
+
return this.nodes.length === 0
|
|
55
|
+
}
|
|
56
|
+
get untrackedSize() {
|
|
57
|
+
return this.nodes.length
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get records(): readonly SearchContext[] {
|
|
61
|
+
return this.nodes.map(({ variables }) => variables)
|
|
62
|
+
}
|
|
63
|
+
get leafNodeThread() {
|
|
64
|
+
return joinThreads(
|
|
65
|
+
this.nodes.map(({ logsOfThisNode: thread }) => thread),
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
get leafNodeLogSet() {
|
|
69
|
+
return this.nodes.map(({ logsOfThisNode: thread }) => thread.applogs)
|
|
70
|
+
}
|
|
71
|
+
get leafNodeLogs() {
|
|
72
|
+
return this.nodes.flatMap(({ logsOfThisNode: thread }) => thread.applogs)
|
|
73
|
+
}
|
|
74
|
+
get threadOfAllTrails() {
|
|
75
|
+
return joinThreads(this.nodes.map(node => node.threadOfTrail))
|
|
76
|
+
}
|
|
77
|
+
get thread() {
|
|
78
|
+
return this.threadOfAllTrails // alias
|
|
79
|
+
}
|
|
80
|
+
get allApplogs() {
|
|
81
|
+
return this.threadOfAllTrails.applogs
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Live query result — eagerly activated, always up-to-date.
|
|
87
|
+
*
|
|
88
|
+
* `.nodes` returns the current live view.
|
|
89
|
+
* `.subscribe()` receives future delta events (consistent with current state).
|
|
90
|
+
* Must call `.dispose()` when done to tear down upstream subscriptions.
|
|
91
|
+
*/
|
|
92
|
+
export class LiveQueryResult implements IQueryResult {
|
|
93
|
+
constructor(
|
|
94
|
+
private _source: SubscribableArray<QueryNode>,
|
|
95
|
+
activate = true,
|
|
96
|
+
) {
|
|
97
|
+
if (activate) {
|
|
98
|
+
// Eagerly activate: subscribe with a no-op to start upstream.
|
|
99
|
+
// Store unsub so dispose() can tear it down.
|
|
100
|
+
this._activationUnsub = this._source.subscribe(() => {})
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private _activationUnsub: Unsubscribe | null = null
|
|
105
|
+
|
|
106
|
+
/** Subscribe to node change events. Callback fires on future changes only. */
|
|
107
|
+
subscribe(cb: (event: ArrayEvent<QueryNode>) => void, type?: 'derived' | 'reaction'): Unsubscribe {
|
|
108
|
+
return this._source.subscribe(cb, type)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Current nodes — live view, always up-to-date while not disposed */
|
|
112
|
+
get nodes(): readonly QueryNode[] {
|
|
113
|
+
return this._source.items
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
get size() {
|
|
117
|
+
return this._source.length
|
|
118
|
+
}
|
|
119
|
+
get isEmpty() {
|
|
120
|
+
return this._source.length === 0
|
|
121
|
+
}
|
|
122
|
+
get untrackedSize() {
|
|
123
|
+
return this._source.length
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
get records(): readonly SearchContext[] {
|
|
127
|
+
return this.nodes.map(({ variables }) => variables)
|
|
128
|
+
}
|
|
129
|
+
get leafNodeThread() {
|
|
130
|
+
return joinThreads(
|
|
131
|
+
this.nodes.map(({ logsOfThisNode: thread }) => thread),
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
get leafNodeLogSet() {
|
|
135
|
+
return this.nodes.map(({ logsOfThisNode: thread }) => thread.applogs)
|
|
136
|
+
}
|
|
137
|
+
get leafNodeLogs() {
|
|
138
|
+
return this.nodes.flatMap(({ logsOfThisNode: thread }) => thread.applogs)
|
|
139
|
+
}
|
|
140
|
+
get threadOfAllTrails() {
|
|
141
|
+
return joinThreads(this.nodes.map(node => node.threadOfTrail))
|
|
142
|
+
}
|
|
143
|
+
get thread() {
|
|
144
|
+
return this.threadOfAllTrails // alias
|
|
145
|
+
}
|
|
146
|
+
get allApplogs() {
|
|
147
|
+
return this.threadOfAllTrails.applogs
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
dispose() {
|
|
151
|
+
this._activationUnsub?.()
|
|
152
|
+
this._activationUnsub = null
|
|
153
|
+
this._source.dispose()
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focused tests for `withoutDeleted.mapDelta` covering the four transition
|
|
3
|
+
* cases of an entity's hidden-state across a delta:
|
|
4
|
+
*
|
|
5
|
+
* F→N : was-fine, still-fine (no transition)
|
|
6
|
+
* F→D : was-fine, becomes hidden (newly deleted)
|
|
7
|
+
* W→N : was-hidden, becomes visible (newly restored / un-deleted)
|
|
8
|
+
* W→D : was-hidden, still hidden (idempotent re-deletion)
|
|
9
|
+
*
|
|
10
|
+
* The bug being guarded against: pre-fix, `mapDelta` filtered `delta.added` /
|
|
11
|
+
* `delta.removed` against the post-mutation `isDeleted(en)` predicate. That
|
|
12
|
+
* misclassifies entities whose hidden-state transitioned during the delta —
|
|
13
|
+
* causing W→N to crash MappedThread (stale `vl:true` log slips into `removed`
|
|
14
|
+
* but was never in result) and F→D-with-content-removal to silently keep
|
|
15
|
+
* stale content in result.
|
|
16
|
+
*/
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
18
|
+
import { finalizeApplogForInsert } from '../applog/applog-helpers.ts'
|
|
19
|
+
import { sortApplogsByTs } from '../applog/applog-utils.ts'
|
|
20
|
+
import type { Applog, ApplogForInsert } from '../applog/datom-types.ts'
|
|
21
|
+
import { ThreadInMemory } from '../thread/writeable.ts'
|
|
22
|
+
import { lastWriteWins, withoutDeleted } from './basic.ts'
|
|
23
|
+
import type { ArrayEvent } from './subscribable.ts'
|
|
24
|
+
|
|
25
|
+
let tsCounter = 0
|
|
26
|
+
function makeApplogs(inputs: ApplogForInsert[]): Applog[] {
|
|
27
|
+
const logs = inputs.map(input =>
|
|
28
|
+
finalizeApplogForInsert({
|
|
29
|
+
ts: new Date(1700000000000 + ++tsCounter * 1000).toISOString(),
|
|
30
|
+
pv: null,
|
|
31
|
+
ag: 'testAgent',
|
|
32
|
+
...input,
|
|
33
|
+
} as ApplogForInsert, {}),
|
|
34
|
+
)
|
|
35
|
+
sortApplogsByTs(logs)
|
|
36
|
+
return logs
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let db: ThreadInMemory
|
|
40
|
+
let events: ArrayEvent<Applog>[]
|
|
41
|
+
let unsub: () => void
|
|
42
|
+
|
|
43
|
+
function recordEvents(thread: { subscribe: (cb: (e: ArrayEvent<Applog>) => void) => () => void }) {
|
|
44
|
+
events = []
|
|
45
|
+
unsub = thread.subscribe(event => events.push(event))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
tsCounter = 0
|
|
50
|
+
const seed: ApplogForInsert[] = [
|
|
51
|
+
{ en: 'e1', at: 'movie/title', vl: 'Predator', ag: 'testAgent' },
|
|
52
|
+
{ en: 'e1', at: 'movie/year', vl: 1987, ag: 'testAgent' },
|
|
53
|
+
{ en: 'e2', at: 'movie/title', vl: 'Lethal Weapon', ag: 'testAgent' },
|
|
54
|
+
{ en: 'e2', at: 'movie/year', vl: 1987, ag: 'testAgent' },
|
|
55
|
+
]
|
|
56
|
+
db = ThreadInMemory.fromArray(makeApplogs(seed), 'test-withoutDeleted')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
unsub?.()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('withoutDeleted.mapDelta — transition truth table', () => {
|
|
64
|
+
it('F→N: ordinary content add/remove passes through unchanged', () => {
|
|
65
|
+
const filtered = withoutDeleted(lastWriteWins(db))
|
|
66
|
+
recordEvents(filtered)
|
|
67
|
+
|
|
68
|
+
// Sanity: e1 is visible at start
|
|
69
|
+
expect(filtered.applogs.some(l => l.en === 'e1' && l.at === 'movie/title')).toBe(true)
|
|
70
|
+
|
|
71
|
+
// Add new content — should pass through (entity stays visible)
|
|
72
|
+
db.insert([{ en: 'e1', at: 'movie/cast', vl: 'p1', ag: 'testAgent' }])
|
|
73
|
+
|
|
74
|
+
const lastEvent = events[events.length - 1] as { added: readonly Applog[]; removed: readonly Applog[] | null }
|
|
75
|
+
expect(lastEvent.added.length).toBe(1)
|
|
76
|
+
expect(lastEvent.added[0]).toMatchObject({ en: 'e1', at: 'movie/cast', vl: 'p1' })
|
|
77
|
+
expect(lastEvent.removed ?? []).toEqual([])
|
|
78
|
+
|
|
79
|
+
// Result still contains e1 + the new cast log
|
|
80
|
+
expect(filtered.applogs.some(l => l.en === 'e1' && l.at === 'movie/cast')).toBe(true)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('F→D: insertion of isDeleted=true emits all entity applogs as removed', () => {
|
|
84
|
+
const filtered = withoutDeleted(lastWriteWins(db))
|
|
85
|
+
recordEvents(filtered)
|
|
86
|
+
|
|
87
|
+
const e1ApplogsBefore = filtered.applogs.filter(l => l.en === 'e1')
|
|
88
|
+
expect(e1ApplogsBefore.length).toBeGreaterThan(0)
|
|
89
|
+
|
|
90
|
+
db.insert([{ en: 'e1', at: 'isDeleted', vl: true, ag: 'testAgent' }])
|
|
91
|
+
|
|
92
|
+
const removedAcrossEvents = events.flatMap(e =>
|
|
93
|
+
'removed' in e ? (e.removed ?? []) : [],
|
|
94
|
+
)
|
|
95
|
+
const removedEns = new Set(removedAcrossEvents.map(l => l.en))
|
|
96
|
+
expect(removedEns).toEqual(new Set(['e1']))
|
|
97
|
+
// All e1 applogs that were in result should be removed
|
|
98
|
+
expect(removedAcrossEvents.length).toBe(e1ApplogsBefore.length)
|
|
99
|
+
|
|
100
|
+
// The isDeleted=true log itself must NOT appear in `added` (entity is now hidden)
|
|
101
|
+
const addedAcrossEvents = events.flatMap(e =>
|
|
102
|
+
'added' in e ? (e.added ?? []) : [],
|
|
103
|
+
)
|
|
104
|
+
expect(addedAcrossEvents.some(l => l.en === 'e1' && l.at === 'isDeleted')).toBe(false)
|
|
105
|
+
|
|
106
|
+
// e1 fully gone from result
|
|
107
|
+
expect(filtered.applogs.some(l => l.en === 'e1')).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('W→N: un-deletion via LWW supersession re-adds entity applogs without crashing or duplicating', () => {
|
|
111
|
+
const filtered = withoutDeleted(lastWriteWins(db))
|
|
112
|
+
|
|
113
|
+
// Hide e1
|
|
114
|
+
db.insert([{ en: 'e1', at: 'isDeleted', vl: true, ag: 'testAgent' }])
|
|
115
|
+
expect(filtered.applogs.filter(l => l.en === 'e1').length).toBe(0)
|
|
116
|
+
|
|
117
|
+
// Now subscribe — only un-deletion events should arrive
|
|
118
|
+
recordEvents(filtered)
|
|
119
|
+
|
|
120
|
+
// LWW supersession: appending vl:false makes the vl:true log non-current,
|
|
121
|
+
// so withoutDeleted's mapper sees `delta.added=[{vl:false}]` and
|
|
122
|
+
// `delta.removed=[{vl:true}]`. With the bug, the stale {vl:true} would
|
|
123
|
+
// slip into our `removed` output and MappedThread.onParentUpdate would
|
|
124
|
+
// throw "log not found" when trying to splice it from _applogs.
|
|
125
|
+
expect(() => {
|
|
126
|
+
db.insert([{ en: 'e1', at: 'isDeleted', vl: false, ag: 'testAgent' }])
|
|
127
|
+
}).not.toThrow()
|
|
128
|
+
|
|
129
|
+
// The stale {vl:true} log must NOT appear in any `removed` — it was never in result.
|
|
130
|
+
const removedAcrossEvents = events.flatMap(e =>
|
|
131
|
+
'removed' in e ? (e.removed ?? []) : [],
|
|
132
|
+
)
|
|
133
|
+
expect(
|
|
134
|
+
removedAcrossEvents.some(l => l.en === 'e1' && l.at === 'isDeleted' && l.vl === true),
|
|
135
|
+
).toBe(false)
|
|
136
|
+
|
|
137
|
+
// All e1 applogs should be re-added (synthetic additions). No duplicates.
|
|
138
|
+
const addedAcrossEvents = events.flatMap(e =>
|
|
139
|
+
'added' in e ? (e.added ?? []) : [],
|
|
140
|
+
)
|
|
141
|
+
const addedE1 = addedAcrossEvents.filter(l => l.en === 'e1')
|
|
142
|
+
expect(addedE1.length).toBeGreaterThan(0)
|
|
143
|
+
|
|
144
|
+
// No duplicate cids in additions
|
|
145
|
+
const cids = addedE1.map(l => l.cid)
|
|
146
|
+
expect(new Set(cids).size).toBe(cids.length)
|
|
147
|
+
|
|
148
|
+
// Result contains e1 again, and original content is back
|
|
149
|
+
expect(filtered.applogs.some(l => l.en === 'e1' && l.at === 'movie/title' && l.vl === 'Predator')).toBe(true)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('W→D: re-deleting an already-hidden entity (multi-attr) is idempotent — no events about that entity', () => {
|
|
153
|
+
const filtered = withoutDeleted(db)
|
|
154
|
+
|
|
155
|
+
// First mark e1 hidden via 'isDeleted'
|
|
156
|
+
db.insert([{ en: 'e1', at: 'isDeleted', vl: true, ag: 'testAgent' }])
|
|
157
|
+
expect(filtered.applogs.some(l => l.en === 'e1')).toBe(false)
|
|
158
|
+
|
|
159
|
+
// Now subscribe and add a SECOND deletion-class marker (block/isDeleted).
|
|
160
|
+
// Entity stays hidden; pass-through filter should exclude the marker.
|
|
161
|
+
recordEvents(filtered)
|
|
162
|
+
db.insert([{ en: 'e1', at: 'block/isDeleted', vl: true, ag: 'testAgent' }])
|
|
163
|
+
|
|
164
|
+
const addedAcrossEvents = events.flatMap(e => ('added' in e ? (e.added ?? []) : []))
|
|
165
|
+
const removedAcrossEvents = events.flatMap(e => ('removed' in e ? (e.removed ?? []) : []))
|
|
166
|
+
|
|
167
|
+
// The new isDeleted-class marker must NOT pass through to result (entity still hidden)
|
|
168
|
+
expect(addedAcrossEvents.some(l => l.en === 'e1' && l.at === 'block/isDeleted')).toBe(false)
|
|
169
|
+
// And nothing should be removed for e1 — it wasn't in result to begin with
|
|
170
|
+
expect(removedAcrossEvents.some(l => l.en === 'e1')).toBe(false)
|
|
171
|
+
|
|
172
|
+
// Result still has no e1 applogs
|
|
173
|
+
expect(filtered.applogs.some(l => l.en === 'e1')).toBe(false)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('F→D + same-tick content removal: the removed content log appears in output exactly once', () => {
|
|
177
|
+
// This guards the silent-drift bug where post-mutation filter excluded
|
|
178
|
+
// legitimate `delta.removed` content for entities that just became hidden.
|
|
179
|
+
// We exercise it by going through LWW: a content update supersedes the
|
|
180
|
+
// previous content log, and in the SAME tick we delete the entity.
|
|
181
|
+
// The downstream withoutDeleted sees `delta.removed=[oldContent]` and
|
|
182
|
+
// `delta.added=[newContent, isDeleted=true]` — the old content log must
|
|
183
|
+
// be reported as removed exactly once.
|
|
184
|
+
const filtered = withoutDeleted(lastWriteWins(db))
|
|
185
|
+
recordEvents(filtered)
|
|
186
|
+
|
|
187
|
+
const oldTitle = filtered.applogs.find(l => l.en === 'e1' && l.at === 'movie/title')
|
|
188
|
+
expect(oldTitle).toBeDefined()
|
|
189
|
+
|
|
190
|
+
db.insert([
|
|
191
|
+
{ en: 'e1', at: 'movie/title', vl: 'Predator (rev)', ag: 'testAgent' },
|
|
192
|
+
{ en: 'e1', at: 'isDeleted', vl: true, ag: 'testAgent' },
|
|
193
|
+
])
|
|
194
|
+
|
|
195
|
+
const removedAcrossEvents = events.flatMap(e =>
|
|
196
|
+
'removed' in e ? (e.removed ?? []) : [],
|
|
197
|
+
)
|
|
198
|
+
const oldTitleOccurrences = removedAcrossEvents.filter(l => l.cid === oldTitle!.cid)
|
|
199
|
+
expect(oldTitleOccurrences.length).toBe(1)
|
|
200
|
+
|
|
201
|
+
// e1 is hidden now
|
|
202
|
+
expect(filtered.applogs.some(l => l.en === 'e1')).toBe(false)
|
|
203
|
+
})
|
|
204
|
+
})
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './utils/debug-name.ts'
|
|
2
|
+
export * from './query/basic.ts'
|
|
3
|
+
export * from './query/divergences.ts'
|
|
4
|
+
export * from './query/matchers.ts'
|
|
5
|
+
export * from './query/memoized.ts'
|
|
6
|
+
export * from './query/subscribable.ts'
|
|
7
|
+
export * from './query/types.ts'
|
|
8
|
+
export * from './query/attr-helpers.ts'
|
|
9
|
+
export * from './query/entity-collection.ts'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './update-thread.ts'
|