@usearete/sdk 0.24.0 → 0.25.1

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/index.cjs CHANGED
@@ -444,6 +444,7 @@ class SubscriptionRegistry {
444
444
  this.queryStore = queryStore;
445
445
  this.subscriptions = new Map();
446
446
  this.subscriptionsById = new Map();
447
+ this.failureListeners = new Set();
447
448
  }
448
449
  subscribe(query, snapshotEnabled = true) {
449
450
  const request = normalizeSubscriptionRequest({
@@ -506,12 +507,30 @@ class SubscriptionRegistry {
506
507
  ? this.queryStore.getSnapshot(tracker.subscription.subscriptionId)
507
508
  : undefined;
508
509
  }
509
- handleConnectionState(state) {
510
+ handleConnectionState(state, message) {
510
511
  if (state === 'reconnecting')
511
512
  this.queryStore.beginReconnect();
512
- if (state === 'error') {
513
- this.queryStore.failRefreshing(new AreteError('Connection failed while refreshing subscriptions', 'CONNECTION_ERROR'));
513
+ if (state !== 'error') {
514
+ this.connectionError = undefined;
515
+ return;
514
516
  }
517
+ this.queryStore.failRefreshing(new AreteError('Connection failed while refreshing subscriptions', 'CONNECTION_ERROR'));
518
+ // Queries still waiting for their first snapshot are not failed above;
519
+ // one-shot reads learn about the failure here instead of timing out.
520
+ this.connectionError = new AreteError(message ?? 'Connection failed', 'CONNECTION_ERROR');
521
+ this.notifyFailure(this.connectionError);
522
+ }
523
+ /** The terminal connection failure while the connection is in the `error` state. */
524
+ getConnectionError() {
525
+ return this.connectionError;
526
+ }
527
+ /**
528
+ * Called when pending queries can no longer resolve: the connection failed
529
+ * terminally, or a disconnect cleared every subscription.
530
+ */
531
+ onFailure(callback) {
532
+ this.failureListeners.add(callback);
533
+ return () => this.failureListeners.delete(callback);
515
534
  }
516
535
  clear() {
517
536
  for (const { subscription } of this.subscriptions.values()) {
@@ -527,6 +546,11 @@ class SubscriptionRegistry {
527
546
  }
528
547
  this.subscriptions.clear();
529
548
  this.subscriptionsById.clear();
549
+ this.notifyFailure(new AreteError('Subscriptions were cleared by a disconnect', 'CONNECTION_CANCELLED'));
550
+ }
551
+ notifyFailure(error) {
552
+ for (const listener of [...this.failureListeners])
553
+ listener(error);
530
554
  }
531
555
  createLease(tracker) {
532
556
  let released = false;
@@ -1874,6 +1898,7 @@ class ConnectionManager {
1874
1898
  }
1875
1899
 
1876
1900
  const INTERNAL_SEQ_FIELD = '__seq';
1901
+ const INTERNAL_VERSION_FIELD = '__version';
1877
1902
  class ProcessedSlotTimeoutError extends Error {
1878
1903
  constructor(targetSlot, processedSlot) {
1879
1904
  super(`Timed out waiting for Arete to process slot ${targetSlot}`);
@@ -1934,6 +1959,9 @@ function toCamelCaseSegment(value) {
1934
1959
  if (value === '_seq') {
1935
1960
  return INTERNAL_SEQ_FIELD;
1936
1961
  }
1962
+ if (value === '_version') {
1963
+ return INTERNAL_VERSION_FIELD;
1964
+ }
1937
1965
  const pascal = value
1938
1966
  .split(/[_.-]/)
1939
1967
  .filter(segment => segment.length > 0)
@@ -1944,6 +1972,16 @@ function toCamelCaseSegment(value) {
1944
1972
  }
1945
1973
  return pascal[0].toLowerCase() + pascal.slice(1);
1946
1974
  }
1975
+ /** `{epoch}:{counter}` from a frame's `_version`, or null if it does not parse. */
1976
+ function parseVersion(version) {
1977
+ const separator = version.lastIndexOf(':');
1978
+ if (separator <= 0)
1979
+ return null;
1980
+ const counter = version.slice(separator + 1);
1981
+ if (!/^\d+$/.test(counter))
1982
+ return null;
1983
+ return { epoch: version.slice(0, separator), counter: BigInt(counter) };
1984
+ }
1947
1985
  class FrameProcessor {
1948
1986
  /**
1949
1987
  * Observe the connection's cursor tracker instead of a private one.
@@ -1962,6 +2000,15 @@ class FrameProcessor {
1962
2000
  this.latestProcessedSlot = null;
1963
2001
  this.processedSlotWaiters = new Set();
1964
2002
  this.cursors = new CursorTracker();
2003
+ /**
2004
+ * Whether the server's latest acknowledgement guaranteed whole entities.
2005
+ * One connection talks to one server, so its latest ack speaks for every
2006
+ * subscription on it; a reconnect re-acknowledges each one.
2007
+ */
2008
+ this.serverSendsWholeEntities = false;
2009
+ /** Keys dropped by `maxEntriesPerView`, per view, oldest first; bounded. */
2010
+ this.evictedKeys = new Map();
2011
+ this.warnedEvictedViews = new Set();
1965
2012
  this.storage = storage;
1966
2013
  this.maxEntriesPerView = config.maxEntriesPerView === undefined
1967
2014
  ? DEFAULT_MAX_ENTRIES_PER_VIEW
@@ -2009,18 +2056,22 @@ class FrameProcessor {
2009
2056
  const diagnostic = {
2010
2057
  view: viewPath,
2011
2058
  ...context,
2059
+ reason: 'schema',
2012
2060
  error: validationError,
2013
2061
  };
2062
+ this.reportDiagnostic(diagnostic);
2063
+ if (this.warnOnValidationError) {
2064
+ console.warn('[Arete] Frame validation failed:', diagnostic);
2065
+ }
2066
+ return null;
2067
+ }
2068
+ reportDiagnostic(diagnostic) {
2014
2069
  try {
2015
2070
  this.onValidationError?.(diagnostic);
2016
2071
  }
2017
2072
  catch (callbackError) {
2018
2073
  console.error('[Arete] Frame validation callback failed:', callbackError);
2019
2074
  }
2020
- if (this.warnOnValidationError) {
2021
- console.warn('[Arete] Frame validation failed:', diagnostic);
2022
- }
2023
- return null;
2024
2075
  }
2025
2076
  hasSchema(viewPath) {
2026
2077
  return this.getSchema(viewPath) !== null;
@@ -2087,6 +2138,50 @@ class FrameProcessor {
2087
2138
  }
2088
2139
  return leftIndex.localeCompare(rightIndex);
2089
2140
  }
2141
+ extractVersion(data) {
2142
+ if (!isObject(data)) {
2143
+ return undefined;
2144
+ }
2145
+ const version = data._version;
2146
+ return typeof version === 'string' ? version : undefined;
2147
+ }
2148
+ getInternalVersion(data) {
2149
+ if (!isObject(data)) {
2150
+ return undefined;
2151
+ }
2152
+ const version = data[INTERNAL_VERSION_FIELD];
2153
+ return typeof version === 'string' ? version : undefined;
2154
+ }
2155
+ /**
2156
+ * Whether a frame at `incoming` is one this client already applied, or older.
2157
+ * A version is `{epoch}:{counter}`, and counters compare only within an
2158
+ * epoch: a server restart or a reloaded stack starts a new one, whose first
2159
+ * frame is newer than anything from the old one. A version that does not
2160
+ * parse cannot be ordered and is never stale.
2161
+ */
2162
+ isStaleVersion(incoming, held) {
2163
+ if (held === undefined) {
2164
+ return false;
2165
+ }
2166
+ const next = parseVersion(incoming);
2167
+ const current = parseVersion(held);
2168
+ if (next === null || current === null || next.epoch !== current.epoch) {
2169
+ return false;
2170
+ }
2171
+ return next.counter <= current.counter;
2172
+ }
2173
+ attachInternalVersion(data, version) {
2174
+ if (!version || !isObject(data)) {
2175
+ return data;
2176
+ }
2177
+ Object.defineProperty(data, INTERNAL_VERSION_FIELD, {
2178
+ value: version,
2179
+ enumerable: false,
2180
+ configurable: true,
2181
+ writable: true,
2182
+ });
2183
+ return data;
2184
+ }
2090
2185
  attachInternalSeq(_viewPath, data, seq) {
2091
2186
  if (!seq || !isObject(data)) {
2092
2187
  return data;
@@ -2307,6 +2402,7 @@ class FrameProcessor {
2307
2402
  }
2308
2403
  handleSubscribedFrame(frame) {
2309
2404
  this.cursors.observe(frame);
2405
+ this.serverSendsWholeEntities = frame.wholeEntities === true;
2310
2406
  const viewPath = frame.query.view;
2311
2407
  if (this.storage.setViewConfig && frame.sort) {
2312
2408
  this.storage.setViewConfig(viewPath, {
@@ -2331,10 +2427,11 @@ class FrameProcessor {
2331
2427
  if (normalized === null) {
2332
2428
  continue;
2333
2429
  }
2334
- const nextValue = this.attachInternalSeq(viewPath, normalized, this.extractSeq(entity.data));
2430
+ const nextValue = this.attachInternalVersion(this.attachInternalSeq(viewPath, normalized, this.extractSeq(entity.data)), this.extractVersion(entity.data));
2335
2431
  acceptedKeys.push(entity.key);
2336
2432
  const previousValue = this.storage.get(viewPath, entity.key);
2337
2433
  this.storage.set(viewPath, entity.key, nextValue);
2434
+ this.forgetEvicted(viewPath, entity.key);
2338
2435
  this.storage.notifyUpdate(viewPath, entity.key, {
2339
2436
  type: 'upsert',
2340
2437
  key: entity.key,
@@ -2353,18 +2450,24 @@ class FrameProcessor {
2353
2450
  const previousValue = this.storage.get(viewPath, frame.key);
2354
2451
  const previousSequence = this.getInternalSeq(previousValue);
2355
2452
  const cursor = this.cursors.observe(frame);
2356
- // On a tape the offset is the identity: two events decoded from one
2357
- // transaction share a seq, so the seq guard would discard the second.
2358
- const duplicateOrStaleSequence = frame.offset === undefined
2359
- && frame.seq !== undefined
2360
- && previousSequence !== undefined
2361
- && this.compareSeq(frame.seq, previousSequence) <= 0;
2453
+ // A server that stamps `_version` orders one key's frames by it. `seq`
2454
+ // cannot: every update decoded from one transaction shares one, and within
2455
+ // a slot account updates and instructions number themselves differently,
2456
+ // so a later frame can carry a lower `seq`. Without a version (an older
2457
+ // server) the `seq` rule applies. On a tape the offset is the identity, so
2458
+ // neither does.
2459
+ const frameVersion = this.extractVersion(frame.data);
2460
+ const duplicateOrStale = frame.offset === undefined && (frameVersion !== undefined
2461
+ ? this.isStaleVersion(frameVersion, this.getInternalVersion(previousValue))
2462
+ : frame.seq !== undefined
2463
+ && previousSequence !== undefined
2464
+ && this.compareSeq(frame.seq, previousSequence) <= 0);
2362
2465
  switch (frame.op) {
2363
2466
  case 'upsert':
2364
2467
  {
2365
2468
  if (frame.data === null)
2366
2469
  break;
2367
- if (duplicateOrStaleSequence && previousValue !== null) {
2470
+ if (duplicateOrStale && previousValue !== null) {
2368
2471
  const update = {
2369
2472
  type: 'upsert',
2370
2473
  key: frame.key,
@@ -2381,14 +2484,17 @@ class FrameProcessor {
2381
2484
  if (normalized === null) {
2382
2485
  break;
2383
2486
  }
2384
- const nextValue = this.attachInternalSeq(viewPath, normalized,
2487
+ const nextValue = this.attachInternalVersion(this.attachInternalSeq(viewPath, normalized,
2385
2488
  // Fall back to the previous entity's sequence, as the patch branch
2386
2489
  // below does. Without this an unsequenced upsert drops the tracked
2387
2490
  // sequence (it rides on the object being replaced), disarming the
2388
2491
  // staleness guard until the next sequenced frame and letting a
2389
2492
  // later older frame overwrite newer data.
2390
- frame.seq ?? this.extractSeq(frame.data) ?? this.getInternalSeq(previousValue));
2493
+ frame.seq ?? this.extractSeq(frame.data) ?? this.getInternalSeq(previousValue)),
2494
+ // An unversioned write keeps the version it replaces, likewise.
2495
+ frameVersion ?? this.getInternalVersion(previousValue));
2391
2496
  this.storage.set(viewPath, frame.key, nextValue);
2497
+ this.forgetEvicted(viewPath, frame.key);
2392
2498
  const update = {
2393
2499
  type: 'upsert',
2394
2500
  key: frame.key,
@@ -2405,6 +2511,19 @@ class FrameProcessor {
2405
2511
  if (frame.data === null)
2406
2512
  break;
2407
2513
  const existing = this.storage.get(viewPath, frame.key);
2514
+ if (existing === null && frame.offset === undefined && this.serverSendsWholeEntities) {
2515
+ // A patch carries only changed fields. With no entity to merge into
2516
+ // (never received, or evicted) it would be stored as a partial
2517
+ // entity typed as complete, so it is discarded: no storage write, no
2518
+ // sequence, no query membership, no update. The key appears when the
2519
+ // server sends a full upsert, which a server acknowledging
2520
+ // `wholeEntities` guarantees for every key it has not sent. Tape
2521
+ // records (frames with an `offset`) are events rather than entity
2522
+ // state and are always applied: a consumer resuming from a cursor
2523
+ // holds what came before it.
2524
+ this.reportDiscardedPatch(viewPath, frame);
2525
+ break;
2526
+ }
2408
2527
  const normalizedPatch = this.normalizeEntity(viewPath, frame.data, {
2409
2528
  key: frame.key,
2410
2529
  operation: 'patch',
@@ -2413,7 +2532,7 @@ class FrameProcessor {
2413
2532
  if (normalizedPatch === null) {
2414
2533
  break;
2415
2534
  }
2416
- if (duplicateOrStaleSequence && existing !== null) {
2535
+ if (duplicateOrStale && existing !== null) {
2417
2536
  const update = {
2418
2537
  type: 'patch',
2419
2538
  key: frame.key,
@@ -2426,7 +2545,7 @@ class FrameProcessor {
2426
2545
  const merged = existing
2427
2546
  ? deepMergeWithAppend(existing, normalizedPatch, appendPaths)
2428
2547
  : normalizedPatch;
2429
- const nextValue = this.attachInternalSeq(viewPath, merged, frame.seq ?? this.extractSeq(frame.data) ?? this.getInternalSeq(existing));
2548
+ const nextValue = this.attachInternalVersion(this.attachInternalSeq(viewPath, merged, frame.seq ?? this.extractSeq(frame.data) ?? this.getInternalSeq(existing)), frameVersion ?? this.getInternalVersion(existing));
2430
2549
  this.storage.set(viewPath, frame.key, nextValue);
2431
2550
  const update = {
2432
2551
  type: 'patch',
@@ -2445,6 +2564,7 @@ class FrameProcessor {
2445
2564
  break;
2446
2565
  case 'delete':
2447
2566
  this.storage.delete(viewPath, frame.key);
2567
+ this.forgetEvicted(viewPath, frame.key);
2448
2568
  this.storage.notifyUpdate(viewPath, frame.key, {
2449
2569
  type: 'delete',
2450
2570
  key: frame.key,
@@ -2481,8 +2601,56 @@ class FrameProcessor {
2481
2601
  return;
2482
2602
  while (this.storage.size(viewPath) > this.maxEntriesPerView) {
2483
2603
  const evicted = this.storage.evictOldest(viewPath);
2484
- if (evicted !== undefined)
2485
- this.queryStore?.evict(viewPath, evicted);
2604
+ if (evicted === undefined)
2605
+ continue;
2606
+ this.queryStore?.evict(viewPath, evicted);
2607
+ this.rememberEvicted(viewPath, evicted, this.maxEntriesPerView);
2608
+ }
2609
+ }
2610
+ /**
2611
+ * Remember a key `maxEntriesPerView` dropped, so a later patch for it can be
2612
+ * reported as such. At most `limit` keys per view, most recent kept.
2613
+ */
2614
+ rememberEvicted(viewPath, key, limit) {
2615
+ let keys = this.evictedKeys.get(viewPath);
2616
+ if (!keys) {
2617
+ keys = new Set();
2618
+ this.evictedKeys.set(viewPath, keys);
2619
+ }
2620
+ keys.delete(key);
2621
+ keys.add(key);
2622
+ while (keys.size > Math.max(limit, 1)) {
2623
+ const oldest = keys.values().next().value;
2624
+ if (oldest === undefined)
2625
+ break;
2626
+ keys.delete(oldest);
2627
+ }
2628
+ }
2629
+ forgetEvicted(viewPath, key) {
2630
+ this.evictedKeys.get(viewPath)?.delete(key);
2631
+ }
2632
+ reportDiscardedPatch(viewPath, frame) {
2633
+ const seq = frame.seq ?? this.extractSeq(frame.data);
2634
+ const evicted = this.evictedKeys.get(viewPath)?.has(frame.key) === true;
2635
+ const error = evicted
2636
+ ? new AreteError(`Discarded patch for '${frame.key}' in ${viewPath}: this client dropped the entity to `
2637
+ + `stay within maxEntriesPerView (${String(this.maxEntriesPerView)}), and the server `
2638
+ + 'sends no upsert for it until it re-enters the query or the subscription is '
2639
+ + 're-established; raise maxEntriesPerView above the subscription size', 'PATCH_FOR_EVICTED_KEY')
2640
+ : new AreteError(`Discarded patch for '${frame.key}' in ${viewPath}: the client holds no entity for `
2641
+ + 'this key; it appears when the server sends a full upsert', 'PATCH_FOR_UNKNOWN_KEY');
2642
+ const diagnostic = {
2643
+ view: viewPath,
2644
+ key: frame.key,
2645
+ ...(seq !== undefined ? { seq } : {}),
2646
+ operation: 'patch',
2647
+ reason: evicted ? 'evicted-key' : 'unknown-key',
2648
+ error,
2649
+ };
2650
+ this.reportDiagnostic(diagnostic);
2651
+ if (evicted && this.warnOnValidationError && !this.warnedEvictedViews.has(viewPath)) {
2652
+ this.warnedEvictedViews.add(viewPath);
2653
+ console.warn('[Arete] Patch dropped for a locally evicted entity:', diagnostic);
2486
2654
  }
2487
2655
  }
2488
2656
  }
@@ -2629,6 +2797,66 @@ class MemoryAdapter {
2629
2797
  }
2630
2798
  }
2631
2799
 
2800
+ /** Whether a sort value cannot be ranked: it is missing or `null`. */
2801
+ function isUnrankedSortValue(value) {
2802
+ return value === undefined || value === null;
2803
+ }
2804
+ /**
2805
+ * Compares two ranked sort values in ascending order. Numbers and bigints
2806
+ * compare numerically (also against each other), booleans as `false < true`,
2807
+ * and everything else with `localeCompare` on its string form (the SDK
2808
+ * ordering rule, shared with the Rust and Python SDKs).
2809
+ *
2810
+ * This is not the server's ranking. The server compares strings by byte
2811
+ * order, except that two decimal-integer strings compare numerically, and it
2812
+ * ranks numbers below strings and booleans below both. The server's order
2813
+ * decides which entities fill a `take` window; this one only orders the
2814
+ * members the client already holds, so the two can list a window's members
2815
+ * differently, never choose different members.
2816
+ */
2817
+ function compareSortValues(left, right) {
2818
+ if (left === right)
2819
+ return 0;
2820
+ if (isUnrankedSortValue(left))
2821
+ return isUnrankedSortValue(right) ? 0 : 1;
2822
+ if (isUnrankedSortValue(right))
2823
+ return -1;
2824
+ if (typeof left === 'number' && typeof right === 'number')
2825
+ return left - right;
2826
+ if (typeof left === 'bigint' && typeof right === 'bigint')
2827
+ return left < right ? -1 : 1;
2828
+ if (typeof left === 'bigint' && typeof right === 'number' && Number.isInteger(right)) {
2829
+ const other = BigInt(right);
2830
+ return left === other ? 0 : left < other ? -1 : 1;
2831
+ }
2832
+ if (typeof left === 'number' && typeof right === 'bigint' && Number.isInteger(left)) {
2833
+ const other = BigInt(left);
2834
+ return other === right ? 0 : other < right ? -1 : 1;
2835
+ }
2836
+ if (typeof left === 'boolean' && typeof right === 'boolean')
2837
+ return Number(left) - Number(right);
2838
+ return String(left).localeCompare(String(right));
2839
+ }
2840
+ /**
2841
+ * Orders two entities' sort values for a view sorted in `order`.
2842
+ *
2843
+ * A missing or `null` sort value cannot be ranked, so it sorts after every
2844
+ * ranked value in both directions: `desc` reverses only the comparison between
2845
+ * two ranked values. Two unranked values tie. Callers break ties on the entity
2846
+ * key, ascending, whatever the order. The unranked-last rule is the server's
2847
+ * and the Rust and Python SDKs'; how two ranked values compare is
2848
+ * `compareRanked` (by default {@link compareSortValues}, which differs from
2849
+ * the server as described there).
2850
+ */
2851
+ function compareSortOrder(left, right, order, compareRanked = compareSortValues) {
2852
+ const leftUnranked = isUnrankedSortValue(left);
2853
+ const rightUnranked = isUnrankedSortValue(right);
2854
+ if (leftUnranked || rightUnranked)
2855
+ return Number(leftUnranked) - Number(rightUnranked);
2856
+ const compared = compareRanked(left, right);
2857
+ return order === 'desc' ? -compared : compared;
2858
+ }
2859
+
2632
2860
  function getNestedValue$1(obj, path) {
2633
2861
  let current = obj;
2634
2862
  for (const segment of path) {
@@ -2640,33 +2868,6 @@ function getNestedValue$1(obj, path) {
2640
2868
  }
2641
2869
  return current;
2642
2870
  }
2643
- function compareSortValues(a, b) {
2644
- if (a === b)
2645
- return 0;
2646
- if (a === undefined || a === null)
2647
- return -1;
2648
- if (b === undefined || b === null)
2649
- return 1;
2650
- if (typeof a === 'bigint' && typeof b === 'bigint') {
2651
- return a < b ? -1 : 1;
2652
- }
2653
- if (typeof a === 'number' && typeof b === 'number') {
2654
- return a - b;
2655
- }
2656
- if (typeof a === 'bigint' && typeof b === 'number' && Number.isInteger(b)) {
2657
- return a < BigInt(b) ? -1 : 1;
2658
- }
2659
- if (typeof a === 'number' && typeof b === 'bigint' && Number.isInteger(a)) {
2660
- return BigInt(a) < b ? -1 : 1;
2661
- }
2662
- if (typeof a === 'string' && typeof b === 'string') {
2663
- return a.localeCompare(b);
2664
- }
2665
- if (typeof a === 'boolean' && typeof b === 'boolean') {
2666
- return (a ? 1 : 0) - (b ? 1 : 0);
2667
- }
2668
- return String(a).localeCompare(String(b));
2669
- }
2670
2871
  class SortedStorageDecorator {
2671
2872
  constructor(inner) {
2672
2873
  this.sortConfigs = new Map();
@@ -2786,7 +2987,6 @@ class SortedStorageDecorator {
2786
2987
  }
2787
2988
  binarySearchInsertPosition(viewPath, sortedKeys, sortConfig, newKey, newValue) {
2788
2989
  const newSortValue = getNestedValue$1(newValue, sortConfig.field);
2789
- const isDesc = sortConfig.order === 'desc';
2790
2990
  let low = 0;
2791
2991
  let high = sortedKeys.length;
2792
2992
  while (low < high) {
@@ -2794,9 +2994,8 @@ class SortedStorageDecorator {
2794
2994
  const midKey = sortedKeys[mid];
2795
2995
  const midEntity = this.inner.get(viewPath, midKey);
2796
2996
  const midValue = getNestedValue$1(midEntity, sortConfig.field);
2797
- let cmp = compareSortValues(newSortValue, midValue);
2798
- if (isDesc)
2799
- cmp = -cmp;
2997
+ // Unranked (missing or null) values sort last in both directions.
2998
+ let cmp = compareSortOrder(newSortValue, midValue, sortConfig.order);
2800
2999
  if (cmp === 0) {
2801
3000
  cmp = newKey.localeCompare(midKey);
2802
3001
  }
@@ -2813,14 +3012,11 @@ class SortedStorageDecorator {
2813
3012
  const allKeys = this.inner.keys(viewPath);
2814
3013
  if (allKeys.length === 0)
2815
3014
  return;
2816
- const isDesc = sortConfig.order === 'desc';
2817
3015
  const entries = allKeys.map(k => [k, this.inner.get(viewPath, k)]);
2818
3016
  entries.sort((a, b) => {
2819
3017
  const aValue = getNestedValue$1(a[1], sortConfig.field);
2820
3018
  const bValue = getNestedValue$1(b[1], sortConfig.field);
2821
- let cmp = compareSortValues(aValue, bValue);
2822
- if (isDesc)
2823
- cmp = -cmp;
3019
+ let cmp = compareSortOrder(aValue, bValue, sortConfig.order);
2824
3020
  if (cmp === 0) {
2825
3021
  cmp = a[0].localeCompare(b[0]);
2826
3022
  }
@@ -2839,25 +3035,9 @@ function getNestedValue(value, path) {
2839
3035
  }
2840
3036
  return current;
2841
3037
  }
2842
- function compareValues(left, right) {
2843
- if (left === right)
2844
- return 0;
2845
- if (left === undefined || left === null)
2846
- return -1;
2847
- if (right === undefined || right === null)
2848
- return 1;
2849
- if (typeof left === 'number' && typeof right === 'number')
2850
- return left - right;
2851
- if (typeof left === 'bigint' && typeof right === 'bigint')
2852
- return left < right ? -1 : 1;
2853
- if (typeof left === 'boolean' && typeof right === 'boolean') {
2854
- return Number(left) - Number(right);
2855
- }
2856
- return String(left).localeCompare(String(right));
2857
- }
2858
3038
  function compareSequences(left, right) {
2859
3039
  if (typeof left !== 'string' || typeof right !== 'string')
2860
- return compareValues(left, right);
3040
+ return compareSortValues(left, right);
2861
3041
  const [leftSlot, leftIndex = ''] = left.split(':', 2);
2862
3042
  const [rightSlot, rightIndex = ''] = right.split(':', 2);
2863
3043
  if (/^\d+$/.test(leftSlot ?? '') && /^\d+$/.test(rightSlot ?? '')) {
@@ -3149,9 +3329,9 @@ class QueryStore {
3149
3329
  const right = field
3150
3330
  ? getNestedValue(this.storage.get(view, rightKey), field)
3151
3331
  : record.sequences.get(rightKey);
3152
- let compared = field ? compareValues(left, right) : compareSequences(left, right);
3153
- if (order === 'desc')
3154
- compared = -compared;
3332
+ // A missing or null sort value (or sequence) sorts after every ranked
3333
+ // one in both directions; ties break on the key, always ascending.
3334
+ const compared = compareSortOrder(left, right, order, field ? compareSortValues : compareSequences);
3155
3335
  return compared === 0 ? leftKey.localeCompare(rightKey) : compared;
3156
3336
  });
3157
3337
  }
@@ -3409,10 +3589,90 @@ function createRichUpdateStream(_storage, subscriptionRegistry, query, keyFilter
3409
3589
  };
3410
3590
  }
3411
3591
 
3592
+ /** How long `get` and `getOne` wait for an initial snapshot, as in the Rust and Python SDKs. */
3593
+ const DEFAULT_INITIAL_DATA_TIMEOUT_MS = 5000;
3594
+ /**
3595
+ * `get` or `getOne` gave up waiting for the initial snapshot: the socket never
3596
+ * delivered it (no connection, a reconnect storm, a server that never
3597
+ * answers). The read's subscription is released before this is thrown.
3598
+ */
3599
+ class InitialDataTimeoutError extends AreteError {
3600
+ constructor(view, timeoutMs) {
3601
+ super(`Timed out after ${timeoutMs}ms waiting for the initial snapshot of view '${view}'`, 'INITIAL_DATA_TIMEOUT', { view, timeoutMs });
3602
+ this.view = view;
3603
+ this.timeoutMs = timeoutMs;
3604
+ this.name = 'InitialDataTimeoutError';
3605
+ }
3606
+ }
3412
3607
  function queryOptions(options) {
3413
- const { schema: _schema, withSnapshot, ...query } = options ?? {};
3608
+ const { schema: _schema, withSnapshot, timeoutMs: _timeoutMs, ...query } = options ?? {};
3414
3609
  return { query, snapshotEnabled: withSnapshot ?? true };
3415
3610
  }
3611
+ function resolveTimeout(options) {
3612
+ const timeoutMs = options?.timeoutMs === undefined
3613
+ ? DEFAULT_INITIAL_DATA_TIMEOUT_MS
3614
+ : options.timeoutMs;
3615
+ if (timeoutMs !== null && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
3616
+ throw new RangeError('timeoutMs must be a positive finite number or null');
3617
+ }
3618
+ return timeoutMs;
3619
+ }
3620
+ /**
3621
+ * Open (or reuse) the equivalent subscription, wait until its snapshot has
3622
+ * resolved or its query has failed, and release it. A terminal connection
3623
+ * failure or a disconnect rejects with that reason. Bounded by `timeoutMs`
3624
+ * (`null` waits forever) so a socket that never delivers cannot hang the caller.
3625
+ */
3626
+ async function readResolved(registry, query, snapshotEnabled, timeoutMs) {
3627
+ const lease = registry.subscribe(query, snapshotEnabled);
3628
+ try {
3629
+ await new Promise((resolve, reject) => {
3630
+ let settled = false;
3631
+ let stop = () => { };
3632
+ let timer;
3633
+ const finish = (error) => {
3634
+ if (settled)
3635
+ return;
3636
+ settled = true;
3637
+ stop();
3638
+ if (timer !== undefined)
3639
+ clearTimeout(timer);
3640
+ if (error)
3641
+ reject(error);
3642
+ else
3643
+ resolve();
3644
+ };
3645
+ const check = () => {
3646
+ const error = lease.getError();
3647
+ if (error)
3648
+ finish(error);
3649
+ else if (!lease.getSnapshot().isLoading)
3650
+ finish();
3651
+ else {
3652
+ const connectionError = registry.getConnectionError();
3653
+ if (connectionError)
3654
+ finish(connectionError);
3655
+ }
3656
+ };
3657
+ check();
3658
+ if (settled)
3659
+ return;
3660
+ const stopChanges = lease.onChange(check);
3661
+ const stopFailures = registry.onFailure(finish);
3662
+ stop = () => {
3663
+ stopChanges();
3664
+ stopFailures();
3665
+ };
3666
+ if (timeoutMs !== null) {
3667
+ timer = setTimeout(() => finish(new InitialDataTimeoutError(query.view, timeoutMs)), timeoutMs);
3668
+ }
3669
+ });
3670
+ return lease.getSnapshot();
3671
+ }
3672
+ finally {
3673
+ lease.release();
3674
+ }
3675
+ }
3416
3676
  function serializeViewKeyValue(value, view, field) {
3417
3677
  const location = field === undefined ? `view '${view}'` : `key field '${field}' for view '${view}'`;
3418
3678
  if (typeof value === 'string')
@@ -3463,7 +3723,10 @@ function createTypedStateView(viewDef, storage, subscriptionRegistry) {
3463
3723
  return createRichUpdateStream(storage, subscriptionRegistry, { view: viewDef.view, key: serializedKey, ...query }, serializedKey, snapshotEnabled);
3464
3724
  },
3465
3725
  async get(key, options) {
3466
- return this.getSync(key, options) ?? null;
3726
+ const timeoutMs = resolveTimeout(options);
3727
+ const { query, snapshotEnabled } = queryOptions(options);
3728
+ const snapshot = await readResolved(subscriptionRegistry, { view: viewDef.view, key: wireKey(key), ...query }, snapshotEnabled, timeoutMs);
3729
+ return snapshot.data[0] ?? null;
3467
3730
  },
3468
3731
  getSync(key, options) {
3469
3732
  const { query, snapshotEnabled } = queryOptions(options);
@@ -3491,7 +3754,14 @@ function createTypedListView(viewDef, storage, subscriptionRegistry) {
3491
3754
  return createRichUpdateStream(storage, subscriptionRegistry, { view: viewDef.view, ...query }, undefined, snapshotEnabled);
3492
3755
  },
3493
3756
  async get(options) {
3494
- return this.getSync(options) ?? [];
3757
+ const timeoutMs = resolveTimeout(options);
3758
+ const { query, snapshotEnabled } = queryOptions(options);
3759
+ const snapshot = await readResolved(subscriptionRegistry, { view: viewDef.view, ...query }, snapshotEnabled, timeoutMs);
3760
+ return [...snapshot.data];
3761
+ },
3762
+ async getOne(options) {
3763
+ const items = await this.get({ ...options, take: 1 });
3764
+ return items[0] ?? null;
3495
3765
  },
3496
3766
  getSync(options) {
3497
3767
  const { query, snapshotEnabled } = queryOptions(options);
@@ -6227,16 +6497,22 @@ const PROGRAM_READ_DESCRIPTOR = Symbol.for('@usearete/sdk/program-read-descripto
6227
6497
  /**
6228
6498
  * Bundle a generated program definition with its default read descriptor.
6229
6499
  *
6230
- * Generated SDK entries call this automatically. The descriptor is deliberately
6231
- * non-enumerable so it remains an implementation detail of the program cartridge.
6500
+ * Generated SDK entries call this automatically. The descriptor lives under a
6501
+ * registry symbol, so `Object.keys` and `JSON.stringify` never see it, while
6502
+ * `{ ...program }` keeps it: a spread program still reads through its release.
6503
+ * Every other own property (including the runtime extension carrier) is copied
6504
+ * unchanged, except `packageReleaseHash`: the result reads differently from
6505
+ * the generated program SDK, so it is no longer provably that SDK. Generated
6506
+ * entries apply `withProgramIdentity` last.
6232
6507
  */
6233
6508
  function withProgramRead(program, descriptor) {
6234
6509
  const properties = Object.getOwnPropertyDescriptors(program);
6235
6510
  Reflect.deleteProperty(properties, PROGRAM_READ_DESCRIPTOR);
6511
+ Reflect.deleteProperty(properties, 'packageReleaseHash');
6236
6512
  const bundled = Object.create(Object.getPrototypeOf(program), properties);
6237
6513
  Object.defineProperty(bundled, PROGRAM_READ_DESCRIPTOR, {
6238
6514
  value: descriptor,
6239
- enumerable: false,
6515
+ enumerable: true,
6240
6516
  configurable: false,
6241
6517
  writable: false,
6242
6518
  });
@@ -6246,9 +6522,62 @@ function withProgramRead(program, descriptor) {
6246
6522
  function getProgramReadDescriptor(program) {
6247
6523
  return program?.[PROGRAM_READ_DESCRIPTOR];
6248
6524
  }
6525
+ /**
6526
+ * Stamp a program definition with the identity of the program SDK it is.
6527
+ *
6528
+ * Generated program SDK entries call this last, after the package's own
6529
+ * extension and read descriptor are applied, so the identity describes
6530
+ * exactly the generated SDK. `extendProgram`, `extendPrograms` and
6531
+ * `withProgramRead` drop the identity again: a program changed outside its
6532
+ * generated SDK is no longer provably that SDK. Every other own property,
6533
+ * including symbol-keyed runtime extensions and the read descriptor, is copied
6534
+ * unchanged. An absent or empty `packageReleaseHash` removes the identity.
6535
+ */
6536
+ function withProgramIdentity(program, identity) {
6537
+ const properties = Object.getOwnPropertyDescriptors(program);
6538
+ Reflect.deleteProperty(properties, 'packageReleaseHash');
6539
+ const stamped = Object.create(Object.getPrototypeOf(program), properties);
6540
+ if (typeof identity.packageReleaseHash === 'string' && identity.packageReleaseHash.length > 0) {
6541
+ Object.defineProperty(stamped, 'packageReleaseHash', {
6542
+ value: identity.packageReleaseHash,
6543
+ enumerable: true,
6544
+ configurable: true,
6545
+ writable: true,
6546
+ });
6547
+ }
6548
+ return stamped;
6549
+ }
6249
6550
 
6250
- const STACK_RUNTIME_EXTENSIONS = '__areteStackRuntimeExtensions';
6251
- const PROGRAM_OPERATION_EXTENSIONS = '__areteProgramOperationExtensions';
6551
+ /**
6552
+ * Version of the extension contract: `defineStackExtensions`,
6553
+ * `defineProgramExtensions`, `extendProgram`, `extendPrograms`, `extendStack`,
6554
+ * `withProgramRead`, and the instruction helpers generated code imports
6555
+ * (`createInstructionHandler`, `buildInstruction`, `instructionOperation`,
6556
+ * `createPreparedInstruction`, the PDA and read helpers, …).
6557
+ *
6558
+ * It only changes on a breaking change to that contract, so generated SDKs and
6559
+ * extensions built for one value keep working with every SDK release that
6560
+ * exports the same value. The same number is published in this package's
6561
+ * `package.json` as `arete.extensionApi` for tools that read `node_modules`.
6562
+ */
6563
+ const EXTENSION_API_VERSION = 1;
6564
+ /**
6565
+ * Runtime extension carriers live under registry symbols (`Symbol.for`), so
6566
+ * two copies of this module still read each other's definitions. The
6567
+ * properties are enumerable: `{ ...stack }` and `{ ...program }` keep them,
6568
+ * while `Object.keys`, `for…in` and `JSON.stringify` never see a symbol key.
6569
+ *
6570
+ * The constants are typed as their historical property names rather than as
6571
+ * `unique symbol`. Generated program SDKs use them as computed keys inside
6572
+ * object literals, and TypeScript can only name a `unique symbol` key in
6573
+ * emitted declarations from a file that imports it; a string-literal key type
6574
+ * keeps every generated `.d.ts` nameable and identical across module copies.
6575
+ * Always read and write these properties through the constants (or
6576
+ * `getStackRuntimeExtensions` / `getProgramRuntimeExtensions`), never by the
6577
+ * literal name.
6578
+ */
6579
+ const STACK_RUNTIME_EXTENSIONS = Symbol.for('@usearete/sdk/stack-runtime-extensions');
6580
+ const PROGRAM_OPERATION_EXTENSIONS = Symbol.for('@usearete/sdk/program-runtime-extensions');
6252
6581
  function defineProgramExtensions() {
6253
6582
  return (extensions) => extensions;
6254
6583
  }
@@ -6277,7 +6606,12 @@ function extendProgram(program, extensions) {
6277
6606
  if (readDescriptor) {
6278
6607
  Object.defineProperty(extended, PROGRAM_READ_DESCRIPTOR, readDescriptor);
6279
6608
  }
6609
+ // The extended program is no longer byte-for-byte the generated one, nor
6610
+ // provably the program SDK its package release describes: extensions
6611
+ // applied outside generated code must not borrow that identity. Generated
6612
+ // entries stamp it back with `withProgramIdentity` after their own.
6280
6613
  delete extended.sdkDefinitionHash;
6614
+ delete extended.packageReleaseHash;
6281
6615
  for (const key of ['pdas', 'accounts', 'queries', 'addresses', 'constants', 'defaults', 'math']) {
6282
6616
  const extensionValue = extensions[key];
6283
6617
  if (extensionValue !== undefined) {
@@ -6313,7 +6647,8 @@ function extendProgram(program, extensions) {
6313
6647
  return mergeNamespace(baseRead, extensionReadFactory?.(context));
6314
6648
  },
6315
6649
  },
6316
- enumerable: false,
6650
+ // Enumerable so spreading the extended program keeps its operations.
6651
+ enumerable: true,
6317
6652
  configurable: false,
6318
6653
  writable: false,
6319
6654
  });
@@ -6358,7 +6693,8 @@ function extendStack(stack, extensions) {
6358
6693
  ? (client) => mergeNamespace(baseRuntime.createFlows(client), extensions.createFlows(client))
6359
6694
  : extensions.createFlows ?? baseRuntime?.createFlows,
6360
6695
  },
6361
- enumerable: false,
6696
+ // Enumerable so `{ ...stack }` keeps `read`, `flows` and `readArgCounts`.
6697
+ enumerable: true,
6362
6698
  configurable: false,
6363
6699
  writable: false,
6364
6700
  });
@@ -6396,7 +6732,7 @@ function isPreparedInstruction(instruction) {
6396
6732
  && instruction.kind === 'instruction'
6397
6733
  && 'instruction' in instruction;
6398
6734
  }
6399
- function nonEmpty(values, label) {
6735
+ function nonEmpty$1(values, label) {
6400
6736
  if (values.length === 0) {
6401
6737
  throw new Error(`${label} must contain at least one item`);
6402
6738
  }
@@ -6409,7 +6745,7 @@ function inferSignerAddresses(instructions) {
6409
6745
  return dedupe(instructions.flatMap((instruction) => instruction.keys.filter((key) => key.isSigner).map((key) => key.pubkey)));
6410
6746
  }
6411
6747
  function createPreparedTransactionBody(input) {
6412
- const instructions = nonEmpty(input.instructions, `Transaction '${input.name}'`);
6748
+ const instructions = nonEmpty$1(input.instructions, `Transaction '${input.name}'`);
6413
6749
  return {
6414
6750
  name: input.name,
6415
6751
  instructions,
@@ -6422,7 +6758,7 @@ function createPlan(name, artifacts, transactions) {
6422
6758
  return {
6423
6759
  name,
6424
6760
  artifacts,
6425
- transactions: nonEmpty(transactions, `Flow '${name}'`),
6761
+ transactions: nonEmpty$1(transactions, `Flow '${name}'`),
6426
6762
  };
6427
6763
  }
6428
6764
  function createPreparedInstruction(input) {
@@ -6708,8 +7044,8 @@ async function executePreparedOperation(host, operation, options = {}) {
6708
7044
  kind: 'flow',
6709
7045
  operationName: operation.name,
6710
7046
  artifacts: operation.artifacts,
6711
- signatures: nonEmpty(receipts.map((receipt) => receipt.signature), `Operation '${operation.name}' signatures`),
6712
- transactions: nonEmpty(receipts, `Operation '${operation.name}' receipts`),
7047
+ signatures: nonEmpty$1(receipts.map((receipt) => receipt.signature), `Operation '${operation.name}' signatures`),
7048
+ transactions: nonEmpty$1(receipts, `Operation '${operation.name}' receipts`),
6713
7049
  ...callbackErrorResult,
6714
7050
  };
6715
7051
  }
@@ -6985,6 +7321,127 @@ function createProgramReadTransport(config) {
6985
7321
  };
6986
7322
  }
6987
7323
 
7324
+ /** Error code for two different program SDKs claiming one program key. */
7325
+ const PROGRAM_KEY_CONFLICT = 'PROGRAM_KEY_CONFLICT';
7326
+ function nonEmpty(value) {
7327
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
7328
+ }
7329
+ /** Compare two program definitions by identity, never by name. */
7330
+ function compareProgramIdentity(left, right) {
7331
+ if (left === right)
7332
+ return 'same';
7333
+ const leftRelease = nonEmpty(left.packageReleaseHash);
7334
+ const rightRelease = nonEmpty(right.packageReleaseHash);
7335
+ if (leftRelease !== undefined && rightRelease !== undefined) {
7336
+ return leftRelease === rightRelease ? 'same' : 'different';
7337
+ }
7338
+ const leftSpec = nonEmpty(left.programSpecHash);
7339
+ return leftSpec !== undefined && leftSpec === nonEmpty(right.programSpecHash)
7340
+ ? 'unproven'
7341
+ : 'different';
7342
+ }
7343
+ /**
7344
+ * Whether two program definitions are provably the same program SDK: the same
7345
+ * object, or both carry the same `packageReleaseHash` (the program package
7346
+ * release they were generated from). Anything else is not provably the same,
7347
+ * even under the same name.
7348
+ */
7349
+ function isSameProgramSdk(left, right) {
7350
+ return compareProgramIdentity(left, right) === 'same';
7351
+ }
7352
+ function identityOf(program) {
7353
+ const release = nonEmpty(program.packageReleaseHash);
7354
+ if (release !== undefined)
7355
+ return `package release ${release}`;
7356
+ const spec = nonEmpty(program.programSpecHash);
7357
+ return spec !== undefined
7358
+ ? `program spec ${spec}, no package release`
7359
+ : 'no program identity';
7360
+ }
7361
+ const warned = new Set();
7362
+ /** Emit a program identity warning once per distinct message. */
7363
+ function warnOnce(message) {
7364
+ if (warned.has(message))
7365
+ return;
7366
+ warned.add(message);
7367
+ console.warn(`[Arete] ${message}`);
7368
+ }
7369
+ /**
7370
+ * A program attached next to a stack (`withPrograms`, `ConnectOptions.programs`,
7371
+ * `useArete(stack, { programs })`, a session member's `programs`) under a key
7372
+ * the stack already provides with a different program SDK.
7373
+ */
7374
+ function attachedProgramKeyConflict(stackName, key, stackProgram, attached) {
7375
+ return new AreteError(`Program key '${key}' conflicts with stack '${stackName}': the stack already provides `
7376
+ + `a different '${key}' program SDK (stack: ${identityOf(stackProgram)}; attached: `
7377
+ + `${identityOf(attached)}). Use the stack's program at programs.${key}, or attach the `
7378
+ + `other program under a different key, one the stack does not use.`, PROGRAM_KEY_CONFLICT, { key, stack: stackName });
7379
+ }
7380
+ /**
7381
+ * An attached program replaces the stack's program under one key without a
7382
+ * provable identity match (same program spec, no shared package release).
7383
+ */
7384
+ function warnUnprovenAttachedProgram(stackName, key, stackProgram, attached) {
7385
+ warnOnce(`programs.${key} uses the program attached to stack '${stackName}': it has the same `
7386
+ + `program spec as the stack's '${key}' program but could not be proven identical `
7387
+ + `(stack: ${identityOf(stackProgram)}; attached: ${identityOf(attached)}).`);
7388
+ }
7389
+ /** A standalone session program under a key a session stack already provides. */
7390
+ function sessionProgramKeyConflict(stackKey, key, stackProgram, standalone) {
7391
+ return new AreteError(`Program key '${key}' conflicts with stack '${stackKey}' in this session: the stack `
7392
+ + `already provides a different '${key}' program SDK (stack: ${identityOf(stackProgram)}; `
7393
+ + `standalone: ${identityOf(standalone)}). Use session.stacks.${stackKey}.programs.${key}, `
7394
+ + `or attach the standalone program under a different key in createSession({ programs }).`, PROGRAM_KEY_CONFLICT, { key, stack: stackKey });
7395
+ }
7396
+ /**
7397
+ * A standalone session program takes the top-level key from stacks whose
7398
+ * program it could not be proven identical to: a different identity
7399
+ * (`'identity'`), or the same program SDK read through a different program
7400
+ * read configuration than the stack's (`'program-read'`).
7401
+ */
7402
+ function warnUnprovenSessionProgram(key, standalone, providers, cause = 'identity') {
7403
+ const stacks = providers.map(({ stackKey }) => `'${stackKey}'`).join(' and ');
7404
+ const paths = providers
7405
+ .map(({ stackKey }) => `session.stacks.${stackKey}.programs.${key}`)
7406
+ .join(' and ');
7407
+ const identities = `standalone: ${identityOf(standalone)}; ${providers
7408
+ .map(({ stackKey, program }) => `${stackKey}: ${identityOf(program)}`)
7409
+ .join('; ')}`;
7410
+ const reason = cause === 'program-read'
7411
+ ? `it is the same program SDK as the '${key}' program of stack ${stacks} (${identities}) `
7412
+ + `but its programReads.${key} descriptor differs from the one the stack reads through, `
7413
+ + `so it keeps its own program read configuration`
7414
+ : `it has the same program spec as the '${key}' program of stack ${stacks} but could not `
7415
+ + `be proven identical (${identities})`;
7416
+ warnOnce(`session.programs.${key} uses the standalone program: ${reason}. `
7417
+ + `${paths} keep${providers.length === 1 ? 's' : ''} the stack's program.`);
7418
+ }
7419
+ /**
7420
+ * Several session stacks bundle one key with the same program spec, not all
7421
+ * provably the same SDK; the first stack's program takes the top-level key.
7422
+ */
7423
+ function warnUnprovenStackPrograms(key, providers) {
7424
+ const first = providers[0];
7425
+ warnOnce(`session.programs.${key} uses stack '${first.stackKey}''s program: stacks `
7426
+ + `${providers.map(({ stackKey }) => `'${stackKey}'`).join(' and ')} bundle '${key}' `
7427
+ + `programs with the same program spec that could not be proven identical (${providers
7428
+ .map(({ stackKey, program }) => `${stackKey}: ${identityOf(program)}`)
7429
+ .join('; ')}). Each stays at session.stacks.<stack>.programs.${key}.`);
7430
+ }
7431
+ /** Two session stacks provide different program SDKs under one key. */
7432
+ function ambiguousSessionProgram(key, providers) {
7433
+ const stacks = providers.map(({ stackKey }) => `'${stackKey}'`).join(' and ');
7434
+ const paths = providers
7435
+ .map(({ stackKey }) => `session.stacks.${stackKey}.programs.${key}`)
7436
+ .join(' or ');
7437
+ const identities = providers
7438
+ .map(({ stackKey, program }) => `${stackKey}: ${identityOf(program)}`)
7439
+ .join('; ');
7440
+ return new AreteError(`session.programs.${key} is ambiguous: stacks ${stacks} provide different '${key}' `
7441
+ + `program SDKs (${identities}). Use ${paths}, or attach the one you want under a `
7442
+ + `different key in createSession({ programs }).`, PROGRAM_KEY_CONFLICT, { key, stacks: providers.map(({ stackKey }) => stackKey) });
7443
+ }
7444
+
6988
7445
  function effectiveProgramRead(stack, name, override) {
6989
7446
  return override
6990
7447
  ?? stack.programReads?.[name]
@@ -7092,13 +7549,38 @@ function validateLocalProgramReadEndpoints(stack, overrides, connectHttpUrl) {
7092
7549
  function hasProgramAccountReads(definition) {
7093
7550
  return Object.keys(definition.accounts ?? {}).length > 0;
7094
7551
  }
7552
+ /**
7553
+ * Attach programs next to a stack's own, matching a key the stack already
7554
+ * provides by program identity (see `compareProgramIdentity`):
7555
+ *
7556
+ * - the same program SDK (same object, or equal `packageReleaseHash`) resolves
7557
+ * to the stack's definition;
7558
+ * - the same `programSpecHash` without a provable identity match (at least one
7559
+ * side has no `packageReleaseHash`) uses the attached program, with one
7560
+ * warning;
7561
+ * - anything else throws `PROGRAM_KEY_CONFLICT`.
7562
+ */
7095
7563
  function mergeAttachedPrograms(stack, attachedPrograms) {
7096
7564
  const merged = { ...(attachedPrograms ?? {}) };
7097
7565
  for (const [name, definition] of Object.entries(stack.programs ?? {})) {
7098
- if (name in merged) {
7099
- console.warn(`Ignoring attached program '${name}' for stack '${stack.name}' because the stack already defines that key`);
7566
+ const attached = attachedPrograms && Object.prototype.hasOwnProperty.call(attachedPrograms, name)
7567
+ ? attachedPrograms[name]
7568
+ : undefined;
7569
+ if (attached === undefined) {
7570
+ merged[name] = definition;
7571
+ continue;
7572
+ }
7573
+ switch (compareProgramIdentity(definition, attached)) {
7574
+ case 'same':
7575
+ merged[name] = definition;
7576
+ break;
7577
+ case 'unproven':
7578
+ warnUnprovenAttachedProgram(stack.name, name, definition, attached);
7579
+ merged[name] = attached;
7580
+ break;
7581
+ case 'different':
7582
+ throw attachedProgramKeyConflict(stack.name, name, definition, attached);
7100
7583
  }
7101
- merged[name] = definition;
7102
7584
  }
7103
7585
  return merged;
7104
7586
  }
@@ -7197,8 +7679,8 @@ let Arete$1 = class Arete {
7197
7679
  this.connection.onFrame((frame) => {
7198
7680
  this.processor.handleFrame(frame);
7199
7681
  });
7200
- this.connection.onStateChange((state) => {
7201
- this.subscriptionRegistry.handleConnectionState(state);
7682
+ this.connection.onStateChange((state, error) => {
7683
+ this.subscriptionRegistry.handleConnectionState(state, error);
7202
7684
  });
7203
7685
  this._views = createTypedViews(this.stack, this.storage, this.subscriptionRegistry);
7204
7686
  this._queries = this.buildQueries();
@@ -7698,6 +8180,34 @@ function resolveSessionProgramReads(stack, member, options) {
7698
8180
  }));
7699
8181
  return Object.keys(reads).length > 0 ? reads : undefined;
7700
8182
  }
8183
+ /**
8184
+ * The program read descriptor a session member connected for `stack` reads
8185
+ * `programKey` through, resolved the way `Arete.connect` resolves it: session
8186
+ * and member overrides, then the stack's `programReads`, then the descriptor
8187
+ * the program carries.
8188
+ */
8189
+ function memberProgramRead(stack, programKey, member, options) {
8190
+ return resolveSessionProgramReads(stack, member, options)?.[programKey]
8191
+ ?? stack.programReads?.[programKey]
8192
+ ?? getProgramReadDescriptor(stack.programs?.[programKey]);
8193
+ }
8194
+ /** Structural equality of two program read descriptors (plain JSON data). */
8195
+ function sameProgramRead(left, right) {
8196
+ if (left === right)
8197
+ return true;
8198
+ if (left === null || right === null
8199
+ || typeof left !== 'object' || typeof right !== 'object'
8200
+ || Array.isArray(left) !== Array.isArray(right)) {
8201
+ return false;
8202
+ }
8203
+ const leftRecord = left;
8204
+ const rightRecord = right;
8205
+ const leftKeys = Object.keys(leftRecord).filter((key) => leftRecord[key] !== undefined);
8206
+ const rightKeys = Object.keys(rightRecord).filter((key) => rightRecord[key] !== undefined);
8207
+ return leftKeys.length === rightKeys.length
8208
+ && leftKeys.every((key) => Object.prototype.hasOwnProperty.call(rightRecord, key)
8209
+ && sameProgramRead(leftRecord[key], rightRecord[key]));
8210
+ }
7701
8211
  function resolveMemberConnectOptions(stack, member, options, forceHttpOnly) {
7702
8212
  const transport = member?.transport ?? options?.transport ?? (forceHttpOnly ? 'http' : 'ws');
7703
8213
  // Stack endpoints remain on the stack definition. Only explicit session/member
@@ -7791,11 +8301,72 @@ async function createSession(definition, ...args) {
7791
8301
  validateCompositionProgramReads(programAsStack(key, program, definition.programReads?.[key]), options?.programs?.[key], options);
7792
8302
  }
7793
8303
  }
8304
+ // Program identity, resolved before anything connects. Every stack's
8305
+ // effective programs (its own plus member-attached ones, matched by
8306
+ // `withPrograms`) are the providers for each key.
8307
+ const effectiveStacks = new Map(stackEntries.map(([key, stack]) => {
8308
+ const memberOptions = options?.stacks?.[key];
8309
+ return [key, withPrograms(stack, memberOptions?.programs)];
8310
+ }));
8311
+ const stackProviders = new Map();
8312
+ for (const [stackKey, stack] of effectiveStacks) {
8313
+ for (const [programKey, program] of Object.entries(stack.programs ?? {})) {
8314
+ const providers = stackProviders.get(programKey) ?? [];
8315
+ providers.push({ stackKey, program });
8316
+ stackProviders.set(programKey, providers);
8317
+ }
8318
+ }
8319
+ // Standalone programs a stack already provides as the same program SDK, read
8320
+ // through the same program read configuration, are served by that stack's
8321
+ // connected instance instead of a second member. One with the same program
8322
+ // spec but no provable identity match, or the same program SDK with its own
8323
+ // `programReads` descriptor that differs from the stack's, gets its own
8324
+ // member and takes session.programs.<key>; the stacks keep theirs.
8325
+ const sharedStandalonePrograms = new Set();
8326
+ if (definition.mode !== 'composition') {
8327
+ for (const [programKey, program] of programEntries) {
8328
+ const providers = stackProviders.get(programKey) ?? [];
8329
+ if (providers.length === 0)
8330
+ continue;
8331
+ const matches = providers.map((provider) => ({
8332
+ ...provider,
8333
+ match: compareProgramIdentity(provider.program, program),
8334
+ }));
8335
+ const conflicting = matches.find(({ match }) => match === 'different');
8336
+ if (conflicting) {
8337
+ throw sessionProgramKeyConflict(conflicting.stackKey, programKey, conflicting.program, program);
8338
+ }
8339
+ if (matches.some(({ match }) => match === 'unproven')) {
8340
+ warnUnprovenSessionProgram(programKey, program, providers);
8341
+ continue;
8342
+ }
8343
+ const explicitRead = definition.programReads?.[programKey];
8344
+ if (explicitRead !== undefined) {
8345
+ // Sharing would read through the serving stack's configuration, so an
8346
+ // explicit descriptor must resolve to the same one.
8347
+ const servingStack = providers[0].stackKey;
8348
+ const standaloneRead = memberProgramRead(programAsStack(programKey, program, explicitRead), programKey, options?.programs?.[programKey], options);
8349
+ const stackRead = memberProgramRead(effectiveStacks.get(servingStack), programKey, options?.stacks?.[servingStack], options);
8350
+ if (!sameProgramRead(standaloneRead, stackRead)) {
8351
+ warnUnprovenSessionProgram(programKey, program, providers, 'program-read');
8352
+ continue;
8353
+ }
8354
+ }
8355
+ if (options?.programs?.[programKey] !== undefined) {
8356
+ throw new AreteError(`Standalone program '${programKey}' is the same program SDK that stack `
8357
+ + `'${providers[0].stackKey}' provides, so the session serves it from the stack's `
8358
+ + `connected instance; configure it through options.stacks.${providers[0].stackKey} `
8359
+ + `instead of options.programs.${programKey}`, 'INVALID_CONFIG');
8360
+ }
8361
+ sharedStandalonePrograms.add(programKey);
8362
+ }
8363
+ }
8364
+ const memberProgramEntries = programEntries.filter(([key]) => !sharedStandalonePrograms.has(key));
7794
8365
  let wallet = options?.wallet;
7795
8366
  const signerRegistry = options?.signerRegistry ?? createSignerRegistry();
7796
8367
  const connectedStacks = await Promise.all(stackEntries.map(async ([key, stack]) => {
7797
8368
  const memberOptions = options?.stacks?.[key];
7798
- const effectiveStack = withPrograms(stack, memberOptions?.programs);
8369
+ const effectiveStack = effectiveStacks.get(key);
7799
8370
  const connectOptions = resolveMemberConnectOptions(effectiveStack, memberOptions, {
7800
8371
  ...options,
7801
8372
  chain: sharedChain,
@@ -7808,7 +8379,7 @@ async function createSession(definition, ...args) {
7808
8379
  });
7809
8380
  return [key, client];
7810
8381
  }));
7811
- const connectedPrograms = await Promise.all(programEntries.map(async ([key, program]) => {
8382
+ const connectedPrograms = await Promise.all(memberProgramEntries.map(async ([key, program]) => {
7812
8383
  const syntheticStack = programAsStack(key, program, definition.programReads?.[key]);
7813
8384
  const connectOptions = resolveMemberConnectOptions(syntheticStack, options?.programs?.[key], {
7814
8385
  ...options,
@@ -7825,30 +8396,38 @@ async function createSession(definition, ...args) {
7825
8396
  const executionHost = memberClients[0];
7826
8397
  const transactions = sharedTransactions ?? executionHost.transactions;
7827
8398
  const stacks = Object.fromEntries(connectedStacks);
7828
- const explicitProgramKeys = new Set(connectedPrograms.map(([key]) => key));
7829
- const programOwners = new Map();
7830
8399
  const connectedProgramEntries = connectedPrograms.map(([key, client]) => [
7831
8400
  key,
7832
8401
  client.programs[key],
7833
8402
  ]);
7834
8403
  const promotedPrograms = Object.fromEntries(connectedProgramEntries);
7835
8404
  if (definition.mode !== 'composition') {
7836
- for (const [stackKey, client] of connectedStacks) {
7837
- for (const [programKey, program] of Object.entries(client.programs)) {
7838
- if (explicitProgramKeys.has(programKey)) {
7839
- continue;
7840
- }
7841
- const existingOwner = programOwners.get(programKey);
7842
- if (existingOwner) {
7843
- console.warn(`Program '${programKey}' is bundled by stacks '${existingOwner.stack}'` +
7844
- ` (${existingOwner.programId ?? 'unknown program ID'}) and '${stackKey}'` +
7845
- ` (${program.programId ?? 'unknown program ID'}); session.programs.${programKey}` +
7846
- ` uses '${existingOwner.stack}' because it was connected first`);
7847
- continue;
7848
- }
7849
- promotedPrograms[programKey] = program;
7850
- programOwners.set(programKey, { stack: stackKey, programId: program.programId });
8405
+ const connectedStackClients = new Map(connectedStacks);
8406
+ for (const [programKey, providers] of stackProviders) {
8407
+ if (Object.prototype.hasOwnProperty.call(promotedPrograms, programKey))
8408
+ continue;
8409
+ const first = providers[0];
8410
+ const matches = providers.flatMap((left, index) => providers.slice(index + 1).map((right) => compareProgramIdentity(left.program, right.program)));
8411
+ if (!matches.includes('different')) {
8412
+ // One program, however many stacks bundle it: the first stack's
8413
+ // connected instance serves the top-level key. Copies with the same
8414
+ // program spec that cannot be proven identical say so once.
8415
+ if (matches.includes('unproven'))
8416
+ warnUnprovenStackPrograms(programKey, providers);
8417
+ promotedPrograms[programKey] = connectedStackClients.get(first.stackKey).programs[programKey];
8418
+ continue;
7851
8419
  }
8420
+ // Different program SDKs under one key: every stack keeps its own at
8421
+ // session.stacks.<stack>.programs.<key>, and the ambiguous top-level key
8422
+ // explains how to choose instead of silently picking one.
8423
+ const error = ambiguousSessionProgram(programKey, providers);
8424
+ Object.defineProperty(promotedPrograms, programKey, {
8425
+ get() {
8426
+ throw error;
8427
+ },
8428
+ enumerable: false,
8429
+ configurable: true,
8430
+ });
7852
8431
  }
7853
8432
  }
7854
8433
  const programs = promotedPrograms;
@@ -8089,13 +8668,17 @@ exports.Arete = Arete;
8089
8668
  exports.AreteError = AreteError;
8090
8669
  exports.ConnectionManager = ConnectionManager;
8091
8670
  exports.DEFAULT_CONFIG = DEFAULT_CONFIG;
8671
+ exports.DEFAULT_INITIAL_DATA_TIMEOUT_MS = DEFAULT_INITIAL_DATA_TIMEOUT_MS;
8092
8672
  exports.DEFAULT_MAX_ENTRIES_PER_VIEW = DEFAULT_MAX_ENTRIES_PER_VIEW;
8673
+ exports.EXTENSION_API_VERSION = EXTENSION_API_VERSION;
8093
8674
  exports.FrameProcessor = FrameProcessor;
8675
+ exports.InitialDataTimeoutError = InitialDataTimeoutError;
8094
8676
  exports.InstructionError = InstructionError;
8095
8677
  exports.MemoryAdapter = MemoryAdapter;
8096
8678
  exports.OPERATION_KIND = OPERATION_KIND;
8097
8679
  exports.OperationCallbackError = OperationCallbackError;
8098
8680
  exports.OperationExecutionError = OperationExecutionError;
8681
+ exports.PROGRAM_KEY_CONFLICT = PROGRAM_KEY_CONFLICT;
8099
8682
  exports.PROGRAM_OPERATION_EXTENSIONS = PROGRAM_OPERATION_EXTENSIONS;
8100
8683
  exports.ProcessedSlotTimeoutError = ProcessedSlotTimeoutError;
8101
8684
  exports.QueryStore = QueryStore;
@@ -8119,6 +8702,7 @@ exports.buildInstruction = buildInstruction;
8119
8702
  exports.bytes = bytes;
8120
8703
  exports.canonicalQueryKey = canonicalQueryKey;
8121
8704
  exports.chainAccountLoader = chainAccountLoader;
8705
+ exports.compareProgramIdentity = compareProgramIdentity;
8122
8706
  exports.createChainClient = createChainClient;
8123
8707
  exports.createEntityStream = createEntityStream;
8124
8708
  exports.createHostedSolanaGatewayTransports = createHostedSolanaGatewayTransports;
@@ -8171,6 +8755,7 @@ exports.isEntityFrame = isEntityFrame;
8171
8755
  exports.isErrorFrame = isErrorFrame;
8172
8756
  exports.isHostedAreteEndpoint = isHostedAreteEndpoint;
8173
8757
  exports.isReplayErrorCode = isReplayErrorCode;
8758
+ exports.isSameProgramSdk = isSameProgramSdk;
8174
8759
  exports.isSnapshotFrame = isSnapshotFrame;
8175
8760
  exports.isStackVersionRefusalCode = isStackVersionRefusalCode;
8176
8761
  exports.isSubscribedFrame = isSubscribedFrame;
@@ -8210,6 +8795,7 @@ exports.unwrapOperationExecutionError = unwrapOperationExecutionError;
8210
8795
  exports.validateAccountResolution = validateAccountResolution;
8211
8796
  exports.validateProgramReadDescriptor = validateProgramReadDescriptor;
8212
8797
  exports.validateSubscriptionId = validateSubscriptionId;
8798
+ exports.withProgramIdentity = withProgramIdentity;
8213
8799
  exports.withProgramRead = withProgramRead;
8214
8800
  exports.withPrograms = withPrograms;
8215
8801
  //# sourceMappingURL=index.cjs.map