@myko/core 6.1.0 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/client.d.ts +114 -2
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +354 -25
  4. package/dist/generated/AdjacencyPolicy.d.ts +2 -0
  5. package/dist/generated/AdjacencyPolicy.d.ts.map +1 -0
  6. package/dist/generated/AdjacencyPolicy.js +2 -0
  7. package/dist/generated/Direction.d.ts +2 -0
  8. package/dist/generated/Direction.d.ts.map +1 -0
  9. package/dist/generated/Direction.js +2 -0
  10. package/dist/generated/EdgeShapeKind.d.ts +2 -0
  11. package/dist/generated/EdgeShapeKind.d.ts.map +1 -0
  12. package/dist/generated/EdgeShapeKind.js +2 -0
  13. package/dist/generated/EndPosition.d.ts +2 -0
  14. package/dist/generated/EndPosition.d.ts.map +1 -0
  15. package/dist/generated/EndPosition.js +2 -0
  16. package/dist/generated/EndpointDeletePolicy.d.ts +2 -0
  17. package/dist/generated/EndpointDeletePolicy.d.ts.map +1 -0
  18. package/dist/generated/EndpointDeletePolicy.js +2 -0
  19. package/dist/generated/EntityRef.d.ts +8 -0
  20. package/dist/generated/EntityRef.d.ts.map +1 -0
  21. package/dist/generated/EntityRef.js +2 -0
  22. package/dist/generated/GraphPlanTelemetry.d.ts +16 -0
  23. package/dist/generated/GraphPlanTelemetry.d.ts.map +1 -0
  24. package/dist/generated/GraphPlanTelemetry.js +1 -0
  25. package/dist/generated/GraphSyncResult.d.ts +10 -0
  26. package/dist/generated/GraphSyncResult.d.ts.map +1 -0
  27. package/dist/generated/GraphSyncResult.js +2 -0
  28. package/dist/generated/MykoMessage.d.ts +4 -0
  29. package/dist/generated/MykoMessage.d.ts.map +1 -1
  30. package/dist/generated/PairPolicy.d.ts +2 -0
  31. package/dist/generated/PairPolicy.d.ts.map +1 -0
  32. package/dist/generated/PairPolicy.js +2 -0
  33. package/dist/generated/PairProjectionPolicy.d.ts +2 -0
  34. package/dist/generated/PairProjectionPolicy.d.ts.map +1 -0
  35. package/dist/generated/PairProjectionPolicy.js +2 -0
  36. package/dist/generated/QueryCursorWindow.d.ts +12 -0
  37. package/dist/generated/QueryCursorWindow.d.ts.map +1 -0
  38. package/dist/generated/QueryCursorWindow.js +2 -0
  39. package/dist/generated/QueryCursorWindowUpdate.d.ts +6 -0
  40. package/dist/generated/QueryCursorWindowUpdate.d.ts.map +1 -0
  41. package/dist/generated/QueryCursorWindowUpdate.js +1 -0
  42. package/dist/generated/SelfLoopPolicy.d.ts +2 -0
  43. package/dist/generated/SelfLoopPolicy.d.ts.map +1 -0
  44. package/dist/generated/SelfLoopPolicy.js +2 -0
  45. package/dist/generated/TraversalPath.d.ts +12 -0
  46. package/dist/generated/TraversalPath.d.ts.map +1 -0
  47. package/dist/generated/TraversalPath.js +1 -0
  48. package/dist/generated/TraversalResult.d.ts +7 -0
  49. package/dist/generated/TraversalResult.d.ts.map +1 -0
  50. package/dist/generated/TraversalResult.js +1 -0
  51. package/dist/generated/index.d.ts +35 -13
  52. package/dist/generated/index.d.ts.map +1 -1
  53. package/dist/generated/index.js +14 -11
  54. package/dist/graph-client.d.ts +128 -0
  55. package/dist/graph-client.d.ts.map +1 -0
  56. package/dist/graph-client.js +192 -0
  57. package/dist/index.d.ts +4 -2
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +2 -0
  60. package/dist/live-collection.d.ts +94 -0
  61. package/dist/live-collection.d.ts.map +1 -0
  62. package/dist/live-collection.js +302 -0
  63. package/package.json +5 -2
package/dist/client.js CHANGED
@@ -4,10 +4,12 @@
4
4
  * Maintains connections to all known servers for instant failover.
