@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.
@@ -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
- private viewSubscriptions;
42
- private viewSubCounter;
43
- private pendingViewSubs;
44
- constructor(baseUrl: string);
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 server-side query via WebSocket.
85
- * Server executes the descriptor and pushes results on change.
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, callback: (data: any[]) => void, scope?: string): string;
110
+ subscribeQuery(descriptor: ContextDescriptor, scope: string, callbacks: QuerySubscriptionCallbacks): string;
88
111
  /**
89
- * Unsubscribe from a server-side query.
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
- private flushPendingViewSubs;
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 {};
@@ -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/SSE
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/SSE
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
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=data-storage-observable.test.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;
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=store-state-master.test.d.ts.map
package/dist/index.js CHANGED
@@ -211,12 +211,13 @@ class EventWire {
211
211
  onSyncedCallback;
212
212
  reconnectTimeout;
213
213
  syncRequested = false;
214
- viewSubscriptions = new Map;
215
- viewSubCounter = 0;
216
- pendingViewSubs = [];
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.requestSync();
266
+ if (this.enableEventSync) {
267
+ this.requestSync();
268
+ }
266
269
  this.flushPendingEvents();
267
- this.flushPendingViewSubs();
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, callback, scope) {
344
- const subscriptionId = `qs_${++this.viewSubCounter}_${Date.now()}`;
345
- this.viewSubscriptions.set(subscriptionId, callback);
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.viewSubscriptions.delete(subscriptionId);
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-data": {
400
- const cb = this.viewSubscriptions.get(message.subscriptionId);
401
- if (cb) {
402
- cb(message.data);
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
- flushPendingViewSubs() {
441
+ sendAllQuerySubscriptions() {
431
442
  if (!this.ws || this.state !== "connected")
432
443
  return;
433
- for (const sub of this.pendingViewSubs) {
444
+ for (const [subscriptionId, sub] of this.querySubscriptions) {
434
445
  this.ws.send(JSON.stringify({
435
446
  type: "subscribe-query",
436
- subscriptionId: sub.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, item);
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
- let existing;
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, item);
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
- let existing;
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, item);
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
- for (const query of this.trackedQueries.values()) {
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
- query.result = currentResult;
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/streaming/streaming-query-cache.ts
4599
- class StreamingQueryCache {
4600
- stores = new Map;
4601
- views = [];
4602
- activeStreams = new Map;
4603
- pendingUnsubscribes = new Map;
4604
- streamScopes = new Map;
4605
- static UNSUBSCRIBE_DELAY_MS = 5000;
4606
- registerViews(views) {
4607
- this.views = views;
4608
- for (const view3 of views) {
4609
- if (!this.stores.has(view3.name)) {
4610
- this.stores.set(view3.name, new StreamingStore);
4611
- }
4612
- }
4613
- }
4614
- getStore(viewName) {
4615
- if (!this.stores.has(viewName)) {
4616
- this.stores.set(viewName, new StreamingStore);
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
- return this.stores.get(viewName);
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
- hasData(viewName) {
4621
- const store = this.stores.get(viewName);
4622
- return store ? store.hasData() : false;
4597
+ if (changes.length === 0)
4598
+ return { kind: "none" };
4599
+ if (changes.length > next.length) {
4600
+ return { kind: "snapshot", result: next };
4623
4601
  }
4624
- hasActiveStream(viewName) {
4625
- return this.activeStreams.has(viewName);
4602
+ if (sim.length !== next.length || json(sim) !== json(next)) {
4603
+ return { kind: "snapshot", result: next };
4626
4604
  }
4627
- registerStream(viewName, createStream) {
4628
- const pending = this.pendingUnsubscribes.get(viewName);
4629
- if (pending) {
4630
- clearTimeout(pending);
4631
- this.pendingUnsubscribes.delete(viewName);
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
- const existing = this.activeStreams.get(viewName);
4634
- if (existing) {
4635
- existing.refCount++;
4636
- return {
4637
- unsubscribe: () => this.unregisterStream(viewName),
4638
- wasReused: true
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
- const streamConn = createStream();
4642
- this.activeStreams.set(viewName, {
4643
- unsubscribe: streamConn.unsubscribe,
4644
- refCount: 1
4645
- });
4646
- return {
4647
- unsubscribe: () => this.unregisterStream(viewName),
4648
- wasReused: false
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
- unregisterStream(viewName) {
4652
- const stream = this.activeStreams.get(viewName);
4653
- if (!stream)
4667
+ stop() {
4668
+ this.stopped = true;
4669
+ this.observable?.clear();
4670
+ }
4671
+ schedule() {
4672
+ if (this.stopped)
4654
4673
  return;
4655
- stream.refCount--;
4656
- if (stream.refCount <= 0) {
4657
- const timeout = setTimeout(() => {
4658
- this.pendingUnsubscribes.delete(viewName);
4659
- const current = this.activeStreams.get(viewName);
4660
- if (current && current.refCount <= 0) {
4661
- current.unsubscribe();
4662
- this.activeStreams.delete(viewName);
4663
- this.streamScopes.delete(viewName);
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
- invalidateScope(scope) {
4682
- for (const [viewName, viewScope] of this.streamScopes) {
4683
- if (viewScope !== scope)
4684
- continue;
4685
- const pending = this.pendingUnsubscribes.get(viewName);
4686
- if (pending) {
4687
- clearTimeout(pending);
4688
- this.pendingUnsubscribes.delete(viewName);
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
- const stream = this.activeStreams.get(viewName);
4691
- if (stream) {
4692
- try {
4693
- stream.unsubscribe();
4694
- } catch {}
4695
- this.activeStreams.delete(viewName);
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
- setViewData(viewName, data) {
4704
- const store = this.stores.get(viewName);
4705
- if (!store)
4706
- return;
4707
- if (Array.isArray(data)) {
4708
- store.setAll(data);
4709
- } else if (data && typeof data === "object" && "_id" in data) {
4710
- store.setAll([data]);
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
- store.setAll([]);
4713
- }
4714
- }
4715
- async applyEvent(event3) {
4716
- for (const view3 of this.views) {
4717
- const handlers = view3.getHandlers();
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
- await handler(ctx, event3);
4743
- }
4744
- }
4745
- clear() {
4746
- for (const stream of this.activeStreams.values()) {
4747
- stream.unsubscribe();
4748
- }
4749
- this.activeStreams.clear();
4750
- this.streamScopes.clear();
4751
- for (const timeout of this.pendingUnsubscribes.values()) {
4752
- clearTimeout(timeout);
4753
- }
4754
- this.pendingUnsubscribes.clear();
4755
- for (const store of this.stores.values()) {
4756
- store.clear();
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
- remove(id3) {
4789
- if (this.data.delete(id3)) {
4790
- this.notifyListeners([{ type: "delete", id: id3, item: null }]);
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.initialized = false;
4795
- this.data.clear();
4796
- this.notifyListeners(null);
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
- return applyOrderByAndLimit(results, options);
4799
+ this.entries.clear();
4804
4800
  }
4805
- findOne(where) {
4806
- const results = this.find({ where });
4807
- return results[0];
4808
- }
4809
- subscribe(listener4) {
4810
- this.listeners.add(listener4);
4811
- return () => {
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(cache, eventWire) {
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=diff.test.d.ts.map
@@ -1,2 +1,4 @@
1
1
  export * from "./live-query";
2
+ export * from "./live-query-subscription";
3
+ export * from "./diff";
2
4
  //# sourceMappingURL=index.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 { CacheChangeListener, StreamingQueryCacheStore, } from "./streaming-query-cache";
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
- * 1. Apply to local cache (optimistic update)
6
- * 2. Send to server via EventWire
7
- *
8
- * This enables instant UI updates while syncing with server.
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(cache: StreamingQueryCache, eventWire: EventWire);
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. Apply to local cache (optimistic)
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 - Lightweight in-memory cache for streaming mode
2
+ * StreamingQueryCache - per-query result cache for streaming mode
3
3
  *
4
- * Used when client connects without local database (no SQLite/IndexedDB).
5
- * Holds view state in memory, receives data from SSE, applies local events.
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
- * - Stores view data in memory (Map-based)
9
- * - Supports reactive queries with listeners
10
- * - Applies view handlers for local event emission
11
- * - Receives updates from SSE stream
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 { ArcEventAny } from "../context-element/event/event";
16
- import type { ArcEventInstance } from "../context-element/event/instance";
17
- import type { ArcViewAny } from "../context-element/view/view";
18
- import type { FindOptions } from "../data-storage/find-options";
19
- import type { ListenerEvent } from "../data-storage/data-storage.abstract";
20
- /**
21
- * Cache change listener receives ListenerEvent[] for incremental changes,
22
- * or null for bulk replacement (setAll) which requires full re-query.
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 stores;
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
- * Register views that this cache will handle
53
- */
54
- registerViews(views: ArcViewAny[]): void;
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
- setViewData<Item extends {
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
- * Apply an event to update view state
120
- * Runs view handlers to update the cache
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
- applyEvent(event: ArcEventInstance<ArcEventAny>): Promise<void>;
45
+ invalidateScope(scope: string, eventWire?: EventWire): void;
123
46
  /**
124
- * Clear all cached data and close all streams
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
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=streaming-query-cache.test.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc",
3
3
  "type": "module",
4
- "version": "0.7.14",
4
+ "version": "0.7.16",
5
5
  "private": false,
6
6
  "author": "Przemysław Krasiński [arcote.tech]",
7
7
  "description": "Arc framework core rewrite with improved event emission and type safety",