@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
  import { isAddress, toEventSelector, } from "viem";
2
2
  import { canonicalSafeTxHash, lookAtSafeProposalEnd, provenSafeProposalEnd, readSafeTransaction, SAFE_PROPOSAL_AWAITING, safeTransactionMessage, } from "../safeService.js";
3
- import { SAFE_PROPOSAL_EXECUTED_LINE, WRITE_HOLD_HASHED_EXIT_LINE, WRITE_HOLD_IN_TAB_LINE, WRITE_HOLD_LOOKS_DONE_LINE, WRITE_HOLD_MINED_LINE, WRITE_HOLD_NONCE_LINE, WRITE_HOLD_PENDING_LINE, WRITE_HOLD_UNCHECKED_LINE, WRITE_HOLD_REVERTED_LINE, WRITE_HOLD_SAFE_REPLACED_LINE, WRITE_HOLD_SAFE_UNPROVEN_LINE, writeHeldMessage, } from "./messages.js";
3
+ import { SAFE_PROPOSAL_EXECUTED_LINE, WRITE_HOLD_HASHED_EXIT_LINE, WRITE_HOLD_IN_TAB_LINE, WRITE_HOLD_LOOKS_DONE_LINE, WRITE_HOLD_MINED_LINE, WRITE_HOLD_NONCE_LINE, WRITE_HOLD_PENDING_LINE, WRITE_HOLD_UNCHECKED_LINE, WRITE_HOLD_REVERTED_LINE, WRITE_HOLD_SAFE_REPLACED_LINE, WRITE_HOLD_SAFE_UNPROVEN_LINE, WRITE_HOLD_STALE_REVIEW_LINE, writeHeldMessage, } from "./messages.js";
4
4
  import { createBrowserWriteRecovery, createHeldWriteStorage, findTransactionAtNonce, readReviewedWriteNonce, SubmittedWritePersistenceError, verifyReviewedWriteExpiry, verifyReviewedWriteReceipt, } from "./writeRecovery.js";
5
5
  import { isBytes32 } from "../untrusted.js";
6
6
  import { atCanonicalFinalizedBlock } from "./relayr.js";
@@ -30,6 +30,18 @@ export class WriteHoldNonceError extends Error {
30
30
  this.name = "WriteHoldNonceError";
31
31
  }
32
32
  }
33
+ /**
34
+ * The review went stale: another window's attempt of the same write was
35
+ * reserved, and maybe settled (`settled`), while it was open. Review again;
36
+ * the next review sends normally.
37
+ */
38
+ export class WriteHoldStaleReviewError extends Error {
39
+ constructor(settled) {
40
+ super(WRITE_HOLD_STALE_REVIEW_LINE);
41
+ this.settled = settled;
42
+ this.name = "WriteHoldStaleReviewError";
43
+ }
44
+ }
33
45
  /** The exact call is already being sent from another flow or tab. */
