@bananapus/nana-sdk-core 2.34.0 → 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.
- package/dist/cjs/review/index.d.ts +1 -0
- package/dist/cjs/review/index.d.ts.map +1 -1
- package/dist/cjs/review/index.js +1 -0
- package/dist/cjs/review/index.js.map +1 -1
- package/dist/cjs/review/messages.d.ts +2 -0
- package/dist/cjs/review/messages.d.ts.map +1 -1
- package/dist/cjs/review/messages.js +3 -1
- package/dist/cjs/review/messages.js.map +1 -1
- package/dist/cjs/review/writeHold.d.ts +53 -59
- package/dist/cjs/review/writeHold.d.ts.map +1 -1
- package/dist/cjs/review/writeHold.js +440 -26
- package/dist/cjs/review/writeHold.js.map +1 -1
- package/dist/cjs/review/writeIntent.d.ts +41 -0
- package/dist/cjs/review/writeIntent.d.ts.map +1 -0
- package/dist/cjs/review/writeIntent.js +77 -0
- package/dist/cjs/review/writeIntent.js.map +1 -0
- package/dist/cjs/review/writeRecovery.d.ts +21 -0
- package/dist/cjs/review/writeRecovery.d.ts.map +1 -1
- package/dist/cjs/review/writeRecovery.js +65 -0
- package/dist/cjs/review/writeRecovery.js.map +1 -1
- package/dist/esm/review/index.d.ts +1 -0
- package/dist/esm/review/index.d.ts.map +1 -1
- package/dist/esm/review/index.js +1 -0
- package/dist/esm/review/index.js.map +1 -1
- package/dist/esm/review/messages.d.ts +2 -0
- package/dist/esm/review/messages.d.ts.map +1 -1
- package/dist/esm/review/messages.js +2 -0
- package/dist/esm/review/messages.js.map +1 -1
- package/dist/esm/review/writeHold.d.ts +53 -59
- package/dist/esm/review/writeHold.d.ts.map +1 -1
- package/dist/esm/review/writeHold.js +439 -26
- package/dist/esm/review/writeHold.js.map +1 -1
- package/dist/esm/review/writeIntent.d.ts +41 -0
- package/dist/esm/review/writeIntent.d.ts.map +1 -0
- package/dist/esm/review/writeIntent.js +73 -0
- package/dist/esm/review/writeIntent.js.map +1 -0
- package/dist/esm/review/writeRecovery.d.ts +21 -0
- package/dist/esm/review/writeRecovery.d.ts.map +1 -1
- package/dist/esm/review/writeRecovery.js +65 -0
- package/dist/esm/review/writeRecovery.js.map +1 -1
- 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");
|
|
@@ -8,6 +8,16 @@ const messages_js_1 = require("./messages.js");
|
|
|
8
8
|
const writeRecovery_js_1 = require("./writeRecovery.js");
|
|
9
9
|
const untrusted_js_1 = require("../untrusted.js");
|
|
10
10
|
const relayr_js_1 = require("./relayr.js");
|
|
11
|
+
/**
|
|
12
|
+
* A state read without the chain (peek, a replay, another tab's change, a
|
|
13
|
+
* send's progress): its status and line, never the exit. Only the gate in
|
|
14
|
+
* state() and exit() offers that.
|
|
15
|
+
*/
|
|
16
|
+
function ungated(state) {
|
|
17
|
+
return state.status === "clear" || !state.exit
|
|
18
|
+
? state
|
|
19
|
+
: { ...state, exit: false };
|
|
20
|
+
}
|
|
11
21
|
/** A send was refused because the exact call is held; `state` says how. */
|
|
12
22
|
class WriteHeldError extends Error {
|
|
13
23
|
constructor(state) {
|
|
@@ -26,6 +36,19 @@ class WriteHoldNonceError extends Error {
|
|
|
26
36
|
}
|
|
27
37
|
}
|
|
28
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;
|
|
29
52
|
/** The exact call is already being sent from another flow or tab. */
|
|
30
53
|
class WriteHoldBusyError extends Error {
|
|
31
54
|
constructor() {
|
|
@@ -35,6 +58,8 @@ class WriteHoldBusyError extends Error {
|
|
|
35
58
|
}
|
|
36
59
|
exports.WriteHoldBusyError = WriteHoldBusyError;
|
|
37
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;
|
|
38
63
|
/** A Safe's execution events, in its 1.3 and 1.4 layouts alike. */
|
|
39
64
|
const SAFE_EXECUTION_TOPICS = [
|
|
40
65
|
(0, viem_1.toEventSelector)("ExecutionSuccess(bytes32,uint256)"),
|
|
@@ -89,8 +114,12 @@ function unchecked(record) {
|
|
|
89
114
|
}
|
|
90
115
|
/** About an hour of polls; a later state() looks again. */
|
|
91
116
|
const SETTLE_POLL_ATTEMPTS = 240;
|
|
92
|
-
function clear(settled) {
|
|
93
|
-
return
|
|
117
|
+
function clear(settled, reservation) {
|
|
118
|
+
return {
|
|
119
|
+
status: "clear",
|
|
120
|
+
...(settled ? { settled } : {}),
|
|
121
|
+
...(reservation === undefined ? {} : { reservation }),
|
|
122
|
+
};
|
|
94
123
|
}
|
|
95
124
|
function ranExecuted(record, executionHash = record.executionHash, result) {
|
|
96
125
|
return {
|
|
@@ -160,6 +189,17 @@ function createWriteHold(options = {}) {
|
|
|
160
189
|
const peekers = new Map();
|
|
161
190
|
const timers = new Map();
|
|
162
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
|
+
}
|
|
163
203
|
/** The latest state emitted for each call, replayed to a late subscriber. */
|
|
164
204
|
const latest = new Map();
|
|
165
205
|
function emit(key, state) {
|
|
@@ -172,7 +212,9 @@ function createWriteHold(options = {}) {
|
|
|
172
212
|
if (onStorage || typeof globalThis.addEventListener !== "function")
|
|
173
213
|
return;
|
|
174
214
|
onStorage = (event) => {
|
|
175
|
-
if (event.key &&
|
|
215
|
+
if (event.key &&
|
|
216
|
+
(!event.key.startsWith("nana-sdk:reviewed-write") ||
|
|
217
|
+
event.key === "nana-sdk:reviewed-write:probe"))
|
|
176
218
|
return;
|
|
177
219
|
for (const [key, peek] of peekers) {
|
|
178
220
|
try {
|
|
@@ -185,7 +227,7 @@ function createWriteHold(options = {}) {
|
|
|
185
227
|
};
|
|
186
228
|
globalThis.addEventListener("storage", onStorage);
|
|
187
229
|
}
|
|
188
|
-
function
|
|
230
|
+
function openOne(call, hooks = {}) {
|
|
189
231
|
const recovery = (0, writeRecovery_js_1.createBrowserWriteRecovery)(call, {
|
|
190
232
|
storage,
|
|
191
233
|
locks: options.locks,
|
|
@@ -196,11 +238,26 @@ function createWriteHold(options = {}) {
|
|
|
196
238
|
let lastEffect;
|
|
197
239
|
/** Nothing held: how the call's last write settled, if saved. */
|
|
198
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
|
+
};
|
|
199
252
|
const peek = () => {
|
|
200
253
|
const record = recovery.read();
|
|
201
|
-
return record ? 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;
|
|
202
260
|
};
|
|
203
|
-
const shown = (state) => (emit(key, state), state);
|
|
204
261
|
async function locked(work, wait) {
|
|
205
262
|
let entered = false;
|
|
206
263
|
try {
|
|
@@ -249,7 +306,7 @@ function createWriteHold(options = {}) {
|
|
|
249
306
|
};
|
|
250
307
|
// Kept for any later reader: another tab, a reload, a follow timer.
|
|
251
308
|
recovery.markSettled(record, settled);
|
|
252
|
-
return clear(settled);
|
|
309
|
+
return clear(settled, record.id);
|
|
253
310
|
}
|
|
254
311
|
if (!record.safe &&
|
|
255
312
|
receipt.status === "reverted" &&
|
|
@@ -341,8 +398,22 @@ function createWriteHold(options = {}) {
|
|
|
341
398
|
// Only positive pending evidence stops the clock; a read that fails
|
|
342
399
|
// keeps the one saved.
|
|
343
400
|
if (look === "pending")
|
|
344
|
-
return {
|
|
401
|
+
return {
|
|
402
|
+
...state,
|
|
403
|
+
record: recovery.seenPending(recovery.mined(state.record, false)),
|
|
404
|
+
};
|
|
345
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);
|
|
346
417
|
if (record.minedAt === undefined)
|
|
347
418
|
return state;
|
|
348
419
|
if (Date.now() - Number(record.minedAt) >= UNSEEN_HASH_MS)
|
|
@@ -351,7 +422,9 @@ function createWriteHold(options = {}) {
|
|
|
351
422
|
}
|
|
352
423
|
/** What the node shows of `hash`: positive pending or mined metadata, or nothing it can say. */
|
|
353
424
|
async function lookAt(hash, client) {
|
|
354
|
-
const sent = await client.getTransaction({ hash }).
|
|
425
|
+
const sent = await client.getTransaction({ hash }).then((tx) => tx, (error) => error?.name === "TransactionNotFoundError" ? null : undefined);
|
|
426
|
+
if (sent === undefined)
|
|
427
|
+
return "unknown";
|
|
355
428
|
if (sent?.hash?.toLowerCase() !== hash)
|
|
356
429
|
return null;
|
|
357
430
|
return sent.blockNumber === null ? "pending" : "mined";
|
|
@@ -372,7 +445,8 @@ function createWriteHold(options = {}) {
|
|
|
372
445
|
for (const hash of seen) {
|
|
373
446
|
const own = hash === record.hash;
|
|
374
447
|
const look = await lookAt(hash, client);
|
|
375
|
-
|
|
448
|
+
// Unknown changes nothing: only positive metadata moves a clock.
|
|
449
|
+
if (look === "pending" || look === "mined")
|
|
376
450
|
record = own
|
|
377
451
|
? recovery.mined(record, look === "mined")
|
|
378
452
|
: recovery.candidateMined(record, hash, look === "mined");
|
|
@@ -470,7 +544,7 @@ function createWriteHold(options = {}) {
|
|
|
470
544
|
if (end.look === "expired" && !record.executed) {
|
|
471
545
|
const expired = await (0, writeRecovery_js_1.verifyReviewedWriteExpiry)(client, record, options.safeService).catch(() => false);
|
|
472
546
|
if (expired && release(record))
|
|
473
|
-
return clear({ result: "expired" });
|
|
547
|
+
return clear({ result: "expired" }, record.id);
|
|
474
548
|
}
|
|
475
549
|
if (end.look === "executed") {
|
|
476
550
|
if (!record.executed ||
|
|
@@ -492,7 +566,7 @@ function createWriteHold(options = {}) {
|
|
|
492
566
|
hash: end.execution.executionHash,
|
|
493
567
|
};
|
|
494
568
|
recovery.markSettled(record, settled);
|
|
495
|
-
return clear(settled);
|
|
569
|
+
return clear(settled, record.id);
|
|
496
570
|
}
|
|
497
571
|
}
|
|
498
572
|
if (!record.executed &&
|
|
@@ -519,10 +593,23 @@ function createWriteHold(options = {}) {
|
|
|
519
593
|
// canonical finalized block) can't run, whatever a node still shows.
|
|
520
594
|
return ((await seenGate(state.record, client, state.status === "dropped-proven")) ?? state);
|
|
521
595
|
}
|
|
596
|
+
/**
|
|
597
|
+
* The way out of every method that has a client: a state that could
|
|
598
|
+
* offer the exit, or track a hash, is the gate's answer (inspect), so no
|
|
599
|
+
* method reports an exit state() would refuse. Under the call's lock.
|
|
600
|
+
*/
|
|
601
|
+
async function finish(state, client) {
|
|
602
|
+
return state.status === "clear" || state.status === "ran-executed"
|
|
603
|
+
? state
|
|
604
|
+
: inspect(client);
|
|
605
|
+
}
|
|
522
606
|
async function inspectChain(client, effect) {
|
|
523
607
|
let record = recovery.read();
|
|
524
608
|
if (!record)
|
|
525
609
|
return cleared();
|
|
610
|
+
// A mirror carries no evidence of its own: its source attempt does.
|
|
611
|
+
if (record.mirrorOf)
|
|
612
|
+
return classify(record);
|
|
526
613
|
// No evidence from another chain.
|
|
527
614
|
if ((await client.getChainId().catch(() => null)) !== record.chainId)
|
|
528
615
|
return classify(record);
|
|
@@ -624,8 +711,11 @@ function createWriteHold(options = {}) {
|
|
|
624
711
|
if (last) {
|
|
625
712
|
const now = peek();
|
|
626
713
|
// A clear is compared by its settlement's identity, a hold by its record.
|
|
627
|
-
const identity = (state) => JSON.stringify(
|
|
628
|
-
|
|
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)));
|
|
629
719
|
}
|
|
630
720
|
return () => {
|
|
631
721
|
set.delete(listener);
|
|
@@ -644,6 +734,10 @@ function createWriteHold(options = {}) {
|
|
|
644
734
|
*/
|
|
645
735
|
async send(client, run) {
|
|
646
736
|
let attempt;
|
|
737
|
+
let ended;
|
|
738
|
+
const until = new Promise((resolve) => (ended = resolve));
|
|
739
|
+
let released;
|
|
740
|
+
let reviewed = "";
|
|
647
741
|
const gate = {
|
|
648
742
|
async beforeSend(actualCall) {
|
|
649
743
|
let evidence;
|
|
@@ -657,11 +751,31 @@ function createWriteHold(options = {}) {
|
|
|
657
751
|
throw new WriteHoldNonceError(cause);
|
|
658
752
|
}
|
|
659
753
|
}
|
|
660
|
-
|
|
754
|
+
const reserve = () => {
|
|
661
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();
|
|
662
765
|
}
|
|
663
766
|
catch (error) {
|
|
664
|
-
|
|
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));
|
|
665
779
|
if (now.status !== "clear")
|
|
666
780
|
throw new WriteHeldError(now);
|
|
667
781
|
throw error;
|
|
@@ -688,7 +802,7 @@ function createWriteHold(options = {}) {
|
|
|
688
802
|
catch {
|
|
689
803
|
/* Not this proposal: the service is asked later. */
|
|
690
804
|
}
|
|
691
|
-
shown(classify(attempt));
|
|
805
|
+
shown(ungated(classify(attempt)));
|
|
692
806
|
},
|
|
693
807
|
onRejected() {
|
|
694
808
|
recovery.rejected();
|
|
@@ -696,6 +810,25 @@ function createWriteHold(options = {}) {
|
|
|
696
810
|
shown(peek());
|
|
697
811
|
},
|
|
698
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
|
+
}
|
|
699
832
|
const result = await locked(async () => {
|
|
700
833
|
const now = shown(await inspect(client));
|
|
701
834
|
if (now.status !== "clear")
|
|
@@ -727,13 +860,20 @@ function createWriteHold(options = {}) {
|
|
|
727
860
|
recovery.abandon(snapshot.record, { checkedWallet: true });
|
|
728
861
|
}
|
|
729
862
|
catch {
|
|
730
|
-
return {
|
|
863
|
+
return {
|
|
864
|
+
released: false,
|
|
865
|
+
state: await finish(classify(recovery.read()), client),
|
|
866
|
+
};
|
|
731
867
|
}
|
|
732
|
-
return {
|
|
733
|
-
|
|
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);
|
|
734
875
|
const ended = result?.value ?? { released: false, state: peek() };
|
|
735
|
-
shown(ended.state);
|
|
736
|
-
return ended;
|
|
876
|
+
return { ...ended, state: shown(ended.state) };
|
|
737
877
|
},
|
|
738
878
|
/**
|
|
739
879
|
* A receipt for the held write: an ordinary write's own hash, or a Safe
|
|
@@ -744,10 +884,11 @@ function createWriteHold(options = {}) {
|
|
|
744
884
|
const result = await locked(async () => {
|
|
745
885
|
const record = recovery.read();
|
|
746
886
|
if (!record ||
|
|
887
|
+
record.mirrorOf ||
|
|
747
888
|
(!record.safe &&
|
|
748
889
|
record.hash !== receipt?.transactionHash?.toLowerCase()))
|
|
749
|
-
return classify(record);
|
|
750
|
-
return settle(record, receipt, client, proof);
|
|
890
|
+
return finish(classify(record), client);
|
|
891
|
+
return finish(await settle(record, receipt, client, proof), client);
|
|
751
892
|
}, true);
|
|
752
893
|
return shown(result?.value ?? peek());
|
|
753
894
|
},
|
|
@@ -782,6 +923,7 @@ function createWriteHold(options = {}) {
|
|
|
782
923
|
if (record &&
|
|
783
924
|
!record.safe &&
|
|
784
925
|
!record.hash &&
|
|
926
|
+
!record.mirrorOf &&
|
|
785
927
|
(0, untrusted_js_1.isBytes32)(hash) &&
|
|
786
928
|
(await client.getChainId().catch(() => null)) === record.chainId) {
|
|
787
929
|
const sent = await client
|
|
@@ -823,7 +965,7 @@ function createWriteHold(options = {}) {
|
|
|
823
965
|
const result = await locked(async () => {
|
|
824
966
|
const record = recovery.read();
|
|
825
967
|
if (!record?.safe || !record.hash)
|
|
826
|
-
return classify(record);
|
|
968
|
+
return finish(classify(record), client);
|
|
827
969
|
if (executionHash) {
|
|
828
970
|
// Authenticated on arrival: its own receipt settles it, or proves
|
|
829
971
|
// it isn't this write's; only an unresolved one is kept.
|
|
@@ -845,6 +987,278 @@ function createWriteHold(options = {}) {
|
|
|
845
987
|
},
|
|
846
988
|
};
|
|
847
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
|
+
}
|
|
848
1262
|
return {
|
|
849
1263
|
open,
|
|
850
1264
|
/** Whether holds outlive this tab right now. */
|