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