5
5
  * When the current server disconnects, instantly switches to another open connection.
6
6
  */
7
- import { GetPeerServers, MykoEvent, } from './generated';
8
7
  import { Decoder, Encoder } from 'cbor-x';
9
- import { bufferCount, bufferTime, catchError, combineLatest, filter, finalize, firstValueFrom, interval, map, merge, Observable, of, ReplaySubject, scan, shareReplay, Subject, switchMap, } from 'rxjs';
8
+ import { bufferCount, bufferTime, catchError, combineLatest, distinctUntilChanged, filter, finalize, firstValueFrom, interval, map, merge, Observable, of, ReplaySubject, Subject, scan, shareReplay, switchMap, } from 'rxjs';
10
9
  import { v4 as uuid } from 'uuid';
10
+ import { GetPeerServers, MykoEvent, } from './generated';
11
+ import { bindGraph } from './graph-client.js';
12
+ import { LiveCollection } from './live-collection.js';
11
13
  // cbor-x's defaults emit several non-standard extensions (the "records" structure
12
14
  // compression, tag 259 for maps with non-string keys, typed-array tags) that other
13
15
  // CBOR implementations — including the Rust server's `ciborium` decoder targeting
@@ -156,6 +158,77 @@ function reportCacheKey(report) {
156
158
  const payload = stableStringify(report.report) ?? '__unstable_report__';
157
159
  return `report:${report.reportId}:${payload}`;
158
160
  }
