@bananapus/nana-sdk-core 2.34.1 → 2.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/cjs/review/index.d.ts +1 -0
  2. package/dist/cjs/review/index.d.ts.map +1 -1
  3. package/dist/cjs/review/index.js +1 -0
  4. package/dist/cjs/review/index.js.map +1 -1
  5. package/dist/cjs/review/messages.d.ts +2 -0
  6. package/dist/cjs/review/messages.d.ts.map +1 -1
  7. package/dist/cjs/review/messages.js +3 -1
  8. package/dist/cjs/review/messages.js.map +1 -1
  9. package/dist/cjs/review/writeHold.d.ts +43 -53
  10. package/dist/cjs/review/writeHold.d.ts.map +1 -1
  11. package/dist/cjs/review/writeHold.js +412 -22
  12. package/dist/cjs/review/writeHold.js.map +1 -1
  13. package/dist/cjs/review/writeIntent.d.ts +41 -0
  14. package/dist/cjs/review/writeIntent.d.ts.map +1 -0
  15. package/dist/cjs/review/writeIntent.js +77 -0
  16. package/dist/cjs/review/writeIntent.js.map +1 -0
  17. package/dist/cjs/review/writeRecovery.d.ts +21 -0
  18. package/dist/cjs/review/writeRecovery.d.ts.map +1 -1
  19. package/dist/cjs/review/writeRecovery.js +65 -0
  20. package/dist/cjs/review/writeRecovery.js.map +1 -1
  21. package/dist/esm/review/index.d.ts +1 -0
  22. package/dist/esm/review/index.d.ts.map +1 -1
  23. package/dist/esm/review/index.js +1 -0
  24. package/dist/esm/review/index.js.map +1 -1
  25. package/dist/esm/review/messages.d.ts +2 -0
  26. package/dist/esm/review/messages.d.ts.map +1 -1
  27. package/dist/esm/review/messages.js +2 -0
  28. package/dist/esm/review/messages.js.map +1 -1
  29. package/dist/esm/review/writeHold.d.ts +43 -53
  30. package/dist/esm/review/writeHold.d.ts.map +1 -1
  31. package/dist/esm/review/writeHold.js +411 -22
  32. package/dist/esm/review/writeHold.js.map +1 -1
  33. package/dist/esm/review/writeIntent.d.ts +41 -0
  34. package/dist/esm/review/writeIntent.d.ts.map +1 -0
  35. package/dist/esm/review/writeIntent.js +73 -0
  36. package/dist/esm/review/writeIntent.js.map +1 -0
  37. package/dist/esm/review/writeRecovery.d.ts +21 -0
  38. package/dist/esm/review/writeRecovery.d.ts.map +1 -1
  39. package/dist/esm/review/writeRecovery.js +65 -0
  40. package/dist/esm/review/writeRecovery.js.map +1 -1
  41. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.WriteHoldBusyError = exports.WriteHoldNonceError = exports.WriteHeldError = void 0;
3
+ exports.WriteHoldBusyError = exports.WriteHoldStaleReviewError = exports.WriteHoldNonceError = exports.WriteHeldError = void 0;
4
4
  exports.createWriteHold = createWriteHold;
5
5
  const viem_1 = require("viem");
6
6
  const safeService_js_1 = require("../safeService.js");
@@ -36,6 +36,19 @@ class WriteHoldNonceError extends Error {
36
36
  }
37
37
  }
38
38
  exports.WriteHoldNonceError = WriteHoldNonceError;
39
+ /**
40
+ * The review went stale: another window's attempt of the same write was
41
+ * reserved, and maybe settled (`settled`), while it was open. Review again;
42
+ * the next review sends normally.
43
+ */
44
+ class WriteHoldStaleReviewError extends Error {
45
+ constructor(settled) {
46
+ super(messages_js_1.WRITE_HOLD_STALE_REVIEW_LINE);
47
+ this.settled = settled;
48
+ this.name = "WriteHoldStaleReviewError";
49
+ }
50
+ }
51
+ exports.WriteHoldStaleReviewError = WriteHoldStaleReviewError;
39
52
  /** The exact call is already being sent from another flow or tab. */
