@arcote.tech/arc 0.7.14 → 0.7.16
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/adapters/event-wire.d.ts +38 -9
- package/dist/adapters/index.d.ts +2 -3
- package/dist/adapters/query-wire.d.ts +4 -16
- package/dist/data-storage/data-storage-observable.test.d.ts +2 -0
- package/dist/data-storage/store-state-master.d.ts +7 -0
- package/dist/data-storage/store-state-master.test.d.ts +2 -0
- package/dist/index.js +280 -291
- package/dist/model/live-query/diff.d.ts +42 -0
- package/dist/model/live-query/diff.test.d.ts +2 -0
- package/dist/model/live-query/index.d.ts +2 -0
- package/dist/model/live-query/live-query-subscription.d.ts +66 -0
- package/dist/model/scoped-model.d.ts +0 -4
- package/dist/streaming/index.d.ts +1 -1
- package/dist/streaming/streaming-event-publisher.d.ts +8 -10
- package/dist/streaming/streaming-query-cache.d.ts +35 -111
- package/dist/streaming/streaming-query-cache.test.d.ts +2 -0
- package/package.json +1 -1
|
@@ -25,7 +25,19 @@ export interface ReceivedEvent {
|
|
|
25
25
|
authContext: EventAuthContext | null;
|
|
26
26
|
}
|
|
27
27
|
import type { ContextDescriptor } from "../model/context-accessor";
|
|
28
|
+
import type { QueryResultChange } from "../model/live-query/diff";
|
|
28
29
|
type EventWireState = "disconnected" | "connecting" | "connected";
|
|
30
|
+
/**
|
|
31
|
+
* Callbacks for a live query subscription. The server answers
|
|
32
|
+
* `subscribe-query` with a full `query-snapshot` (the query's result),
|
|
33
|
+
* then pushes positional `query-changes` deltas computed by the server-side
|
|
34
|
+
* LiveQuery (re-execute + diff). Non-list results always arrive as
|
|
35
|
+
* snapshots.
|
|
36
|
+
*/
|
|
37
|
+
export interface QuerySubscriptionCallbacks {
|
|
38
|
+
onSnapshot: (result: any) => void;
|
|
39
|
+
onChanges: (changes: QueryResultChange[]) => void;
|
|
40
|
+
}
|
|
29
41
|
export declare class EventWire {
|
|
30
42
|
private readonly baseUrl;
|
|
31
43
|
private instanceId;
|
|
@@ -38,10 +50,19 @@ export declare class EventWire {
|
|
|
38
50
|
private onSyncedCallback?;
|
|
39
51
|
private reconnectTimeout?;
|
|
40
52
|
private syncRequested;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
53
|
+
/** Active query subscriptions keyed by subscriptionId. Re-sent in full
|
|
54
|
+
* on every (re)connect — the server drops its registry on disconnect,
|
|
55
|
+
* and a fresh snapshot follows each re-subscribe. */
|
|
56
|
+
private querySubscriptions;
|
|
57
|
+
private querySubCounter;
|
|
58
|
+
/** When false (streaming mode), the client neither requests the event
|
|
59
|
+
* log (`request-sync`) nor consumes domain events — live query
|
|
60
|
+
* subscriptions are the only data channel. Local mode keeps full
|
|
61
|
+
* event sync. */
|
|
62
|
+
private readonly enableEventSync;
|
|
63
|
+
constructor(baseUrl: string, options?: {
|
|
64
|
+
enableEventSync?: boolean;
|
|
65
|
+
});
|
|
45
66
|
/**
|
|
46
67
|
* Set a scope token. If connected, sends scope:auth message to server.
|
|
47
68
|
* If token is null, removes the scope.
|
|
@@ -81,12 +102,14 @@ export declare class EventWire {
|
|
|
81
102
|
*/
|
|
82
103
|
onSynced(callback: (localIds: string[]) => void): void;
|
|
83
104
|
/**
|
|
84
|
-
* Subscribe to a
|
|
85
|
-
*
|
|
105
|
+
* Subscribe to a live query. The server executes the descriptor with
|
|
106
|
+
* tracking, responds with a full `query-snapshot`, then pushes positional
|
|
107
|
+
* `query-changes` deltas whenever the result changes. Callers dedupe
|
|
108
|
+
* identical descriptors (StreamingQueryCache).
|
|
86
109
|
*/
|
|
87
|
-
subscribeQuery(descriptor: ContextDescriptor,
|
|
110
|
+
subscribeQuery(descriptor: ContextDescriptor, scope: string, callbacks: QuerySubscriptionCallbacks): string;
|
|
88
111
|
/**
|
|
89
|
-
* Unsubscribe from a
|
|
112
|
+
* Unsubscribe from a live query.
|
|
90
113
|
*/
|
|
91
114
|
unsubscribeQuery(subscriptionId: string): void;
|
|
92
115
|
/**
|
|
@@ -101,7 +124,13 @@ export declare class EventWire {
|
|
|
101
124
|
private handleMessage;
|
|
102
125
|
private requestSync;
|
|
103
126
|
private flushPendingEvents;
|
|
104
|
-
|
|
127
|
+
/**
|
|
128
|
+
* (Re)send every active query subscription. Called on each (re)connect —
|
|
129
|
+
* covers both subscriptions made while offline and re-establishing the
|
|
130
|
+
* server-side registry after a reconnect (server cleans it on disconnect).
|
|
131
|
+
* Each re-subscribe yields a fresh snapshot.
|
|
132
|
+
*/
|
|
133
|
+
private sendAllQuerySubscriptions;
|
|
105
134
|
private scheduleReconnect;
|
|
106
135
|
}
|
|
107
136
|
export {};
|
package/dist/adapters/index.d.ts
CHANGED
|
@@ -5,16 +5,15 @@
|
|
|
5
5
|
* - Wire: Client-server communication
|
|
6
6
|
* - CommandWire: Command execution over network
|
|
7
7
|
* - EventPublisher: Event persistence and synchronization
|
|
8
|
-
* - QueryWire: Remote view queries via HTTP
|
|
8
|
+
* - QueryWire: Remote view queries via HTTP
|
|
9
9
|
* - DataStorage: Data persistence (defined elsewhere)
|
|
10
10
|
*/
|
|
11
11
|
export { AuthAdapter } from "./auth-adapter";
|
|
12
12
|
export type { DecodedToken } from "./auth-adapter";
|
|
13
13
|
export { CommandWire } from "./command-wire";
|
|
14
14
|
export { EventWire } from "./event-wire";
|
|
15
|
-
export type { ReceivedEvent, SyncableEvent } from "./event-wire";
|
|
15
|
+
export type { QuerySubscriptionCallbacks, ReceivedEvent, SyncableEvent, } from "./event-wire";
|
|
16
16
|
export { QueryWire } from "./query-wire";
|
|
17
|
-
export type { StreamConnection } from "./query-wire";
|
|
18
17
|
export { Wire } from "./wire";
|
|
19
18
|
export type { WireAuth } from "./wire";
|
|
20
19
|
export { EVENT_TABLES, LocalEventPublisher } from "./event-publisher";
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* QueryWire - Wire adapter for remote view queries via HTTP
|
|
2
|
+
* QueryWire - Wire adapter for remote view queries via HTTP
|
|
3
3
|
*
|
|
4
4
|
* Provides:
|
|
5
5
|
* - One-shot queries via HTTP POST
|
|
6
|
-
* - Live queries via Server-Sent Events (SSE)
|
|
7
6
|
* - Per-request auth via scope + token
|
|
7
|
+
*
|
|
8
|
+
* Live updates go through EventWire query subscriptions
|
|
9
|
+
* (query-snapshot + query-changes), not through this adapter.
|
|
8
10
|
*/
|
|
9
11
|
import { Wire, type WireAuth } from "./wire";
|
|
10
|
-
export interface StreamConnection {
|
|
11
|
-
eventSource: EventSource;
|
|
12
|
-
unsubscribe: () => void;
|
|
13
|
-
}
|
|
14
12
|
export declare class QueryWire extends Wire {
|
|
15
13
|
constructor(baseUrl: string);
|
|
16
14
|
/**
|
|
@@ -22,15 +20,5 @@ export declare class QueryWire extends Wire {
|
|
|
22
20
|
* @returns Query results
|
|
23
21
|
*/
|
|
24
22
|
query(viewName: string, options?: any, auth?: WireAuth): Promise<any[]>;
|
|
25
|
-
/**
|
|
26
|
-
* Create a live query stream using SSE
|
|
27
|
-
*
|
|
28
|
-
* @param viewName - Name of the view to stream
|
|
29
|
-
* @param options - Query options (where, orderBy, limit)
|
|
30
|
-
* @param callback - Called when data changes
|
|
31
|
-
* @param auth - Scope and token for this request (SSE uses URL params since headers aren't supported)
|
|
32
|
-
* @returns StreamConnection with unsubscribe method
|
|
33
|
-
*/
|
|
34
|
-
stream(viewName: string, options: any, callback: (data: any[]) => void, auth?: WireAuth): StreamConnection;
|
|
35
23
|
}
|
|
36
24
|
//# sourceMappingURL=query-wire.d.ts.map
|
|
@@ -6,6 +6,13 @@ export declare class MasterStoreState<Item extends {
|
|
|
6
6
|
_id: string;
|
|
7
7
|
}> extends StoreState<Item> {
|
|
8
8
|
constructor(storeName: string, dataStorage: DataStorage, deserialize?: (data: any) => Item);
|
|
9
|
+
/**
|
|
10
|
+
* Read the current row for `id` — transaction cache first, then the store.
|
|
11
|
+
* Cache keys are prefixed with the store name: two stores in one
|
|
12
|
+
* transaction can hold rows under the same `_id` (e.g. two views keyed by
|
|
13
|
+
* the same aggregate id) and must not shadow each other.
|
|
14
|
+
*/
|
|
15
|
+
private readExisting;
|
|
9
16
|
applyChangeAndReturnEvent(transaction: ReadWriteTransaction, change: StoreStateChange<Item>, transactionCache?: Map<string, Item>): Promise<{
|
|
10
17
|
from: Item | null;
|
|
11
18
|
to: Item | null;
|
package/dist/index.js
CHANGED
|
@@ -211,12 +211,13 @@ class EventWire {
|
|
|
211
211
|
onSyncedCallback;
|
|
212
212
|
reconnectTimeout;
|
|
213
213
|
syncRequested = false;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
constructor(baseUrl) {
|
|
214
|
+
querySubscriptions = new Map;
|
|
215
|
+
querySubCounter = 0;
|
|
216
|
+
enableEventSync;
|
|
217
|
+
constructor(baseUrl, options) {
|
|
218
218
|
this.baseUrl = baseUrl;
|
|
219
219
|
this.instanceId = ++eventWireInstanceCounter;
|
|
220
|
+
this.enableEventSync = options?.enableEventSync ?? true;
|
|
220
221
|
}
|
|
221
222
|
setScopeToken(scope, token) {
|
|
222
223
|
if (token === null) {
|
|
@@ -262,9 +263,11 @@ class EventWire {
|
|
|
262
263
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
263
264
|
this.state = "connected";
|
|
264
265
|
this.sendAllScopeTokens();
|
|
265
|
-
this.
|
|
266
|
+
if (this.enableEventSync) {
|
|
267
|
+
this.requestSync();
|
|
268
|
+
}
|
|
266
269
|
this.flushPendingEvents();
|
|
267
|
-
this.
|
|
270
|
+
this.sendAllQuerySubscriptions();
|
|
268
271
|
} else {
|
|
269
272
|
console.log(`[EventWire] onopen called but ws is not OPEN, readyState:`, this.ws?.readyState);
|
|
270
273
|
}
|
|
@@ -340,9 +343,13 @@ class EventWire {
|
|
|
340
343
|
onSynced(callback) {
|
|
341
344
|
this.onSyncedCallback = callback;
|
|
342
345
|
}
|
|
343
|
-
subscribeQuery(descriptor,
|
|
344
|
-
const subscriptionId = `qs_${
|
|
345
|
-
this.
|
|
346
|
+
subscribeQuery(descriptor, scope, callbacks) {
|
|
347
|
+
const subscriptionId = `qs_${this.instanceId}_${++this.querySubCounter}`;
|
|
348
|
+
this.querySubscriptions.set(subscriptionId, {
|
|
349
|
+
descriptor,
|
|
350
|
+
scope,
|
|
351
|
+
callbacks
|
|
352
|
+
});
|
|
346
353
|
if (this.state === "connected" && this.ws) {
|
|
347
354
|
this.ws.send(JSON.stringify({
|
|
348
355
|
type: "subscribe-query",
|
|
@@ -350,20 +357,17 @@ class EventWire {
|
|
|
350
357
|
descriptor,
|
|
351
358
|
scope
|
|
352
359
|
}));
|
|
353
|
-
} else {
|
|
354
|
-
this.pendingViewSubs.push({ subscriptionId, descriptor, scope });
|
|
355
360
|
}
|
|
356
361
|
return subscriptionId;
|
|
357
362
|
}
|
|
358
363
|
unsubscribeQuery(subscriptionId) {
|
|
359
|
-
this.
|
|
364
|
+
this.querySubscriptions.delete(subscriptionId);
|
|
360
365
|
if (this.state === "connected" && this.ws) {
|
|
361
366
|
this.ws.send(JSON.stringify({
|
|
362
367
|
type: "unsubscribe-query",
|
|
363
368
|
subscriptionId
|
|
364
369
|
}));
|
|
365
370
|
}
|
|
366
|
-
this.pendingViewSubs = this.pendingViewSubs.filter((s) => s.subscriptionId !== subscriptionId);
|
|
367
371
|
}
|
|
368
372
|
getState() {
|
|
369
373
|
return this.state;
|
|
@@ -396,10 +400,17 @@ class EventWire {
|
|
|
396
400
|
this.lastHostEventId = message.lastHostEventId;
|
|
397
401
|
}
|
|
398
402
|
break;
|
|
399
|
-
case "query-
|
|
400
|
-
const
|
|
401
|
-
if (
|
|
402
|
-
|
|
403
|
+
case "query-snapshot": {
|
|
404
|
+
const sub = this.querySubscriptions.get(message.subscriptionId);
|
|
405
|
+
if (sub) {
|
|
406
|
+
sub.callbacks.onSnapshot(message.result ?? null);
|
|
407
|
+
}
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
case "query-changes": {
|
|
411
|
+
const sub = this.querySubscriptions.get(message.subscriptionId);
|
|
412
|
+
if (sub && Array.isArray(message.changes)) {
|
|
413
|
+
sub.callbacks.onChanges(message.changes);
|
|
403
414
|
}
|
|
404
415
|
break;
|
|
405
416
|
}
|
|
@@ -427,18 +438,17 @@ class EventWire {
|
|
|
427
438
|
this.pendingEvents = [];
|
|
428
439
|
}
|
|
429
440
|
}
|
|
430
|
-
|
|
441
|
+
sendAllQuerySubscriptions() {
|
|
431
442
|
if (!this.ws || this.state !== "connected")
|
|
432
443
|
return;
|
|
433
|
-
for (const sub of this.
|
|
444
|
+
for (const [subscriptionId, sub] of this.querySubscriptions) {
|
|
434
445
|
this.ws.send(JSON.stringify({
|
|
435
446
|
type: "subscribe-query",
|
|
436
|
-
subscriptionId
|
|
447
|
+
subscriptionId,
|
|
437
448
|
descriptor: sub.descriptor,
|
|
438
449
|
scope: sub.scope
|
|
439
450
|
}));
|
|
440
451
|
}
|
|
441
|
-
this.pendingViewSubs = [];
|
|
442
452
|
}
|
|
443
453
|
scheduleReconnect() {
|
|
444
454
|
if (this.reconnectTimeout)
|
|
@@ -465,47 +475,6 @@ class QueryWire extends Wire {
|
|
|
465
475
|
}
|
|
466
476
|
return await response.json();
|
|
467
477
|
}
|
|
468
|
-
stream(viewName, options, callback, auth) {
|
|
469
|
-
const params = new URLSearchParams;
|
|
470
|
-
if (options?.where) {
|
|
471
|
-
params.set("where", JSON.stringify(options.where));
|
|
472
|
-
}
|
|
473
|
-
if (options?.orderBy) {
|
|
474
|
-
params.set("orderBy", JSON.stringify(options.orderBy));
|
|
475
|
-
}
|
|
476
|
-
if (options?.limit) {
|
|
477
|
-
params.set("limit", String(options.limit));
|
|
478
|
-
}
|
|
479
|
-
if (auth?.token) {
|
|
480
|
-
params.set("token", auth.token);
|
|
481
|
-
}
|
|
482
|
-
if (auth?.scope) {
|
|
483
|
-
params.set("scope", auth.scope);
|
|
484
|
-
}
|
|
485
|
-
const queryString = params.toString();
|
|
486
|
-
const url = `${this.getBaseUrl()}/stream/${viewName}${queryString ? `?${queryString}` : ""}`;
|
|
487
|
-
const eventSource = new EventSource(url);
|
|
488
|
-
eventSource.onmessage = (event) => {
|
|
489
|
-
try {
|
|
490
|
-
const message = JSON.parse(event.data);
|
|
491
|
-
if (message.type === "data") {
|
|
492
|
-
callback(message.data);
|
|
493
|
-
}
|
|
494
|
-
} catch (err) {
|
|
495
|
-
console.error("QueryWire: Failed to parse SSE message", err);
|
|
496
|
-
}
|
|
497
|
-
};
|
|
498
|
-
eventSource.onerror = (err) => {
|
|
499
|
-
console.error("QueryWire: SSE error", err);
|
|
500
|
-
};
|
|
501
|
-
const unsubscribe = () => {
|
|
502
|
-
eventSource.close();
|
|
503
|
-
};
|
|
504
|
-
return {
|
|
505
|
-
eventSource,
|
|
506
|
-
unsubscribe
|
|
507
|
-
};
|
|
508
|
-
}
|
|
509
478
|
}
|
|
510
479
|
// src/adapters/event-publisher.ts
|
|
511
480
|
var EVENT_TABLES = {
|
|
@@ -2244,8 +2213,6 @@ class ArcAggregateElement extends ArcContextElement {
|
|
|
2244
2213
|
}
|
|
2245
2214
|
return adapters.dataStorage.getStore(viewName).find(options);
|
|
2246
2215
|
}
|
|
2247
|
-
if (adapters.streamingCache)
|
|
2248
|
-
return adapters.streamingCache.getStore(viewName).find(options);
|
|
2249
2216
|
if (adapters.queryWire)
|
|
2250
2217
|
return adapters.queryWire.query(viewName, options);
|
|
2251
2218
|
return [];
|
|
@@ -3385,12 +3352,19 @@ class MasterStoreState extends StoreState {
|
|
|
3385
3352
|
constructor(storeName, dataStorage, deserialize) {
|
|
3386
3353
|
super(storeName, dataStorage, deserialize);
|
|
3387
3354
|
}
|
|
3355
|
+
async readExisting(transaction, id2, transactionCache) {
|
|
3356
|
+
const cacheKey = `${this.storeName}:${id2}`;
|
|
3357
|
+
if (transactionCache && transactionCache.has(cacheKey)) {
|
|
3358
|
+
return transactionCache.get(cacheKey);
|
|
3359
|
+
}
|
|
3360
|
+
return transaction.find(this.storeName, { where: { _id: id2 } }).then((results) => results[0]);
|
|
3361
|
+
}
|
|
3388
3362
|
async applyChangeAndReturnEvent(transaction, change, transactionCache) {
|
|
3389
3363
|
if (change.type === "set") {
|
|
3390
3364
|
await transaction.set(this.storeName, change.data);
|
|
3391
3365
|
const item = this.deserialize ? this.deserialize(change.data) : change.data;
|
|
3392
3366
|
if (transactionCache) {
|
|
3393
|
-
transactionCache.set(change.data._id
|
|
3367
|
+
transactionCache.set(`${this.storeName}:${change.data._id}`, item);
|
|
3394
3368
|
}
|
|
3395
3369
|
return {
|
|
3396
3370
|
from: null,
|
|
@@ -3404,6 +3378,9 @@ class MasterStoreState extends StoreState {
|
|
|
3404
3378
|
}
|
|
3405
3379
|
if (change.type === "delete") {
|
|
3406
3380
|
await transaction.remove(this.storeName, change.id);
|
|
3381
|
+
if (transactionCache) {
|
|
3382
|
+
transactionCache.delete(`${this.storeName}:${change.id}`);
|
|
3383
|
+
}
|
|
3407
3384
|
return {
|
|
3408
3385
|
from: null,
|
|
3409
3386
|
to: null,
|
|
@@ -3415,17 +3392,12 @@ class MasterStoreState extends StoreState {
|
|
|
3415
3392
|
};
|
|
3416
3393
|
}
|
|
3417
3394
|
if (change.type === "modify") {
|
|
3418
|
-
|
|
3419
|
-
if (transactionCache && transactionCache.has(change.id)) {
|
|
3420
|
-
existing = transactionCache.get(change.id);
|
|
3421
|
-
} else {
|
|
3422
|
-
existing = await transaction.find(this.storeName, { where: { _id: change.id } }).then((results) => results[0]);
|
|
3423
|
-
}
|
|
3395
|
+
const existing = await this.readExisting(transaction, change.id, transactionCache);
|
|
3424
3396
|
const updated = existing ? deepMerge(existing, change.data) : { _id: change.id, ...change.data };
|
|
3425
3397
|
await transaction.set(this.storeName, updated);
|
|
3426
3398
|
const item = this.deserialize ? this.deserialize(updated) : updated;
|
|
3427
3399
|
if (transactionCache) {
|
|
3428
|
-
transactionCache.set(change.id
|
|
3400
|
+
transactionCache.set(`${this.storeName}:${change.id}`, item);
|
|
3429
3401
|
}
|
|
3430
3402
|
return {
|
|
3431
3403
|
from: null,
|
|
@@ -3438,17 +3410,12 @@ class MasterStoreState extends StoreState {
|
|
|
3438
3410
|
};
|
|
3439
3411
|
}
|
|
3440
3412
|
if (change.type === "mutate") {
|
|
3441
|
-
|
|
3442
|
-
if (transactionCache && transactionCache.has(change.id)) {
|
|
3443
|
-
existing = transactionCache.get(change.id);
|
|
3444
|
-
} else {
|
|
3445
|
-
existing = await transaction.find(this.storeName, { where: { _id: change.id } }).then((results) => results[0]);
|
|
3446
|
-
}
|
|
3413
|
+
const existing = await this.readExisting(transaction, change.id, transactionCache);
|
|
3447
3414
|
const updated = apply2(existing || {}, change.patches);
|
|
3448
3415
|
await transaction.set(this.storeName, updated);
|
|
3449
3416
|
const item = this.deserialize ? this.deserialize(updated) : updated;
|
|
3450
3417
|
if (transactionCache) {
|
|
3451
|
-
transactionCache.set(change.id
|
|
3418
|
+
transactionCache.set(`${this.storeName}:${change.id}`, item);
|
|
3452
3419
|
}
|
|
3453
3420
|
return {
|
|
3454
3421
|
from: null,
|
|
@@ -3687,7 +3654,8 @@ class ObservableDataStorage {
|
|
|
3687
3654
|
}
|
|
3688
3655
|
handleStoreChange(storeName, events) {
|
|
3689
3656
|
let hasChanges = false;
|
|
3690
|
-
|
|
3657
|
+
const staleKeys = [];
|
|
3658
|
+
for (const [key, query] of this.trackedQueries) {
|
|
3691
3659
|
if (query.storeName !== storeName)
|
|
3692
3660
|
continue;
|
|
3693
3661
|
let currentResult = query.result;
|
|
@@ -3699,10 +3667,20 @@ class ObservableDataStorage {
|
|
|
3699
3667
|
queryChanged = true;
|
|
3700
3668
|
}
|
|
3701
3669
|
}
|
|
3702
|
-
if (queryChanged)
|
|
3703
|
-
|
|
3670
|
+
if (!queryChanged)
|
|
3671
|
+
continue;
|
|
3672
|
+
if (query.options.limit !== undefined && query.result.length === query.options.limit && currentResult.length < query.options.limit) {
|
|
3673
|
+
staleKeys.push(key);
|
|
3704
3674
|
hasChanges = true;
|
|
3675
|
+
continue;
|
|
3705
3676
|
}
|
|
3677
|
+
query.result = currentResult;
|
|
3678
|
+
hasChanges = true;
|
|
3679
|
+
}
|
|
3680
|
+
for (const key of staleKeys) {
|
|
3681
|
+
const query = this.trackedQueries.get(key);
|
|
3682
|
+
this.source.getStore(query.storeName).unsubscribe(query.listener);
|
|
3683
|
+
this.trackedQueries.delete(key);
|
|
3706
3684
|
}
|
|
3707
3685
|
if (hasChanges) {
|
|
3708
3686
|
this.onChange();
|
|
@@ -4516,13 +4494,6 @@ class ScopedModel {
|
|
|
4516
4494
|
}
|
|
4517
4495
|
return wire.query(viewName, options, this.getAuth());
|
|
4518
4496
|
}
|
|
4519
|
-
subscribeQuery(descriptor, callback) {
|
|
4520
|
-
const wire = this.parent.getAdapters().eventWire;
|
|
4521
|
-
if (!wire) {
|
|
4522
|
-
throw new Error(`Cannot subscribe to query: no eventWire available.`);
|
|
4523
|
-
}
|
|
4524
|
-
return wire.subscribeQuery(descriptor, callback, this.scopeName);
|
|
4525
|
-
}
|
|
4526
4497
|
get query() {
|
|
4527
4498
|
return buildContextAccessor(this.context, this.scopedAdapters, "queryContext", (descriptor) => descriptor);
|
|
4528
4499
|
}
|
|
@@ -4595,245 +4566,260 @@ function mutationExecutor(model) {
|
|
|
4595
4566
|
}
|
|
4596
4567
|
});
|
|
4597
4568
|
}
|
|
4598
|
-
// src/
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
}
|
|
4612
|
-
}
|
|
4613
|
-
}
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
4569
|
+
// src/model/live-query/diff.ts
|
|
4570
|
+
function isIdList(value) {
|
|
4571
|
+
return Array.isArray(value) && value.every((it) => it && typeof it === "object" && typeof it._id === "string");
|
|
4572
|
+
}
|
|
4573
|
+
function diffResults(prev, next) {
|
|
4574
|
+
if (!isIdList(prev) || !isIdList(next)) {
|
|
4575
|
+
return JSON.stringify(prev) === JSON.stringify(next) ? { kind: "none" } : { kind: "snapshot", result: next };
|
|
4576
|
+
}
|
|
4577
|
+
const json = (o) => JSON.stringify(o);
|
|
4578
|
+
const nextIds = new Set(next.map((it) => it._id));
|
|
4579
|
+
const changes = [];
|
|
4580
|
+
for (const it of prev) {
|
|
4581
|
+
if (!nextIds.has(it._id)) {
|
|
4582
|
+
changes.push({ type: "delete", id: it._id });
|
|
4583
|
+
}
|
|
4584
|
+
}
|
|
4585
|
+
const sim = prev.filter((it) => nextIds.has(it._id));
|
|
4586
|
+
for (let i = 0;i < next.length; i++) {
|
|
4587
|
+
const target = next[i];
|
|
4588
|
+
if (sim[i] && sim[i]._id === target._id && json(sim[i]) === json(target)) {
|
|
4589
|
+
continue;
|
|
4617
4590
|
}
|
|
4618
|
-
|
|
4591
|
+
changes.push({ type: "set", id: target._id, item: target, index: i });
|
|
4592
|
+
const oldIdx = sim.findIndex((it) => it._id === target._id);
|
|
4593
|
+
if (oldIdx !== -1)
|
|
4594
|
+
sim.splice(oldIdx, 1);
|
|
4595
|
+
sim.splice(i, 0, target);
|
|
4619
4596
|
}
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
|
|
4597
|
+
if (changes.length === 0)
|
|
4598
|
+
return { kind: "none" };
|
|
4599
|
+
if (changes.length > next.length) {
|
|
4600
|
+
return { kind: "snapshot", result: next };
|
|
4623
4601
|
}
|
|
4624
|
-
|
|
4625
|
-
return
|
|
4602
|
+
if (sim.length !== next.length || json(sim) !== json(next)) {
|
|
4603
|
+
return { kind: "snapshot", result: next };
|
|
4626
4604
|
}
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
4605
|
+
return { kind: "changes", changes };
|
|
4606
|
+
}
|
|
4607
|
+
function applyQueryChanges(result, changes) {
|
|
4608
|
+
const next = [...result];
|
|
4609
|
+
for (const change of changes) {
|
|
4610
|
+
if (change.type === "delete") {
|
|
4611
|
+
const idx = next.findIndex((it) => it._id === change.id);
|
|
4612
|
+
if (idx !== -1)
|
|
4613
|
+
next.splice(idx, 1);
|
|
4632
4614
|
}
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4615
|
+
}
|
|
4616
|
+
for (const change of changes) {
|
|
4617
|
+
if (change.type === "set") {
|
|
4618
|
+
const idx = next.findIndex((it) => it._id === change.id);
|
|
4619
|
+
if (idx !== -1)
|
|
4620
|
+
next.splice(idx, 1);
|
|
4621
|
+
next.splice(change.index, 0, change.item);
|
|
4640
4622
|
}
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4623
|
+
}
|
|
4624
|
+
return next;
|
|
4625
|
+
}
|
|
4626
|
+
|
|
4627
|
+
// src/model/live-query/live-query-subscription.ts
|
|
4628
|
+
class LiveQuery {
|
|
4629
|
+
model;
|
|
4630
|
+
descriptor;
|
|
4631
|
+
scope;
|
|
4632
|
+
rawToken;
|
|
4633
|
+
onUpdate;
|
|
4634
|
+
observable = null;
|
|
4635
|
+
adapters = null;
|
|
4636
|
+
lastResult;
|
|
4637
|
+
scheduled = false;
|
|
4638
|
+
running = false;
|
|
4639
|
+
rerunRequested = false;
|
|
4640
|
+
stopped = false;
|
|
4641
|
+
constructor(model, descriptor, scope, rawToken, onUpdate) {
|
|
4642
|
+
this.model = model;
|
|
4643
|
+
this.descriptor = descriptor;
|
|
4644
|
+
this.scope = scope;
|
|
4645
|
+
this.rawToken = rawToken;
|
|
4646
|
+
this.onUpdate = onUpdate;
|
|
4647
|
+
}
|
|
4648
|
+
async start() {
|
|
4649
|
+
const scoped = new ScopedModel(this.model, this.scope);
|
|
4650
|
+
if (this.rawToken)
|
|
4651
|
+
scoped.setToken(this.rawToken);
|
|
4652
|
+
const baseAdapters = scoped.getAdapters();
|
|
4653
|
+
if (!baseAdapters.dataStorage) {
|
|
4654
|
+
throw new Error("LiveQuery requires a dataStorage adapter (server-side)");
|
|
4655
|
+
}
|
|
4656
|
+
this.observable = new ObservableDataStorage(baseAdapters.dataStorage, () => this.schedule());
|
|
4657
|
+
this.adapters = {
|
|
4658
|
+
...baseAdapters,
|
|
4659
|
+
dataStorage: this.observable
|
|
4649
4660
|
};
|
|
4661
|
+
this.lastResult = await executeDescriptor(this.descriptor, this.model.context, this.adapters, "queryContext", { fromWire: true });
|
|
4662
|
+
return this.lastResult;
|
|
4663
|
+
}
|
|
4664
|
+
flush() {
|
|
4665
|
+
this.schedule();
|
|
4650
4666
|
}
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4667
|
+
stop() {
|
|
4668
|
+
this.stopped = true;
|
|
4669
|
+
this.observable?.clear();
|
|
4670
|
+
}
|
|
4671
|
+
schedule() {
|
|
4672
|
+
if (this.stopped)
|
|
4654
4673
|
return;
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
}, StreamingQueryCache.UNSUBSCRIBE_DELAY_MS);
|
|
4666
|
-
this.pendingUnsubscribes.set(viewName, timeout);
|
|
4667
|
-
}
|
|
4668
|
-
}
|
|
4669
|
-
subscribeQuery(descriptor, eventWire, scope) {
|
|
4670
|
-
const key = descriptor.element;
|
|
4671
|
-
if (scope)
|
|
4672
|
-
this.streamScopes.set(key, scope);
|
|
4673
|
-
const { unsubscribe } = this.registerStream(key, () => {
|
|
4674
|
-
const subId = eventWire.subscribeQuery(descriptor, (data) => {
|
|
4675
|
-
this.setViewData(descriptor.element, data);
|
|
4676
|
-
}, scope);
|
|
4677
|
-
return { unsubscribe: () => eventWire.unsubscribeQuery(subId) };
|
|
4674
|
+
if (this.running) {
|
|
4675
|
+
this.rerunRequested = true;
|
|
4676
|
+
return;
|
|
4677
|
+
}
|
|
4678
|
+
if (this.scheduled)
|
|
4679
|
+
return;
|
|
4680
|
+
this.scheduled = true;
|
|
4681
|
+
queueMicrotask(() => {
|
|
4682
|
+
this.scheduled = false;
|
|
4683
|
+
this.run();
|
|
4678
4684
|
});
|
|
4679
|
-
return unsubscribe;
|
|
4680
4685
|
}
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4686
|
+
async run() {
|
|
4687
|
+
if (this.stopped)
|
|
4688
|
+
return;
|
|
4689
|
+
this.running = true;
|
|
4690
|
+
try {
|
|
4691
|
+
const next = await executeDescriptor(this.descriptor, this.model.context, this.adapters, "queryContext", { fromWire: true });
|
|
4692
|
+
if (this.stopped)
|
|
4693
|
+
return;
|
|
4694
|
+
const diff = diffResults(this.lastResult, next);
|
|
4695
|
+
this.lastResult = next;
|
|
4696
|
+
if (diff.kind === "changes") {
|
|
4697
|
+
this.onUpdate({ type: "changes", changes: diff.changes });
|
|
4698
|
+
} else if (diff.kind === "snapshot") {
|
|
4699
|
+
this.onUpdate({ type: "snapshot", result: diff.result });
|
|
4689
4700
|
}
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
this.
|
|
4701
|
+
} catch (err) {
|
|
4702
|
+
console.error(`[Arc] LiveQuery re-execute error:`, err);
|
|
4703
|
+
} finally {
|
|
4704
|
+
this.running = false;
|
|
4705
|
+
if (this.rerunRequested) {
|
|
4706
|
+
this.rerunRequested = false;
|
|
4707
|
+
this.schedule();
|
|
4696
4708
|
}
|
|
4697
|
-
this.streamScopes.delete(viewName);
|
|
4698
|
-
const store = this.stores.get(viewName);
|
|
4699
|
-
if (store)
|
|
4700
|
-
store.clear();
|
|
4701
4709
|
}
|
|
4702
4710
|
}
|
|
4703
|
-
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
+
}
|
|
4712
|
+
// src/streaming/streaming-query-cache.ts
|
|
4713
|
+
class StreamingQueryCache {
|
|
4714
|
+
entries = new Map;
|
|
4715
|
+
static UNSUBSCRIBE_DELAY_MS = 5000;
|
|
4716
|
+
entryKey(descriptor, scope) {
|
|
4717
|
+
return `${scope ?? "default"}:${murmurHash(JSON.stringify(descriptor))}`;
|
|
4718
|
+
}
|
|
4719
|
+
subscribe(descriptor, scope, eventWire, onChange) {
|
|
4720
|
+
const key = this.entryKey(descriptor, scope);
|
|
4721
|
+
let entry = this.entries.get(key);
|
|
4722
|
+
if (entry) {
|
|
4723
|
+
if (entry.pendingUnsub) {
|
|
4724
|
+
clearTimeout(entry.pendingUnsub);
|
|
4725
|
+
entry.pendingUnsub = undefined;
|
|
4726
|
+
}
|
|
4727
|
+
entry.refCount++;
|
|
4711
4728
|
} else {
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
const handler = handlers[event3.type];
|
|
4719
|
-
if (!handler)
|
|
4720
|
-
continue;
|
|
4721
|
-
const store = this.stores.get(view3.name);
|
|
4722
|
-
if (!store)
|
|
4723
|
-
continue;
|
|
4724
|
-
const ctx = {
|
|
4725
|
-
set: async (id3, data) => {
|
|
4726
|
-
store.set(String(id3), { _id: String(id3), ...data });
|
|
4727
|
-
},
|
|
4728
|
-
modify: async (id3, data) => {
|
|
4729
|
-
store.modify(String(id3), data);
|
|
4730
|
-
},
|
|
4731
|
-
remove: async (id3) => {
|
|
4732
|
-
store.remove(String(id3));
|
|
4733
|
-
},
|
|
4734
|
-
find: async (options) => {
|
|
4735
|
-
return store.find(options);
|
|
4736
|
-
},
|
|
4737
|
-
findOne: async (where) => {
|
|
4738
|
-
return store.findOne(where);
|
|
4739
|
-
},
|
|
4740
|
-
$auth: {}
|
|
4729
|
+
const newEntry = {
|
|
4730
|
+
result: undefined,
|
|
4731
|
+
hasResult: false,
|
|
4732
|
+
listeners: new Set,
|
|
4733
|
+
refCount: 1,
|
|
4734
|
+
subscriptionId: ""
|
|
4741
4735
|
};
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
4758
|
-
}
|
|
4759
|
-
}
|
|
4760
|
-
|
|
4761
|
-
class StreamingStore {
|
|
4762
|
-
data = new Map;
|
|
4763
|
-
listeners = new Set;
|
|
4764
|
-
initialized = false;
|
|
4765
|
-
hasData() {
|
|
4766
|
-
return this.initialized;
|
|
4767
|
-
}
|
|
4768
|
-
setAll(items) {
|
|
4769
|
-
this.initialized = true;
|
|
4770
|
-
this.data.clear();
|
|
4771
|
-
for (const item of items) {
|
|
4772
|
-
this.data.set(item._id, item);
|
|
4773
|
-
}
|
|
4774
|
-
this.notifyListeners(null);
|
|
4775
|
-
}
|
|
4776
|
-
set(id3, item) {
|
|
4777
|
-
this.data.set(id3, item);
|
|
4778
|
-
this.notifyListeners([{ type: "set", id: id3, item }]);
|
|
4779
|
-
}
|
|
4780
|
-
modify(id3, updates) {
|
|
4781
|
-
const existing = this.data.get(id3);
|
|
4782
|
-
if (existing) {
|
|
4783
|
-
const updated = { ...existing, ...updates };
|
|
4784
|
-
this.data.set(id3, updated);
|
|
4785
|
-
this.notifyListeners([{ type: "set", id: id3, item: updated }]);
|
|
4736
|
+
newEntry.subscriptionId = eventWire.subscribeQuery(descriptor, scope, {
|
|
4737
|
+
onSnapshot: (result) => {
|
|
4738
|
+
newEntry.result = result;
|
|
4739
|
+
newEntry.hasResult = true;
|
|
4740
|
+
this.notify(newEntry);
|
|
4741
|
+
},
|
|
4742
|
+
onChanges: (changes) => {
|
|
4743
|
+
if (!newEntry.hasResult || !Array.isArray(newEntry.result)) {
|
|
4744
|
+
return;
|
|
4745
|
+
}
|
|
4746
|
+
newEntry.result = applyQueryChanges(newEntry.result, changes);
|
|
4747
|
+
this.notify(newEntry);
|
|
4748
|
+
}
|
|
4749
|
+
});
|
|
4750
|
+
this.entries.set(key, newEntry);
|
|
4751
|
+
entry = newEntry;
|
|
4786
4752
|
}
|
|
4753
|
+
const subscribed = entry;
|
|
4754
|
+
subscribed.listeners.add(onChange);
|
|
4755
|
+
let active = true;
|
|
4756
|
+
return {
|
|
4757
|
+
read: () => ({
|
|
4758
|
+
result: subscribed.result,
|
|
4759
|
+
loading: !subscribed.hasResult
|
|
4760
|
+
}),
|
|
4761
|
+
unsubscribe: () => {
|
|
4762
|
+
if (!active)
|
|
4763
|
+
return;
|
|
4764
|
+
active = false;
|
|
4765
|
+
subscribed.listeners.delete(onChange);
|
|
4766
|
+
subscribed.refCount--;
|
|
4767
|
+
if (subscribed.refCount > 0)
|
|
4768
|
+
return;
|
|
4769
|
+
subscribed.pendingUnsub = setTimeout(() => {
|
|
4770
|
+
subscribed.pendingUnsub = undefined;
|
|
4771
|
+
if (subscribed.refCount > 0)
|
|
4772
|
+
return;
|
|
4773
|
+
eventWire.unsubscribeQuery(subscribed.subscriptionId);
|
|
4774
|
+
this.entries.delete(key);
|
|
4775
|
+
}, StreamingQueryCache.UNSUBSCRIBE_DELAY_MS);
|
|
4776
|
+
}
|
|
4777
|
+
};
|
|
4787
4778
|
}
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
4779
|
+
invalidateScope(scope, eventWire) {
|
|
4780
|
+
const prefix = `${scope}:`;
|
|
4781
|
+
for (const [key, entry] of this.entries) {
|
|
4782
|
+
if (!key.startsWith(prefix))
|
|
4783
|
+
continue;
|
|
4784
|
+
if (entry.pendingUnsub)
|
|
4785
|
+
clearTimeout(entry.pendingUnsub);
|
|
4786
|
+
eventWire?.unsubscribeQuery(entry.subscriptionId);
|
|
4787
|
+
this.entries.delete(key);
|
|
4788
|
+
entry.result = undefined;
|
|
4789
|
+
entry.hasResult = false;
|
|
4790
|
+
this.notify(entry);
|
|
4791
4791
|
}
|
|
4792
4792
|
}
|
|
4793
|
-
clear() {
|
|
4794
|
-
this.
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
find(options = {}) {
|
|
4799
|
-
let results = Array.from(this.data.values());
|
|
4800
|
-
if (options.where) {
|
|
4801
|
-
results = results.filter((item) => checkItemMatchesWhere(item, options.where));
|
|
4793
|
+
clear(eventWire) {
|
|
4794
|
+
for (const entry of this.entries.values()) {
|
|
4795
|
+
if (entry.pendingUnsub)
|
|
4796
|
+
clearTimeout(entry.pendingUnsub);
|
|
4797
|
+
eventWire?.unsubscribeQuery(entry.subscriptionId);
|
|
4802
4798
|
}
|
|
4803
|
-
|
|
4799
|
+
this.entries.clear();
|
|
4804
4800
|
}
|
|
4805
|
-
|
|
4806
|
-
const
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
this.listeners.delete(listener4);
|
|
4813
|
-
};
|
|
4814
|
-
}
|
|
4815
|
-
notifyListeners(events) {
|
|
4816
|
-
for (const listener4 of this.listeners) {
|
|
4817
|
-
listener4(events);
|
|
4801
|
+
notify(entry) {
|
|
4802
|
+
for (const listener4 of entry.listeners) {
|
|
4803
|
+
try {
|
|
4804
|
+
listener4();
|
|
4805
|
+
} catch (err) {
|
|
4806
|
+
console.error(`[Arc] Query cache listener error:`, err);
|
|
4807
|
+
}
|
|
4818
4808
|
}
|
|
4819
4809
|
}
|
|
4820
4810
|
}
|
|
4821
4811
|
// src/streaming/streaming-event-publisher.ts
|
|
4822
4812
|
class StreamingEventPublisher {
|
|
4823
|
-
cache;
|
|
4824
4813
|
eventWire;
|
|
4825
4814
|
views = [];
|
|
4826
4815
|
subscribers = new Map;
|
|
4827
|
-
constructor(
|
|
4828
|
-
this.cache = cache;
|
|
4816
|
+
constructor(eventWire) {
|
|
4829
4817
|
this.eventWire = eventWire;
|
|
4830
4818
|
}
|
|
4831
4819
|
registerViews(views) {
|
|
4832
4820
|
this.views = views;
|
|
4833
|
-
this.cache.registerViews(views);
|
|
4834
4821
|
}
|
|
4835
4822
|
async publish(event3) {
|
|
4836
|
-
await this.cache.applyEvent(event3);
|
|
4837
4823
|
await this.notifySubscribers(event3);
|
|
4838
4824
|
this.eventWire.syncEvents([
|
|
4839
4825
|
{
|
|
@@ -5285,6 +5271,7 @@ export {
|
|
|
5285
5271
|
extractDatabaseAgnosticSchema,
|
|
5286
5272
|
executeDescriptor,
|
|
5287
5273
|
event,
|
|
5274
|
+
diffResults,
|
|
5288
5275
|
defaultFunctionData,
|
|
5289
5276
|
deepMerge,
|
|
5290
5277
|
date,
|
|
@@ -5301,6 +5288,7 @@ export {
|
|
|
5301
5288
|
array,
|
|
5302
5289
|
arcFunctionWithCtx,
|
|
5303
5290
|
arcFunction,
|
|
5291
|
+
applyQueryChanges,
|
|
5304
5292
|
applyOrderByAndLimit,
|
|
5305
5293
|
any,
|
|
5306
5294
|
aggregate,
|
|
@@ -5321,6 +5309,7 @@ export {
|
|
|
5321
5309
|
MasterStoreState,
|
|
5322
5310
|
MasterDataStorage,
|
|
5323
5311
|
LocalEventPublisher,
|
|
5312
|
+
LiveQuery,
|
|
5324
5313
|
ForkedStoreState,
|
|
5325
5314
|
ForkedDataStorage,
|
|
5326
5315
|
EventWire,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff between two query results → positional deltas for the wire.
|
|
3
|
+
*
|
|
4
|
+
* The server re-executes a subscribed query and diffs the previous result
|
|
5
|
+
* against the new one. For lists of items with `_id` it produces minimal
|
|
6
|
+
* `set`/`delete` changes WITH target positions — the client applies them
|
|
7
|
+
* blindly (remove-by-id + splice at index), so result order is always the
|
|
8
|
+
* server's order (only the query handler knows its orderBy).
|
|
9
|
+
*
|
|
10
|
+
* Correctness over cleverness: the deltas are verified by simulating their
|
|
11
|
+
* application; any mismatch falls back to a full snapshot.
|
|
12
|
+
*/
|
|
13
|
+
export type QueryResultChange = {
|
|
14
|
+
type: "set";
|
|
15
|
+
id: string;
|
|
16
|
+
item: any;
|
|
17
|
+
index: number;
|
|
18
|
+
} | {
|
|
19
|
+
type: "delete";
|
|
20
|
+
id: string;
|
|
21
|
+
};
|
|
22
|
+
export type QueryDiff = {
|
|
23
|
+
kind: "none";
|
|
24
|
+
} | {
|
|
25
|
+
kind: "changes";
|
|
26
|
+
changes: QueryResultChange[];
|
|
27
|
+
} | {
|
|
28
|
+
kind: "snapshot";
|
|
29
|
+
result: any;
|
|
30
|
+
};
|
|
31
|
+
export declare function diffResults(prev: any, next: any): QueryDiff;
|
|
32
|
+
/**
|
|
33
|
+
* Apply positional deltas to a result list — the exact client-side
|
|
34
|
+
* algorithm (exported so client cache and tests share one implementation):
|
|
35
|
+
* all deletes first, then sets in ascending index order.
|
|
36
|
+
*/
|
|
37
|
+
export declare function applyQueryChanges(result: Array<{
|
|
38
|
+
_id: string;
|
|
39
|
+
}>, changes: QueryResultChange[]): Array<{
|
|
40
|
+
_id: string;
|
|
41
|
+
}>;
|
|
42
|
+
//# sourceMappingURL=diff.d.ts.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiveQuery — a server-side query subscription that owns its deltas.
|
|
3
|
+
*
|
|
4
|
+
* The SAME query (any descriptor: bare view find or custom clientQuery
|
|
5
|
+
* handler) is executed with an ObservableDataStorage wrapped around the
|
|
6
|
+
* real storage. Every `find` the handler makes — already merged with token
|
|
7
|
+
* restrictions by ScopedStore — gets tracked. After each store commit,
|
|
8
|
+
* `resolveQueryChange` updates the tracked results in memory; when any of
|
|
9
|
+
* them changed, the descriptor is re-executed AGAINST THE CACHE (0 SQL)
|
|
10
|
+
* and the new result is diffed against the previous one.
|
|
11
|
+
*
|
|
12
|
+
* The transport layer (WS) only forwards `onUpdate` payloads — all query
|
|
13
|
+
* logic (filtering, scoping, ordering) lives here, in the query layer.
|
|
14
|
+
*/
|
|
15
|
+
import { type ContextDescriptor } from "../context-accessor";
|
|
16
|
+
import type { ModelLike } from "../model-like";
|
|
17
|
+
import { type QueryResultChange } from "./diff";
|
|
18
|
+
export type LiveQueryUpdate = {
|
|
19
|
+
type: "changes";
|
|
20
|
+
changes: QueryResultChange[];
|
|
21
|
+
} | {
|
|
22
|
+
type: "snapshot";
|
|
23
|
+
result: any;
|
|
24
|
+
};
|
|
25
|
+
export declare class LiveQuery {
|
|
26
|
+
private readonly model;
|
|
27
|
+
private readonly descriptor;
|
|
28
|
+
private readonly scope;
|
|
29
|
+
private readonly rawToken;
|
|
30
|
+
private readonly onUpdate;
|
|
31
|
+
private observable;
|
|
32
|
+
private adapters;
|
|
33
|
+
private lastResult;
|
|
34
|
+
private scheduled;
|
|
35
|
+
private running;
|
|
36
|
+
private rerunRequested;
|
|
37
|
+
private stopped;
|
|
38
|
+
constructor(model: ModelLike<any>, descriptor: ContextDescriptor, scope: string, rawToken: string | null, onUpdate: (update: LiveQueryUpdate) => void);
|
|
39
|
+
/**
|
|
40
|
+
* Execute the descriptor with tracking and return the initial result.
|
|
41
|
+
*/
|
|
42
|
+
start(): Promise<any>;
|
|
43
|
+
/**
|
|
44
|
+
* Close the initial-execute window: the store listener is registered
|
|
45
|
+
* before the read transaction, but trackQuery happens after the await —
|
|
46
|
+
* a commit in between could slip past the tracked entry. One forced
|
|
47
|
+
* re-execute (cache-backed, no SQL when nothing changed) diffs out any
|
|
48
|
+
* missed delta.
|
|
49
|
+
*
|
|
50
|
+
* Call AFTER the initial result has been delivered (e.g. the snapshot
|
|
51
|
+
* message was sent) — otherwise the catch-up delta could overtake it.
|
|
52
|
+
*/
|
|
53
|
+
flush(): void;
|
|
54
|
+
/**
|
|
55
|
+
* Stop tracking — unsubscribes all store listeners.
|
|
56
|
+
*/
|
|
57
|
+
stop(): void;
|
|
58
|
+
/**
|
|
59
|
+
* Coalesce re-executes: a single commit can touch multiple stores and
|
|
60
|
+
* fire onChange several times — one microtask handles them all. A commit
|
|
61
|
+
* landing DURING a re-execute requests another pass afterwards.
|
|
62
|
+
*/
|
|
63
|
+
private schedule;
|
|
64
|
+
private run;
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=live-query-subscription.d.ts.map
|
|
@@ -38,10 +38,6 @@ export declare class ScopedModel<Context extends ArcContextAny> implements Model
|
|
|
38
38
|
* Execute a remote query via QueryWire with auto-injected scope + token.
|
|
39
39
|
*/
|
|
40
40
|
remoteQuery(viewName: string, options?: any): Promise<any[]>;
|
|
41
|
-
/**
|
|
42
|
-
* Subscribe to a server-side query via EventWire with auto-injected scope.
|
|
43
|
-
*/
|
|
44
|
-
subscribeQuery(descriptor: ContextDescriptor, callback: (data: any[]) => void): string;
|
|
45
41
|
/**
|
|
46
42
|
* Query descriptor builder. Returns serializable descriptors.
|
|
47
43
|
*
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { StreamingQueryCache } from "./streaming-query-cache";
|
|
2
|
-
export type {
|
|
2
|
+
export type { QuerySubscriptionHandle } from "./streaming-query-cache";
|
|
3
3
|
export { StreamingEventPublisher } from "./streaming-event-publisher";
|
|
4
4
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,35 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* StreamingEventPublisher - Event publisher for streaming mode (no local database)
|
|
3
3
|
*
|
|
4
|
-
* When events are emitted
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* When events are emitted locally (client-side mutate handlers), they are
|
|
5
|
+
* sent to the server via EventWire; the server commits them and pushes the
|
|
6
|
+
* resulting query deltas back through live query subscriptions. No local
|
|
7
|
+
* optimistic apply — the client holds query RESULTS, not view data, so it
|
|
8
|
+
* cannot compute how an event affects a custom query handler's output.
|
|
9
9
|
*/
|
|
10
10
|
import type { EventPublisher, EventWithSyncStatus } from "../adapters/event-publisher";
|
|
11
11
|
import type { EventWire } from "../adapters/event-wire";
|
|
12
12
|
import type { ArcEventAny } from "../context-element/event/event";
|
|
13
13
|
import type { ArcEventInstance } from "../context-element/event/instance";
|
|
14
14
|
import type { ArcViewAny } from "../context-element/view/view";
|
|
15
|
-
import type { StreamingQueryCache } from "./streaming-query-cache";
|
|
16
15
|
/**
|
|
17
16
|
* StreamingEventPublisher
|
|
18
17
|
*/
|
|
19
18
|
export declare class StreamingEventPublisher implements EventPublisher {
|
|
20
|
-
private readonly cache;
|
|
21
19
|
private readonly eventWire;
|
|
22
20
|
private views;
|
|
23
21
|
private subscribers;
|
|
24
|
-
constructor(
|
|
22
|
+
constructor(eventWire: EventWire);
|
|
25
23
|
/**
|
|
26
24
|
* Register views for event handling
|
|
27
25
|
*/
|
|
28
26
|
registerViews(views: ArcViewAny[]): void;
|
|
29
27
|
/**
|
|
30
28
|
* Publish an event
|
|
31
|
-
* 1.
|
|
32
|
-
* 2. Send to server
|
|
29
|
+
* 1. Notify local subscribers
|
|
30
|
+
* 2. Send to server (server commits → live query deltas come back)
|
|
33
31
|
*/
|
|
34
32
|
publish(event: ArcEventInstance<ArcEventAny>): Promise<void>;
|
|
35
33
|
/**
|
|
@@ -1,128 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* StreamingQueryCache -
|
|
2
|
+
* StreamingQueryCache - per-query result cache for streaming mode
|
|
3
3
|
*
|
|
4
|
-
* Used when client connects without local database
|
|
5
|
-
*
|
|
4
|
+
* Used when the client connects without a local database. Each unique
|
|
5
|
+
* (scope, descriptor) gets ONE live subscription over the EventWire:
|
|
6
|
+
* the server executes the query with tracking (LiveQuery), sends a full
|
|
7
|
+
* `query-snapshot`, then positional `query-changes` deltas. The client
|
|
8
|
+
* applies deltas blindly — ALL query logic (filtering, scoping, ordering)
|
|
9
|
+
* lives on the server, in the query layer.
|
|
6
10
|
*
|
|
7
11
|
* Features:
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
12
|
-
* - Deduplicates SSE streams (one stream per view)
|
|
12
|
+
* - Dedup: many components with the same query share one subscription
|
|
13
|
+
* (refCount + UNSUBSCRIBE_DELAY grace window for quick remounts)
|
|
14
|
+
* - Snapshot/delta application with listener notifications
|
|
15
|
+
* - Scope invalidation on token change (workspace switch / re-auth)
|
|
13
16
|
*/
|
|
14
17
|
import type { EventWire } from "../adapters/event-wire";
|
|
15
|
-
import type {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
*/
|
|
24
|
-
export type CacheChangeListener = (events: ListenerEvent<any>[] | null) => void;
|
|
25
|
-
export interface StreamingQueryCacheStore<Item extends {
|
|
26
|
-
_id: string;
|
|
27
|
-
}> {
|
|
28
|
-
find(options?: FindOptions<Item>): Item[];
|
|
29
|
-
findOne(where?: Record<string, any>): Item | undefined;
|
|
30
|
-
subscribe(listener: CacheChangeListener): () => void;
|
|
31
|
-
hasData(): boolean;
|
|
18
|
+
import type { ContextDescriptor } from "../model/context-accessor";
|
|
19
|
+
export interface QuerySubscriptionHandle {
|
|
20
|
+
/** Current state — stable shape for React reads. */
|
|
21
|
+
read(): {
|
|
22
|
+
result: any;
|
|
23
|
+
loading: boolean;
|
|
24
|
+
};
|
|
25
|
+
unsubscribe(): void;
|
|
32
26
|
}
|
|
33
|
-
/**
|
|
34
|
-
* StreamingQueryCache - Main cache class
|
|
35
|
-
*/
|
|
36
27
|
export declare class StreamingQueryCache {
|
|
37
|
-
private
|
|
38
|
-
private views;
|
|
39
|
-
private activeStreams;
|
|
40
|
-
private pendingUnsubscribes;
|
|
41
|
-
/**
|
|
42
|
-
* Tag each active stream with the scope name that subscribed it. Used by
|
|
43
|
-
* `invalidateScope()` to force-close streams when a scope's token changes
|
|
44
|
-
* (workspace switch / re-auth) — without this tag, `registerStream()` would
|
|
45
|
-
* reuse the stale WS subscription (refCount > 0, or within the
|
|
46
|
-
* UNSUBSCRIBE_DELAY_MS grace window) and the client would keep receiving
|
|
47
|
-
* data filtered by the previous token.
|
|
48
|
-
*/
|
|
49
|
-
private streamScopes;
|
|
28
|
+
private entries;
|
|
50
29
|
private static UNSUBSCRIBE_DELAY_MS;
|
|
30
|
+
private entryKey;
|
|
51
31
|
/**
|
|
52
|
-
*
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
* Get a store for a specific view
|
|
57
|
-
*/
|
|
58
|
-
getStore<Item extends {
|
|
59
|
-
_id: string;
|
|
60
|
-
}>(viewName: string): StreamingQueryCacheStore<Item>;
|
|
61
|
-
/**
|
|
62
|
-
* Check if a store has any data
|
|
63
|
-
*/
|
|
64
|
-
hasData(viewName: string): boolean;
|
|
65
|
-
/**
|
|
66
|
-
* Check if a stream is already active for a view
|
|
67
|
-
*/
|
|
68
|
-
hasActiveStream(viewName: string): boolean;
|
|
69
|
-
/**
|
|
70
|
-
* Register an active stream for a view (increment ref count if exists)
|
|
71
|
-
* Returns object with unsubscribe function and whether stream was reused
|
|
72
|
-
*/
|
|
73
|
-
registerStream(viewName: string, createStream: () => {
|
|
74
|
-
unsubscribe: () => void;
|
|
75
|
-
}): {
|
|
76
|
-
unsubscribe: () => void;
|
|
77
|
-
wasReused: boolean;
|
|
78
|
-
};
|
|
79
|
-
/**
|
|
80
|
-
* Unregister from a stream. When refCount hits 0, delays actual WS
|
|
81
|
-
* unsubscribe by UNSUBSCRIBE_DELAY_MS. If re-registered within the
|
|
82
|
-
* window, the existing subscription is reused (cache serves immediately).
|
|
83
|
-
*/
|
|
84
|
-
private unregisterStream;
|
|
85
|
-
/**
|
|
86
|
-
* Subscribe to a query via WebSocket with deduplication.
|
|
87
|
-
* Multiple callers share a single WS subscription per descriptor key.
|
|
88
|
-
* Returns unsubscribe function that decrements refcount.
|
|
89
|
-
*/
|
|
90
|
-
subscribeQuery(descriptor: {
|
|
91
|
-
element: string;
|
|
92
|
-
method: string;
|
|
93
|
-
args: any[];
|
|
94
|
-
}, eventWire: EventWire, scope?: string): () => void;
|
|
95
|
-
/**
|
|
96
|
-
* Force-close every active stream tagged with `scope`. Called when a
|
|
97
|
-
* scope's token changes (workspace switch / re-auth) so the next
|
|
98
|
-
* `subscribeQuery()` creates a fresh WS subscription with the new token
|
|
99
|
-
* instead of reusing the stale one (which would keep pumping data filtered
|
|
100
|
-
* by the previous token until the page reload).
|
|
101
|
-
*
|
|
102
|
-
* Bypasses both `refCount` (other subscribers still mounted) and the
|
|
103
|
-
* UNSUBSCRIBE_DELAY_MS grace window — both became invalid the moment the
|
|
104
|
-
* token changed. React's `useQuery` re-subscribes immediately afterwards
|
|
105
|
-
* via the `subKey` change (token is in the key), getting a fresh stream.
|
|
106
|
-
*
|
|
107
|
-
* Bonus: each affected store is also cleared so any in-progress render
|
|
108
|
-
* that reads `store.find()` between `setToken` and the new WS data
|
|
109
|
-
* arriving gets `[]` rather than stale rows from the previous workspace.
|
|
110
|
-
*/
|
|
111
|
-
invalidateScope(scope: string): void;
|
|
112
|
-
/**
|
|
113
|
-
* Set data for a view. Accepts array or single item (from queryMethod findOne).
|
|
32
|
+
* Subscribe to a live query. Identical (scope, descriptor) pairs share
|
|
33
|
+
* one WS subscription; the last unsubscriber tears it down after a grace
|
|
34
|
+
* window (instant remounts reuse the cached result without a snapshot
|
|
35
|
+
* round-trip).
|
|
114
36
|
*/
|
|
115
|
-
|
|
116
|
-
_id: string;
|
|
117
|
-
}>(viewName: string, data: Item[] | Item | undefined | null): void;
|
|
37
|
+
subscribe(descriptor: ContextDescriptor, scope: string, eventWire: EventWire, onChange: () => void): QuerySubscriptionHandle;
|
|
118
38
|
/**
|
|
119
|
-
*
|
|
120
|
-
*
|
|
39
|
+
* Force-drop every cached query of `scope`. Called when the scope's token
|
|
40
|
+
* changes (workspace switch / re-auth) — cached results and the WS
|
|
41
|
+
* subscriptions behind them were computed with the previous token.
|
|
42
|
+
* React's `useQuery` re-subscribes immediately afterwards (token is part
|
|
43
|
+
* of its subscription key), getting fresh snapshots.
|
|
121
44
|
*/
|
|
122
|
-
|
|
45
|
+
invalidateScope(scope: string, eventWire?: EventWire): void;
|
|
123
46
|
/**
|
|
124
|
-
* Clear all cached
|
|
47
|
+
* Clear all cached queries and tear down their subscriptions.
|
|
125
48
|
*/
|
|
126
|
-
clear(): void;
|
|
49
|
+
clear(eventWire?: EventWire): void;
|
|
50
|
+
private notify;
|
|
127
51
|
}
|
|
128
52
|
//# sourceMappingURL=streaming-query-cache.d.ts.map
|
package/package.json
CHANGED