161
+ /** One shared source subscription with direct fine-grained state listeners. */
162
+ class LiveSelectionHub {
163
+ #source;
164
+ #onEmpty;
165
+ #registrations = new Set();
166
+ #state;
167
+ #sourceSubscription;
168
+ constructor(source, onEmpty) {
169
+ this.#source = source;
170
+ this.#onEmpty = onEmpty;
171
+ }
172
+ watch(register, equals = Object.is) {
173
+ return new Observable((subscriber) => {
174
+ let emitted = false;
175
+ let previous;
176
+ const emit = (value) => {
177
+ if (emitted && equals(previous, value))
178
+ return;
179
+ emitted = true;
180
+ previous = value;
181
+ subscriber.next(value);
182
+ };
183
+ const registration = {
184
+ attach: (state) => {
185
+ registration.detach = register(state, emit);
186
+ },
187
+ complete: () => subscriber.complete(),
188
+ error: (cause) => subscriber.error(cause),
189
+ };
190
+ this.#registrations.add(registration);
191
+ if (this.#state !== undefined)
192
+ registration.attach(this.#state);
193
+ if (!this.#sourceSubscription)
194
+ this.#start();
195
+ return () => {
196
+ registration.detach?.();
197
+ this.#registrations.delete(registration);
198
+ if (this.#registrations.size === 0) {
199
+ this.#sourceSubscription?.unsubscribe();
200
+ this.#sourceSubscription = undefined;
201
+ this.#state = undefined;
202
+ this.#onEmpty();
203
+ }
204
+ };
205
+ });
206
+ }
207
+ #start() {
208
+ const subscription = this.#source().subscribe({
209
+ next: (state) => {
210
+ if (this.#state === state)
211
+ return;
212
+ this.#state = state;
213
+ for (const registration of this.#registrations) {
214
+ registration.detach?.();
215
+ registration.attach(state);
216
+ }
217
+ },
218
+ error: (cause) => {
219
+ for (const registration of [...this.#registrations]) {
220
+ registration.error(cause);
221
+ }
222
+ },
223
+ complete: () => {
224
+ for (const registration of [...this.#registrations]) {
225
+ registration.complete();
226
+ }
227
+ },
228
+ });
229
+ this.#sourceSubscription = subscription.closed ? undefined : subscription;
230
+ }
231
+ }
159
232
  /**
160
233
  * Reactive WebSocket client for Myko servers with automatic failover.
161
234
  */
@@ -187,6 +260,7 @@ export class MykoClient {
187
260
  queryErrorRoutes = new Map();
188
261
  viewErrorRoutes = new Map();
189
262
  reportErrorRoutes = new Map();
263
+ pendingCommands = new Map();
190
264
  // Lighter-weight direct-callback routing for `subscribeReport` — avoids
191
265
  // allocating an RxJS Subject per anchor when the consumer just needs a
192
266
  // single callback (which is the case for liveReport in the Svelte runtime).
@@ -197,6 +271,7 @@ export class MykoClient {
197
271
  currentServerSubject = new ReplaySubject(1);
198
272
  // Subscription tracking
199
273
  activeQueries = new Map();
274
+ activeQueryCursorWindows = new Map();
200
275
  activeViews = new Map();
201
276
  activeReports = new Map();
202
277
  activeQueryNames = new Map();
@@ -204,8 +279,14 @@ export class MykoClient {
204
279
  activeReportNames = new Map();
205
280
  sharedQueries = new Map();
206
281
  sharedViews = new Map();
282
+ sharedQueryResponseStreams = new Map();
283
+ sharedViewResponseStreams = new Map();
207
284
  sharedQueryDiffs = new Map();
208
285
  sharedViewDiffs = new Map();
286
+ sharedQueryStates = new Map();
287
+ sharedViewStates = new Map();
288
+ querySelectionHubs = new Map();
289
+ viewSelectionHubs = new Map();
209
290
  sharedReports = new Map();
210
291
  subscriptionStartMs = new Map();
211
292
  firstResponseLogged = new Set();
@@ -456,6 +537,7 @@ export class MykoClient {
456
537
  activeQueriesBefore: this.activeQueries.size,
457
538
  });
458
539
  this.activeQueries.delete(tx);
540
+ this.activeQueryCursorWindows.delete(tx);
459
541
  this.activeQueryNames.delete(tx);
460
542
  this.subscriptionStartMs.delete(tx);
461
543
  this.firstResponseLogged.delete(tx);
@@ -467,6 +549,29 @@ export class MykoClient {
467
549
  }));
468
550
  return [tx, responses$];
469
551
  }
552
+ /**
553
+ * One ref-counted wire stream per immutable query identity.
554
+ *
555
+ * Array, diff, and keyed-state projections all consume this stream. Keeping
556
+ * the cache below those projections prevents the same query from opening
557
+ * several server subscriptions merely because different UI layers prefer
558
+ * different result shapes.
559
+ */
560
+ sharedQueryResponses(query, options) {
561
+ const cacheKey = queryCacheKey(query, options);
562
+ const existing = this.sharedQueryResponseStreams.get(cacheKey);
563
+ if (existing)
564
+ return existing;
565
+ const [, responses$] = this.startQuery(query, options);
566
+ let shared$;
567
+ shared$ = responses$.pipe(finalize(() => {
568
+ if (this.sharedQueryResponseStreams.get(cacheKey) === shared$) {
569
+ this.sharedQueryResponseStreams.delete(cacheKey);
570
+ }
571
+ }), shareReplay({ bufferSize: 1, refCount: true }));
572
+ this.sharedQueryResponseStreams.set(cacheKey, shared$);
573
+ return shared$;
574
+ }
470
575
  /** Update server-side window for an active query subscription */
471
576
  setQueryWindow(tx, window) {
472
577
  const active = this.activeQueries.get(tx);
@@ -489,6 +594,19 @@ export class MykoClient {
489
594
  });
490
595
  this.send({ event: MykoEvent.QueryWindow, data: { tx, window } });
491
596
  }
597
+ /** Move an active query to an exclusive ID-keyset page. */
598
+ setQueryCursorWindow(tx, window) {
599
+ if (window.after !== undefined && window.before !== undefined) {
600
+ throw new Error('cursor window cannot contain both after and before');
601
+ }
602
+ if (!this.activeQueries.has(tx))
603
+ return;
604
+ this.activeQueryCursorWindows.set(tx, window);
605
+ this.send({
606
+ event: 'ws:m:query-cursor-window',
607
+ data: { tx, window },
608
+ });
609
+ }
492
610
  /** Start a view subscription, returns [tx, responses$] */
493
611
  startView(view, options) {
494
612
  if (!view.viewId || !view.viewItemType || !view.view) {
@@ -558,6 +676,22 @@ export class MykoClient {
558
676
  }));
559
677
  return [tx, responses$];
560
678
  }
