@signaldb/svelte 2.0.0-beta.13 → 2.0.0-beta.14
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/dist/base/core/src/AsyncDataAdapter.d.ts +11 -2
- package/dist/base/core/src/AsyncDataAdapter.js +91 -28
- package/dist/base/core/src/Collection/Cursor.d.ts +11 -1
- package/dist/base/core/src/Collection/Cursor.js +18 -1
- package/dist/base/core/src/Collection/Observer.d.ts +31 -0
- package/dist/base/core/src/Collection/Observer.js +92 -46
- package/dist/base/core/src/Collection/index.js +66 -49
- package/dist/base/core/src/DataAdapter.d.ts +15 -2
- package/dist/base/core/src/DefaultDataAdapter.js +24 -16
- package/dist/base/core/src/WorkerDataAdapter.d.ts +25 -2
- package/dist/base/core/src/WorkerDataAdapter.js +360 -93
- package/dist/base/core/src/WorkerDataAdapterHost.d.ts +2 -0
- package/dist/base/core/src/WorkerDataAdapterHost.js +96 -26
- package/dist/base/core/src/index.d.ts +2 -0
- package/dist/base/core/src/utils/incrementalQueryUpdate.d.ts +60 -0
- package/dist/base/core/src/utils/incrementalQueryUpdate.js +159 -0
- package/dist/base/core/src/utils/projectItems.d.ts +12 -0
- package/dist/base/core/src/utils/projectItems.js +25 -0
- package/dist/base/core/src/utils/queryDelta.d.ts +83 -0
- package/dist/base/core/src/utils/queryDelta.js +231 -0
- package/dist/base/core/src/utils/queryId.js +32 -1
- package/package.json +1 -1
|
@@ -6,10 +6,37 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
const queryId_1 = __importDefault(require("./utils/queryId"));
|
|
7
7
|
const randomId_1 = __importDefault(require("./utils/randomId"));
|
|
8
8
|
const batchOnNextTick_1 = __importDefault(require("./utils/batchOnNextTick"));
|
|
9
|
-
const
|
|
9
|
+
const incrementalQueryUpdate_1 = require("./utils/incrementalQueryUpdate");
|
|
10
|
+
const queryDelta_1 = require("./utils/queryDelta");
|
|
10
11
|
const match_1 = __importDefault(require("./utils/match"));
|
|
11
12
|
const modify_1 = __importDefault(require("./utils/modify"));
|
|
12
13
|
const deepClone_1 = __importDefault(require("./utils/deepClone"));
|
|
14
|
+
// The ids a selector names outright, or `null` when it asks something the ids alone cannot answer.
|
|
15
|
+
// Deliberately strict: one key, `id`, holding a primitive or a lone `$in`. Anything else — another
|
|
16
|
+
// field alongside it, an operator, a nested condition — falls back to matching, because guessing
|
|
17
|
+
// wrong here would silently drop a row from a write.
|
|
18
|
+
/**
|
|
19
|
+
* Extracts the ids a selector names outright.
|
|
20
|
+
* @param selector - The selector to inspect.
|
|
21
|
+
* @returns The named ids, or `null` when the selector asks more than ids can answer.
|
|
22
|
+
*/
|
|
23
|
+
function selectorIds(selector) {
|
|
24
|
+
if (selector == null || typeof selector !== 'object')
|
|
25
|
+
return null;
|
|
26
|
+
const keys = Object.keys(selector);
|
|
27
|
+
if (keys.length !== 1 || keys[0] !== 'id')
|
|
28
|
+
return null;
|
|
29
|
+
const value = selector.id;
|
|
30
|
+
if (value == null)
|
|
31
|
+
return null;
|
|
32
|
+
if (typeof value !== 'object')
|
|
33
|
+
return [value];
|
|
34
|
+
const valueKeys = Object.keys(value);
|
|
35
|
+
if (valueKeys.length !== 1 || valueKeys[0] !== '$in')
|
|
36
|
+
return null;
|
|
37
|
+
const inValues = value.$in;
|
|
38
|
+
return Array.isArray(inValues) ? inValues : null;
|
|
39
|
+
}
|
|
13
40
|
class WorkerDataAdapter {
|
|
14
41
|
worker;
|
|
15
42
|
options;
|
|
@@ -20,6 +47,10 @@ class WorkerDataAdapter {
|
|
|
20
47
|
collectionReady = new Map();
|
|
21
48
|
batchExecutionHelpers = new Map();
|
|
22
49
|
queries = {};
|
|
50
|
+
// Resolvers for `exec` calls that are still waiting for their response, keyed by message id.
|
|
51
|
+
// Together with the query registry above this is everything the shared dispatcher needs to route
|
|
52
|
+
// a message, which is why there is no longer a listener per request or per query.
|
|
53
|
+
pendingRequests = new Map();
|
|
23
54
|
// Writes that have been issued but not yet confirmed by the worker. Their
|
|
24
55
|
// effect is layered on top of each active query's last authoritative result
|
|
25
56
|
// in `getQueryResult`, so a cursor reflects a write immediately instead of
|
|
@@ -33,6 +64,13 @@ class WorkerDataAdapter {
|
|
|
33
64
|
// rollback.
|
|
34
65
|
pendingWrites = new Map();
|
|
35
66
|
pendingWriteSeq = 0;
|
|
67
|
+
// Bumped whenever a collection's pending writes change, in either direction. Anything derived
|
|
68
|
+
// from them is stale from that moment on.
|
|
69
|
+
pendingWriteVersions = new Map();
|
|
70
|
+
bumpPendingWriteVersion(collectionName) {
|
|
71
|
+
const current = this.pendingWriteVersions.get(collectionName) ?? 0;
|
|
72
|
+
this.pendingWriteVersions.set(collectionName, current + 1);
|
|
73
|
+
}
|
|
36
74
|
constructor(worker, options) {
|
|
37
75
|
this.worker = worker;
|
|
38
76
|
this.options = options;
|
|
@@ -43,18 +81,115 @@ class WorkerDataAdapter {
|
|
|
43
81
|
const timeoutId = setTimeout(() => {
|
|
44
82
|
reject(new Error('WorkerDataAdapter initialization timed out'));
|
|
45
83
|
}, 5000);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return;
|
|
50
|
-
if (type === 'ready') {
|
|
51
|
-
resolve();
|
|
52
|
-
clearTimeout(timeoutId);
|
|
53
|
-
this.worker.removeEventListener('message', handleMessage);
|
|
54
|
-
}
|
|
84
|
+
this.resolveWorkerReady = () => {
|
|
85
|
+
clearTimeout(timeoutId);
|
|
86
|
+
resolve();
|
|
55
87
|
};
|
|
56
|
-
this.worker.addEventListener('message', handleMessage);
|
|
57
88
|
});
|
|
89
|
+
this.worker.addEventListener('message', this.handleWorkerMessage);
|
|
90
|
+
}
|
|
91
|
+
resolveWorkerReady = () => { };
|
|
92
|
+
// The one and only message listener this adapter installs. Every response and every query update
|
|
93
|
+
// is routed from here by a map lookup. Listening per request and per query instead meant each
|
|
94
|
+
// incoming message was offered to every listener in turn, and each of them re-serialized its own
|
|
95
|
+
// selector to decide the message was not for it — turning a write that touches N queries into
|
|
96
|
+
// N² selector serializations before any of the actual work started.
|
|
97
|
+
handleWorkerMessage = (event) => {
|
|
98
|
+
const message = event.data;
|
|
99
|
+
if (message == null)
|
|
100
|
+
return;
|
|
101
|
+
if (message.workerId !== this.id)
|
|
102
|
+
return;
|
|
103
|
+
if (message.type === 'ready') {
|
|
104
|
+
this.resolveWorkerReady();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (message.type === 'response') {
|
|
108
|
+
if (message.id == null)
|
|
109
|
+
return;
|
|
110
|
+
const pending = this.pendingRequests.get(message.id);
|
|
111
|
+
if (!pending)
|
|
112
|
+
return;
|
|
113
|
+
this.pendingRequests.delete(message.id);
|
|
114
|
+
this.log('response', message.data ?? message.error);
|
|
115
|
+
if (message.error) {
|
|
116
|
+
pending.reject(message.error);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
pending.resolve(message.data);
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (message.type === 'queryUpdate')
|
|
124
|
+
this.handleQueryUpdate(message.data, message.error ?? null);
|
|
125
|
+
};
|
|
126
|
+
handleQueryUpdate(data, error) {
|
|
127
|
+
if (data == null)
|
|
128
|
+
return;
|
|
129
|
+
const { collectionName, qid, selector, options, state, items, delta, } = data;
|
|
130
|
+
if (collectionName == null)
|
|
131
|
+
return;
|
|
132
|
+
const collectionQueries = this.queries[collectionName];
|
|
133
|
+
if (!collectionQueries)
|
|
134
|
+
return;
|
|
135
|
+
// The host names the query outright; deriving the id from the selector is only for messages
|
|
136
|
+
// that predate that (and for tests that hand-roll one).
|
|
137
|
+
const id = qid ?? (selector === undefined ? undefined : (0, queryId_1.default)(selector, options));
|
|
138
|
+
if (id == null)
|
|
139
|
+
return;
|
|
140
|
+
const query = collectionQueries.get(id);
|
|
141
|
+
if (!query)
|
|
142
|
+
return;
|
|
143
|
+
this.log('queryUpdate', query.selector, query.options, state, data ?? error);
|
|
144
|
+
let nextItems = items;
|
|
145
|
+
let deltaToPublish;
|
|
146
|
+
if (delta != null) {
|
|
147
|
+
// A delta only makes sense against the result it was computed from. If this adapter is
|
|
148
|
+
// holding something else — a message lost, a query re-registered underneath, a host and an
|
|
149
|
+
// adapter that disagree — applying it anyway would leave a result that silently drifts from
|
|
150
|
+
// the store. Refusing it keeps the last coherent result instead, and the next full answer
|
|
151
|
+
// puts things right.
|
|
152
|
+
if (!(0, queryDelta_1.canApplyQueryDelta)(query.items, delta))
|
|
153
|
+
return;
|
|
154
|
+
if ((0, queryDelta_1.isEmptyQueryDelta)(delta))
|
|
155
|
+
return;
|
|
156
|
+
const pendingBefore = this.flattenPendingWrites(collectionName);
|
|
157
|
+
const servedBefore = pendingBefore == null
|
|
158
|
+
? null
|
|
159
|
+
: this.servedResult(collectionName, query);
|
|
160
|
+
nextItems = (0, queryDelta_1.applyQueryDelta)(query.items, delta);
|
|
161
|
+
if (servedBefore == null) {
|
|
162
|
+
// Nothing is layered on top of the stored result, so what the host described is exactly
|
|
163
|
+
// what a reader of this query will see change.
|
|
164
|
+
deltaToPublish = delta;
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
// A write is still in flight, and its effect has been shown to readers all along. What
|
|
168
|
+
// they see change is the difference between the two layered results — which, for the
|
|
169
|
+
// ordinary case of the host confirming the write that is in flight, is nothing at all.
|
|
170
|
+
this.updateQuery(collectionName, {
|
|
171
|
+
selector: query.selector,
|
|
172
|
+
options: query.options,
|
|
173
|
+
}, { state, error, items: nextItems });
|
|
174
|
+
const stored = collectionQueries.get(id);
|
|
175
|
+
if (!stored)
|
|
176
|
+
return;
|
|
177
|
+
const servedDelta = (0, queryDelta_1.diffQueryResults)(servedBefore, this.servedResult(collectionName, stored));
|
|
178
|
+
if ((0, queryDelta_1.isEmptyQueryDelta)(servedDelta) && state === query.state)
|
|
179
|
+
return;
|
|
180
|
+
stored.stateChangeCallbacks
|
|
181
|
+
.forEach(callback => (0, queryDelta_1.callWithDelta)(callback, state, servedDelta));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
this.updateQuery(collectionName, {
|
|
186
|
+
selector: query.selector,
|
|
187
|
+
options: query.options,
|
|
188
|
+
}, { state, error, items: nextItems });
|
|
189
|
+
const updated = collectionQueries.get(id);
|
|
190
|
+
if (!updated)
|
|
191
|
+
return;
|
|
192
|
+
updated.stateChangeCallbacks.forEach(callback => (0, queryDelta_1.callWithDelta)(callback, state, deltaToPublish));
|
|
58
193
|
}
|
|
59
194
|
async exec(method, collectionName, ...args) {
|
|
60
195
|
await this.workerReady;
|
|
@@ -69,24 +204,7 @@ class WorkerDataAdapter {
|
|
|
69
204
|
}
|
|
70
205
|
return new Promise((resolve, reject) => {
|
|
71
206
|
const messageId = (0, randomId_1.default)();
|
|
72
|
-
|
|
73
|
-
const { id, workerId, type, data, error } = event.data;
|
|
74
|
-
if (workerId !== this.id)
|
|
75
|
-
return;
|
|
76
|
-
if (type !== 'response')
|
|
77
|
-
return;
|
|
78
|
-
if (id !== messageId)
|
|
79
|
-
return;
|
|
80
|
-
this.log(method, 'result', data ?? error);
|
|
81
|
-
if (error) {
|
|
82
|
-
reject(error);
|
|
83
|
-
}
|
|
84
|
-
else {
|
|
85
|
-
resolve(data);
|
|
86
|
-
}
|
|
87
|
-
this.worker.removeEventListener('message', handleMessage);
|
|
88
|
-
};
|
|
89
|
-
this.worker.addEventListener('message', handleMessage);
|
|
207
|
+
this.pendingRequests.set(messageId, { resolve, reject });
|
|
90
208
|
this.worker.postMessage({
|
|
91
209
|
id: messageId,
|
|
92
210
|
workerId: this.id,
|
|
@@ -95,30 +213,156 @@ class WorkerDataAdapter {
|
|
|
95
213
|
});
|
|
96
214
|
});
|
|
97
215
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
216
|
+
/**
|
|
217
|
+
* Issues a call whose result nobody is waiting for, and makes sure a failure has somewhere to
|
|
218
|
+
* go. A bare rejection here would surface as an uncaught error — which is what a disposed
|
|
219
|
+
* collection produced every time a cursor was cleaned up after it.
|
|
220
|
+
* @param method - The method to call on the worker.
|
|
221
|
+
* @param collectionName - The collection it applies to.
|
|
222
|
+
* @param args - The remaining arguments.
|
|
223
|
+
* @param onError - Called when the call fails, in place of merely logging it.
|
|
224
|
+
*/
|
|
225
|
+
execInBackground(method, collectionName, args = [], onError) {
|
|
226
|
+
this.exec(method, collectionName, ...args).catch((error) => {
|
|
227
|
+
if (onError) {
|
|
228
|
+
onError(error);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
this.log(method, 'failed', error);
|
|
107
232
|
});
|
|
108
|
-
return [...byId.values()];
|
|
109
233
|
}
|
|
110
|
-
|
|
234
|
+
queryItemsById(query) {
|
|
235
|
+
if (!query.itemsById) {
|
|
236
|
+
query.itemsById = new Map(query.items.map(item => [item.id, item]));
|
|
237
|
+
}
|
|
238
|
+
return query.itemsById;
|
|
239
|
+
}
|
|
240
|
+
// The pending writes of a collection collapsed into one upsert/delete view, newest write winning.
|
|
241
|
+
// `null` when there are none, which is the overwhelmingly common case and the one every caller
|
|
242
|
+
// below short-circuits on. Pending sets are tiny — a write or two in flight — so this is cheap in
|
|
243
|
+
// a way that touching each query's items is not.
|
|
244
|
+
flattenPendingWrites(collectionName) {
|
|
111
245
|
const pending = this.pendingWrites.get(collectionName);
|
|
112
246
|
if (!pending || pending.size === 0)
|
|
113
|
-
return
|
|
114
|
-
const
|
|
247
|
+
return null;
|
|
248
|
+
const upserts = new Map();
|
|
249
|
+
const deletes = new Set();
|
|
115
250
|
[...pending.entries()]
|
|
116
251
|
.sort(([a], [b]) => a - b) // eslint-disable-line unicorn/no-array-sort -- unavailable on Hermes
|
|
117
252
|
.forEach(([, write]) => {
|
|
118
|
-
write.upserts.forEach((item, id) =>
|
|
119
|
-
|
|
253
|
+
write.upserts.forEach((item, id) => {
|
|
254
|
+
upserts.set(id, item);
|
|
255
|
+
deletes.delete(id);
|
|
256
|
+
});
|
|
257
|
+
write.deletes.forEach((id) => {
|
|
258
|
+
deletes.add(id);
|
|
259
|
+
upserts.delete(id);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
return { upserts, deletes };
|
|
263
|
+
}
|
|
264
|
+
// The items an active query currently holds, deduplicated by id, plus whatever the pending writes
|
|
265
|
+
// add or remove — the only items this adapter knows about, and the set a selector-based write is
|
|
266
|
+
// resolved against locally. One pass over the queries rather than a merge per query.
|
|
267
|
+
// Whether a query's result is the items themselves rather than a projection of them. A write is
|
|
268
|
+
// resolved locally by applying its modifier to the item this adapter holds, and applying it to an
|
|
269
|
+
// item that has had fields removed produces something that is not the item — one that a selector
|
|
270
|
+
// naming a projected-away field no longer matches, so the row would vanish from every other
|
|
271
|
+
// query until the store answered. An item known only through a projection is therefore treated as
|
|
272
|
+
// not known at all: the write still happens, it simply is not shown before the store confirms it.
|
|
273
|
+
static providesFullItems(query) {
|
|
274
|
+
return query.options?.fields == null;
|
|
275
|
+
}
|
|
276
|
+
observableItems(collectionName) {
|
|
277
|
+
const byId = new Map();
|
|
278
|
+
this.queries[collectionName]?.forEach((query) => {
|
|
279
|
+
if (!WorkerDataAdapter.providesFullItems(query))
|
|
280
|
+
return;
|
|
281
|
+
query.items.forEach(item => byId.set(item.id, item));
|
|
282
|
+
});
|
|
283
|
+
const pending = this.flattenPendingWrites(collectionName);
|
|
284
|
+
if (pending) {
|
|
285
|
+
pending.upserts.forEach((item, id) => byId.set(id, item));
|
|
286
|
+
pending.deletes.forEach(id => byId.delete(id));
|
|
287
|
+
}
|
|
288
|
+
return [...byId.values()];
|
|
289
|
+
}
|
|
290
|
+
// The same answer as `observableItems` restricted to known ids — for the selector shapes that
|
|
291
|
+
// name them (`{ id }`, `{ id: { $in } }`), which is what an ordinary `updateOne`/`removeOne`
|
|
292
|
+
// carries. Costs a handful of map lookups instead of materialising every active query's result
|
|
293
|
+
// and running the matcher over all of it.
|
|
294
|
+
observableItemsByIds(collectionName, ids) {
|
|
295
|
+
const pending = this.flattenPendingWrites(collectionName);
|
|
296
|
+
const found = new Map();
|
|
297
|
+
ids.forEach((id) => {
|
|
298
|
+
if (pending?.deletes.has(id))
|
|
299
|
+
return;
|
|
300
|
+
const pendingItem = pending?.upserts.get(id);
|
|
301
|
+
if (pendingItem) {
|
|
302
|
+
found.set(id, pendingItem);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const queries = this.queries[collectionName];
|
|
306
|
+
if (!queries)
|
|
307
|
+
return;
|
|
308
|
+
for (const query of queries.values()) {
|
|
309
|
+
if (!WorkerDataAdapter.providesFullItems(query))
|
|
310
|
+
continue;
|
|
311
|
+
const item = this.queryItemsById(query).get(id);
|
|
312
|
+
if (item) {
|
|
313
|
+
found.set(id, item);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
return [...found.values()];
|
|
319
|
+
}
|
|
320
|
+
// Whether any pending write changes what this query would return. Answered against the pending
|
|
321
|
+
// set (small) and the query's id index, never by walking its items: a query no in-flight write
|
|
322
|
+
// touches — nearly all of them, nearly always — keeps its own array, so its readers skip both the
|
|
323
|
+
// merge and the re-filtering that would follow it.
|
|
324
|
+
// What `getQueryResult` answers: the query's last confirmed result with whatever writes are still
|
|
325
|
+
// in flight folded into it. Only the items those writes touch are examined — the rest matched
|
|
326
|
+
// when the store produced them and are carried over untouched, which is both what makes this cost
|
|
327
|
+
// the size of the pending writes rather than the size of the result, and what keeps a projected
|
|
328
|
+
// result from being re-matched against fields its projection has already dropped.
|
|
329
|
+
servedResult(collectionName, query) {
|
|
330
|
+
const pendingVersion = this.pendingWriteVersions.get(collectionName) ?? 0;
|
|
331
|
+
if (query.served
|
|
332
|
+
&& query.served.fromItems === query.items
|
|
333
|
+
&& query.served.pendingVersion === pendingVersion) {
|
|
334
|
+
return query.served.items;
|
|
335
|
+
}
|
|
336
|
+
const items = this.computeServedResult(collectionName, query);
|
|
337
|
+
query.served = { items, fromItems: query.items, pendingVersion };
|
|
338
|
+
return items;
|
|
339
|
+
}
|
|
340
|
+
computeServedResult(collectionName, query) {
|
|
341
|
+
const pending = this.flattenPendingWrites(collectionName);
|
|
342
|
+
if (!pending)
|
|
343
|
+
return query.items;
|
|
344
|
+
const byId = this.queryItemsById(query);
|
|
345
|
+
let affected = false;
|
|
346
|
+
pending.deletes.forEach((id) => {
|
|
347
|
+
if (byId.has(id))
|
|
348
|
+
affected = true;
|
|
349
|
+
});
|
|
350
|
+
if (!affected) {
|
|
351
|
+
pending.upserts.forEach((item, id) => {
|
|
352
|
+
if (affected)
|
|
353
|
+
return;
|
|
354
|
+
if (byId.has(id))
|
|
355
|
+
affected = true;
|
|
356
|
+
else if (query.selector != null && (0, match_1.default)(item, query.selector))
|
|
357
|
+
affected = true;
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
if (!affected)
|
|
361
|
+
return query.items;
|
|
362
|
+
return (0, incrementalQueryUpdate_1.mergeChangesetIntoResult)(query.items, query.selector, query.options, {
|
|
363
|
+
upserts: [...pending.upserts.values()],
|
|
364
|
+
deletes: [...pending.deletes],
|
|
120
365
|
});
|
|
121
|
-
return [...merged.values()];
|
|
122
366
|
}
|
|
123
367
|
/**
|
|
124
368
|
* Registers a write's effect locally and notifies every active query it
|
|
@@ -132,6 +376,11 @@ class WorkerDataAdapter {
|
|
|
132
376
|
applyPendingWrite(collectionName, upserts, deletes) {
|
|
133
377
|
if (upserts.length === 0 && deletes.length === 0)
|
|
134
378
|
return () => { };
|
|
379
|
+
const affectedIds = new Set([...upserts.map(item => item.id), ...deletes]);
|
|
380
|
+
const affected = this.affectedQueries(collectionName, upserts, affectedIds);
|
|
381
|
+
// Captured before the write is registered, so the notification below can say what actually
|
|
382
|
+
// changed for a reader rather than just that something did.
|
|
383
|
+
const servedBefore = this.servedResults(collectionName, affected);
|
|
135
384
|
const seq = this.pendingWriteSeq += 1;
|
|
136
385
|
const pending = this.pendingWrites.get(collectionName)
|
|
137
386
|
?? new Map();
|
|
@@ -140,36 +389,70 @@ class WorkerDataAdapter {
|
|
|
140
389
|
deletes: new Set(deletes),
|
|
141
390
|
});
|
|
142
391
|
this.pendingWrites.set(collectionName, pending);
|
|
143
|
-
|
|
144
|
-
this.
|
|
392
|
+
this.bumpPendingWriteVersion(collectionName);
|
|
393
|
+
this.notifyWithDeltas(collectionName, affected, servedBefore);
|
|
145
394
|
return () => {
|
|
146
395
|
const current = this.pendingWrites.get(collectionName);
|
|
147
396
|
if (!current)
|
|
148
397
|
return;
|
|
398
|
+
const affectedOnDrop = this.affectedQueries(collectionName, upserts, affectedIds);
|
|
399
|
+
const beforeDrop = this.servedResults(collectionName, affectedOnDrop);
|
|
149
400
|
current.delete(seq);
|
|
150
401
|
if (current.size === 0)
|
|
151
402
|
this.pendingWrites.delete(collectionName);
|
|
152
|
-
this.
|
|
403
|
+
this.bumpPendingWriteVersion(collectionName);
|
|
404
|
+
// By the time a write settles the host's own answer has usually already landed, so dropping
|
|
405
|
+
// the optimistic copy changes nothing a reader can see and produces no notification at all.
|
|
406
|
+
this.notifyWithDeltas(collectionName, affectedOnDrop, beforeDrop);
|
|
153
407
|
};
|
|
154
408
|
}
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
409
|
+
// The queries whose result the write can have changed — either because a written item matches
|
|
410
|
+
// their selector, or because they already hold one of the affected items (an update that moves
|
|
411
|
+
// an item out of a query, or a removal).
|
|
412
|
+
affectedQueries(collectionName, upserts, affectedIds) {
|
|
413
|
+
const affected = [];
|
|
160
414
|
this.queries[collectionName]?.forEach((query) => {
|
|
161
|
-
const
|
|
415
|
+
const byId = this.queryItemsById(query);
|
|
416
|
+
let wasHolding = false;
|
|
417
|
+
affectedIds.forEach((id) => {
|
|
418
|
+
if (byId.has(id))
|
|
419
|
+
wasHolding = true;
|
|
420
|
+
});
|
|
162
421
|
const nowMatches = upserts.some(item => query.selector != null
|
|
163
422
|
&& (0, match_1.default)(item, query.selector));
|
|
164
423
|
if (!wasHolding && !nowMatches)
|
|
165
424
|
return;
|
|
166
|
-
|
|
425
|
+
affected.push(query);
|
|
426
|
+
});
|
|
427
|
+
return affected;
|
|
428
|
+
}
|
|
429
|
+
servedResults(collectionName, queries) {
|
|
430
|
+
return new Map(queries.map(query => [query, this.servedResult(collectionName, query)]));
|
|
431
|
+
}
|
|
432
|
+
// Tells each query what changed for someone reading it, and says nothing to a query where the
|
|
433
|
+
// answer is the same as before. A reader that has to re-run the query to find that out pays for
|
|
434
|
+
// the whole result to learn nothing.
|
|
435
|
+
notifyWithDeltas(collectionName, queries, servedBefore) {
|
|
436
|
+
queries.forEach((query) => {
|
|
437
|
+
const before = servedBefore.get(query);
|
|
438
|
+
if (before == null)
|
|
439
|
+
return;
|
|
440
|
+
const delta = (0, queryDelta_1.diffQueryResults)(before, this.servedResult(collectionName, query));
|
|
441
|
+
if ((0, queryDelta_1.isEmptyQueryDelta)(delta))
|
|
442
|
+
return;
|
|
443
|
+
query.stateChangeCallbacks.forEach(callback => (0, queryDelta_1.callWithDelta)(callback, query.state, delta));
|
|
167
444
|
});
|
|
168
445
|
}
|
|
169
446
|
matchObservableItems(collectionName, selector, onlyFirst) {
|
|
170
447
|
if (selector == null)
|
|
171
448
|
return [];
|
|
172
|
-
|
|
449
|
+
// `updateOne({ id })` and `removeOne({ id })` are what an application writes most of the time,
|
|
450
|
+
// and they name exactly the rows they touch — no reason to materialise every active query's
|
|
451
|
+
// result and run the matcher over all of it to find them.
|
|
452
|
+
const ids = selectorIds(selector);
|
|
453
|
+
const matches = ids == null
|
|
454
|
+
? this.observableItems(collectionName).filter(item => (0, match_1.default)(item, selector))
|
|
455
|
+
: this.observableItemsByIds(collectionName, ids);
|
|
173
456
|
return onlyFirst ? matches.slice(0, 1) : matches;
|
|
174
457
|
}
|
|
175
458
|
resolveUpdate(collectionName, selector, modifier, onlyFirst) {
|
|
@@ -224,18 +507,22 @@ class WorkerDataAdapter {
|
|
|
224
507
|
options: query.options,
|
|
225
508
|
state: 'active',
|
|
226
509
|
error: null,
|
|
227
|
-
items: [],
|
|
228
510
|
stateChangeCallbacks: [],
|
|
229
|
-
eventHandler: existing?.eventHandler,
|
|
230
511
|
...existing,
|
|
231
512
|
...update,
|
|
513
|
+
// An update that says nothing about the items leaves them alone. A query going back to
|
|
514
|
+
// `'active'` while it is recomputed is exactly that, and letting it blank the result would
|
|
515
|
+
// leave every reader of this query with nothing to show until the recomputation lands.
|
|
516
|
+
...update.items
|
|
517
|
+
? { items: update.items, itemsById: undefined }
|
|
518
|
+
: { items: existing?.items ?? [] },
|
|
232
519
|
};
|
|
233
520
|
collectionQueries.set(id, newState);
|
|
234
521
|
this.queries[collectionName] = collectionQueries;
|
|
235
522
|
}
|
|
236
523
|
createCollectionBackend(collection, indices) {
|
|
237
524
|
this.queries[collection.name] = new Map();
|
|
238
|
-
|
|
525
|
+
this.execInBackground('registerCollection', collection.name, [indices]);
|
|
239
526
|
this.collectionReady.set(collection.name, this.exec('isReady', collection.name));
|
|
240
527
|
this.batchExecutionHelpers.set(collection.name, (0, batchOnNextTick_1.default)(async (method, args) => this.exec(method, collection.name, args)));
|
|
241
528
|
return {
|
|
@@ -263,41 +550,24 @@ class WorkerDataAdapter {
|
|
|
263
550
|
// methods for registering and unregistering queries that will be called from the collection during find/findOne
|
|
264
551
|
registerQuery: (selector, options) => {
|
|
265
552
|
this.updateQuery(collection.name, { selector, options }, { state: 'active', error: null, items: [] });
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (data == null)
|
|
272
|
-
return;
|
|
273
|
-
const { collectionName, selector: responseSelector, options: responseOptions, state, items, } = data;
|
|
274
|
-
if (workerId !== this.id)
|
|
275
|
-
return;
|
|
276
|
-
if (collectionName !== collection.name)
|
|
277
|
-
return;
|
|
278
|
-
if ((0, queryId_1.default)(responseSelector, responseOptions) !== (0, queryId_1.default)(selector, options))
|
|
279
|
-
return;
|
|
280
|
-
this.log('queryUpdate', responseSelector, responseOptions, state, data ?? error);
|
|
281
|
-
this.updateQuery(collection.name, {
|
|
282
|
-
selector: responseSelector,
|
|
283
|
-
options: responseOptions,
|
|
284
|
-
}, { state, error, items });
|
|
553
|
+
// A query the worker could not register will never answer. Left as a bare rejection it
|
|
554
|
+
// would surface as an uncaught error and the cursor would sit on its empty result forever,
|
|
555
|
+
// indistinguishable from a query with nothing to show; published as an error it reaches
|
|
556
|
+
// the collection's `query.error` event, which is what that event is for.
|
|
557
|
+
this.execInBackground('registerQuery', collection.name, [selector, options], (error) => {
|
|
285
558
|
const query = this.queries[collection.name]?.get((0, queryId_1.default)(selector, options));
|
|
286
559
|
if (!query)
|
|
287
560
|
return;
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
this.updateQuery(collection.name, { selector, options }, { eventHandler: handler });
|
|
561
|
+
this.updateQuery(collection.name, { selector, options }, { state: 'error', error });
|
|
562
|
+
query.stateChangeCallbacks.forEach(callback => callback('error'));
|
|
563
|
+
});
|
|
292
564
|
},
|
|
293
565
|
unregisterQuery: (selector, options) => {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
this.queries[collection.name]?.delete(qid);
|
|
300
|
-
void this.exec('unregisterQuery', collection.name, selector, options);
|
|
566
|
+
this.queries[collection.name]?.delete((0, queryId_1.default)(selector, options));
|
|
567
|
+
// Nothing holds the query any more, so a failure here has nobody to report to — but it
|
|
568
|
+
// still must not escape as an uncaught error, which is what a disposed collection would
|
|
569
|
+
// otherwise produce every time a cursor was cleaned up after it.
|
|
570
|
+
this.execInBackground('unregisterQuery', collection.name, [selector, options]);
|
|
301
571
|
},
|
|
302
572
|
getQueryState: (selector, options) => {
|
|
303
573
|
const query = this.queries[collection.name]?.get((0, queryId_1.default)(selector, options));
|
|
@@ -311,10 +581,7 @@ class WorkerDataAdapter {
|
|
|
311
581
|
const query = this.queries[collection.name]?.get((0, queryId_1.default)(selector, options));
|
|
312
582
|
if (!query)
|
|
313
583
|
return [];
|
|
314
|
-
|
|
315
|
-
if (!pending || pending.size === 0)
|
|
316
|
-
return query.items;
|
|
317
|
-
return (0, applyQueryOptions_1.default)(this.mergePendingWrites(collection.name, query.items), selector, options);
|
|
584
|
+
return this.servedResult(collection.name, query);
|
|
318
585
|
},
|
|
319
586
|
onQueryStateChange: (selector, options, callback) => {
|
|
320
587
|
this.updateQuery(collection.name, { selector, options }, {
|
|
@@ -44,6 +44,8 @@ export default class WorkerDataAdapterHost<T extends BaseItem<I>, I = any> {
|
|
|
44
44
|
private queryItems;
|
|
45
45
|
private executeQuery;
|
|
46
46
|
private ensureQuery;
|
|
47
|
+
private setQueryItems;
|
|
48
|
+
private queryItemIds;
|
|
47
49
|
private emitQueryUpdate;
|
|
48
50
|
private ensureStorageAdapter;
|
|
49
51
|
private checkQueryUpdates;
|