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