40
53
  class WriteHoldBusyError extends Error {
41
54
  constructor() {
@@ -45,6 +58,8 @@ class WriteHoldBusyError extends Error {
45
58
  }
46
59
  exports.WriteHoldBusyError = WriteHoldBusyError;
47
60
  const SETTLE_POLL_MS = 15_000;
61
+ /** How long a reservation retries a lock another writer briefly holds. */
62
+ const RESERVE_RETRY_MS = 2_000;
48
63
  /** A Safe's execution events, in its 1.3 and 1.4 layouts alike. */
49
64
  const SAFE_EXECUTION_TOPICS = [
50
65
  (0, viem_1.toEventSelector)("ExecutionSuccess(bytes32,uint256)"),
@@ -99,8 +114,12 @@ function unchecked(record) {
99
114
  }
100
115
  /** About an hour of polls; a later state() looks again. */
101
116
  const SETTLE_POLL_ATTEMPTS = 240;
102
- function clear(settled) {
103
- return settled ? { status: "clear", settled } : { status: "clear" };
117
+ function clear(settled, reservation) {
118
+ return {
119
+ status: "clear",
120
+ ...(settled ? { settled } : {}),
121
+ ...(reservation === undefined ? {} : { reservation }),
122
+ };
104
123
  }
105
124
  function ranExecuted(record, executionHash = record.executionHash, result) {
106
125
  return {
@@ -170,6 +189,17 @@ function createWriteHold(options = {}) {
170
189
  const peekers = new Map();
171
190
  const timers = new Map();
172
191
  let onStorage;
192
+ /**
193
+ * The one decorator every state leaves through for its storage notice:
194
+ * while holds last in this tab alone, a held state carries
195
+ * {@link WRITE_HOLD_IN_TAB_LINE}; otherwise none.
196
+ */
197
+ function noticed(state) {
198
+ const { notice: _, ...rest } = state;
199
+ return state.status !== "clear" && !storage.durable()
200
+ ? { ...rest, notice: messages_js_1.WRITE_HOLD_IN_TAB_LINE }
201
+ : rest;
202
+ }
173
203
  /** The latest state emitted for each call, replayed to a late subscriber. */
174
204
  const latest = new Map();
175
205
  function emit(key, state) {
@@ -182,7 +212,9 @@ function createWriteHold(options = {}) {
182
212
  if (onStorage || typeof globalThis.addEventListener !== "function")
183
213
  return;
184
214
  onStorage = (event) => {
185
- if (event.key && !event.key.startsWith("nana-sdk:reviewed-write"))
215
+ if (event.key &&
216
+ (!event.key.startsWith("nana-sdk:reviewed-write") ||
217
+ event.key === "nana-sdk:reviewed-write:probe"))
186
218
  return;
187
219
  for (const [key, peek] of peekers) {
188
220
  try {
@@ -195,7 +227,7 @@ function createWriteHold(options = {}) {
195
227
  };
196
228
  globalThis.addEventListener("storage", onStorage);
197
229
  }
198
- function open(call) {
230
+ function openOne(call, hooks = {}) {
199
231
  const recovery = (0, writeRecovery_js_1.createBrowserWriteRecovery)(call, {
200
232
  storage,
201
233
  locks: options.locks,
@@ -206,11 +238,26 @@ function createWriteHold(options = {}) {
206
238
  let lastEffect;
207
239
  /** Nothing held: how the call's last write settled, if saved. */
208
240
  const cleared = () => clear(recovery.settled());
241
+ /** Every state leaves with the reservation it belongs to. */
242
+ const tagged = (state) => {
243
+ const reservation = state.reservation ??
244
+ (state.status === "clear"
245
+ ? recovery.latestReservation()
246
+ : state.record.id);
247
+ return noticed({
248
+ ...state,
249
+ ...(reservation === undefined ? {} : { reservation }),
250
+ });
251
+ };
209
252
  const peek = () => {
210
253
  const record = recovery.read();
211
- return record ? ungated(classify(record)) : cleared();
254
+ return tagged(record ? ungated(classify(record)) : cleared());
255
+ };
256
+ const shown = (state) => {
257
+ const out = tagged(state);
258
+ emit(key, out);
259
+ return out;
212
260
  };
213
- const shown = (state) => (emit(key, state), state);
214
261
  async function locked(work, wait) {
215
262
  let entered = false;
216
263
  try {
@@ -259,7 +306,7 @@ function createWriteHold(options = {}) {
259
306
  };
260
307
  // Kept for any later reader: another tab, a reload, a follow timer.
261
308
  recovery.markSettled(record, settled);
262
- return clear(settled);
309
+ return clear(settled, record.id);
263
310
  }
264
311
  if (!record.safe &&
265
312
  receipt.status === "reverted" &&
@@ -351,8 +398,22 @@ function createWriteHold(options = {}) {
351
398
  // Only positive pending evidence stops the clock; a read that fails
352
399
  // keeps the one saved.
353
400
  if (look === "pending")
354
- return { ...state, record: recovery.mined(state.record, false) };
401
+ return {
402
+ ...state,
403
+ record: recovery.seenPending(recovery.mined(state.record, false)),
404
+ };
355
405
  const record = look === "mined" ? recovery.mined(state.record, true) : state.record;
406
+ // Unknown or not found is not pending: with no read showing it pending
407
+ // or mined for 30 minutes (since it was last seen pending, or else
408
+ // submitted), the warned explicit exit, never a release on its own. A
409
+ // Safe proposal's hash isn't a transaction to read.
410
+ if ((look === "unknown" || look === null) &&
411
+ !record.safe &&
412
+ record.minedAt === undefined &&
413
+ Date.now() -
414
+ Number(record.pendingSeenAt ?? record.submittedAt ?? record.firstSeenAt) >=
415
+ UNSEEN_HASH_MS)
416
+ return unchecked(record);
356
417
  if (record.minedAt === undefined)
357
418
  return state;
358
419
  if (Date.now() - Number(record.minedAt) >= UNSEEN_HASH_MS)
@@ -361,7 +422,9 @@ function createWriteHold(options = {}) {
361
422
  }
362
423
  /** What the node shows of `hash`: positive pending or mined metadata, or nothing it can say. */
363
424
  async function lookAt(hash, client) {
364
- const sent = await client.getTransaction({ hash }).catch(() => null);
425
+ const sent = await client.getTransaction({ hash }).then((tx) => tx, (error) => error?.name === "TransactionNotFoundError" ? null : undefined);
426
+ if (sent === undefined)
427
+ return "unknown";
365
428
  if (sent?.hash?.toLowerCase() !== hash)
366
429
  return null;
367
430
  return sent.blockNumber === null ? "pending" : "mined";
@@ -382,7 +445,8 @@ function createWriteHold(options = {}) {
382
445
  for (const hash of seen) {
383
446
  const own = hash === record.hash;
384
447
  const look = await lookAt(hash, client);
385
- if (look !== null)
448
+ // Unknown changes nothing: only positive metadata moves a clock.
449
+ if (look === "pending" || look === "mined")
386
450
  record = own
387
451
  ? recovery.mined(record, look === "mined")
388
452
  : recovery.candidateMined(record, hash, look === "mined");
@@ -480,7 +544,7 @@ function createWriteHold(options = {}) {
480
544
  if (end.look === "expired" && !record.executed) {
481
545
  const expired = await (0, writeRecovery_js_1.verifyReviewedWriteExpiry)(client, record, options.safeService).catch(() => false);
482
546
  if (expired && release(record))
483
- return clear({ result: "expired" });
547
+ return clear({ result: "expired" }, record.id);
484
548
  }
485
549
  if (end.look === "executed") {
486
550
  if (!record.executed ||
@@ -502,7 +566,7 @@ function createWriteHold(options = {}) {
502
566
  hash: end.execution.executionHash,
503
567
  };
504
568
  recovery.markSettled(record, settled);
505
- return clear(settled);
569
+ return clear(settled, record.id);
506
570
  }
507
571
  }
508
572
  if (!record.executed &&
@@ -543,6 +607,9 @@ function createWriteHold(options = {}) {
543
607
  let record = recovery.read();
544
608
  if (!record)
545
609
  return cleared();
610
+ // A mirror carries no evidence of its own: its source attempt does.
611
+ if (record.mirrorOf)
612
+ return classify(record);
546
613
  // No evidence from another chain.
547
614
  if ((await client.getChainId().catch(() => null)) !== record.chainId)
548
615
  return classify(record);
@@ -644,8 +711,11 @@ function createWriteHold(options = {}) {
644
711
  if (last) {
645
712
  const now = peek();
646
713
  // A clear is compared by its settlement's identity, a hold by its record.
647
- const identity = (state) => JSON.stringify(state.status === "clear" ? (state.settled ?? null) : state.record);
648
- listener(ungated(identity(now) === identity(last) ? last : now));
714
+ const identity = (state) => JSON.stringify([
715
+ state.reservation ?? null,
716
+ state.status === "clear" ? (state.settled ?? null) : state.record,
717
+ ]);
718
+ listener(noticed(ungated(identity(now) === identity(last) ? last : now)));
649
719
  }
650
720
  return () => {
651
721
  set.delete(listener);
@@ -664,6 +734,10 @@ function createWriteHold(options = {}) {
664
734
  */
665
735
  async send(client, run) {
666
736
  let attempt;
737
+ let ended;
738
+ const until = new Promise((resolve) => (ended = resolve));
739
+ let released;
740
+ let reviewed = "";
667
741
  const gate = {
668
742
  async beforeSend(actualCall) {
669
743
  let evidence;
@@ -677,12 +751,31 @@ function createWriteHold(options = {}) {
677
751
  throw new WriteHoldNonceError(cause);
678
752
  }
679
753
  }
680
- try {
754
+ const reserve = () => {
681
755
  attempt = recovery.reserve(actualCall, evidence);
756
+ };
757
+ try {
758
+ if (hooks.across)
759
+ ({ released } = await hooks.across((work) => recovery.withLock(work), reserve, () => {
760
+ recovery.rejected();
761
+ attempt = undefined;
762
+ }, until, reviewed));
763
+ else
764
+ reserve();
682
765
  }
683
766
  catch (error) {
684
- const now = await finish(classify(recovery.read()), client);
685
- shown(now);
767
+ if (error instanceof WriteHeldError ||
768
+ error instanceof WriteHoldBusyError ||
769
+ error instanceof WriteHoldStaleReviewError)
770
+ throw error;
771
+ if (hooks.across) {
772
+ // No lock is held here: what the saved record says.
773
+ const now = shown(peek());
774
+ if (now.status !== "clear")
775
+ throw new WriteHeldError(now);
776
+ throw error;
777
+ }
778
+ const now = shown(await finish(classify(recovery.read()), client));
686
779
  if (now.status !== "clear")
687
780
  throw new WriteHeldError(now);
688
781
  throw error;
@@ -717,6 +810,25 @@ function createWriteHold(options = {}) {
717
810
  shown(peek());
718
811
  },
719
812
  };
813
+ if (hooks.across) {
814
+ // Checked under this key's lock, which is then let go: the final
815
+ // check and reservation take every lock again, in order.
816
+ const checked = await locked(async () => shown(await inspect(client)), false);
817
+ if (!checked)
818
+ throw new WriteHoldBusyError();
819
+ if (checked.value.status !== "clear")
820
+ throw new WriteHeldError(checked.value);
821
+ // Given with `across`: the generations the review is checked against.
822
+ reviewed = hooks.generations();
823
+ try {
824
+ return await run(gate);
825
+ }
826
+ finally {
827
+ // The send ends: every lock it reserved under is let go first.
828
+ ended();
829
+ await released;
830
+ }
831
+ }
720
832
  const result = await locked(async () => {
721
833
  const now = shown(await inspect(client));
722
834
  if (now.status !== "clear")
@@ -753,11 +865,15 @@ function createWriteHold(options = {}) {
753
865
  state: await finish(classify(recovery.read()), client),
754
866
  };
755
867
  }
756
- return { released: true, state: clear() };
757
- }, true);
868
+ return {
869
+ released: true,
870
+ state: clear(undefined, snapshot.record.id),
871
+ };
872
+ // Never waits behind another send's wallet prompt: on contention,
873
+ // what the saved record says, with no exit.
874
+ }, false);
758
875
  const ended = result?.value ?? { released: false, state: peek() };
759
- shown(ended.state);
760
- return ended;
876
+ return { ...ended, state: shown(ended.state) };
761
877
  },
762
878
  /**
763
879
  * A receipt for the held write: an ordinary write's own hash, or a Safe
@@ -768,6 +884,7 @@ function createWriteHold(options = {}) {
768
884
  const result = await locked(async () => {
769
885
  const record = recovery.read();
770
886
  if (!record ||
887
+ record.mirrorOf ||
771
888
  (!record.safe &&
772
889
  record.hash !== receipt?.transactionHash?.toLowerCase()))
773
890
  return finish(classify(record), client);
@@ -806,6 +923,7 @@ function createWriteHold(options = {}) {
806
923
  if (record &&
807
924
  !record.safe &&
808
925
  !record.hash &&
926
+ !record.mirrorOf &&
809
927
  (0, untrusted_js_1.isBytes32)(hash) &&
810
928
  (await client.getChainId().catch(() => null)) === record.chainId) {
811
929
  const sent = await client
@@ -869,6 +987,278 @@ function createWriteHold(options = {}) {
869
987
  },
870
988
  };
871
989
  }
990
+ /**
991
+ * `legacy`: for a write held under an intent namespace, the keys an earlier
992
+ * version held it under (an old namespace, or `{ call }` for an exact
993
+ * call); the current exact call is always checked. While any of them holds
994
+ * an unresolved write, the intent handle *is* that hold: its state, its
995
+ * refusal and its own exit, in place. Each is checked alone, under its own
996
+ * lock only, never two at once. Only when all are clear does the intent's
997
+ * own record apply, and only then can a send reserve; they are checked
998
+ * again right before it does.
999
+ */
1000
+ function open(call, { legacy = [], } = {}) {
1001
+ if (call.namespace === undefined)
1002
+ return openExact(call);
1003
+ const namespace = call.namespace;
1004
+ const own = openOne(call, {
1005
+ across: reserveAcross,
1006
+ generations: () => generations(),
1007
+ });
1008
+ const ownJournal = (0, writeRecovery_js_1.createBrowserWriteRecovery)(call, { storage });
1009
+ /** Every involved key's latest reservation id, its generation. */
1010
+ const generations = () => JSON.stringify([ownJournal, ...journals].map((journal) => journal.latestReservation()));
1011
+ const { namespace: _, ...exact } = call;
1012
+ const scopeOf = (input) => (0, writeRecovery_js_1.createBrowserWriteRecovery)(input, { storage }).scopeKey;
1013
+ const scopes = new Set([scopeOf(call)]);
1014
+ const inputs = [{ call: call.call }, ...legacy]
1015
+ .map((source) => typeof source === "string"
1016
+ ? { ...call, namespace: source }
1017
+ : { ...exact, call: source.call })
1018
+ .filter((input) => {
1019
+ const scope = scopeOf(input);
1020
+ return !scopes.has(scope) && !!scopes.add(scope);
1021
+ })
1022
+ // One canonical order, by scope, for every writer's locks.
1023
+ .sort((a, b) => Number(scopeOf(a) > scopeOf(b)) - Number(scopeOf(a) < scopeOf(b)));
1024
+ // Mirror-aware: evidence and exits always act on the source attempt.
1025
+ const delegates = inputs.map((input) => openExact(input));
1026
+ const journals = inputs.map((input) => (0, writeRecovery_js_1.createBrowserWriteRecovery)(input, { storage, locks: options.locks }));
1027
+ /** A legacy key holding anything but this intent's own mirror. */
1028
+ const foreign = (index) => journals[index].read()?.mirrorOf !== namespace &&
1029
+ delegates[index].peek().status !== "clear";
1030
+ /** The legacy hold the intent stands for right now, from storage alone. */
1031
+ const active = () => delegates.find((_, index) => foreign(index));
1032
+ /** The first legacy key holding an unresolved write, through its own gate. */
1033
+ async function delegated(client) {
1034
+ for (const [index, handle] of delegates.entries()) {
1035
+ if (!foreign(index))
1036
+ continue;
1037
+ const state = await handle.state(client);
1038
+ if (state.status !== "clear")
1039
+ return state;
1040
+ }
1041
+ return null;
1042
+ }
1043
+ /**
1044
+ * The final check and reservation, under every legacy key's lock taken
1045
+ * in one sorted order (the intent's own is held by its send), each
1046
+ * without waiting: a key another writer holds refuses as busy. A legacy
1047
+ * hold refuses; otherwise the intent reserves, and mirrors itself at each
1048
+ * legacy key so its readers, older versions included, see it held.
1049
+ */
1050
+ async function reserveAcross(ownLock, reserve, unreserve, until, reviewed) {
1051
+ const locks = [
1052
+ { scope: scopeOf(call), take: ownLock },
1053
+ ...journals.map((journal) => ({
1054
+ scope: journal.scopeKey,
1055
+ take: (work) => journal.withLock(work),
1056
+ })),
1057
+ ].sort((a, b) => Number(a.scope > b.scope) - Number(a.scope < b.scope));
1058
+ const deadline = Date.now() + RESERVE_RETRY_MS;
1059
+ for (;;) {
1060
+ let entered = 0;
1061
+ let reserved;
1062
+ const done = new Promise((resolve) => (reserved = resolve));
1063
+ const acquire = async (index) => {
1064
+ if (index < locks.length)
1065
+ return locks[index].take(async () => {
1066
+ entered += 1;
1067
+ return acquire(index + 1);
1068
+ });
1069
+ for (const journal of journals) {
1070
+ const held = journal.read();
1071
+ if (held && held.mirrorOf !== namespace)
1072
+ throw new WriteHeldError(noticed({
1073
+ ...ungated(classify(held)),
1074
+ reservation: held.id,
1075
+ }));
1076
+ }
1077
+ // Another attempt since this review opened: review it again.
1078
+ if (generations() !== reviewed)
1079
+ throw new WriteHoldStaleReviewError(ownJournal.settled());
1080
+ reserve();
1081
+ const record = own.peek().record;
1082
+ // Every mirror is saved, in the store the hold itself uses (its
1083
+ // memory fallback included), before the wallet opens; otherwise
1084
+ // this unsent reservation and its partial mirrors are undone.
1085
+ const mirrors = [];
1086
+ try {
1087
+ for (const journal of journals)
1088
+ mirrors.push([journal, journal.mirror(record, namespace)]);
1089
+ }
1090
+ catch (error) {
1091
+ for (const [journal, mirror] of mirrors)
1092
+ try {
1093
+ journal.clear(mirror);
1094
+ }
1095
+ catch {
1096
+ /* Held for old readers until their explicit exit. */
1097
+ }
1098
+ try {
1099
+ unreserve();
1100
+ }
1101
+ catch {
1102
+ /* Unsent, it stays held with its explicit exit. */
1103
+ }
1104
+ throw error;
1105
+ }
1106
+ // Reserved: every lock is kept until the send ends.
1107
+ reserved();
1108
+ await until;
1109
+ };
1110
+ const holding = acquire(0);
1111
+ const failed = await Promise.race([
1112
+ done.then(() => null),
1113
+ holding.then(() => null, (error) => ({ error })),
1114
+ ]);
1115
+ if (!failed)
1116
+ return { released: holding.catch(() => { }) };
1117
+ // Not reserved: every lock, its own included, is let go before any
1118
+ // retry; the checks run again each time.
1119
+ const { error } = failed;
1120
+ if (entered === locks.length ||
1121
+ error instanceof WriteHeldError ||
1122
+ error instanceof WriteHoldStaleReviewError)
1123
+ throw error;
1124
+ if (Date.now() >= deadline)
1125
+ throw new WriteHoldBusyError();
1126
+ await new Promise((resolve) => setTimeout(resolve, 25 + Math.random() * 75));
1127
+ }
1128
+ }
1129
+ /** Remove this intent's mirrors of the reservation that ended, by its id only. */
1130
+ async function clearMirrors(ended) {
1131
+ for (const journal of journals) {
1132
+ if (journal.read()?.id !== ended)
1133
+ continue;
1134
+ await journal
1135
+ .withLock(async () => {
1136
+ const current = journal.read();
1137
+ if (current?.mirrorOf === namespace && current.id === ended)
1138
+ journal.clear(current);
1139
+ })
1140
+ .catch(() => {
1141
+ /* Kept for old readers until their explicit exit. */
1142
+ });
1143
+ }
1144
+ }
1145
+ /** After a call that ended this intent's reservation, its mirrors go. */
1146
+ const after = async (value) => {
1147
+ if (value.status === "clear" && value.reservation)
1148
+ await clearMirrors(value.reservation);
1149
+ return value;
1150
+ };
1151
+ return {
1152
+ ...own,
1153
+ peek: () => active()?.peek() ?? own.peek(),
1154
+ async state(client, checks) {
1155
+ return ((await delegated(client)) ?? after(await own.state(client, checks)));
1156
+ },
1157
+ async send(client, run) {
1158
+ const held = await delegated(client);
1159
+ if (held)
1160
+ throw new WriteHeldError(held);
1161
+ return own.send(client, run);
1162
+ },
1163
+ async exit(snapshot, acknowledgement, client) {
1164
+ const owner = snapshot.status === "clear"
1165
+ ? undefined
1166
+ : delegates.find((handle, index) => {
1167
+ const now = handle.peek();
1168
+ return (foreign(index) &&
1169
+ now.status !== "clear" &&
1170
+ now.record.id === snapshot.record.id);
1171
+ });
1172
+ if (owner)
1173
+ return owner.exit(snapshot, acknowledgement, client);
1174
+ const held = await delegated(client);
1175
+ if (held)
1176
+ return { released: false, state: held };
1177
+ const ended = await own.exit(snapshot, acknowledgement, client);
1178
+ await after(ended.state);
1179
+ return ended;
1180
+ },
1181
+ onReceipt: async (...args) => active()?.onReceipt(...args) ?? after(await own.onReceipt(...args)),
1182
+ onUnproven: async (...args) => active()?.onUnproven(...args) ?? after(await own.onUnproven(...args)),
1183
+ onExecuted: async (...args) => active()?.onExecuted(...args) ?? after(await own.onExecuted(...args)),
1184
+ attachHash: async (...args) => active()?.attachHash(...args) ?? after(await own.attachHash(...args)),
1185
+ };
1186
+ }
1187
+ /**
1188
+ * An exact-call handle. When its key holds a mirror of an intent's write,
1189
+ * it is that intent's hold: it never sends or releases on its own, and its
1190
+ * exit is the intent's. A mirror left after the intent ended is removed.
1191
+ */
1192
+ function openExact(call) {
1193
+ const own = openOne(call);
1194
+ const journal = (0, writeRecovery_js_1.createBrowserWriteRecovery)(call, {
1195
+ storage,
1196
+ locks: options.locks,
1197
+ });
1198
+ /** The intent this key mirrors, from storage alone. */
1199
+ const mirrored = () => {
1200
+ const record = journal.read();
1201
+ return record?.mirrorOf
1202
+ ? { record, intent: openOne({ ...call, namespace: record.mirrorOf }) }
1203
+ : undefined;
1204
+ };
1205
+ /** Remove a mirror whose intent holds nothing any more. */
1206
+ async function clearStale(found) {
1207
+ await journal
1208
+ .withLock(async () => {
1209
+ const current = journal.read();
1210
+ if (current?.id === found.record.id)
1211
+ journal.clear(current);
1212
+ })
1213
+ .catch(() => {
1214
+ /* The stale mirror still holds; its intent is clear. */
1215
+ });
1216
+ }
1217
+ /** The intent's state while it holds anything: this key is its mirror. */
1218
+ async function intentState(client) {
1219
+ const found = mirrored();
1220
+ if (!found)
1221
+ return null;
1222
+ const state = await found.intent.state(client);
1223
+ if (state.status !== "clear")
1224
+ return state;
1225
+ await clearStale(found);
1226
+ return null;
1227
+ }
1228
+ /** The intent in charge, from storage alone. */
1229
+ const route = () => {
1230
+ const found = mirrored();
1231
+ return found && found.intent.peek().status !== "clear"
1232
+ ? found.intent
1233
+ : undefined;
1234
+ };
1235
+ return {
1236
+ ...own,
1237
+ peek: () => route()?.peek() ?? own.peek(),
1238
+ async state(client, checks) {
1239
+ return (await intentState(client)) ?? own.state(client, checks);
1240
+ },
1241
+ async send(client, run) {
1242
+ const held = await intentState(client);
1243
+ if (held)
1244
+ throw new WriteHeldError(held);
1245
+ return own.send(client, run);
1246
+ },
1247
+ async exit(snapshot, acknowledgement, client) {
1248
+ const found = mirrored();
1249
+ if (!found || found.intent.peek().status === "clear")
1250
+ return own.exit(snapshot, acknowledgement, client);
1251
+ const ended = await found.intent.exit(snapshot, acknowledgement, client);
1252
+ if (ended.released)
1253
+ await clearStale(found);
1254
+ return ended;
1255
+ },
1256
+ onReceipt: (...args) => (route() ?? own).onReceipt(...args),
1257
+ onUnproven: (...args) => (route() ?? own).onUnproven(...args),
1258
+ onExecuted: (...args) => (route() ?? own).onExecuted(...args),
1259
+ attachHash: (...args) => (route() ?? own).attachHash(...args),
1260
+ };
1261
+ }
872
1262
  return {
873
1263
  open,
874
1264
  /** Whether holds outlive this tab right now. */