@wovin/core 0.1.36 → 0.2.2
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 +40 -6
- package/dist/applog/applog-utils.d.ts.map +1 -1
- package/dist/applog/datom-types.d.ts +67 -12
- 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} +12 -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-22WDFLXO.js +138 -0
- package/dist/chunk-22WDFLXO.js.map +1 -0
- package/dist/chunk-3SUFNJEZ.js +1026 -0
- package/dist/chunk-3SUFNJEZ.js.map +1 -0
- package/dist/chunk-6ALNRM3J.js +435 -0
- package/dist/chunk-6ALNRM3J.js.map +1 -0
- package/dist/chunk-7Z5YDQKK.js +1 -0
- package/dist/{chunk-KXMTKPF4.min.js → chunk-BLF5MAWU.js} +8 -8
- package/dist/chunk-BLF5MAWU.js.map +1 -0
- package/dist/chunk-E46VTKTZ.js +1 -0
- package/dist/{chunk-H3VQJP56.min.js → chunk-HUIQ54TT.js} +9 -9
- package/dist/chunk-HUIQ54TT.js.map +1 -0
- package/dist/{chunk-BRC7LSM6.min.js → chunk-OC6Z6CQW.js} +5 -5
- package/dist/chunk-OC6Z6CQW.js.map +1 -0
- package/dist/chunk-SHUHRHOT.js +1923 -0
- package/dist/chunk-SHUHRHOT.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} +81 -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 +87 -23
- 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 +12 -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} +55 -34
- 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 +57 -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 +551 -0
- package/src/applog/datom-types.ts +167 -0
- package/src/applog/object-values.test.ts +106 -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 +278 -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 +132 -0
- package/src/query/liveFilterAndMap.test.ts +102 -0
- package/src/query/matchers.ts +30 -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 +256 -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,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
|
+
}
|