@usearete/sdk 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1874,6 +1874,7 @@ class ConnectionManager {
1874
1874
  }
1875
1875
 
1876
1876
  const INTERNAL_SEQ_FIELD = '__seq';
1877
+ const INTERNAL_VERSION_FIELD = '__version';
1877
1878
  class ProcessedSlotTimeoutError extends Error {
1878
1879
  constructor(targetSlot, processedSlot) {
1879
1880
  super(`Timed out waiting for Arete to process slot ${targetSlot}`);
@@ -1934,6 +1935,9 @@ function toCamelCaseSegment(value) {
1934
1935
  if (value === '_seq') {
1935
1936
  return INTERNAL_SEQ_FIELD;
1936
1937
  }
1938
+ if (value === '_version') {
1939
+ return INTERNAL_VERSION_FIELD;
1940
+ }
1937
1941
  const pascal = value
1938
1942
  .split(/[_.-]/)
1939
1943
  .filter(segment => segment.length > 0)
@@ -1944,6 +1948,16 @@ function toCamelCaseSegment(value) {
1944
1948
  }
1945
1949
  return pascal[0].toLowerCase() + pascal.slice(1);
1946
1950
  }
1951
+ /** `{epoch}:{counter}` from a frame's `_version`, or null if it does not parse. */
1952
+ function parseVersion(version) {
1953
+ const separator = version.lastIndexOf(':');
1954
+ if (separator <= 0)
1955
+ return null;
1956
+ const counter = version.slice(separator + 1);
1957
+ if (!/^\d+$/.test(counter))
1958
+ return null;
1959
+ return { epoch: version.slice(0, separator), counter: BigInt(counter) };
1960
+ }
1947
1961
  class FrameProcessor {
1948
1962
  /**
1949
1963
  * Observe the connection's cursor tracker instead of a private one.
@@ -1962,6 +1976,15 @@ class FrameProcessor {
1962
1976
  this.latestProcessedSlot = null;
1963
1977
  this.processedSlotWaiters = new Set();
1964
1978
  this.cursors = new CursorTracker();
1979
+ /**
1980
+ * Whether the server's latest acknowledgement guaranteed whole entities.
1981
+ * One connection talks to one server, so its latest ack speaks for every
1982
+ * subscription on it; a reconnect re-acknowledges each one.
1983
+ */
1984
+ this.serverSendsWholeEntities = false;
1985
+ /** Keys dropped by `maxEntriesPerView`, per view, oldest first; bounded. */
1986
+ this.evictedKeys = new Map();
1987
+ this.warnedEvictedViews = new Set();
1965
1988
  this.storage = storage;
1966
1989
  this.maxEntriesPerView = config.maxEntriesPerView === undefined
1967
1990
  ? DEFAULT_MAX_ENTRIES_PER_VIEW
@@ -2009,18 +2032,22 @@ class FrameProcessor {
2009
2032
  const diagnostic = {
2010
2033
  view: viewPath,
2011
2034
  ...context,
2035
+ reason: 'schema',
2012
2036
  error: validationError,
2013
2037
  };
2038
+ this.reportDiagnostic(diagnostic);
2039
+ if (this.warnOnValidationError) {
2040
+ console.warn('[Arete] Frame validation failed:', diagnostic);
2041
+ }
2042
+ return null;
2043
+ }
2044
+ reportDiagnostic(diagnostic) {
2014
2045
  try {
2015
2046
  this.onValidationError?.(diagnostic);
2016
2047
  }
2017
2048
  catch (callbackError) {
2018
2049
  console.error('[Arete] Frame validation callback failed:', callbackError);
2019
2050
  }
2020
- if (this.warnOnValidationError) {
2021
- console.warn('[Arete] Frame validation failed:', diagnostic);
2022
- }
2023
- return null;
2024
2051
  }
2025
2052
  hasSchema(viewPath) {
2026
2053
  return this.getSchema(viewPath) !== null;
@@ -2087,6 +2114,50 @@ class FrameProcessor {
2087
2114
  }
2088
2115
  return leftIndex.localeCompare(rightIndex);
2089
2116
  }
2117
+ extractVersion(data) {
2118
+ if (!isObject(data)) {
2119
+ return undefined;
2120
+ }
2121
+ const version = data._version;
2122
+ return typeof version === 'string' ? version : undefined;
2123
+ }
2124
+ getInternalVersion(data) {
2125
+ if (!isObject(data)) {
2126
+ return undefined;
2127
+ }
2128
+ const version = data[INTERNAL_VERSION_FIELD];
2129
+ return typeof version === 'string' ? version : undefined;
2130
+ }
2131
+ /**
2132
+ * Whether a frame at `incoming` is one this client already applied, or older.
2133
+ * A version is `{epoch}:{counter}`, and counters compare only within an
2134
+ * epoch: a server restart or a reloaded stack starts a new one, whose first
2135
+ * frame is newer than anything from the old one. A version that does not
2136
+ * parse cannot be ordered and is never stale.
2137
+ */
2138
+ isStaleVersion(incoming, held) {
2139
+ if (held === undefined) {
2140
+ return false;
2141
+ }
2142
+ const next = parseVersion(incoming);
2143
+ const current = parseVersion(held);
2144
+ if (next === null || current === null || next.epoch !== current.epoch) {
2145
+ return false;
2146
+ }
2147
+ return next.counter <= current.counter;
2148
+ }
2149
+ attachInternalVersion(data, version) {
2150
+ if (!version || !isObject(data)) {
2151
+ return data;
2152
+ }
2153
+ Object.defineProperty(data, INTERNAL_VERSION_FIELD, {
2154
+ value: version,
2155
+ enumerable: false,
2156
+ configurable: true,
2157
+ writable: true,
2158
+ });
2159
+ return data;
2160
+ }
2090
2161
  attachInternalSeq(_viewPath, data, seq) {
2091
2162
  if (!seq || !isObject(data)) {
2092
2163
  return data;
@@ -2307,6 +2378,7 @@ class FrameProcessor {
2307
2378
  }
2308
2379
  handleSubscribedFrame(frame) {
2309
2380
  this.cursors.observe(frame);
2381
+ this.serverSendsWholeEntities = frame.wholeEntities === true;
2310
2382
  const viewPath = frame.query.view;
2311
2383
  if (this.storage.setViewConfig && frame.sort) {
2312
2384
  this.storage.setViewConfig(viewPath, {
@@ -2331,10 +2403,11 @@ class FrameProcessor {
2331
2403
  if (normalized === null) {
2332
2404
  continue;
2333
2405
  }
2334
- const nextValue = this.attachInternalSeq(viewPath, normalized, this.extractSeq(entity.data));
2406
+ const nextValue = this.attachInternalVersion(this.attachInternalSeq(viewPath, normalized, this.extractSeq(entity.data)), this.extractVersion(entity.data));
2335
2407
  acceptedKeys.push(entity.key);
2336
2408
  const previousValue = this.storage.get(viewPath, entity.key);
2337
2409
  this.storage.set(viewPath, entity.key, nextValue);
2410
+ this.forgetEvicted(viewPath, entity.key);
2338
2411
  this.storage.notifyUpdate(viewPath, entity.key, {
2339
2412
  type: 'upsert',
2340
2413
  key: entity.key,
@@ -2353,18 +2426,24 @@ class FrameProcessor {
2353
2426
  const previousValue = this.storage.get(viewPath, frame.key);
2354
2427
  const previousSequence = this.getInternalSeq(previousValue);
2355
2428
  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;
2429
+ // A server that stamps `_version` orders one key's frames by it. `seq`
2430
+ // cannot: every update decoded from one transaction shares one, and within
2431
+ // a slot account updates and instructions number themselves differently,
2432
+ // so a later frame can carry a lower `seq`. Without a version (an older
2433
+ // server) the `seq` rule applies. On a tape the offset is the identity, so
2434
+ // neither does.
2435
+ const frameVersion = this.extractVersion(frame.data);
2436
+ const duplicateOrStale = frame.offset === undefined && (frameVersion !== undefined
2437
+ ? this.isStaleVersion(frameVersion, this.getInternalVersion(previousValue))
2438
+ : frame.seq !== undefined
2439
+ && previousSequence !== undefined
2440
+ && this.compareSeq(frame.seq, previousSequence) <= 0);
2362
2441
  switch (frame.op) {
2363
2442
  case 'upsert':
2364
2443
  {
2365
2444
  if (frame.data === null)
2366
2445
  break;
2367
- if (duplicateOrStaleSequence && previousValue !== null) {
2446
+ if (duplicateOrStale && previousValue !== null) {
2368
2447
  const update = {
2369
2448
  type: 'upsert',
2370
2449
  key: frame.key,
@@ -2381,14 +2460,17 @@ class FrameProcessor {
2381
2460
  if (normalized === null) {
2382
2461
  break;
2383
2462
  }
2384
- const nextValue = this.attachInternalSeq(viewPath, normalized,
2463
+ const nextValue = this.attachInternalVersion(this.attachInternalSeq(viewPath, normalized,
2385
2464
  // Fall back to the previous entity's sequence, as the patch branch
2386
2465
  // below does. Without this an unsequenced upsert drops the tracked
2387
2466
  // sequence (it rides on the object being replaced), disarming the
2388
2467
  // staleness guard until the next sequenced frame and letting a
2389
2468
  // later older frame overwrite newer data.
2390
- frame.seq ?? this.extractSeq(frame.data) ?? this.getInternalSeq(previousValue));
2469
+ frame.seq ?? this.extractSeq(frame.data) ?? this.getInternalSeq(previousValue)),
2470
+ // An unversioned write keeps the version it replaces, likewise.
2471
+ frameVersion ?? this.getInternalVersion(previousValue));
2391
2472
  this.storage.set(viewPath, frame.key, nextValue);
2473
+ this.forgetEvicted(viewPath, frame.key);
2392
2474
  const update = {
2393
2475
  type: 'upsert',
2394
2476
  key: frame.key,
@@ -2405,6 +2487,19 @@ class FrameProcessor {
2405
2487
  if (frame.data === null)
2406
2488
  break;
2407
2489
  const existing = this.storage.get(viewPath, frame.key);
2490
+ if (existing === null && frame.offset === undefined && this.serverSendsWholeEntities) {
2491
+ // A patch carries only changed fields. With no entity to merge into
2492
+ // (never received, or evicted) it would be stored as a partial
2493
+ // entity typed as complete, so it is discarded: no storage write, no
2494
+ // sequence, no query membership, no update. The key appears when the
2495
+ // server sends a full upsert, which a server acknowledging
2496
+ // `wholeEntities` guarantees for every key it has not sent. Tape
2497
+ // records (frames with an `offset`) are events rather than entity
2498
+ // state and are always applied: a consumer resuming from a cursor
2499
+ // holds what came before it.
2500
+ this.reportDiscardedPatch(viewPath, frame);
2501
+ break;
2502
+ }
2408
2503
  const normalizedPatch = this.normalizeEntity(viewPath, frame.data, {
2409
2504
  key: frame.key,
2410
2505
  operation: 'patch',
@@ -2413,7 +2508,7 @@ class FrameProcessor {
2413
2508
  if (normalizedPatch === null) {
2414
2509
  break;
2415
2510
  }
2416
- if (duplicateOrStaleSequence && existing !== null) {
2511
+ if (duplicateOrStale && existing !== null) {
2417
2512
  const update = {
2418
2513
  type: 'patch',
2419
2514
  key: frame.key,
@@ -2426,7 +2521,7 @@ class FrameProcessor {
2426
2521
  const merged = existing
2427
2522
  ? deepMergeWithAppend(existing, normalizedPatch, appendPaths)
2428
2523
  : normalizedPatch;
2429
- const nextValue = this.attachInternalSeq(viewPath, merged, frame.seq ?? this.extractSeq(frame.data) ?? this.getInternalSeq(existing));
2524
+ const nextValue = this.attachInternalVersion(this.attachInternalSeq(viewPath, merged, frame.seq ?? this.extractSeq(frame.data) ?? this.getInternalSeq(existing)), frameVersion ?? this.getInternalVersion(existing));
2430
2525
  this.storage.set(viewPath, frame.key, nextValue);
2431
2526
  const update = {
2432
2527
  type: 'patch',
@@ -2445,6 +2540,7 @@ class FrameProcessor {
2445
2540
  break;
2446
2541
  case 'delete':
2447
2542
  this.storage.delete(viewPath, frame.key);
2543
+ this.forgetEvicted(viewPath, frame.key);
2448
2544
  this.storage.notifyUpdate(viewPath, frame.key, {
2449
2545
  type: 'delete',
2450
2546
  key: frame.key,
@@ -2481,8 +2577,56 @@ class FrameProcessor {
2481
2577
  return;
2482
2578
  while (this.storage.size(viewPath) > this.maxEntriesPerView) {
2483
2579
  const evicted = this.storage.evictOldest(viewPath);
2484
- if (evicted !== undefined)
2485
- this.queryStore?.evict(viewPath, evicted);
2580
+ if (evicted === undefined)
2581
+ continue;
2582
+ this.queryStore?.evict(viewPath, evicted);
2583
+ this.rememberEvicted(viewPath, evicted, this.maxEntriesPerView);
2584
+ }
2585
+ }
2586
+ /**
2587
+ * Remember a key `maxEntriesPerView` dropped, so a later patch for it can be
2588
+ * reported as such. At most `limit` keys per view, most recent kept.
2589
+ */
2590
+ rememberEvicted(viewPath, key, limit) {
2591
+ let keys = this.evictedKeys.get(viewPath);
2592
+ if (!keys) {
2593
+ keys = new Set();
2594
+ this.evictedKeys.set(viewPath, keys);
2595
+ }
2596
+ keys.delete(key);
2597
+ keys.add(key);
2598
+ while (keys.size > Math.max(limit, 1)) {
2599
+ const oldest = keys.values().next().value;
2600
+ if (oldest === undefined)
2601
+ break;
2602
+ keys.delete(oldest);
2603
+ }
2604
+ }
2605
+ forgetEvicted(viewPath, key) {
2606
+ this.evictedKeys.get(viewPath)?.delete(key);
2607
+ }
2608
+ reportDiscardedPatch(viewPath, frame) {
2609
+ const seq = frame.seq ?? this.extractSeq(frame.data);
2610
+ const evicted = this.evictedKeys.get(viewPath)?.has(frame.key) === true;
2611
+ const error = evicted
2612
+ ? new AreteError(`Discarded patch for '${frame.key}' in ${viewPath}: this client dropped the entity to `
2613
+ + `stay within maxEntriesPerView (${String(this.maxEntriesPerView)}), and the server `
2614
+ + 'sends no upsert for it until it re-enters the query or the subscription is '
2615
+ + 're-established; raise maxEntriesPerView above the subscription size', 'PATCH_FOR_EVICTED_KEY')
2616
+ : new AreteError(`Discarded patch for '${frame.key}' in ${viewPath}: the client holds no entity for `
2617
+ + 'this key; it appears when the server sends a full upsert', 'PATCH_FOR_UNKNOWN_KEY');
2618
+ const diagnostic = {
2619
+ view: viewPath,
2620
+ key: frame.key,
2621
+ ...(seq !== undefined ? { seq } : {}),
2622
+ operation: 'patch',
2623
+ reason: evicted ? 'evicted-key' : 'unknown-key',
2624
+ error,
2625
+ };
2626
+ this.reportDiagnostic(diagnostic);
2627
+ if (evicted && this.warnOnValidationError && !this.warnedEvictedViews.has(viewPath)) {
2628
+ this.warnedEvictedViews.add(viewPath);
2629
+ console.warn('[Arete] Patch dropped for a locally evicted entity:', diagnostic);
2486
2630
  }
2487
2631
  }
2488
2632
  }
@@ -2629,6 +2773,66 @@ class MemoryAdapter {
2629
2773
  }
2630
2774
  }
2631
2775
 
2776
+ /** Whether a sort value cannot be ranked: it is missing or `null`. */
2777
+ function isUnrankedSortValue(value) {
2778
+ return value === undefined || value === null;
2779
+ }
2780
+ /**
2781
+ * Compares two ranked sort values in ascending order. Numbers and bigints
2782
+ * compare numerically (also against each other), booleans as `false < true`,
2783
+ * and everything else with `localeCompare` on its string form (the SDK
2784
+ * ordering rule, shared with the Rust and Python SDKs).
2785
+ *
2786
+ * This is not the server's ranking. The server compares strings by byte
2787
+ * order, except that two decimal-integer strings compare numerically, and it
2788
+ * ranks numbers below strings and booleans below both. The server's order
2789
+ * decides which entities fill a `take` window; this one only orders the
2790
+ * members the client already holds, so the two can list a window's members
2791
+ * differently, never choose different members.
2792
+ */
2793
+ function compareSortValues(left, right) {
2794
+ if (left === right)
2795
+ return 0;
2796
+ if (isUnrankedSortValue(left))
2797
+ return isUnrankedSortValue(right) ? 0 : 1;
2798
+ if (isUnrankedSortValue(right))
2799
+ return -1;
2800
+ if (typeof left === 'number' && typeof right === 'number')
2801
+ return left - right;
2802
+ if (typeof left === 'bigint' && typeof right === 'bigint')
2803
+ return left < right ? -1 : 1;
2804
+ if (typeof left === 'bigint' && typeof right === 'number' && Number.isInteger(right)) {
2805
+ const other = BigInt(right);
2806
+ return left === other ? 0 : left < other ? -1 : 1;
2807
+ }
2808
+ if (typeof left === 'number' && typeof right === 'bigint' && Number.isInteger(left)) {
2809
+ const other = BigInt(left);
2810
+ return other === right ? 0 : other < right ? -1 : 1;
2811
+ }
2812
+ if (typeof left === 'boolean' && typeof right === 'boolean')
2813
+ return Number(left) - Number(right);
2814
+ return String(left).localeCompare(String(right));
2815
+ }
2816
+ /**
2817
+ * Orders two entities' sort values for a view sorted in `order`.
2818
+ *
2819
+ * A missing or `null` sort value cannot be ranked, so it sorts after every
2820
+ * ranked value in both directions: `desc` reverses only the comparison between
2821
+ * two ranked values. Two unranked values tie. Callers break ties on the entity
2822
+ * key, ascending, whatever the order. The unranked-last rule is the server's
2823
+ * and the Rust and Python SDKs'; how two ranked values compare is
2824
+ * `compareRanked` (by default {@link compareSortValues}, which differs from
2825
+ * the server as described there).
2826
+ */
2827
+ function compareSortOrder(left, right, order, compareRanked = compareSortValues) {
2828
+ const leftUnranked = isUnrankedSortValue(left);
2829
+ const rightUnranked = isUnrankedSortValue(right);
2830
+ if (leftUnranked || rightUnranked)
2831
+ return Number(leftUnranked) - Number(rightUnranked);
2832
+ const compared = compareRanked(left, right);
2833
+ return order === 'desc' ? -compared : compared;
2834
+ }
2835
+
2632
2836
  function getNestedValue$1(obj, path) {
2633
2837
  let current = obj;
2634
2838
  for (const segment of path) {
@@ -2640,33 +2844,6 @@ function getNestedValue$1(obj, path) {
2640
2844
  }
2641
2845
  return current;
2642
2846
  }
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
2847
  class SortedStorageDecorator {
2671
2848
  constructor(inner) {
2672
2849
  this.sortConfigs = new Map();
@@ -2786,7 +2963,6 @@ class SortedStorageDecorator {
2786
2963
  }
2787
2964
  binarySearchInsertPosition(viewPath, sortedKeys, sortConfig, newKey, newValue) {
2788
2965
  const newSortValue = getNestedValue$1(newValue, sortConfig.field);
2789
- const isDesc = sortConfig.order === 'desc';
2790
2966
  let low = 0;
2791
2967
  let high = sortedKeys.length;
2792
2968
  while (low < high) {
@@ -2794,9 +2970,8 @@ class SortedStorageDecorator {
2794
2970
  const midKey = sortedKeys[mid];
2795
2971
  const midEntity = this.inner.get(viewPath, midKey);
2796
2972
  const midValue = getNestedValue$1(midEntity, sortConfig.field);
2797
- let cmp = compareSortValues(newSortValue, midValue);
2798
- if (isDesc)
2799
- cmp = -cmp;
2973
+ // Unranked (missing or null) values sort last in both directions.
2974
+ let cmp = compareSortOrder(newSortValue, midValue, sortConfig.order);
2800
2975
  if (cmp === 0) {
2801
2976
  cmp = newKey.localeCompare(midKey);
2802
2977
  }
@@ -2813,14 +2988,11 @@ class SortedStorageDecorator {
2813
2988
  const allKeys = this.inner.keys(viewPath);
2814
2989
  if (allKeys.length === 0)
2815
2990
  return;
2816
- const isDesc = sortConfig.order === 'desc';
2817
2991
  const entries = allKeys.map(k => [k, this.inner.get(viewPath, k)]);
2818
2992
  entries.sort((a, b) => {
2819
2993
  const aValue = getNestedValue$1(a[1], sortConfig.field);
2820
2994
  const bValue = getNestedValue$1(b[1], sortConfig.field);
2821
- let cmp = compareSortValues(aValue, bValue);
2822
- if (isDesc)
2823
- cmp = -cmp;
2995
+ let cmp = compareSortOrder(aValue, bValue, sortConfig.order);
2824
2996
  if (cmp === 0) {
2825
2997
  cmp = a[0].localeCompare(b[0]);
2826
2998
  }
@@ -2839,25 +3011,9 @@ function getNestedValue(value, path) {
2839
3011
  }
2840
3012
  return current;
2841
3013
  }
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
3014
  function compareSequences(left, right) {
2859
3015
  if (typeof left !== 'string' || typeof right !== 'string')
2860
- return compareValues(left, right);
3016
+ return compareSortValues(left, right);
2861
3017
  const [leftSlot, leftIndex = ''] = left.split(':', 2);
2862
3018
  const [rightSlot, rightIndex = ''] = right.split(':', 2);
2863
3019
  if (/^\d+$/.test(leftSlot ?? '') && /^\d+$/.test(rightSlot ?? '')) {
@@ -3149,9 +3305,9 @@ class QueryStore {
3149
3305
  const right = field
3150
3306
  ? getNestedValue(this.storage.get(view, rightKey), field)
3151
3307
  : record.sequences.get(rightKey);
3152
- let compared = field ? compareValues(left, right) : compareSequences(left, right);
3153
- if (order === 'desc')
3154
- compared = -compared;
3308
+ // A missing or null sort value (or sequence) sorts after every ranked
3309
+ // one in both directions; ties break on the key, always ascending.
3310
+ const compared = compareSortOrder(left, right, order, field ? compareSortValues : compareSequences);
3155
3311
  return compared === 0 ? leftKey.localeCompare(rightKey) : compared;
3156
3312
  });
3157
3313
  }
@@ -6227,16 +6383,22 @@ const PROGRAM_READ_DESCRIPTOR = Symbol.for('@usearete/sdk/program-read-descripto
6227
6383
  /**
6228
6384
  * Bundle a generated program definition with its default read descriptor.
6229
6385
  *
6230
- * Generated SDK entries call this automatically. The descriptor is deliberately
6231
- * non-enumerable so it remains an implementation detail of the program cartridge.
6386
+ * Generated SDK entries call this automatically. The descriptor lives under a
6387
+ * registry symbol, so `Object.keys` and `JSON.stringify` never see it, while
6388
+ * `{ ...program }` keeps it: a spread program still reads through its release.
6389
+ * Every other own property (including the runtime extension carrier) is copied
6390
+ * unchanged, except `packageReleaseHash`: the result reads differently from
6391
+ * the generated program SDK, so it is no longer provably that SDK. Generated
6392
+ * entries apply `withProgramIdentity` last.
6232
6393
  */
6233
6394
  function withProgramRead(program, descriptor) {
6234
6395
  const properties = Object.getOwnPropertyDescriptors(program);
6235
6396
  Reflect.deleteProperty(properties, PROGRAM_READ_DESCRIPTOR);
6397
+ Reflect.deleteProperty(properties, 'packageReleaseHash');
6236
6398
  const bundled = Object.create(Object.getPrototypeOf(program), properties);
6237
6399
  Object.defineProperty(bundled, PROGRAM_READ_DESCRIPTOR, {
6238
6400
  value: descriptor,
6239
- enumerable: false,
6401
+ enumerable: true,
6240
6402
  configurable: false,
6241
6403
  writable: false,
6242
6404
  });
@@ -6246,9 +6408,62 @@ function withProgramRead(program, descriptor) {
6246
6408
  function getProgramReadDescriptor(program) {
6247
6409
  return program?.[PROGRAM_READ_DESCRIPTOR];
6248
6410
  }
6411
+ /**
6412
+ * Stamp a program definition with the identity of the program SDK it is.
6413
+ *
6414
+ * Generated program SDK entries call this last, after the package's own
6415
+ * extension and read descriptor are applied, so the identity describes
6416
+ * exactly the generated SDK. `extendProgram`, `extendPrograms` and
6417
+ * `withProgramRead` drop the identity again: a program changed outside its
6418
+ * generated SDK is no longer provably that SDK. Every other own property,
6419
+ * including symbol-keyed runtime extensions and the read descriptor, is copied
6420
+ * unchanged. An absent or empty `packageReleaseHash` removes the identity.
6421
+ */
6422
+ function withProgramIdentity(program, identity) {
6423
+ const properties = Object.getOwnPropertyDescriptors(program);
6424
+ Reflect.deleteProperty(properties, 'packageReleaseHash');
6425
+ const stamped = Object.create(Object.getPrototypeOf(program), properties);
6426
+ if (typeof identity.packageReleaseHash === 'string' && identity.packageReleaseHash.length > 0) {
6427
+ Object.defineProperty(stamped, 'packageReleaseHash', {
6428
+ value: identity.packageReleaseHash,
6429
+ enumerable: true,
6430
+ configurable: true,
6431
+ writable: true,
6432
+ });
6433
+ }
6434
+ return stamped;
6435
+ }
6249
6436
 
6250
- const STACK_RUNTIME_EXTENSIONS = '__areteStackRuntimeExtensions';
6251
- const PROGRAM_OPERATION_EXTENSIONS = '__areteProgramOperationExtensions';
6437
+ /**
6438
+ * Version of the extension contract: `defineStackExtensions`,
6439
+ * `defineProgramExtensions`, `extendProgram`, `extendPrograms`, `extendStack`,
6440
+ * `withProgramRead`, and the instruction helpers generated code imports
6441
+ * (`createInstructionHandler`, `buildInstruction`, `instructionOperation`,
6442
+ * `createPreparedInstruction`, the PDA and read helpers, …).
6443
+ *
6444
+ * It only changes on a breaking change to that contract, so generated SDKs and
6445
+ * extensions built for one value keep working with every SDK release that
6446
+ * exports the same value. The same number is published in this package's
6447
+ * `package.json` as `arete.extensionApi` for tools that read `node_modules`.
6448
+ */
6449
+ const EXTENSION_API_VERSION = 1;
6450
+ /**
6451
+ * Runtime extension carriers live under registry symbols (`Symbol.for`), so
6452
+ * two copies of this module still read each other's definitions. The
6453
+ * properties are enumerable: `{ ...stack }` and `{ ...program }` keep them,
6454
+ * while `Object.keys`, `for…in` and `JSON.stringify` never see a symbol key.
6455
+ *
6456
+ * The constants are typed as their historical property names rather than as
6457
+ * `unique symbol`. Generated program SDKs use them as computed keys inside
6458
+ * object literals, and TypeScript can only name a `unique symbol` key in
6459
+ * emitted declarations from a file that imports it; a string-literal key type
6460
+ * keeps every generated `.d.ts` nameable and identical across module copies.
6461
+ * Always read and write these properties through the constants (or
6462
+ * `getStackRuntimeExtensions` / `getProgramRuntimeExtensions`), never by the
6463
+ * literal name.
6464
+ */
6465
+ const STACK_RUNTIME_EXTENSIONS = Symbol.for('@usearete/sdk/stack-runtime-extensions');
6466
+ const PROGRAM_OPERATION_EXTENSIONS = Symbol.for('@usearete/sdk/program-runtime-extensions');
6252
6467
  function defineProgramExtensions() {
6253
6468
  return (extensions) => extensions;
6254
6469
  }
@@ -6277,7 +6492,12 @@ function extendProgram(program, extensions) {
6277
6492
  if (readDescriptor) {
6278
6493
  Object.defineProperty(extended, PROGRAM_READ_DESCRIPTOR, readDescriptor);
6279
6494
  }
6495
+ // The extended program is no longer byte-for-byte the generated one, nor
6496
+ // provably the program SDK its package release describes: extensions
6497
+ // applied outside generated code must not borrow that identity. Generated
6498
+ // entries stamp it back with `withProgramIdentity` after their own.
6280
6499
  delete extended.sdkDefinitionHash;
6500
+ delete extended.packageReleaseHash;
6281
6501
  for (const key of ['pdas', 'accounts', 'queries', 'addresses', 'constants', 'defaults', 'math']) {
6282
6502
  const extensionValue = extensions[key];
6283
6503
  if (extensionValue !== undefined) {
@@ -6313,7 +6533,8 @@ function extendProgram(program, extensions) {
6313
6533
  return mergeNamespace(baseRead, extensionReadFactory?.(context));
6314
6534
  },
6315
6535
  },
6316
- enumerable: false,
6536
+ // Enumerable so spreading the extended program keeps its operations.
6537
+ enumerable: true,
6317
6538
  configurable: false,
6318
6539
  writable: false,
6319
6540
  });
@@ -6358,7 +6579,8 @@ function extendStack(stack, extensions) {
6358
6579
  ? (client) => mergeNamespace(baseRuntime.createFlows(client), extensions.createFlows(client))
6359
6580
  : extensions.createFlows ?? baseRuntime?.createFlows,
6360
6581
  },
6361
- enumerable: false,
6582
+ // Enumerable so `{ ...stack }` keeps `read`, `flows` and `readArgCounts`.
6583
+ enumerable: true,
6362
6584
  configurable: false,
6363
6585
  writable: false,
6364
6586
  });
@@ -6396,7 +6618,7 @@ function isPreparedInstruction(instruction) {
6396
6618
  && instruction.kind === 'instruction'
6397
6619
  && 'instruction' in instruction;
6398
6620
  }
6399
- function nonEmpty(values, label) {
6621
+ function nonEmpty$1(values, label) {
6400
6622
  if (values.length === 0) {
6401
6623
  throw new Error(`${label} must contain at least one item`);
6402
6624
  }
@@ -6409,7 +6631,7 @@ function inferSignerAddresses(instructions) {
6409
6631
  return dedupe(instructions.flatMap((instruction) => instruction.keys.filter((key) => key.isSigner).map((key) => key.pubkey)));
6410
6632
  }
6411
6633
  function createPreparedTransactionBody(input) {
6412
- const instructions = nonEmpty(input.instructions, `Transaction '${input.name}'`);
6634
+ const instructions = nonEmpty$1(input.instructions, `Transaction '${input.name}'`);
6413
6635
  return {
6414
6636
  name: input.name,
6415
6637
  instructions,
@@ -6422,7 +6644,7 @@ function createPlan(name, artifacts, transactions) {
6422
6644
  return {
6423
6645
  name,
6424
6646
  artifacts,
6425
- transactions: nonEmpty(transactions, `Flow '${name}'`),
6647
+ transactions: nonEmpty$1(transactions, `Flow '${name}'`),
6426
6648
  };
6427
6649
  }
6428
6650
  function createPreparedInstruction(input) {
@@ -6708,8 +6930,8 @@ async function executePreparedOperation(host, operation, options = {}) {
6708
6930
  kind: 'flow',
6709
6931
  operationName: operation.name,
6710
6932
  artifacts: operation.artifacts,
6711
- signatures: nonEmpty(receipts.map((receipt) => receipt.signature), `Operation '${operation.name}' signatures`),
6712
- transactions: nonEmpty(receipts, `Operation '${operation.name}' receipts`),
6933
+ signatures: nonEmpty$1(receipts.map((receipt) => receipt.signature), `Operation '${operation.name}' signatures`),
6934
+ transactions: nonEmpty$1(receipts, `Operation '${operation.name}' receipts`),
6713
6935
  ...callbackErrorResult,
6714
6936
  };
6715
6937
  }
@@ -6985,6 +7207,127 @@ function createProgramReadTransport(config) {
6985
7207
  };
6986
7208
  }
6987
7209
 
7210
+ /** Error code for two different program SDKs claiming one program key. */
7211
+ const PROGRAM_KEY_CONFLICT = 'PROGRAM_KEY_CONFLICT';
7212
+ function nonEmpty(value) {
7213
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
7214
+ }
7215
+ /** Compare two program definitions by identity, never by name. */
7216
+ function compareProgramIdentity(left, right) {
7217
+ if (left === right)
7218
+ return 'same';
7219
+ const leftRelease = nonEmpty(left.packageReleaseHash);
7220
+ const rightRelease = nonEmpty(right.packageReleaseHash);
7221
+ if (leftRelease !== undefined && rightRelease !== undefined) {
7222
+ return leftRelease === rightRelease ? 'same' : 'different';
7223
+ }
7224
+ const leftSpec = nonEmpty(left.programSpecHash);
7225
+ return leftSpec !== undefined && leftSpec === nonEmpty(right.programSpecHash)
7226
+ ? 'unproven'
7227
+ : 'different';
7228
+ }
7229
+ /**
7230
+ * Whether two program definitions are provably the same program SDK: the same
7231
+ * object, or both carry the same `packageReleaseHash` (the program package
7232
+ * release they were generated from). Anything else is not provably the same,
7233
+ * even under the same name.
7234
+ */
7235
+ function isSameProgramSdk(left, right) {
7236
+ return compareProgramIdentity(left, right) === 'same';
7237
+ }
7238
+ function identityOf(program) {
7239
+ const release = nonEmpty(program.packageReleaseHash);
7240
+ if (release !== undefined)
7241
+ return `package release ${release}`;
7242
+ const spec = nonEmpty(program.programSpecHash);
7243
+ return spec !== undefined
7244
+ ? `program spec ${spec}, no package release`
7245
+ : 'no program identity';
7246
+ }
7247
+ const warned = new Set();
7248
+ /** Emit a program identity warning once per distinct message. */
7249
+ function warnOnce(message) {
7250
+ if (warned.has(message))
7251
+ return;
7252
+ warned.add(message);
7253
+ console.warn(`[Arete] ${message}`);
7254
+ }
7255
+ /**
7256
+ * A program attached next to a stack (`withPrograms`, `ConnectOptions.programs`,
7257
+ * `useArete(stack, { programs })`, a session member's `programs`) under a key
7258
+ * the stack already provides with a different program SDK.
7259
+ */
7260
+ function attachedProgramKeyConflict(stackName, key, stackProgram, attached) {
7261
+ return new AreteError(`Program key '${key}' conflicts with stack '${stackName}': the stack already provides `
7262
+ + `a different '${key}' program SDK (stack: ${identityOf(stackProgram)}; attached: `
7263
+ + `${identityOf(attached)}). Use the stack's program at programs.${key}, or attach the `
7264
+ + `other program under a different key, one the stack does not use.`, PROGRAM_KEY_CONFLICT, { key, stack: stackName });
7265
+ }
7266
+ /**
7267
+ * An attached program replaces the stack's program under one key without a
7268
+ * provable identity match (same program spec, no shared package release).
7269
+ */
7270
+ function warnUnprovenAttachedProgram(stackName, key, stackProgram, attached) {
7271
+ warnOnce(`programs.${key} uses the program attached to stack '${stackName}': it has the same `
7272
+ + `program spec as the stack's '${key}' program but could not be proven identical `
7273
+ + `(stack: ${identityOf(stackProgram)}; attached: ${identityOf(attached)}).`);
7274
+ }
7275
+ /** A standalone session program under a key a session stack already provides. */
7276
+ function sessionProgramKeyConflict(stackKey, key, stackProgram, standalone) {
7277
+ return new AreteError(`Program key '${key}' conflicts with stack '${stackKey}' in this session: the stack `
7278
+ + `already provides a different '${key}' program SDK (stack: ${identityOf(stackProgram)}; `
7279
+ + `standalone: ${identityOf(standalone)}). Use session.stacks.${stackKey}.programs.${key}, `
7280
+ + `or attach the standalone program under a different key in createSession({ programs }).`, PROGRAM_KEY_CONFLICT, { key, stack: stackKey });
7281
+ }
7282
+ /**
7283
+ * A standalone session program takes the top-level key from stacks whose
7284
+ * program it could not be proven identical to: a different identity
7285
+ * (`'identity'`), or the same program SDK read through a different program
7286
+ * read configuration than the stack's (`'program-read'`).
7287
+ */
7288
+ function warnUnprovenSessionProgram(key, standalone, providers, cause = 'identity') {
7289
+ const stacks = providers.map(({ stackKey }) => `'${stackKey}'`).join(' and ');
7290
+ const paths = providers
7291
+ .map(({ stackKey }) => `session.stacks.${stackKey}.programs.${key}`)
7292
+ .join(' and ');
7293
+ const identities = `standalone: ${identityOf(standalone)}; ${providers
7294
+ .map(({ stackKey, program }) => `${stackKey}: ${identityOf(program)}`)
7295
+ .join('; ')}`;
7296
+ const reason = cause === 'program-read'
7297
+ ? `it is the same program SDK as the '${key}' program of stack ${stacks} (${identities}) `
7298
+ + `but its programReads.${key} descriptor differs from the one the stack reads through, `
7299
+ + `so it keeps its own program read configuration`
7300
+ : `it has the same program spec as the '${key}' program of stack ${stacks} but could not `
7301
+ + `be proven identical (${identities})`;
7302
+ warnOnce(`session.programs.${key} uses the standalone program: ${reason}. `
7303
+ + `${paths} keep${providers.length === 1 ? 's' : ''} the stack's program.`);
7304
+ }
7305
+ /**
7306
+ * Several session stacks bundle one key with the same program spec, not all
7307
+ * provably the same SDK; the first stack's program takes the top-level key.
7308
+ */
7309
+ function warnUnprovenStackPrograms(key, providers) {
7310
+ const first = providers[0];
7311
+ warnOnce(`session.programs.${key} uses stack '${first.stackKey}''s program: stacks `
7312
+ + `${providers.map(({ stackKey }) => `'${stackKey}'`).join(' and ')} bundle '${key}' `
7313
+ + `programs with the same program spec that could not be proven identical (${providers
7314
+ .map(({ stackKey, program }) => `${stackKey}: ${identityOf(program)}`)
7315
+ .join('; ')}). Each stays at session.stacks.<stack>.programs.${key}.`);
7316
+ }
7317
+ /** Two session stacks provide different program SDKs under one key. */
7318
+ function ambiguousSessionProgram(key, providers) {
7319
+ const stacks = providers.map(({ stackKey }) => `'${stackKey}'`).join(' and ');
7320
+ const paths = providers
7321
+ .map(({ stackKey }) => `session.stacks.${stackKey}.programs.${key}`)
7322
+ .join(' or ');
7323
+ const identities = providers
7324
+ .map(({ stackKey, program }) => `${stackKey}: ${identityOf(program)}`)
7325
+ .join('; ');
7326
+ return new AreteError(`session.programs.${key} is ambiguous: stacks ${stacks} provide different '${key}' `
7327
+ + `program SDKs (${identities}). Use ${paths}, or attach the one you want under a `
7328
+ + `different key in createSession({ programs }).`, PROGRAM_KEY_CONFLICT, { key, stacks: providers.map(({ stackKey }) => stackKey) });
7329
+ }
7330
+
6988
7331
  function effectiveProgramRead(stack, name, override) {
6989
7332
  return override
6990
7333
  ?? stack.programReads?.[name]
@@ -7092,13 +7435,38 @@ function validateLocalProgramReadEndpoints(stack, overrides, connectHttpUrl) {
7092
7435
  function hasProgramAccountReads(definition) {
7093
7436
  return Object.keys(definition.accounts ?? {}).length > 0;
7094
7437
  }
7438
+ /**
7439
+ * Attach programs next to a stack's own, matching a key the stack already
7440
+ * provides by program identity (see `compareProgramIdentity`):
7441
+ *
7442
+ * - the same program SDK (same object, or equal `packageReleaseHash`) resolves
7443
+ * to the stack's definition;
7444
+ * - the same `programSpecHash` without a provable identity match (at least one
7445
+ * side has no `packageReleaseHash`) uses the attached program, with one
7446
+ * warning;
7447
+ * - anything else throws `PROGRAM_KEY_CONFLICT`.
7448
+ */
7095
7449
  function mergeAttachedPrograms(stack, attachedPrograms) {
7096
7450
  const merged = { ...(attachedPrograms ?? {}) };
7097
7451
  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`);
7452
+ const attached = attachedPrograms && Object.prototype.hasOwnProperty.call(attachedPrograms, name)
7453
+ ? attachedPrograms[name]
7454
+ : undefined;
7455
+ if (attached === undefined) {
7456
+ merged[name] = definition;
7457
+ continue;
7458
+ }
7459
+ switch (compareProgramIdentity(definition, attached)) {
7460
+ case 'same':
7461
+ merged[name] = definition;
7462
+ break;
7463
+ case 'unproven':
7464
+ warnUnprovenAttachedProgram(stack.name, name, definition, attached);
7465
+ merged[name] = attached;
7466
+ break;
7467
+ case 'different':
7468
+ throw attachedProgramKeyConflict(stack.name, name, definition, attached);
7100
7469
  }
7101
- merged[name] = definition;
7102
7470
  }
7103
7471
  return merged;
7104
7472
  }
@@ -7698,6 +8066,34 @@ function resolveSessionProgramReads(stack, member, options) {
7698
8066
  }));
7699
8067
  return Object.keys(reads).length > 0 ? reads : undefined;
7700
8068
  }
8069
+ /**
8070
+ * The program read descriptor a session member connected for `stack` reads
8071
+ * `programKey` through, resolved the way `Arete.connect` resolves it: session
8072
+ * and member overrides, then the stack's `programReads`, then the descriptor
8073
+ * the program carries.
8074
+ */
8075
+ function memberProgramRead(stack, programKey, member, options) {
8076
+ return resolveSessionProgramReads(stack, member, options)?.[programKey]
8077
+ ?? stack.programReads?.[programKey]
8078
+ ?? getProgramReadDescriptor(stack.programs?.[programKey]);
8079
+ }
8080
+ /** Structural equality of two program read descriptors (plain JSON data). */
8081
+ function sameProgramRead(left, right) {
8082
+ if (left === right)
8083
+ return true;
8084
+ if (left === null || right === null
8085
+ || typeof left !== 'object' || typeof right !== 'object'
8086
+ || Array.isArray(left) !== Array.isArray(right)) {
8087
+ return false;
8088
+ }
8089
+ const leftRecord = left;
8090
+ const rightRecord = right;
8091
+ const leftKeys = Object.keys(leftRecord).filter((key) => leftRecord[key] !== undefined);
8092
+ const rightKeys = Object.keys(rightRecord).filter((key) => rightRecord[key] !== undefined);
8093
+ return leftKeys.length === rightKeys.length
8094
+ && leftKeys.every((key) => Object.prototype.hasOwnProperty.call(rightRecord, key)
8095
+ && sameProgramRead(leftRecord[key], rightRecord[key]));
8096
+ }
7701
8097
  function resolveMemberConnectOptions(stack, member, options, forceHttpOnly) {
7702
8098
  const transport = member?.transport ?? options?.transport ?? (forceHttpOnly ? 'http' : 'ws');
7703
8099
  // Stack endpoints remain on the stack definition. Only explicit session/member
@@ -7791,11 +8187,72 @@ async function createSession(definition, ...args) {
7791
8187
  validateCompositionProgramReads(programAsStack(key, program, definition.programReads?.[key]), options?.programs?.[key], options);
7792
8188
  }
7793
8189
  }
8190
+ // Program identity, resolved before anything connects. Every stack's
8191
+ // effective programs (its own plus member-attached ones, matched by
8192
+ // `withPrograms`) are the providers for each key.
8193
+ const effectiveStacks = new Map(stackEntries.map(([key, stack]) => {
8194
+ const memberOptions = options?.stacks?.[key];
8195
+ return [key, withPrograms(stack, memberOptions?.programs)];
8196
+ }));
8197
+ const stackProviders = new Map();
8198
+ for (const [stackKey, stack] of effectiveStacks) {
8199
+ for (const [programKey, program] of Object.entries(stack.programs ?? {})) {
8200
+ const providers = stackProviders.get(programKey) ?? [];
8201
+ providers.push({ stackKey, program });
8202
+ stackProviders.set(programKey, providers);
8203
+ }
8204
+ }
8205
+ // Standalone programs a stack already provides as the same program SDK, read
8206
+ // through the same program read configuration, are served by that stack's
8207
+ // connected instance instead of a second member. One with the same program
8208
+ // spec but no provable identity match, or the same program SDK with its own
8209
+ // `programReads` descriptor that differs from the stack's, gets its own
8210
+ // member and takes session.programs.<key>; the stacks keep theirs.
8211
+ const sharedStandalonePrograms = new Set();
8212
+ if (definition.mode !== 'composition') {
8213
+ for (const [programKey, program] of programEntries) {
8214
+ const providers = stackProviders.get(programKey) ?? [];
8215
+ if (providers.length === 0)
8216
+ continue;
8217
+ const matches = providers.map((provider) => ({
8218
+ ...provider,
8219
+ match: compareProgramIdentity(provider.program, program),
8220
+ }));
8221
+ const conflicting = matches.find(({ match }) => match === 'different');
8222
+ if (conflicting) {
8223
+ throw sessionProgramKeyConflict(conflicting.stackKey, programKey, conflicting.program, program);
8224
+ }
8225
+ if (matches.some(({ match }) => match === 'unproven')) {
8226
+ warnUnprovenSessionProgram(programKey, program, providers);
8227
+ continue;
8228
+ }
8229
+ const explicitRead = definition.programReads?.[programKey];
8230
+ if (explicitRead !== undefined) {
8231
+ // Sharing would read through the serving stack's configuration, so an
8232
+ // explicit descriptor must resolve to the same one.
8233
+ const servingStack = providers[0].stackKey;
8234
+ const standaloneRead = memberProgramRead(programAsStack(programKey, program, explicitRead), programKey, options?.programs?.[programKey], options);
8235
+ const stackRead = memberProgramRead(effectiveStacks.get(servingStack), programKey, options?.stacks?.[servingStack], options);
8236
+ if (!sameProgramRead(standaloneRead, stackRead)) {
8237
+ warnUnprovenSessionProgram(programKey, program, providers, 'program-read');
8238
+ continue;
8239
+ }
8240
+ }
8241
+ if (options?.programs?.[programKey] !== undefined) {
8242
+ throw new AreteError(`Standalone program '${programKey}' is the same program SDK that stack `
8243
+ + `'${providers[0].stackKey}' provides, so the session serves it from the stack's `
8244
+ + `connected instance; configure it through options.stacks.${providers[0].stackKey} `
8245
+ + `instead of options.programs.${programKey}`, 'INVALID_CONFIG');
8246
+ }
8247
+ sharedStandalonePrograms.add(programKey);
8248
+ }
8249
+ }
8250
+ const memberProgramEntries = programEntries.filter(([key]) => !sharedStandalonePrograms.has(key));
7794
8251
  let wallet = options?.wallet;
7795
8252
  const signerRegistry = options?.signerRegistry ?? createSignerRegistry();
7796
8253
  const connectedStacks = await Promise.all(stackEntries.map(async ([key, stack]) => {
7797
8254
  const memberOptions = options?.stacks?.[key];
7798
- const effectiveStack = withPrograms(stack, memberOptions?.programs);
8255
+ const effectiveStack = effectiveStacks.get(key);
7799
8256
  const connectOptions = resolveMemberConnectOptions(effectiveStack, memberOptions, {
7800
8257
  ...options,
7801
8258
  chain: sharedChain,
@@ -7808,7 +8265,7 @@ async function createSession(definition, ...args) {
7808
8265
  });
7809
8266
  return [key, client];
7810
8267
  }));
7811
- const connectedPrograms = await Promise.all(programEntries.map(async ([key, program]) => {
8268
+ const connectedPrograms = await Promise.all(memberProgramEntries.map(async ([key, program]) => {
7812
8269
  const syntheticStack = programAsStack(key, program, definition.programReads?.[key]);
7813
8270
  const connectOptions = resolveMemberConnectOptions(syntheticStack, options?.programs?.[key], {
7814
8271
  ...options,
@@ -7825,30 +8282,38 @@ async function createSession(definition, ...args) {
7825
8282
  const executionHost = memberClients[0];
7826
8283
  const transactions = sharedTransactions ?? executionHost.transactions;
7827
8284
  const stacks = Object.fromEntries(connectedStacks);
7828
- const explicitProgramKeys = new Set(connectedPrograms.map(([key]) => key));
7829
- const programOwners = new Map();
7830
8285
  const connectedProgramEntries = connectedPrograms.map(([key, client]) => [
7831
8286
  key,
7832
8287
  client.programs[key],
7833
8288
  ]);
7834
8289
  const promotedPrograms = Object.fromEntries(connectedProgramEntries);
7835
8290
  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 });
8291
+ const connectedStackClients = new Map(connectedStacks);
8292
+ for (const [programKey, providers] of stackProviders) {
8293
+ if (Object.prototype.hasOwnProperty.call(promotedPrograms, programKey))
8294
+ continue;
8295
+ const first = providers[0];
8296
+ const matches = providers.flatMap((left, index) => providers.slice(index + 1).map((right) => compareProgramIdentity(left.program, right.program)));
8297
+ if (!matches.includes('different')) {
8298
+ // One program, however many stacks bundle it: the first stack's
8299
+ // connected instance serves the top-level key. Copies with the same
8300
+ // program spec that cannot be proven identical say so once.
8301
+ if (matches.includes('unproven'))
8302
+ warnUnprovenStackPrograms(programKey, providers);
8303
+ promotedPrograms[programKey] = connectedStackClients.get(first.stackKey).programs[programKey];
8304
+ continue;
7851
8305
  }
8306
+ // Different program SDKs under one key: every stack keeps its own at
8307
+ // session.stacks.<stack>.programs.<key>, and the ambiguous top-level key
8308
+ // explains how to choose instead of silently picking one.
8309
+ const error = ambiguousSessionProgram(programKey, providers);
8310
+ Object.defineProperty(promotedPrograms, programKey, {
8311
+ get() {
8312
+ throw error;
8313
+ },
8314
+ enumerable: false,
8315
+ configurable: true,
8316
+ });
7852
8317
  }
7853
8318
  }
7854
8319
  const programs = promotedPrograms;
@@ -8090,12 +8555,14 @@ exports.AreteError = AreteError;
8090
8555
  exports.ConnectionManager = ConnectionManager;
8091
8556
  exports.DEFAULT_CONFIG = DEFAULT_CONFIG;
8092
8557
  exports.DEFAULT_MAX_ENTRIES_PER_VIEW = DEFAULT_MAX_ENTRIES_PER_VIEW;
8558
+ exports.EXTENSION_API_VERSION = EXTENSION_API_VERSION;
8093
8559
  exports.FrameProcessor = FrameProcessor;
8094
8560
  exports.InstructionError = InstructionError;
8095
8561
  exports.MemoryAdapter = MemoryAdapter;
8096
8562
  exports.OPERATION_KIND = OPERATION_KIND;
8097
8563
  exports.OperationCallbackError = OperationCallbackError;
8098
8564
  exports.OperationExecutionError = OperationExecutionError;
8565
+ exports.PROGRAM_KEY_CONFLICT = PROGRAM_KEY_CONFLICT;
8099
8566
  exports.PROGRAM_OPERATION_EXTENSIONS = PROGRAM_OPERATION_EXTENSIONS;
8100
8567
  exports.ProcessedSlotTimeoutError = ProcessedSlotTimeoutError;
8101
8568
  exports.QueryStore = QueryStore;
@@ -8119,6 +8586,7 @@ exports.buildInstruction = buildInstruction;
8119
8586
  exports.bytes = bytes;
8120
8587
  exports.canonicalQueryKey = canonicalQueryKey;
8121
8588
  exports.chainAccountLoader = chainAccountLoader;
8589
+ exports.compareProgramIdentity = compareProgramIdentity;
8122
8590
  exports.createChainClient = createChainClient;
8123
8591
  exports.createEntityStream = createEntityStream;
8124
8592
  exports.createHostedSolanaGatewayTransports = createHostedSolanaGatewayTransports;
@@ -8171,6 +8639,7 @@ exports.isEntityFrame = isEntityFrame;
8171
8639
  exports.isErrorFrame = isErrorFrame;
8172
8640
  exports.isHostedAreteEndpoint = isHostedAreteEndpoint;
8173
8641
  exports.isReplayErrorCode = isReplayErrorCode;
8642
+ exports.isSameProgramSdk = isSameProgramSdk;
8174
8643
  exports.isSnapshotFrame = isSnapshotFrame;
8175
8644
  exports.isStackVersionRefusalCode = isStackVersionRefusalCode;
8176
8645
  exports.isSubscribedFrame = isSubscribedFrame;
@@ -8210,6 +8679,7 @@ exports.unwrapOperationExecutionError = unwrapOperationExecutionError;
8210
8679
  exports.validateAccountResolution = validateAccountResolution;
8211
8680
  exports.validateProgramReadDescriptor = validateProgramReadDescriptor;
8212
8681
  exports.validateSubscriptionId = validateSubscriptionId;
8682
+ exports.withProgramIdentity = withProgramIdentity;
8213
8683
  exports.withProgramRead = withProgramRead;
8214
8684
  exports.withPrograms = withPrograms;
8215
8685
  //# sourceMappingURL=index.cjs.map