@relaymessenger/openclaw-plugin 0.3.4 → 0.4.0-staging.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 (61) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +159 -124
  3. package/contracts/relay-sdk-0.3.0-staging.4.registry.json +58 -0
  4. package/contracts/relay-v1.lock.json +77 -0
  5. package/dist/index.js +2 -2
  6. package/dist/setup-entry.js +1 -2
  7. package/dist/src/accounts.js +63 -34
  8. package/dist/src/channel.js +144 -533
  9. package/dist/src/dispatch.js +257 -0
  10. package/dist/src/full-sync.js +24 -0
  11. package/dist/src/gateway.js +171 -0
  12. package/dist/src/inbound.js +54 -85
  13. package/dist/src/ingress.js +64 -0
  14. package/dist/src/outbound.js +48 -111
  15. package/dist/src/runtime.js +2 -3
  16. package/dist/src/state.js +492 -0
  17. package/dist/src/types.js +1 -3
  18. package/index.ts +1 -2
  19. package/openclaw.plugin.json +15 -18
  20. package/package.json +113 -40
  21. package/setup-entry.ts +0 -2
  22. package/src/accounts.ts +95 -51
  23. package/src/channel.ts +271 -646
  24. package/src/dispatch.ts +324 -0
  25. package/src/full-sync.ts +47 -0
  26. package/src/gateway.ts +216 -0
  27. package/src/inbound.ts +71 -122
  28. package/src/ingress.ts +123 -0
  29. package/src/outbound.ts +70 -149
  30. package/src/runtime.ts +4 -4
  31. package/src/state.ts +609 -0
  32. package/src/types.ts +51 -162
  33. package/dist/src/account-lock.js +0 -91
  34. package/dist/src/client.js +0 -13
  35. package/dist/src/cursor-store.js +0 -136
  36. package/dist/src/inbound-dedupe.js +0 -175
  37. package/dist/src/invocations.js +0 -47
  38. package/dist/src/lifecycle.js +0 -35
  39. package/dist/src/poll-loop.js +0 -137
  40. package/dist/src/responding.js +0 -36
  41. package/dist/src/security.js +0 -26
  42. package/dist/src/state-files.js +0 -243
  43. package/dist/src/vendor/relay-sdk/client.js +0 -163
  44. package/dist/src/vendor/relay-sdk/errors.js +0 -45
  45. package/dist/src/vendor/relay-sdk/types.js +0 -2
  46. package/dist/src/vendor/relay-sdk/url.js +0 -39
  47. package/src/account-lock.ts +0 -108
  48. package/src/client.ts +0 -51
  49. package/src/cursor-store.ts +0 -186
  50. package/src/inbound-dedupe.ts +0 -241
  51. package/src/invocations.ts +0 -58
  52. package/src/lifecycle.ts +0 -42
  53. package/src/poll-loop.ts +0 -173
  54. package/src/responding.ts +0 -52
  55. package/src/security.ts +0 -36
  56. package/src/state-files.ts +0 -298
  57. package/src/vendor/relay-sdk/README.md +0 -28
  58. package/src/vendor/relay-sdk/client.ts +0 -293
  59. package/src/vendor/relay-sdk/errors.ts +0 -61
  60. package/src/vendor/relay-sdk/types.ts +0 -82
  61. package/src/vendor/relay-sdk/url.ts +0 -43