34
46
  export class WriteHoldBusyError extends Error {
35
47
  constructor() {
@@ -38,6 +50,8 @@ export class WriteHoldBusyError extends Error {
38
50
  }
39
51
  }
40
52
  const SETTLE_POLL_MS = 15_000;
53
+ /** How long a reservation retries a lock another writer briefly holds. */
54
+ const RESERVE_RETRY_MS = 2_000;
41
55
  /** A Safe's execution events, in its 1.3 and 1.4 layouts alike. */
42
56
  const SAFE_EXECUTION_TOPICS = [
43
57
  toEventSelector("ExecutionSuccess(bytes32,uint256)"),
@@ -92,8 +106,12 @@ function unchecked(record) {
92
106
  }
93
107
  /** About an hour of polls; a later state() looks again. */
94
108
  const SETTLE_POLL_ATTEMPTS = 240;
95
- function clear(settled) {
96
- return settled ? { status: "clear", settled } : { status: "clear" };
109
+ function clear(settled, reservation) {
110
+ return {
111
+ status: "clear",
112
+ ...(settled ? { settled } : {}),
113
+ ...(reservation === undefined ? {} : { reservation }),
114
+ };
97
115
  }
98
116
  function ranExecuted(record, executionHash = record.executionHash, result) {
99
117
  return {
@@ -163,6 +181,17 @@ export function createWriteHold(options = {}) {
163
181
  const peekers = new Map();
164
182
  const timers = new Map();
165
183
  let onStorage;
184
+ /**
185
+ * The one decorator every state leaves through for its storage notice:
186
+ * while holds last in this tab alone, a held state carries
187
+ * {@link WRITE_HOLD_IN_TAB_LINE}; otherwise none.
188
+ */
189
+ function noticed(state) {
190
+ const { notice: _, ...rest } = state;
191
+ return state.status !== "clear" && !storage.durable()
192
+ ? { ...rest, notice: WRITE_HOLD_IN_TAB_LINE }
193
+ : rest;
194
+ }
166
195
  /** The latest state emitted for each call, replayed to a late subscriber. */
167
196
  const latest = new Map();
168
197
  function emit(key, state) {
@@ -175,7 +204,9 @@ export function createWriteHold(options = {}) {
175
204
  if (onStorage || typeof globalThis.addEventListener !== "function")
176
205
  return;
177
206
  onStorage = (event) => {
178
- if (event.key && !event.key.startsWith("nana-sdk:reviewed-write"))
207
+ if (event.key &&
208
+ (!event.key.startsWith("nana-sdk:reviewed-write") ||
209
+ event.key === "nana-sdk:reviewed-write:probe"))
179
210
  return;
180
211
  for (const [key, peek] of peekers) {
181
212
  try {
@@ -188,7 +219,7 @@ export function createWriteHold(options = {}) {
188
219
  };
189
220
  globalThis.addEventListener("storage", onStorage);
190
221
  }
191
- function open(call) {
222
+ function openOne(call, hooks = {}) {
192
223
  const recovery = createBrowserWriteRecovery(call, {
193
224
  storage,
194
225
  locks: options.locks,
@@ -199,11 +230,26 @@ export function createWriteHold(options = {}) {
199
230
  let lastEffect;
200
231
  /** Nothing held: how the call's last write settled, if saved. */
201
232
  const cleared = () => clear(recovery.settled());
233
+ /** Every state leaves with the reservation it belongs to. */
234
+ const tagged = (state) => {
235
+ const reservation = state.reservation ??
236
+ (state.status === "clear"
237
+ ? recovery.latestReservation()
238
+ : state.record.id);
239
+ return noticed({
240
+ ...state,
241
+ ...(reservation === undefined ? {} : { reservation }),
242
+ });
243
+ };
202
244
  const peek = () => {
203
245
  const record = recovery.read();
204
- return record ? ungated(classify(record)) : cleared();
246
+ return tagged(record ? ungated(classify(record)) : cleared());
247
+ };
248
+ const shown = (state) => {
249
+ const out = tagged(state);
250
+ emit(key, out);
251
+ return out;
205
252
  };
206
- const shown = (state) => (emit(key, state), state);
207
253
  async function locked(work, wait) {
208
254
  let entered = false;
209
255
  try {
@@ -252,7 +298,7 @@ export function createWriteHold(options = {}) {
252
298
  };
253
299
  // Kept for any later reader: another tab, a reload, a follow timer.
254
300
  recovery.markSettled(record, settled);
255
- return clear(settled);
301
+ return clear(settled, record.id);
256
302
  }
257
303
  if (!record.safe &&
258
304
  receipt.status === "reverted" &&
@@ -344,8 +390,22 @@ export function createWriteHold(options = {}) {
344
390
  // Only positive pending evidence stops the clock; a read that fails
345
391
  // keeps the one saved.
346
392
  if (look === "pending")
347
- return { ...state, record: recovery.mined(state.record, false) };
393
+ return {
394
+ ...state,
395
+ record: recovery.seenPending(recovery.mined(state.record, false)),
396
+ };
348
397
  const record = look === "mined" ? recovery.mined(state.record, true) : state.record;
398
+ // Unknown or not found is not pending: with no read showing it pending
399
+ // or mined for 30 minutes (since it was last seen pending, or else
400
+ // submitted), the warned explicit exit, never a release on its own. A
401
+ // Safe proposal's hash isn't a transaction to read.
402
+ if ((look === "unknown" || look === null) &&
403
+ !record.safe &&
404
+ record.minedAt === undefined &&
405
+ Date.now() -
406
+ Number(record.pendingSeenAt ?? record.submittedAt ?? record.firstSeenAt) >=
407
+ UNSEEN_HASH_MS)
408
+ return unchecked(record);
349
409
  if (record.minedAt === undefined)
350
410
  return state;
351
411
  if (Date.now() - Number(record.minedAt) >= UNSEEN_HASH_MS)
@@ -354,7 +414,9 @@ export function createWriteHold(options = {}) {
354
414
  }
355
415
  /** What the node shows of `hash`: positive pending or mined metadata, or nothing it can say. */
356
416
  async function lookAt(hash, client) {
357
- const sent = await client.getTransaction({ hash }).catch(() => null);
417
+ const sent = await client.getTransaction({ hash }).then((tx) => tx, (error) => error?.name === "TransactionNotFoundError" ? null : undefined);
418
+ if (sent === undefined)
419
+ return "unknown";
358
420
  if (sent?.hash?.toLowerCase() !== hash)
359
421
  return null;
360
422
  return sent.blockNumber === null ? "pending" : "mined";
@@ -375,7 +437,8 @@ export function createWriteHold(options = {}) {
375
437
  for (const hash of seen) {
376
438
  const own = hash === record.hash;
377
439
  const look = await lookAt(hash, client);
378
- if (look !== null)
440
+ // Unknown changes nothing: only positive metadata moves a clock.
441
+ if (look === "pending" || look === "mined")
379
442
  record = own
380
443
  ? recovery.mined(record, look === "mined")
381
444
  : recovery.candidateMined(record, hash, look === "mined");
@@ -473,7 +536,7 @@ export function createWriteHold(options = {}) {
473
536
  if (end.look === "expired" && !record.executed) {
474
537
  const expired = await verifyReviewedWriteExpiry(client, record, options.safeService).catch(() => false);
475
538
  if (expired && release(record))
476
- return clear({ result: "expired" });
539
+ return clear({ result: "expired" }, record.id);
477
540
  }
478
541
  if (end.look === "executed") {
479
542
  if (!record.executed ||
@@ -495,7 +558,7 @@ export function createWriteHold(options = {}) {
495
558
  hash: end.execution.executionHash,
496
559
  };
497
560
  recovery.markSettled(record, settled);
498
- return clear(settled);
561
+ return clear(settled, record.id);
499
562
  }
500
563
  }
501
564
  if (!record.executed &&
@@ -536,6 +599,9 @@ export function createWriteHold(options = {}) {
536
599
  let record = recovery.read();
537
600
  if (!record)
538
601
  return cleared();
602
+ // A mirror carries no evidence of its own: its source attempt does.
603
+ if (record.mirrorOf)
604
+ return classify(record);
539
605
  // No evidence from another chain.
540
606
  if ((await client.getChainId().catch(() => null)) !== record.chainId)
541
607
  return classify(record);
@@ -637,8 +703,11 @@ export function createWriteHold(options = {}) {
637
703
  if (last) {
638
704
  const now = peek();
639
705
  // A clear is compared by its settlement's identity, a hold by its record.
640
- const identity = (state) => JSON.stringify(state.status === "clear" ? (state.settled ?? null) : state.record);
641
- listener(ungated(identity(now) === identity(last) ? last : now));
706
+ const identity = (state) => JSON.stringify([
707
+ state.reservation ?? null,
708
+ state.status === "clear" ? (state.settled ?? null) : state.record,
709
+ ]);
710
+ listener(noticed(ungated(identity(now) === identity(last) ? last : now)));
642
711
  }
643
712
  return () => {
644
713
  set.delete(listener);
@@ -657,6 +726,10 @@ export function createWriteHold(options = {}) {
657
726
  */
658
727
  async send(client, run) {
659
728
  let attempt;
729
+ let ended;
730
+ const until = new Promise((resolve) => (ended = resolve));
731
+ let released;
732
+ let reviewed = "";
660
733
  const gate = {
661
734
  async beforeSend(actualCall) {
662
735
  let evidence;
@@ -670,12 +743,31 @@ export function createWriteHold(options = {}) {
670
743
  throw new WriteHoldNonceError(cause);
671
744
  }
672
745
  }
673
- try {
746
+ const reserve = () => {
674
747
  attempt = recovery.reserve(actualCall, evidence);
748
+ };
749
+ try {
750
+ if (hooks.across)
751
+ ({ released } = await hooks.across((work) => recovery.withLock(work), reserve, () => {
752
+ recovery.rejected();
753
+ attempt = undefined;
754
+ }, until, reviewed));
755
+ else
756
+ reserve();
675
757
  }
676
758
  catch (error) {
677
- const now = await finish(classify(recovery.read()), client);
678
- shown(now);
759
+ if (error instanceof WriteHeldError ||
760
+ error instanceof WriteHoldBusyError ||
761
+ error instanceof WriteHoldStaleReviewError)
762
+ throw error;
763
+ if (hooks.across) {
764
+ // No lock is held here: what the saved record says.
765
+ const now = shown(peek());
766
+ if (now.status !== "clear")
767
+ throw new WriteHeldError(now);
768
+ throw error;
769
+ }
770
+ const now = shown(await finish(classify(recovery.read()), client));
679
771
  if (now.status !== "clear")
680
772
  throw new WriteHeldError(now);
681
773
  throw error;
@@ -710,6 +802,25 @@ export function createWriteHold(options = {}) {
710
802
  shown(peek());
711
803
  },
712
804
  };
805
+ if (hooks.across) {
806
+ // Checked under this key's lock, which is then let go: the final
807
+ // check and reservation take every lock again, in order.
808
+ const checked = await locked(async () => shown(await inspect(client)), false);
809
+ if (!checked)
810
+ throw new WriteHoldBusyError();
811
+ if (checked.value.status !== "clear")
812
+ throw new WriteHeldError(checked.value);
813
+ // Given with `across`: the generations the review is checked against.
814
+ reviewed = hooks.generations();
815
+ try {
816
+ return await run(gate);
817
+ }
818
+ finally {
819
+ // The send ends: every lock it reserved under is let go first.
820
+ ended();
821
+ await released;
822
+ }
823
+ }
713
824
  const result = await locked(async () => {
714
825
  const now = shown(await inspect(client));
715
826
  if (now.status !== "clear")
@@ -746,11 +857,15 @@ export function createWriteHold(options = {}) {
746
857
  state: await finish(classify(recovery.read()), client),
747
858
  };
748
859
  }
749
- return { released: true, state: clear() };
750
- }, true);
860
+ return {
861
+ released: true,
862
+ state: clear(undefined, snapshot.record.id),
863
+ };
864
+ // Never waits behind another send's wallet prompt: on contention,
865
+ // what the saved record says, with no exit.
866
+ }, false);
751
867
  const ended = result?.value ?? { released: false, state: peek() };
752
- shown(ended.state);
753
- return ended;
868
+ return { ...ended, state: shown(ended.state) };
754
869
  },
755
870
  /**
756
871
  * A receipt for the held write: an ordinary write's own hash, or a Safe
@@ -761,6 +876,7 @@ export function createWriteHold(options = {}) {
761
876
  const result = await locked(async () => {
762
877
  const record = recovery.read();
763
878
  if (!record ||
879
+ record.mirrorOf ||
764
880
  (!record.safe &&
765
881
  record.hash !== receipt?.transactionHash?.toLowerCase()))
766
882
  return finish(classify(record), client);
@@ -799,6 +915,7 @@ export function createWriteHold(options = {}) {
799
915
  if (record &&
800
916
  !record.safe &&
801
917
  !record.hash &&
918
+ !record.mirrorOf &&
802
919
  isBytes32(hash) &&
803
920
  (await client.getChainId().catch(() => null)) === record.chainId) {
804
921
  const sent = await client
@@ -862,6 +979,278 @@ export function createWriteHold(options = {}) {
862
979
  },
863
980
  };
864
981
  }
982
+ /**
983
+ * `legacy`: for a write held under an intent namespace, the keys an earlier
984
+ * version held it under (an old namespace, or `{ call }` for an exact
985
+ * call); the current exact call is always checked. While any of them holds
986
+ * an unresolved write, the intent handle *is* that hold: its state, its
987
+ * refusal and its own exit, in place. Each is checked alone, under its own
988
+ * lock only, never two at once. Only when all are clear does the intent's
989
+ * own record apply, and only then can a send reserve; they are checked
990
+ * again right before it does.
991
+ */
992
+ function open(call, { legacy = [], } = {}) {
993
+ if (call.namespace === undefined)
994
+ return openExact(call);
995
+ const namespace = call.namespace;
996
+ const own = openOne(call, {
997
+ across: reserveAcross,
998
+ generations: () => generations(),
999
+ });
1000
+ const ownJournal = createBrowserWriteRecovery(call, { storage });
1001
+ /** Every involved key's latest reservation id, its generation. */
1002
+ const generations = () => JSON.stringify([ownJournal, ...journals].map((journal) => journal.latestReservation()));
1003
+ const { namespace: _, ...exact } = call;
1004
+ const scopeOf = (input) => createBrowserWriteRecovery(input, { storage }).scopeKey;
1005
+ const scopes = new Set([scopeOf(call)]);
1006
+ const inputs = [{ call: call.call }, ...legacy]
1007
+ .map((source) => typeof source === "string"
1008
+ ? { ...call, namespace: source }
1009
+ : { ...exact, call: source.call })
1010
+ .filter((input) => {
1011
+ const scope = scopeOf(input);
1012
+ return !scopes.has(scope) && !!scopes.add(scope);
1013
+ })
1014
+ // One canonical order, by scope, for every writer's locks.
1015
+ .sort((a, b) => Number(scopeOf(a) > scopeOf(b)) - Number(scopeOf(a) < scopeOf(b)));
1016
+ // Mirror-aware: evidence and exits always act on the source attempt.
1017
+ const delegates = inputs.map((input) => openExact(input));
1018
+ const journals = inputs.map((input) => createBrowserWriteRecovery(input, { storage, locks: options.locks }));
1019
+ /** A legacy key holding anything but this intent's own mirror. */
1020
+ const foreign = (index) => journals[index].read()?.mirrorOf !== namespace &&
1021
+ delegates[index].peek().status !== "clear";
1022
+ /** The legacy hold the intent stands for right now, from storage alone. */
1023
+ const active = () => delegates.find((_, index) => foreign(index));
1024
+ /** The first legacy key holding an unresolved write, through its own gate. */
1025
+ async function delegated(client) {
1026
+ for (const [index, handle] of delegates.entries()) {
1027
+ if (!foreign(index))
1028
+ continue;
1029
+ const state = await handle.state(client);
1030
+ if (state.status !== "clear")
1031
+ return state;
1032
+ }
1033
+ return null;
1034
+ }
1035
+ /**
1036
+ * The final check and reservation, under every legacy key's lock taken
1037
+ * in one sorted order (the intent's own is held by its send), each
1038
+ * without waiting: a key another writer holds refuses as busy. A legacy
1039
+ * hold refuses; otherwise the intent reserves, and mirrors itself at each
1040
+ * legacy key so its readers, older versions included, see it held.
1041
+ */
1042
+ async function reserveAcross(ownLock, reserve, unreserve, until, reviewed) {
1043
+ const locks = [
1044
+ { scope: scopeOf(call), take: ownLock },
1045
+ ...journals.map((journal) => ({
1046
+ scope: journal.scopeKey,
1047
+ take: (work) => journal.withLock(work),
1048
+ })),
1049
+ ].sort((a, b) => Number(a.scope > b.scope) - Number(a.scope < b.scope));
1050
+ const deadline = Date.now() + RESERVE_RETRY_MS;
1051
+ for (;;) {
1052
+ let entered = 0;
1053
+ let reserved;
1054
+ const done = new Promise((resolve) => (reserved = resolve));
1055
+ const acquire = async (index) => {
1056
+ if (index < locks.length)
1057
+ return locks[index].take(async () => {
1058
+ entered += 1;
1059
+ return acquire(index + 1);
1060
+ });
1061
+ for (const journal of journals) {
1062
+ const held = journal.read();
1063
+ if (held && held.mirrorOf !== namespace)
1064
+ throw new WriteHeldError(noticed({
1065
+ ...ungated(classify(held)),
1066
+ reservation: held.id,
1067
+ }));
1068
+ }
1069
+ // Another attempt since this review opened: review it again.
1070
+ if (generations() !== reviewed)
1071
+ throw new WriteHoldStaleReviewError(ownJournal.settled());
1072
+ reserve();
1073
+ const record = own.peek().record;
1074
+ // Every mirror is saved, in the store the hold itself uses (its
1075
+ // memory fallback included), before the wallet opens; otherwise
1076
+ // this unsent reservation and its partial mirrors are undone.
1077
+ const mirrors = [];
1078
+ try {
1079
+ for (const journal of journals)
1080
+ mirrors.push([journal, journal.mirror(record, namespace)]);
1081
+ }
1082
+ catch (error) {
1083
+ for (const [journal, mirror] of mirrors)
1084
+ try {
1085
+ journal.clear(mirror);
1086
+ }
1087
+ catch {
1088
+ /* Held for old readers until their explicit exit. */
1089
+ }
1090
+ try {
1091
+ unreserve();
1092
+ }
1093
+ catch {
1094
+ /* Unsent, it stays held with its explicit exit. */
1095
+ }
1096
+ throw error;
1097
+ }
1098
+ // Reserved: every lock is kept until the send ends.
1099
+ reserved();
1100
+ await until;
1101
+ };
1102
+ const holding = acquire(0);
1103
+ const failed = await Promise.race([
1104
+ done.then(() => null),
1105
+ holding.then(() => null, (error) => ({ error })),
1106
+ ]);
1107
+ if (!failed)
1108
+ return { released: holding.catch(() => { }) };
1109
+ // Not reserved: every lock, its own included, is let go before any
1110
+ // retry; the checks run again each time.
1111
+ const { error } = failed;
1112
+ if (entered === locks.length ||
1113
+ error instanceof WriteHeldError ||
1114
+ error instanceof WriteHoldStaleReviewError)
1115
+ throw error;
1116
+ if (Date.now() >= deadline)
1117
+ throw new WriteHoldBusyError();
1118
+ await new Promise((resolve) => setTimeout(resolve, 25 + Math.random() * 75));
1119
+ }
1120
+ }
1121
+ /** Remove this intent's mirrors of the reservation that ended, by its id only. */
1122
+ async function clearMirrors(ended) {
1123
+ for (const journal of journals) {
1124
+ if (journal.read()?.id !== ended)
1125
+ continue;
1126
+ await journal
1127
+ .withLock(async () => {
1128
+ const current = journal.read();
1129
+ if (current?.mirrorOf === namespace && current.id === ended)
1130
+ journal.clear(current);
1131
+ })
1132
+ .catch(() => {
1133
+ /* Kept for old readers until their explicit exit. */
1134
+ });
1135
+ }
1136
+ }
1137
+ /** After a call that ended this intent's reservation, its mirrors go. */
1138
+ const after = async (value) => {
1139
+ if (value.status === "clear" && value.reservation)
1140
+ await clearMirrors(value.reservation);
1141
+ return value;
1142
+ };
1143
+ return {
1144
+ ...own,
1145
+ peek: () => active()?.peek() ?? own.peek(),
1146
+ async state(client, checks) {
1147
+ return ((await delegated(client)) ?? after(await own.state(client, checks)));
1148
+ },
1149
+ async send(client, run) {
1150
+ const held = await delegated(client);
1151
+ if (held)
1152
+ throw new WriteHeldError(held);
1153
+ return own.send(client, run);
1154
+ },
1155
+ async exit(snapshot, acknowledgement, client) {
1156
+ const owner = snapshot.status === "clear"
1157
+ ? undefined
1158
+ : delegates.find((handle, index) => {
1159
+ const now = handle.peek();
1160
+ return (foreign(index) &&
1161
+ now.status !== "clear" &&
1162
+ now.record.id === snapshot.record.id);
1163
+ });
1164
+ if (owner)
1165
+ return owner.exit(snapshot, acknowledgement, client);
1166
+ const held = await delegated(client);
1167
+ if (held)
1168
+ return { released: false, state: held };
1169
+ const ended = await own.exit(snapshot, acknowledgement, client);
1170
+ await after(ended.state);
1171
+ return ended;
1172
+ },
1173
+ onReceipt: async (...args) => active()?.onReceipt(...args) ?? after(await own.onReceipt(...args)),
1174
+ onUnproven: async (...args) => active()?.onUnproven(...args) ?? after(await own.onUnproven(...args)),
1175
+ onExecuted: async (...args) => active()?.onExecuted(...args) ?? after(await own.onExecuted(...args)),
1176
+ attachHash: async (...args) => active()?.attachHash(...args) ?? after(await own.attachHash(...args)),
1177
+ };
1178
+ }
1179
+ /**
1180
+ * An exact-call handle. When its key holds a mirror of an intent's write,
1181
+ * it is that intent's hold: it never sends or releases on its own, and its
1182
+ * exit is the intent's. A mirror left after the intent ended is removed.
1183
+ */
1184
+ function openExact(call) {
1185
+ const own = openOne(call);
1186
+ const journal = createBrowserWriteRecovery(call, {
1187
+ storage,
1188
+ locks: options.locks,
1189
+ });
1190
+ /** The intent this key mirrors, from storage alone. */
1191
+ const mirrored = () => {
1192
+ const record = journal.read();
1193
+ return record?.mirrorOf
1194
+ ? { record, intent: openOne({ ...call, namespace: record.mirrorOf }) }
1195
+ : undefined;
1196
+ };
1197
+ /** Remove a mirror whose intent holds nothing any more. */
1198
+ async function clearStale(found) {
1199
+ await journal
1200
+ .withLock(async () => {
1201
+ const current = journal.read();
1202
+ if (current?.id === found.record.id)
1203
+ journal.clear(current);
1204
+ })
1205
+ .catch(() => {
1206
+ /* The stale mirror still holds; its intent is clear. */
1207
+ });
1208
+ }
1209
+ /** The intent's state while it holds anything: this key is its mirror. */
1210
+ async function intentState(client) {
1211
+ const found = mirrored();
1212
+ if (!found)
1213
+ return null;
1214
+ const state = await found.intent.state(client);
1215
+ if (state.status !== "clear")
1216
+ return state;
1217
+ await clearStale(found);
1218
+ return null;
1219
+ }
1220
+ /** The intent in charge, from storage alone. */
1221
+ const route = () => {
1222
+ const found = mirrored();
1223
+ return found && found.intent.peek().status !== "clear"
1224
+ ? found.intent
1225
+ : undefined;
1226
+ };
1227
+ return {
1228
+ ...own,
1229
+ peek: () => route()?.peek() ?? own.peek(),
1230
+ async state(client, checks) {
1231
+ return (await intentState(client)) ?? own.state(client, checks);
1232
+ },
1233
+ async send(client, run) {
1234
+ const held = await intentState(client);
1235
+ if (held)
1236
+ throw new WriteHeldError(held);
1237
+ return own.send(client, run);
1238
+ },
1239
+ async exit(snapshot, acknowledgement, client) {
1240
+ const found = mirrored();
1241
+ if (!found || found.intent.peek().status === "clear")
1242
+ return own.exit(snapshot, acknowledgement, client);
1243
+ const ended = await found.intent.exit(snapshot, acknowledgement, client);
1244
+ if (ended.released)
1245
+ await clearStale(found);
1246
+ return ended;
1247
+ },
1248
+ onReceipt: (...args) => (route() ?? own).onReceipt(...args),
1249
+ onUnproven: (...args) => (route() ?? own).onUnproven(...args),
1250
+ onExecuted: (...args) => (route() ?? own).onExecuted(...args),
1251
+ attachHash: (...args) => (route() ?? own).attachHash(...args),
1252
+ };
1253
+ }
865
1254
  return {
866
1255
  open,
867
1256
  /** Whether holds outlive this tab right now. */