679
+ /** One ref-counted wire stream shared by every immutable view projection. */
680
+ sharedViewResponses(view, options) {
681
+ const cacheKey = viewCacheKey(view, options);
682
+ const existing = this.sharedViewResponseStreams.get(cacheKey);
683
+ if (existing)
684
+ return existing;
685
+ const [, responses$] = this.startView(view, options);
686
+ let shared$;
687
+ shared$ = responses$.pipe(finalize(() => {
688
+ if (this.sharedViewResponseStreams.get(cacheKey) === shared$) {
689
+ this.sharedViewResponseStreams.delete(cacheKey);
690
+ }
691
+ }), shareReplay({ bufferSize: 1, refCount: true }));
692
+ this.sharedViewResponseStreams.set(cacheKey, shared$);
693
+ return shared$;
694
+ }
561
695
  /** Update server-side window for an active view subscription */
562
696
  setViewWindow(tx, window) {
563
697
  const active = this.activeViews.get(tx);
@@ -585,7 +719,7 @@ export class MykoClient {
585
719
  const existing = this.sharedQueries.get(cacheKey);
586
720
  if (existing)
587
721
  return existing;
588
- const [, responses$] = this.startQuery(query, options);
722
+ const responses$ = this.sharedQueryResponses(query, options);
589
723
  const shared$ = responses$.pipe(scan((acc, update) => {
590
724
  if (BigInt(update.data.sequence) === 0n)
591
725
  acc.clear();
@@ -613,7 +747,7 @@ export class MykoClient {
613
747
  const existing = this.sharedViews.get(cacheKey);
614
748
  if (existing)
615
749
  return existing;
616
- const [, responses$] = this.startView(view, options);
750
+ const responses$ = this.sharedViewResponses(view, options);
617
751
  const shared$ = responses$.pipe(scan((acc, update) => {
618
752
  if (BigInt(update.data.sequence) === 0n)
619
753
  acc.clear();
@@ -637,7 +771,7 @@ export class MykoClient {
637
771
  const existing = this.sharedQueryDiffs.get(cacheKey);
638
772
  if (existing)
639
773
  return existing;
640
- const [, responses$] = this.startQuery(query, options);
774
+ const responses$ = this.sharedQueryResponses(query, options);
641
775
  const shared$ = responses$.pipe(map((r) => ({
642
776
  sequence: BigInt(r.data.sequence),
643
777
  deletes: r.data.deletes.slice(),
@@ -658,7 +792,7 @@ export class MykoClient {
658
792
  const existing = this.sharedViewDiffs.get(cacheKey);
659
793
  if (existing)
660
794
  return existing;
661
- const [, responses$] = this.startView(view, options);
795
+ const responses$ = this.sharedViewResponses(view, options);
662
796
  const shared$ = responses$.pipe(map((r) => ({
663
797
  sequence: BigInt(r.data.sequence),
664
798
  deletes: r.data.deletes.slice(),
@@ -673,6 +807,108 @@ export class MykoClient {
673
807
  this.sharedViewDiffs.set(cacheKey, shared$);
674
808
  return shared$;
675
809
  }
810
+ /**
811
+ * Watch a query as stable keyed state without rebuilding its full result array.
812
+ *
813
+ * Identical requests share both the wire subscription and collection instance.
814
+ * Use `toArray()` only where array rendering is required; `get()`, `items`, and
815
+ * `changes` remain O(changes) for incremental updates.
816
+ */
817
+ watchQueryState(query, options) {
818
+ const cacheKey = queryCacheKey(query, options);
819
+ const existing = this.sharedQueryStates.get(cacheKey);
820
+ if (existing)
821
+ return existing;
822
+ const state = new LiveCollection();
823
+ const shared$ = this.watchQueryDiff(query, options).pipe(map((diff) => state.apply(diff)), catchError((error) => of(state.fail(error))), finalize(() => {
824
+ this.sharedQueryStates.delete(cacheKey);
825
+ }), shareReplay({ bufferSize: 1, refCount: true }));
826
+ this.sharedQueryStates.set(cacheKey, shared$);
827
+ return shared$;
828
+ }
829
+ /** Watch a view as stable keyed state without full-array reconstruction. */
830
+ watchViewState(view, options) {
831
+ const cacheKey = viewCacheKey(view, options);
832
+ const existing = this.sharedViewStates.get(cacheKey);
833
+ if (existing)
834
+ return existing;
835
+ const state = new LiveCollection();
836
+ const shared$ = this.watchViewDiff(view, options).pipe(map((diff) => state.apply(diff)), catchError((error) => of(state.fail(error))), finalize(() => {
837
+ this.sharedViewStates.delete(cacheKey);
838
+ }), shareReplay({ bufferSize: 1, refCount: true }));
839
+ this.sharedViewStates.set(cacheKey, shared$);
840
+ return shared$;
841
+ }
842
+ /**
843
+ * Watch an immutable projection of keyed query state.
844
+ *
845
+ * The underlying query/state subscription remains shared. The selector runs
846
+ * once per source revision, while equal results suppress downstream work.
847
+ * Select scalars, entity references, or newly-created immutable values; do
848
+ * not return `state.items` directly because that Map is intentionally stable
849
+ * and mutates in place.
850
+ */
851
+ watchQuerySelection(query, select, options) {
852
+ return this.watchQueryState(query, options).pipe(map(select), distinctUntilChanged(options?.equals ?? Object.is));
853
+ }
854
+ /** Watch one query result by ID without reacting to unrelated rows. */
855
+ watchQueryItem(query, id, options) {
856
+ return this.querySelectionHub(query, options).watch((state, emit) => state.subscribeItem(id, emit));
857
+ }
858
+ /** Watch membership of one ID without reacting to unrelated rows. */
859
+ watchQueryHas(query, id, options) {
860
+ return this.querySelectionHub(query, options).watch((state, emit) => state.subscribeItem(id, (item) => emit(item !== undefined)));
861
+ }
862
+ /** Watch live query cardinality without materializing its result array. */
863
+ watchQuerySize(query, options) {
864
+ return this.querySelectionHub(query, options).watch((state, emit) => state.subscribeSize(emit));
865
+ }
866
+ /** Watch an immutable projection of keyed view state. */
867
+ watchViewSelection(view, select, options) {
868
+ return this.watchViewState(view, options).pipe(map(select), distinctUntilChanged(options?.equals ?? Object.is));
869
+ }
870
+ /** Watch one view result by ID without reacting to unrelated rows. */
871
+ watchViewItem(view, id, options) {
872
+ return this.viewSelectionHub(view, options).watch((state, emit) => state.subscribeItem(id, emit));
873
+ }
874
+ /** Watch membership of one view ID without reacting to unrelated rows. */
875
+ watchViewHas(view, id, options) {
876
+ return this.viewSelectionHub(view, options).watch((state, emit) => state.subscribeItem(id, (item) => emit(item !== undefined)));
877
+ }
878
+ /** Watch live view cardinality without materializing its result array. */
879
+ watchViewSize(view, options) {
880
+ return this.viewSelectionHub(view, options).watch((state, emit) => state.subscribeSize(emit));
881
+ }
882
+ querySelectionHub(query, options) {
883
+ const cacheKey = queryCacheKey(query, options);
884
+ const existing = this.querySelectionHubs.get(cacheKey);
885
+ if (existing) {
886
+ return existing;
887
+ }
888
+ let hub;
889
+ hub = new LiveSelectionHub(() => this.watchQueryState(query, options), () => {
890
+ if (this.querySelectionHubs.get(cacheKey) === hub) {
891
+ this.querySelectionHubs.delete(cacheKey);
892
+ }
893
+ });
894
+ this.querySelectionHubs.set(cacheKey, hub);
895
+ return hub;
896
+ }
897
+ viewSelectionHub(view, options) {
898
+ const cacheKey = viewCacheKey(view, options);
899
+ const existing = this.viewSelectionHubs.get(cacheKey);
900
+ if (existing) {
901
+ return existing;
902
+ }
903
+ let hub;
904
+ hub = new LiveSelectionHub(() => this.watchViewState(view, options), () => {
905
+ if (this.viewSelectionHubs.get(cacheKey) === hub) {
906
+ this.viewSelectionHubs.delete(cacheKey);
907
+ }
908
+ });
909
+ this.viewSelectionHubs.set(cacheKey, hub);
910
+ return hub;
911
+ }
676
912
  /**
677
913
  * Start a live query with a mutable server-side window.
678
914
  * Use `setWindow` to scroll without re-subscribing.
@@ -731,6 +967,38 @@ export class MykoClient {
731
967
  setWindow: (window) => this.setQueryWindow(tx, window),
732
968
  };
733
969
  }
970
+ /** Start a live query whose subsequent pages use stable ID keysets. */
971
+ watchQueryCursor(query, limit, options) {
972
+ const watch = this.watchQueryWindowed(query, {
973
+ ...options,
974
+ window: { offset: 0, limit },
975
+ });
976
+ let requested = { limit };
977
+ const pageInfo$ = combineLatest([watch.results$, watch.windowInfo$]).pipe(map(([items, info]) => {
978
+ const rows = items;
979
+ const startCursor = rows.at(0)?.id ?? null;
980
+ const endCursor = rows.at(-1)?.id ?? null;
981
+ return {
982
+ totalCount: info.totalCount,
983
+ startCursor,
984
+ endCursor,
985
+ hasPreviousPage: requested.before !== undefined || requested.after !== undefined,
986
+ hasNextPage: rows.length === requested.limit &&
987
+ (info.totalCount === null || rows.length < info.totalCount),
988
+ };
989
+ }), shareReplay({ bufferSize: 1, refCount: true }));
990
+ const setWindow = (window) => {
991
+ requested = window;
992
+ this.setQueryCursorWindow(watch.tx, window);
993
+ };
994
+ return {
995
+ tx: watch.tx,
996
+ results$: watch.results$,
997
+ pageInfo$,
998
+ setWindow,
999
+ firstPage: () => setWindow({ limit }),
1000
+ };
1001
+ }
734
1002
  /** Start a live view with a mutable server-side window. */
735
1003
  watchViewWindowed(view, options) {
736
1004
  const [tx, responses$] = this.startView(view, options);
@@ -932,7 +1200,16 @@ export class MykoClient {
932
1200
  this.scheduleEventBatchFlush();
933
1201
  }
934
1202
  /** Send a command and wait for response */
935
- sendCommand(command) {
1203
+ sendCommand(command, options = {}) {
1204
+ if (options.timeoutMs !== undefined &&
1205
+ (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 0)) {
1206
+ return Promise.reject(new RangeError('Command timeoutMs must be a finite non-negative number'));
1207
+ }
1208
+ if (options.signal?.aborted) {
1209
+ const error = new Error('Command aborted before it was sent');
1210
+ error.name = 'AbortError';
1211
+ return Promise.reject(error);
1212
+ }
936
1213
  const tx = uuid();
937
1214
  const wrappedCommand = {
938
1215
  command: {
@@ -944,25 +1221,55 @@ export class MykoClient {
944
1221
  commandId: command.commandId,
945
1222
  };
946
1223
  return new Promise((resolve, reject) => {
947
- const responseSub = this.commandResponses
948
- .pipe(filter((r) => r.data.tx === tx))
949
- .subscribe((r) => {
950
- cleanup();
951
- resolve(r.data.response);
952
- });
953
- const errorSub = this.commandErrors
954
- .pipe(filter((r) => r.data.tx === tx))
955
- .subscribe((r) => {
956
- cleanup();
957
- reject(new Error(r.data.message));
958
- });
959
- const cleanup = () => {
960
- responseSub.unsubscribe();
961
- errorSub.unsubscribe();
1224
+ let timeout;
1225
+ const onAbort = () => {
1226
+ const error = new Error('Command aborted');
1227
+ error.name = 'AbortError';
1228
+ fail(error);
1229
+ };
1230
+ const cleanup = (removeQueuedCommand = false) => {
1231
+ this.pendingCommands.delete(tx);
1232
+ if (timeout !== undefined)
1233
+ clearTimeout(timeout);
1234
+ options.signal?.removeEventListener('abort', onAbort);
1235
+ if (removeQueuedCommand && this.messageQueue.length > 0) {
1236
+ this.messageQueue = this.messageQueue.filter((message) => this.messageTx(message) !== tx);
1237
+ }
1238
+ };
1239
+ const fail = (error) => {
1240
+ cleanup(true);
1241
+ reject(error);
962
1242
  };
963
- this.send({ event: MykoEvent.Command, data: wrappedCommand });
1243
+ this.pendingCommands.set(tx, {
1244
+ resolve: (message) => {
1245
+ cleanup();
1246
+ resolve(message.data.response);
1247
+ },
1248
+ reject: fail,
1249
+ });
1250
+ options.signal?.addEventListener('abort', onAbort, { once: true });
1251
+ if (options.timeoutMs !== undefined) {
1252
+ timeout = setTimeout(() => {
1253
+ const error = new Error(`Command timed out after ${options.timeoutMs}ms`);
1254
+ error.name = 'TimeoutError';
1255
+ fail(error);
1256
+ }, options.timeoutMs);
1257
+ }
1258
+ try {
1259
+ this.send({ event: MykoEvent.Command, data: wrappedCommand });
1260
+ }
1261
+ catch (error) {
1262
+ fail(error instanceof Error ? error : new Error(String(error)));
1263
+ }
964
1264
  });
965
1265
  }
1266
+ /**
1267
+ * Bind a generated `*Graph` descriptor into endpoint-scoped live state,
1268
+ * aggregate, and mutation helpers.
1269
+ */
1270
+ graph(graph) {
1271
+ return bindGraph(this, graph);
1272
+ }
966
1273
  // ─────────────────────────────────────────────────────────────────────────────
967
1274
  // Private: Socket Management
968
1275
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1009,6 +1316,7 @@ export class MykoClient {
1009
1316
  return loopback.has(a) && loopback.has(b);
1010
1317
  }
1011
1318
  closeAllSockets() {
1319
+ this.failPendingCommands(new Error('Disconnected before command completion'));
1012
1320
  for (const timer of this.reconnectTimers.values())
1013
1321
  clearTimeout(timer);
1014
1322
  this.reconnectTimers.clear();
@@ -1071,6 +1379,7 @@ export class MykoClient {
1071
1379
  this.sockets.delete(address);
1072
1380
  this.endpointSockets.delete(endpointKey);
1073
1381
  if (this.currentServer === address) {
1382
+ this.failPendingCommands(new Error(`Connection closed before command completion: ${address}`));
1074
1383
  const next = this.getFirstOpenSocket();
1075
1384
  if (next) {
1076
1385
  this.setCurrentServer(next.address, `failover from ${address} to ${next.address}`);
@@ -1216,9 +1525,15 @@ export class MykoClient {
1216
1525
  this.reportResponses.next(message);
1217
1526
  break;
1218
1527
  case MykoEvent.CommandResponse:
1528
+ this.pendingCommands
1529
+ .get(message.data.tx)
1530
+ ?.resolve(message);
1219
1531
  this.commandResponses.next(message);
1220
1532
  break;
1221
1533
  case MykoEvent.CommandError:
1534
+ this.pendingCommands
1535
+ .get(message.data.tx)
1536
+ ?.reject(new Error(message.data.message));
1222
1537
  this.commandErrors.next(message);
1223
1538
  break;
1224
1539
  case MykoEvent.QueryError:
@@ -1291,9 +1606,16 @@ export class MykoClient {
1291
1606
  }
1292
1607
  this.sendNow(message);
1293
1608
  }
1609
+ failPendingCommands(error) {
1610
+ for (const pending of [...this.pendingCommands.values()]) {
1611
+ pending.reject(error);
1612
+ }
1613
+ }
1294
1614
  messageTx(message) {
1295
1615
  const data = message.data;
1296
- return typeof data?.tx === 'string' ? data.tx : null;
1616
+ if (typeof data?.tx === 'string')
1617
+ return data.tx;
1618
+ return typeof data?.command?.tx === 'string' ? data.command.tx : null;
1297
1619
  }
1298
1620
  sendNow(message) {
1299
1621
  const event = message.event;
@@ -1347,11 +1669,18 @@ export class MykoClient {
1347
1669
  return query;
1348
1670
  }
1349
1671
  resendSubscriptions() {
1350
- for (const q of this.activeQueries.values()) {
1672
+ for (const [tx, q] of this.activeQueries) {
1351
1673
  this.send({
1352
1674
  event: MykoEvent.Query,
1353
1675
  data: this.withReconnectSequenceReset(q),
1354
1676
  });
1677
+ const cursor = this.activeQueryCursorWindows.get(tx);
1678
+ if (cursor) {
1679
+ this.send({
1680
+ event: 'ws:m:query-cursor-window',
1681
+ data: { tx, window: cursor },
1682
+ });
1683
+ }
1355
1684
  }
1356
1685
  for (const v of this.activeViews.values()) {
1357
1686
  this.send({ event: MykoEvent.View, data: v });
@@ -0,0 +1,2 @@
1
+ export type AdjacencyPolicy = "demandDriven" | "eager";
2
+ //# sourceMappingURL=AdjacencyPolicy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AdjacencyPolicy.d.ts","sourceRoot":"","sources":["../../src/generated/AdjacencyPolicy.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,eAAe,GAAG,cAAc,GAAG,OAAO,CAAC"}
@@ -0,0 +1,2 @@
1
+ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
+ export {};
@@ -0,0 +1,2 @@
1
+ export type Direction = "forward" | "reverse" | "both";
2
+ //# sourceMappingURL=Direction.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Direction.d.ts","sourceRoot":"","sources":["../../src/generated/Direction.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,MAAM,CAAC"}
@@ -0,0 +1,2 @@
1
+ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
+ export {};
@@ -0,0 +1,2 @@
1
+ export type EdgeShapeKind = "directed" | "undirected";
2
+ //# sourceMappingURL=EdgeShapeKind.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"EdgeShapeKind.d.ts","sourceRoot":"","sources":["../../src/generated/EdgeShapeKind.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,YAAY,CAAC"}
@@ -0,0 +1,2 @@
1
+ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
+ export {};
@@ -0,0 +1,2 @@
1
+ export type EndPosition = "a" | "b";
2
+ //# sourceMappingURL=EndPosition.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"EndPosition.d.ts","sourceRoot":"","sources":["../../src/generated/EndPosition.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,WAAW,GAAG,GAAG,GAAG,GAAG,CAAC"}
@@ -0,0 +1,2 @@
1
+ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
+ export {};
@@ -0,0 +1,2 @@
1
+ export type EndpointDeletePolicy = "cascadeEdge" | "restrictEndpointDelete" | "retainDangling";
2
+ //# sourceMappingURL=EndpointDeletePolicy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"EndpointDeletePolicy.d.ts","sourceRoot":"","sources":["../../src/generated/EndpointDeletePolicy.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,oBAAoB,GAAG,aAAa,GAAG,wBAAwB,GAAG,gBAAgB,CAAC"}
@@ -0,0 +1,2 @@
1
+ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
+ export {};
@@ -0,0 +1,8 @@
1
+ /**
2
+ * A stable, serializable reference to any registered Myko item.
3
+ */
4
+ export type EntityRef = {
5
+ entityType: string;
6
+ id: string;
7
+ };
8
+ //# sourceMappingURL=EntityRef.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"EntityRef.d.ts","sourceRoot":"","sources":["../../src/generated/EntityRef.ts"],"names":[],"mappings":"AAEA;;GAEG;AACH,MAAM,MAAM,SAAS,GAAG;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;CAAE,CAAC"}
@@ -0,0 +1,2 @@
1
+ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
+ export {};
@@ -0,0 +1,16 @@
1
+ import type { AdjacencyPolicy } from "./AdjacencyPolicy";
2
+ import type { EndPosition } from "./EndPosition";
3
+ /**
4
+ * Observed endpoint-query work and an actionable projection recommendation.
5
+ */
6
+ export type GraphPlanTelemetry = {
7
+ edgeType: string;
8
+ position: EndPosition;
9
+ policy: AdjacencyPolicy;
10
+ lookups: bigint;
11
+ scannedEdges: bigint;
12
+ returnedEdges: bigint;
13
+ recommendEager: boolean;
14
+ reason: string;
15
+ };
16
+ //# sourceMappingURL=GraphPlanTelemetry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GraphPlanTelemetry.d.ts","sourceRoot":"","sources":["../../src/generated/GraphPlanTelemetry.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAEjD;;GAEG;AACH,MAAM,MAAM,kBAAkB,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE,eAAe,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;CAAE,CAAC"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Summary returned by an authoritative endpoint reconciliation.
3
+ */
4
+ export type GraphSyncResult = {
5
+ inserted: number;
6
+ updated: number;
7
+ deleted: number;
8
+ unchanged: number;
9
+ };
10
+ //# sourceMappingURL=GraphSyncResult.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GraphSyncResult.d.ts","sourceRoot":"","sources":["../../src/generated/GraphSyncResult.ts"],"names":[],"mappings":"AAEA;;GAEG;AACH,MAAM,MAAM,eAAe,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;CAAE,CAAC"}
@@ -0,0 +1,2 @@
1
+ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
+ export {};