@@ -1,47 +0,0 @@
1
- /**
2
- * Which group invocation an in-flight turn belongs to.
3
- *
4
- * Relay mints an invocation when a human invokes an agent in a group, and
5
- * every call the agent then makes about that message has to carry the id back:
6
- * `/typing` and `/responding` refuse without it, and so does the reply itself.
7
- *
8
- * The reply does not leave through this plugin's own code. It leaves through
9
- * core's durable message adapter, whose send context carries `to`, `text`, and
10
- * delivery bookkeeping and nothing about the message being answered
11
- * (`ChannelMessageSendTextContext`). There is no field to thread the id
12
- * through, so the turn parks it here for the adapter to find.
13
- *
14
- * Keyed by (accountId, conversationId) because `to` and `accountId` are all
15
- * the adapter knows. Two agents in one group get separate slots. Two
16
- * overlapping turns for ONE agent in ONE group share a slot and the later one
17
- * wins — bounded by the server, which spends an invocation exactly once and
18
- * refuses the loser rather than misattributing it.
19
- */
20
- const pendingInvocations = new Map();
21
- function slotKey(accountId, conversationId) {
22
- return `${accountId}\0${conversationId}`;
23
- }
24
- /**
25
- * Hold `invocationId` for the life of one turn. Returns the release function;
26
- * call it in a `finally` so a thrown turn cannot strand the slot.
27
- *
28
- * Releasing only clears the slot if this turn still owns it, so a turn that
29
- * finishes after being superseded cannot delete its successor's id.
30
- */
31
- export function rememberRelayInvocation(params) {
32
- const key = slotKey(params.accountId, params.conversationId);
33
- pendingInvocations.set(key, params.invocationId);
34
- return () => {
35
- if (pendingInvocations.get(key) === params.invocationId) {
36
- pendingInvocations.delete(key);
37
- }
38
- };
39
- }
40
- /** The invocation an outbound send in this conversation belongs to, if any. */
41
- export function relayInvocationFor(params) {
42
- return pendingInvocations.get(slotKey(params.accountId, params.conversationId));
43
- }
44
- /** Test seam: drop every slot. */
45
- export function resetRelayInvocationsForTest() {
46
- pendingInvocations.clear();
47
- }
@@ -1,35 +0,0 @@
1
- /** Owns exactly one abortable long-poll lifecycle per configured account. */
2
- export function createRelayAccountLifecycleRegistry() {
3
- const controllers = new Map();
4
- return {
5
- acquire(accountId, parentSignal) {
6
- if (controllers.has(accountId)) {
7
- throw new Error(`relay: account "${accountId}" already has an active consumer`);
8
- }
9
- const controller = new AbortController();
10
- controllers.set(accountId, controller);
11
- const signal = AbortSignal.any([parentSignal, controller.signal]);
12
- let released = false;
13
- return {
14
- signal,
15
- release: () => {
16
- if (released) {
17
- return;
18
- }
19
- released = true;
20
- if (controllers.get(accountId) === controller) {
21
- controllers.delete(accountId);
22
- }
23
- },
24
- };
25
- },
26
- stop(accountId) {
27
- const controller = controllers.get(accountId);
28
- if (!controller) {
29
- return false;
30
- }
31
- controller.abort();
32
- return true;
33
- },
34
- };
35
- }
@@ -1,137 +0,0 @@
1
- // startAccount receive engine: abort-aware long poll over GET /v1/events with
2
- // claim -> durable attempt commit -> dispatch per event and the cursor advanced
3
- // only after the batch's commits. The supervisor owns restart/backoff: this loop settles
4
- // (throws) on terminal auth failure and consumer conflicts, and only sleeps
5
- // in-loop for transient errors.
6
- import { RelayApiError, isAbortError } from "./client.js";
7
- const TRANSIENT_BASE_DELAY_MS = 500;
8
- const TRANSIENT_MAX_DELAY_MS = 30_000;
9
- function defaultSleep(ms, signal) {
10
- return new Promise((resolve) => {
11
- if (signal.aborted) {
12
- resolve();
13
- return;
14
- }
15
- const timer = setTimeout(() => {
16
- signal.removeEventListener("abort", onAbort);
17
- resolve();
18
- }, ms);
19
- const onAbort = () => {
20
- clearTimeout(timer);
21
- resolve();
22
- };
23
- signal.addEventListener("abort", onAbort, { once: true });
24
- });
25
- }
26
- export async function runRelayPollLoop(params) {
27
- const sleep = params.sleep ?? defaultSleep;
28
- const random = params.random ?? Math.random;
29
- const log = params.log ?? (() => { });
30
- let transientAttempts = 0;
31
- const transientDelayMs = () => {
32
- const backoff = Math.min(TRANSIENT_MAX_DELAY_MS, TRANSIENT_BASE_DELAY_MS * 2 ** Math.min(transientAttempts, 6));
33
- return backoff + Math.floor(random() * 250);
34
- };
35
- while (!params.abortSignal.aborted) {
36
- let page;
37
- try {
38
- page = await params.client.pollEvents({
39
- cursor: params.cursorStore.current(),
40
- timeoutSeconds: params.timeoutSeconds ?? 30,
41
- ...(params.limit === undefined ? {} : { limit: params.limit }),
42
- signal: params.abortSignal,
43
- });
44
- }
45
- catch (error) {
46
- if (params.abortSignal.aborted || isAbortError(error)) {
47
- return;
48
- }
49
- if (error instanceof RelayApiError && error.kind === "auth") {
50
- // Token revoked: settle so the supervisor applies terminalDisconnect
51
- // (server-channels.ts:718) — an operator has to fix the token.
52
- throw error;
53
- }
54
- if (error instanceof RelayApiError && error.kind === "conflict") {
55
- // Another consumer took the long poll. Settle and let the
56
- // supervisor's backoff arbitrate.
57
- log(`[relay] long poll terminated by another consumer: ${error.message}`);
58
- throw error;
59
- }
60
- transientAttempts += 1;
61
- log(`[relay] transient poll error (attempt ${transientAttempts}): ${String(error)}`);
62
- await sleep(transientDelayMs(), params.abortSignal);
63
- continue;
64
- }
65
- transientAttempts = 0;
66
- if (page.events.length > 0) {
67
- params.onBatch?.(page.events);
68
- }
69
- let batchFailed = false;
70
- for (const event of page.events) {
71
- if (params.abortSignal.aborted) {
72
- batchFailed = true;
73
- break;
74
- }
75
- if (params.shouldProcess && !params.shouldProcess(event)) {
76
- // Bookkeeping events (receipts, reactions, echoes) never dispatch, so
77
- // they are acked by the batch cursor without a dedupe row.
78
- continue;
79
- }
80
- const claimed = await params.deduper.claimEvent(event.event_id);
81
- if (claimed) {
82
- let attempted = false;
83
- const markAttempt = async () => {
84
- if (attempted)
85
- return;
86
- await params.deduper.commitEvent(event.event_id);
87
- attempted = true;
88
- if (params.abortSignal.aborted)
89
- throw new Error("aborted after durable attempt");
90
- };
91
- try {
92
- await params.handleEvent(event, markAttempt);
93
- }
94
- catch (error) {
95
- if (!attempted) {
96
- params.deduper.releaseEvent(event.event_id);
97
- // A rejection is the server's final answer: replaying the identical
98
- // request produces the identical refusal. Holding the cursor for it
99
- // is a livelock, and the cursor is ONE watermark for the whole
100
- // channel — so a single permanently-refused event would starve every
101
- // later message, direct ones included (REL-167). Losing one event is
102
- // strictly better than losing the channel, so log it loudly and let
103
- // the page cursor move past it.
104
- if (error instanceof RelayApiError && error.kind === "rejected") {
105
- log(`[relay] event ${event.event_id} was permanently rejected by the server, ` +
106
- `skipping it so later messages are not starved: ${String(error)}`);
107
- continue;
108
- }
109
- log(`[relay] event ${event.event_id} safe preflight failed, will replay: ${String(error)}`);
110
- batchFailed = true;
111
- break;
112
- }
113
- log(`[relay] event ${event.event_id} dispatch failed after its attempt was committed; ` +
114
- `will not replay possible tool side effects: ${String(error)}`);
115
- }
116
- if (!attempted) {
117
- // Admission/preflight intentionally consumed no side effect; release
118
- // the claim and allow the page cursor to acknowledge it.
119
- params.deduper.releaseEvent(event.event_id);
120
- }
121
- if (params.abortSignal.aborted) {
122
- batchFailed = true;
123
- break;
124
- }
125
- }
126
- }
127
- // The server's next_cursor covers the whole page (cursor N acks <= N), so
128
- // only a batch with durable attempt markers may ack it. A marker-write
129
- // failure acks nothing and committed predecessors absorb the replay.
130
- if (!batchFailed) {
131
- await params.cursorStore.advance(page.nextCursor);
132
- }
133
- else {
134
- await sleep(transientDelayMs(), params.abortSignal);
135
- }
136
- }
137
- }
@@ -1,36 +0,0 @@
1
- import { RelayApiError } from "./client.js";
2
- /**
3
- * Record the read/responding receipt, then commit the durable attempt marker.
4
- *
5
- * The receipt is a courtesy to the person waiting: it turns their message Read
6
- * and shows that something is composing. It is NOT permission to answer, and
7
- * it used to be treated as such — a rejected receipt threw here, before
8
- * `markAttempt`, which sent the poll loop down its replay branch and froze the
9
- * channel's single delivery cursor. One group mention whose receipt the server
10
- * refused therefore starved every later message, direct ones included
11
- * (REL-167).
12
- *
13
- * So a failed receipt is reported and the turn continues. The ordering that
14
- * mattered is kept: the receipt is still attempted BEFORE the attempt marker,
15
- * so a receipt that succeeds still precedes any agent or tool work.
16
- */
17
- export async function markRespondingBeforeAttempt(params) {
18
- const { facts } = params;
19
- try {
20
- await params.client.setResponding({
21
- conversationId: facts.conversationId,
22
- messageId: facts.messageId,
23
- label: params.label,
24
- ...(facts.invocationId ? { invocationId: facts.invocationId } : {}),
25
- });
26
- }
27
- catch (error) {
28
- // An aborted shutdown is not a receipt failure; let it settle the loop.
29
- if (error instanceof Error && error.name === "AbortError") {
30
- throw error;
31
- }
32
- const detail = error instanceof RelayApiError ? error.message : String(error);
33
- params.onReceiptFailure?.(`responding receipt for message ${facts.messageId} failed, answering anyway: ${detail}`);
34
- }
35
- await params.markAttempt();
36
- }
@@ -1,26 +0,0 @@
1
- function normalizeSenderId(value) {
2
- const normalized = String(value).trim();
3
- return normalized ? normalized : null;
4
- }
5
- /**
6
- * Build the only identities allowed to start an OpenClaw turn. The Relay
7
- * account owner is pinned from the authenticated agent profile; operators can
8
- * deliberately extend that set with `allowFrom`. No wildcard is accepted.
9
- */
10
- export function resolveRelayAllowedSenderIds(params) {
11
- const allowed = new Set();
12
- const owner = params.profile.owner_user_id?.trim();
13
- if (owner) {
14
- allowed.add(owner);
15
- }
16
- for (const entry of params.allowFrom ?? []) {
17
- const normalized = normalizeSenderId(entry);
18
- if (normalized && normalized !== "*") {
19
- allowed.add(normalized);
20
- }
21
- }
22
- return [...allowed];
23
- }
24
- export function relaySenderIsAllowed(allowedSenderIds, senderId) {
25
- return allowedSenderIds.includes(senderId);
26
- }
@@ -1,243 +0,0 @@
1
- import { jsonStore } from "@openclaw/fs-safe/store";
2
- import { withFileLock } from "@openclaw/fs-safe/file-lock";
3
- import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
4
- import { chmodSync, lstatSync, mkdirSync } from "node:fs";
5
- import { hostname } from "node:os";
6
- import { basename, join } from "node:path";
7
- const RELAY_STATE_DOCUMENT_VERSION = 1;
8
- const RELAY_STATE_LOCK_VERSION = 1;
9
- const RELAY_STATE_LOCK_TIMEOUT_MS = 30_000;
10
- export function emptyRelayStateDocument() {
11
- return { version: RELAY_STATE_DOCUMENT_VERSION, entries: {} };
12
- }
13
- function isRecord(value) {
14
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
15
- }
16
- export function assertRelayStateDocument(value, label, validateEntry) {
17
- if (!isRecord(value) ||
18
- value.version !== RELAY_STATE_DOCUMENT_VERSION ||
19
- !isRecord(value.entries)) {
20
- throw new Error(`relay ${label} state is corrupt`);
21
- }
22
- for (const [key, entry] of Object.entries(value.entries)) {
23
- if (!validateEntry(key, entry)) {
24
- throw new Error(`relay ${label} state is corrupt`);
25
- }
26
- }
27
- }
28
- function isRelayStateLockOwner(value) {
29
- if (!isRecord(value))
30
- return false;
31
- return (value.version === RELAY_STATE_LOCK_VERSION &&
32
- value.kind === "relay-state" &&
33
- Number.isSafeInteger(value.pid) &&
34
- value.pid > 0 &&
35
- typeof value.host === "string" &&
36
- value.host.length > 0 &&
37
- typeof value.createdAt === "string" &&
38
- Number.isFinite(Date.parse(value.createdAt)));
39
- }
40
- function localProcessIsLive(pid) {
41
- try {
42
- process.kill(pid, 0);
43
- return true;
44
- }
45
- catch (error) {
46
- // EPERM proves the process exists but is owned by another user. Unknown
47
- // failures also fail closed; only ESRCH proves this host no longer has it.
48
- return !(error instanceof Error && "code" in error && error.code === "ESRCH");
49
- }
50
- }
51
- /**
52
- * Windows opens the sidecar lock with an openat-style
53
- * `O_CREAT | O_EXCL` beneath a parent handle. While a just-released lock file
54
- * is still delete-pending, that create returns `ACCESS_DENIED` instead of the
55
- * `already exists` fs-safe retries on, so contention escapes the acquire loop
56
- * as a hard `EACCES`. Retry those on Windows only, bounded by the caller's lock
57
- * timeout: a genuine permission failure simply reproduces until the deadline
58
- * and then surfaces unchanged.
59
- */
60
- function isWindowsLockAcquisitionContention(error) {
61
- if (process.platform !== "win32")
62
- return false;
63
- const code = error?.code;
64
- return code === "EACCES" || code === "EPERM";
65
- }
66
- function lockRetryDelayMs(attempt, remainingMs) {
67
- const backoff = Math.min(25 * 2 ** attempt, 250);
68
- return Math.max(1, Math.min(backoff * (0.5 + Math.random() / 2), remainingMs));
69
- }
70
- /**
71
- * The sidecar lock has no in-process fast path: every waiter polls the lock
72
- * file, and losing an attempt costs an exclusive create plus a snapshot read.
73
- * Relay mutates one document from several tasks at once — a poll batch
74
- * registers one dedupe entry per inbound message — so N in-process writers
75
- * become N pollers competing with the holder for the same file. Funnelling
76
- * them through one in-memory queue leaves a single poller per process, which
77
- * matters most on Windows: same-process losers no longer race the holder's
78
- * unlink, so contention stops manifesting as delete-pending denials.
79
- *
80
- * Keyed by the store's file path. Two paths spelled differently for one file
81
- * would each get a queue and simply fall back to the sidecar lock for
82
- * correctness, so a miss costs throughput rather than serialization.
83
- */
84
- const RELAY_STATE_MUTEX_KEY = Symbol.for("relay.stateFileMutexes");
85
- function stateFileMutexes() {
86
- const container = globalThis;
87
- container[RELAY_STATE_MUTEX_KEY] ??= new Map();
88
- return container[RELAY_STATE_MUTEX_KEY];
89
- }
90
- function fileLockTimeout(filePath) {
91
- return Object.assign(new Error(`file lock timeout for ${filePath}`), {
92
- code: "file_lock_timeout",
93
- });
94
- }
95
- /** Waits for our turn, but never past the caller's lock deadline. */
96
- async function awaitTurn(turn, deadline, filePath) {
97
- const remaining = deadline - Date.now();
98
- if (remaining <= 0)
99
- throw fileLockTimeout(filePath);
100
- let timer;
101
- try {
102
- await Promise.race([
103
- turn,
104
- new Promise((_resolve, reject) => {
105
- timer = setTimeout(() => reject(fileLockTimeout(filePath)), remaining);
106
- }),
107
- ]);
108
- }
109
- finally {
110
- if (timer)
111
- clearTimeout(timer);
112
- }
113
- }
114
- async function withStateFileMutex(filePath, deadline, run) {
115
- const mutexes = stateFileMutexes();
116
- const previous = mutexes.get(filePath);
117
- let release;
118
- const ours = new Promise((resolve) => {
119
- release = resolve;
120
- });
121
- // Chain even when we abandon our turn on timeout: later waiters still queue
122
- // behind the holder we were waiting on, so ordering survives a giving-up
123
- // waiter.
124
- const tail = previous ? previous.then(() => ours) : ours;
125
- mutexes.set(filePath, tail);
126
- let tookTurn = false;
127
- try {
128
- if (previous)
129
- await awaitTurn(previous, deadline, filePath);
130
- tookTurn = true;
131
- return await run();
132
- }
133
- finally {
134
- release();
135
- // Forgetting the queue is only safe once it has drained. A waiter that gave
136
- // up is still queued behind a holder that is running, so dropping the entry
137
- // there would let the next caller past the holder and back onto the lock
138
- // file the queue exists to keep it off. Leaving it costs one settled promise
139
- // until the next caller drains it.
140
- if (tookTurn && mutexes.get(filePath) === tail)
141
- mutexes.delete(filePath);
142
- }
143
- }
144
- function canRecoverRelayStateLock(value) {
145
- return (isRelayStateLockOwner(value) &&
146
- value.host === hostname() &&
147
- !localProcessIsLive(value.pid));
148
- }
149
- function ensurePrivateStateDirectory(path) {
150
- mkdirSync(path, { recursive: true, mode: 0o700 });
151
- const stat = lstatSync(path);
152
- if (stat.isSymbolicLink() || !stat.isDirectory()) {
153
- throw new Error(`relay state path is not a private directory: ${path}`);
154
- }
155
- try {
156
- chmodSync(path, 0o700);
157
- }
158
- catch {
159
- // POSIX modes are not fully implemented on Windows. fs-safe's private
160
- // write path still owns the platform-specific file guarantees.
161
- }
162
- }
163
- /**
164
- * Relay owns these files rather than requesting OpenClaw's privileged host
165
- * SQLite. jsonStore gives every mutation a private atomic replacement. An
166
- * fs-safe sidecar lock serializes cross-process mutations and is recovered
167
- * only when its valid Relay owner names this host and its PID is provably dead.
168
- */
169
- export function openRelayStateDocument(params) {
170
- if (!params.fileName || basename(params.fileName) !== params.fileName) {
171
- throw new Error("relay state fileName must be one file name");
172
- }
173
- const lockTimeoutMs = params.lockTimeoutMs ?? RELAY_STATE_LOCK_TIMEOUT_MS;
174
- if (!Number.isSafeInteger(lockTimeoutMs) || lockTimeoutMs < 1) {
175
- throw new Error("relay state lockTimeoutMs must be a positive safe integer");
176
- }
177
- const stateRoot = resolveStateDir(params.env ?? process.env);
178
- const relayRoot = join(stateRoot, "relay");
179
- const relayStateRoot = join(relayRoot, "state");
180
- ensurePrivateStateDirectory(relayRoot);
181
- ensurePrivateStateDirectory(relayStateRoot);
182
- const store = jsonStore({
183
- filePath: join(relayStateRoot, params.fileName),
184
- dirMode: 0o700,
185
- mode: 0o600,
186
- });
187
- const withMutationLock = async (run) => {
188
- const deadline = Date.now() + lockTimeoutMs;
189
- return await withStateFileMutex(store.filePath, deadline, async () => {
190
- for (let attempt = 0;; attempt += 1) {
191
- // Only acquisition is retried. Once the mutation itself has started it
192
- // has observed state under the lock, so replaying it could double-apply.
193
- let mutationStarted = false;
194
- try {
195
- return await withFileLock(store.filePath, {
196
- managerKey: `relay-state:${store.filePath}`,
197
- staleMs: RELAY_STATE_LOCK_TIMEOUT_MS,
198
- timeoutMs: Math.max(1, deadline - Date.now()),
199
- staleRecovery: "remove-if-unchanged",
200
- retry: {
201
- retries: 300,
202
- minTimeout: 25,
203
- maxTimeout: 250,
204
- randomize: true,
205
- },
206
- payload: () => ({
207
- version: RELAY_STATE_LOCK_VERSION,
208
- kind: "relay-state",
209
- pid: process.pid,
210
- host: hostname(),
211
- createdAt: new Date().toISOString(),
212
- }),
213
- shouldReclaim: ({ payload }) => canRecoverRelayStateLock(payload),
214
- shouldRemoveStaleLock: ({ payload }) => canRecoverRelayStateLock(payload),
215
- }, async () => {
216
- mutationStarted = true;
217
- return await run();
218
- });
219
- }
220
- catch (error) {
221
- const remaining = deadline - Date.now();
222
- if (mutationStarted ||
223
- remaining <= 0 ||
224
- !isWindowsLockAcquisitionContention(error)) {
225
- throw error;
226
- }
227
- await new Promise((resolve) => setTimeout(resolve, lockRetryDelayMs(attempt, remaining)));
228
- }
229
- }
230
- });
231
- };
232
- return {
233
- filePath: store.filePath,
234
- read: store.read,
235
- readOr: store.readOr,
236
- readRequired: store.readRequired,
237
- write: async (value) => {
238
- await withMutationLock(async () => await store.write(value));
239
- },
240
- update: async (run) => await withMutationLock(async () => await store.update(run)),
241
- updateOr: async (fallback, run) => await withMutationLock(async () => await store.updateOr(fallback, run)),
242
- };
243
- }