@serfab/cadre-core 1.8.0 → 1.10.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 (126) hide show
  1. package/dist/cadre-node.d.ts +301 -121
  2. package/dist/cadre-node.js +588 -244
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/control-authorization.d.ts +2 -2
  5. package/dist/control-authorization.js +1 -0
  6. package/dist/control-authorization.js.map +1 -1
  7. package/dist/control-cohort.d.ts +1 -1
  8. package/dist/control-cohort.js +1 -1
  9. package/dist/control-database.d.ts +105 -14
  10. package/dist/control-database.js +242 -73
  11. package/dist/control-database.js.map +1 -1
  12. package/dist/control-formation-recorder.d.ts +6 -6
  13. package/dist/control-formation-recorder.js +7 -8
  14. package/dist/control-formation-recorder.js.map +1 -1
  15. package/dist/control-schema.d.ts +1 -1
  16. package/dist/control-schema.js +90 -3
  17. package/dist/control-schema.js.map +1 -1
  18. package/dist/control-write-retry.d.ts +21 -15
  19. package/dist/control-write-retry.js +21 -15
  20. package/dist/control-write-retry.js.map +1 -1
  21. package/dist/index.d.ts +7 -7
  22. package/dist/index.js +16 -13
  23. package/dist/index.js.map +1 -1
  24. package/dist/invitation-bootstrap.d.ts +32 -0
  25. package/dist/invitation-bootstrap.js +30 -0
  26. package/dist/invitation-bootstrap.js.map +1 -0
  27. package/dist/link-budget.d.ts +204 -119
  28. package/dist/link-budget.js +208 -120
  29. package/dist/link-budget.js.map +1 -1
  30. package/dist/membership-connection-gater.d.ts +3 -3
  31. package/dist/membership-connection-gater.js +3 -3
  32. package/dist/node-local-snapshot.d.ts +6 -4
  33. package/dist/node-local-snapshot.js +7 -3
  34. package/dist/node-local-snapshot.js.map +1 -1
  35. package/dist/peer-dial.d.ts +18 -14
  36. package/dist/peer-dial.js +18 -14
  37. package/dist/peer-dial.js.map +1 -1
  38. package/dist/peer-join-backfill.d.ts +3 -12
  39. package/dist/peer-join-backfill.js +2 -3
  40. package/dist/peer-join-backfill.js.map +1 -1
  41. package/dist/pending-join-runner.d.ts +186 -0
  42. package/dist/pending-join-runner.js +649 -0
  43. package/dist/pending-join-runner.js.map +1 -0
  44. package/dist/push-fanout.d.ts +1 -1
  45. package/dist/push-fanout.js +1 -1
  46. package/dist/seed-bootstrap.d.ts +2 -2
  47. package/dist/seed-bootstrap.js +1 -1
  48. package/dist/strand-addr-protocol.js +2 -2
  49. package/dist/strand-formation-deadlines.d.ts +5 -5
  50. package/dist/strand-formation-deadlines.js +5 -5
  51. package/dist/strand-formation-manager.d.ts +18 -4
  52. package/dist/strand-formation-manager.js +48 -33
  53. package/dist/strand-formation-manager.js.map +1 -1
  54. package/dist/strand-formation-protocol.d.ts +71 -25
  55. package/dist/strand-formation-protocol.js +225 -51
  56. package/dist/strand-formation-protocol.js.map +1 -1
  57. package/dist/strand-formation-rejection.d.ts +94 -0
  58. package/dist/strand-formation-rejection.js +77 -0
  59. package/dist/strand-formation-rejection.js.map +1 -0
  60. package/dist/strand-fret-addrs.d.ts +54 -0
  61. package/dist/strand-fret-addrs.js +108 -0
  62. package/dist/strand-fret-addrs.js.map +1 -0
  63. package/dist/strand-instance-manager.d.ts +9 -38
  64. package/dist/strand-instance-manager.js +17 -74
  65. package/dist/strand-instance-manager.js.map +1 -1
  66. package/dist/strand-network-state-file.d.ts +12 -0
  67. package/dist/strand-network-state-file.js +38 -0
  68. package/dist/strand-network-state-file.js.map +1 -0
  69. package/dist/strand-network-state.d.ts +111 -0
  70. package/dist/strand-network-state.js +191 -0
  71. package/dist/strand-network-state.js.map +1 -0
  72. package/dist/strand-solicitation.d.ts +11 -3
  73. package/dist/strand-solicitation.js +9 -0
  74. package/dist/strand-solicitation.js.map +1 -1
  75. package/dist/types.d.ts +123 -40
  76. package/dist/types.js +5 -9
  77. package/dist/types.js.map +1 -1
  78. package/package.json +25 -24
  79. package/src/cadre-node.ts +637 -251
  80. package/src/control-authorization.ts +117 -116
  81. package/src/control-cohort.ts +1 -1
  82. package/src/control-database.ts +303 -82
  83. package/src/control-formation-recorder.ts +8 -9
  84. package/src/control-schema.ts +90 -3
  85. package/src/control-write-retry.ts +21 -15
  86. package/src/index.ts +36 -56
  87. package/src/invitation-bootstrap.ts +40 -0
  88. package/src/link-budget.ts +221 -120
  89. package/src/membership-connection-gater.ts +3 -3
  90. package/src/node-local-snapshot.ts +12 -7
  91. package/src/peer-dial.ts +18 -14
  92. package/src/peer-join-backfill.ts +5 -6
  93. package/src/pending-join-runner.ts +735 -0
  94. package/src/push-fanout.ts +1 -1
  95. package/src/seed-bootstrap.ts +2 -2
  96. package/src/strand-addr-protocol.ts +2 -2
  97. package/src/strand-formation-deadlines.ts +5 -5
  98. package/src/strand-formation-manager.ts +62 -37
  99. package/src/strand-formation-protocol.ts +1036 -803
  100. package/src/strand-formation-rejection.ts +129 -0
  101. package/src/strand-fret-addrs.ts +145 -0
  102. package/src/strand-instance-manager.ts +24 -97
  103. package/src/strand-network-state-file.ts +40 -0
  104. package/src/strand-network-state.ts +243 -0
  105. package/src/strand-solicitation.ts +17 -4
  106. package/src/types.ts +119 -40
  107. package/dist/strand-peer-book-file.d.ts +0 -12
  108. package/dist/strand-peer-book-file.js +0 -37
  109. package/dist/strand-peer-book-file.js.map +0 -1
  110. package/dist/strand-peer-book-protocol.d.ts +0 -168
  111. package/dist/strand-peer-book-protocol.js +0 -356
  112. package/dist/strand-peer-book-protocol.js.map +0 -1
  113. package/dist/strand-peer-book-swap.d.ts +0 -104
  114. package/dist/strand-peer-book-swap.js +0 -284
  115. package/dist/strand-peer-book-swap.js.map +0 -1
  116. package/dist/strand-peer-book.d.ts +0 -159
  117. package/dist/strand-peer-book.js +0 -361
  118. package/dist/strand-peer-book.js.map +0 -1
  119. package/dist/strand-peer-observer.d.ts +0 -88
  120. package/dist/strand-peer-observer.js +0 -180
  121. package/dist/strand-peer-observer.js.map +0 -1
  122. package/src/strand-peer-book-file.ts +0 -39
  123. package/src/strand-peer-book-protocol.ts +0 -466
  124. package/src/strand-peer-book-swap.ts +0 -338
  125. package/src/strand-peer-book.ts +0 -475
  126. package/src/strand-peer-observer.ts +0 -235
@@ -0,0 +1,649 @@
1
+ /**
2
+ * The pending-join retry loop: carries on a join this party asked for through another party's
3
+ * invitation (`CadreControl.PendingJoin`, one row per invitation) in the background, on every
4
+ * owner machine of the party, until the join works, the invitation is used up or expires, or
5
+ * the user dismisses it. The behaviour is described in `docs/strands.md` → "Joining while the
6
+ * inviter is offline".
7
+ *
8
+ * `CadreNode` owns every side effect and hands this module plain functions
9
+ * ({@link PendingJoinRunnerDeps}): read the rows, run one attempt (its own `formStrand`), write
10
+ * an outcome, say whether this machine is an owner, and the clock. This module owns the policy:
11
+ * when to attempt, how to read an attempt's failure, and how to settle an outcome write that
12
+ * another machine's write beat.
13
+ *
14
+ * Only owner machines run it, because every `PendingJoin` write is owner-signed. Letting a
15
+ * machine without an owner key finish a join is `blocked/decide-non-owner-machine-completes-a-pending-join`;
16
+ * that changes {@link PendingJoinRunnerDeps.isOwner} and the write functions, not this loop.
17
+ */
18
+ import debug from 'debug';
19
+ import { canonicalJson } from './canonical-json.js';
20
+ import { PendingJoinChangedError } from './control-database.js';
21
+ import { FormationPostApprovalError, FormationRejectedError, FormationUnreachableError } from './strand-formation-rejection.js';
22
+ import { formationDeadlines } from './strand-formation-deadlines.js';
23
+ import { MEMBERSHIP_INVITE_TTL_MS } from './strand-formation-manager.js';
24
+ import { defaultTimeoutScheduler } from './timeout-scheduler.js';
25
+ const log = debug('sereus:cadre:pending-join');
26
+ /** How often an owner machine re-reads the party's pending joins (it also reads once right after start). */
27
+ export const PENDING_JOIN_POLL_MS = 30000;
28
+ /** Ceiling on the wait between two attempts of one join on one machine, before jitter. */
29
+ export const PENDING_JOIN_MAX_BACKOFF_MS = 10 * 60000;
30
+ /**
31
+ * The longest a join is tried for. A row's `ExpiresAt` is the earlier of this and the
32
+ * invitation's own expiration, which the inviter chose.
33
+ */
34
+ export const MAX_PENDING_JOIN_MS = 30 * 24 * 3600000;
35
+ /** Background attempts one machine runs at once. An explicit `requestJoin` does not wait for a slot. */
36
+ const MAX_BACKGROUND_ATTEMPTS = 2;
37
+ /** Each retry delay is moved by up to this fraction either way, so machines that failed together do not retry together. */
38
+ const RETRY_JITTER = 0.2;
39
+ /** Re-read-and-rewrite rounds after another machine's write won, before the next pass decides instead. */
40
+ const MAX_OUTCOME_WRITE_ROUNDS = 3;
41
+ /**
42
+ * A `joined` outcome replaces anything still live, since a join that happened cannot be undone
43
+ * by another machine's failure. A failure replaces a pending row of the same request: only a
44
+ * re-issue of a row written alone writes one, unchanged, so it decides nothing. Any other live
45
+ * row wins, and a fresh request is then decided again by the loop.
46
+ */
47
+ function resolveLostWrite(live, next) {
48
+ if (live === null)
49
+ return 'gone';
50
+ if (live.Outcome === 'joined')
51
+ return 'adopt';
52
+ if (next.Outcome === 'joined')
53
+ return 'rewrite';
54
+ return live.Outcome === null && live.RequestedAt === next.RequestedAt ? 'rewrite' : 'adopt';
55
+ }
56
+ /**
57
+ * The pending row a `requestJoin` writes: tried until the invitation's own expiration or
58
+ * {@link MAX_PENDING_JOIN_MS} from now, whichever comes first.
59
+ */
60
+ export function requestedPendingJoin(id, encodedInvitation, disclosure, invitationExpiresAt, now) {
61
+ return {
62
+ Id: id,
63
+ Invitation: encodedInvitation,
64
+ Disclosure: canonicalJson(disclosure),
65
+ RequestedAt: now,
66
+ ExpiresAt: Math.min(invitationExpiresAt, now + MAX_PENDING_JOIN_MS),
67
+ Outcome: null,
68
+ OutcomeAt: null,
69
+ StrandId: null,
70
+ MembershipInvite: null,
71
+ FailureCode: null,
72
+ FailureReason: null,
73
+ };
74
+ }
75
+ /** A row's stored disclosure, as the next attempt sends it. Owner-signed, so anything but an object is a bug. */
76
+ export function parseStoredDisclosure(row) {
77
+ const parsed = JSON.parse(row.Disclosure);
78
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
79
+ throw new Error(`PendingJoin ${row.Id}: the stored disclosure is not a JSON object`);
80
+ }
81
+ return parsed;
82
+ }
83
+ /** A `joined` row's `MembershipInvite` column, or null when it is not the `{inviteKey, invitePrivateKey}` JSON the loop writes. */
84
+ export function parseStoredMembershipInvite(text) {
85
+ let parsed;
86
+ try {
87
+ parsed = JSON.parse(text);
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ if (typeof parsed !== 'object' || parsed === null)
93
+ return null;
94
+ const { inviteKey, invitePrivateKey } = parsed;
95
+ return typeof inviteKey === 'string' && typeof invitePrivateKey === 'string' ? { inviteKey, invitePrivateKey } : null;
96
+ }
97
+ /**
98
+ * The membership invitations `joined` rows carry that this process has not staged yet, so a
99
+ * closed strand joined on one owner machine can be seated by whichever machine launches it
100
+ * first. Rows older than {@link MEMBERSHIP_INVITE_TTL_MS} are skipped: their invitation is dead.
101
+ * `alreadyStaged` holds invite keys; a malformed column is skipped and logged.
102
+ */
103
+ export function membershipInvitesToStage(rows, alreadyStaged, now) {
104
+ const toStage = [];
105
+ for (const row of rows) {
106
+ if (row.Outcome !== 'joined' || row.StrandId === null || row.MembershipInvite === null || row.OutcomeAt === null)
107
+ continue;
108
+ if (now >= row.OutcomeAt + MEMBERSHIP_INVITE_TTL_MS)
109
+ continue;
110
+ const invite = parseStoredMembershipInvite(row.MembershipInvite);
111
+ if (!invite) {
112
+ log('pending join %s: its MembershipInvite column does not parse; not staged', row.Id);
113
+ continue;
114
+ }
115
+ if (!alreadyStaged.has(invite.inviteKey)) {
116
+ toStage.push({ rowId: row.Id, strandId: row.StrandId, invite });
117
+ }
118
+ }
119
+ return toStage;
120
+ }
121
+ /** The row's columns without its stamp, as a replacement is written. */
122
+ function fieldsOf(row) {
123
+ const { StampId: _stampId, ...fields } = row;
124
+ return fields;
125
+ }
126
+ function joinedFields(row, result, now) {
127
+ const invite = result.membershipInvite;
128
+ return {
129
+ ...fieldsOf(row),
130
+ Outcome: 'joined',
131
+ OutcomeAt: now,
132
+ StrandId: result.strandId,
133
+ MembershipInvite: invite ? JSON.stringify({ inviteKey: invite.inviteKey, invitePrivateKey: invite.invitePrivateKey }) : null,
134
+ FailureCode: null,
135
+ FailureReason: null,
136
+ };
137
+ }
138
+ function failedFields(row, code, reason, now) {
139
+ return {
140
+ ...fieldsOf(row),
141
+ Outcome: 'failed',
142
+ OutcomeAt: now,
143
+ StrandId: null,
144
+ MembershipInvite: null,
145
+ FailureCode: code,
146
+ FailureReason: reason,
147
+ };
148
+ }
149
+ function errorText(error) {
150
+ return error instanceof Error ? error.message : String(error);
151
+ }
152
+ /** A fraction in [0, 1) fixed by `key` (32-bit FNV-1a): the same machine staggers the same row the same way every time. */
153
+ function stableFraction(key) {
154
+ let hash = 0x811c9dc5;
155
+ for (let i = 0; i < key.length; i++) {
156
+ hash ^= key.charCodeAt(i);
157
+ hash = Math.imul(hash, 0x01000193);
158
+ }
159
+ return (hash >>> 0) / 4294967296;
160
+ }
161
+ export class PendingJoinRunner {
162
+ constructor(deps) {
163
+ this.deps = deps;
164
+ this.tracked = new Map();
165
+ /** Rows whose latest write by this machine committed with no control connection: id → the stamp written. */
166
+ this.writtenAlone = new Map();
167
+ this.dueQueue = [];
168
+ this.running = false;
169
+ this.passInFlight = null;
170
+ this.passAgain = false;
171
+ this.backgroundAttempts = 0;
172
+ this.scheduler = deps.scheduler ?? defaultTimeoutScheduler;
173
+ this.now = deps.now ?? Date.now;
174
+ this.random = deps.random ?? Math.random;
175
+ const deadlines = formationDeadlines(deps.linkRoundTripMs);
176
+ this.baseDelayMs = 2 * deadlines.dialMs;
177
+ this.spentConfirmDelayMs = 2 * deadlines.sessionMs;
178
+ }
179
+ /** Start passes: one at once, then every {@link PENDING_JOIN_POLL_MS}. */
180
+ start() {
181
+ if (this.running)
182
+ return;
183
+ this.running = true;
184
+ this.schedulePass(0);
185
+ }
186
+ /**
187
+ * Stop scheduling. Clears every timer and starts no new attempt.
188
+ *
189
+ * NOTE: an attempt already in flight is not awaited, because `formStrand` takes no abort
190
+ * signal. An approval that lands while the node stops can lose its local records; the row then
191
+ * stays pending, and the next start's attempt answers `token-spent`, which fails the row after
192
+ * its confirming attempt. Honest and bounded; if it shows up in practice, give `formStrand` a
193
+ * signal and abort it here.
194
+ */
195
+ stop() {
196
+ this.running = false;
197
+ this.clearPollTimer();
198
+ for (const entry of this.tracked.values())
199
+ this.cancelTimer(entry);
200
+ this.tracked.clear();
201
+ this.dueQueue.length = 0;
202
+ this.writtenAlone.clear();
203
+ }
204
+ /** Run a pass now, for a change that may make one useful (this machine just became an owner). */
205
+ kick() {
206
+ if (!this.running)
207
+ return;
208
+ this.clearPollTimer();
209
+ void this.pass();
210
+ }
211
+ /**
212
+ * Try `row` on this machine at once, outside the background slots, and report the status
213
+ * after the attempt. Joins an attempt already running for the row.
214
+ */
215
+ async attemptNow(row) {
216
+ const entry = this.adopt(row);
217
+ if (entry.row.Outcome === null) {
218
+ await this.startAttempt(entry);
219
+ }
220
+ return this.describe(entry.row, entry);
221
+ }
222
+ /** This machine's view of `row`: the row's own state, with this machine's attempt state laid over a pending row. */
223
+ statusOf(row) {
224
+ return this.describe(row, this.tracked.get(row.Id));
225
+ }
226
+ /** Record a `PendingJoin` write this machine made outside the loop (a `requestJoin`), for {@link reissueWritesMadeAlone}. */
227
+ noteWritten(row) {
228
+ if (this.deps.isAlone()) {
229
+ this.writtenAlone.set(row.Id, row.StampId);
230
+ }
231
+ else {
232
+ this.writtenAlone.delete(row.Id);
233
+ }
234
+ }
235
+ /** Stop tracking a row the user dismissed. An attempt in flight finishes; its outcome write then finds the row gone. */
236
+ forgetRow(id) {
237
+ const entry = this.tracked.get(id);
238
+ if (entry)
239
+ this.forget(entry);
240
+ }
241
+ /**
242
+ * Re-write, with identical content under a fresh stamp, each row this machine last wrote while
243
+ * it had no control connection, so the other machines receive it. Called on the control
244
+ * connection growth edge. A row someone wrote since is left alone.
245
+ *
246
+ * NOTE: in memory only, so a row written alone by a process that stopped before it reconnected
247
+ * reaches the party only with that row's next write. A sweep of every row this machine wrote,
248
+ * on the first connection after start, would cover that, at the cost of one permanent
249
+ * `Revocation` tombstone per row per start. Revisit if other machines are seen missing such rows.
250
+ */
251
+ async reissueWritesMadeAlone() {
252
+ if (this.writtenAlone.size === 0)
253
+ return;
254
+ if (!(await this.deps.isOwner())) {
255
+ this.writtenAlone.clear();
256
+ return;
257
+ }
258
+ for (const [id, stampId] of [...this.writtenAlone]) {
259
+ try {
260
+ const live = await this.deps.readRow(id);
261
+ if (!live || live.StampId !== stampId) {
262
+ this.writtenAlone.delete(id);
263
+ continue;
264
+ }
265
+ const written = await this.deps.replace(stampId, fieldsOf(live));
266
+ this.noteWritten(written);
267
+ const entry = this.tracked.get(id);
268
+ if (entry?.row.StampId === stampId)
269
+ entry.row = written;
270
+ log('pending join %s: re-issued the row written while alone', id);
271
+ }
272
+ catch (error) {
273
+ log('pending join %s: re-issuing the row written while alone failed; kept for the next connection: %s', id, errorText(error));
274
+ }
275
+ }
276
+ }
277
+ schedulePass(delayMs) {
278
+ if (!this.running)
279
+ return;
280
+ this.clearPollTimer();
281
+ this.pollTimer = this.scheduler.setTimeout(() => {
282
+ this.pollTimer = undefined;
283
+ void this.pass();
284
+ }, delayMs);
285
+ }
286
+ clearPollTimer() {
287
+ if (this.pollTimer !== undefined) {
288
+ this.scheduler.clearTimeout(this.pollTimer);
289
+ this.pollTimer = undefined;
290
+ }
291
+ }
292
+ /** One pass at a time; a kick during a pass runs one more after it. */
293
+ pass() {
294
+ if (this.passInFlight) {
295
+ this.passAgain = true;
296
+ return this.passInFlight;
297
+ }
298
+ const run = this.runPass()
299
+ .catch((error) => log('pending-join pass failed; the next one retries: %s', errorText(error)))
300
+ .finally(() => {
301
+ this.passInFlight = null;
302
+ if (this.passAgain && this.running) {
303
+ this.passAgain = false;
304
+ void this.pass();
305
+ return;
306
+ }
307
+ this.schedulePass(PENDING_JOIN_POLL_MS);
308
+ });
309
+ this.passInFlight = run;
310
+ return run;
311
+ }
312
+ async runPass() {
313
+ if (!this.running)
314
+ return;
315
+ if (!(await this.deps.isOwner())) {
316
+ log('pending-join pass skipped: this machine is not an enrolled owner');
317
+ this.pauseAttempts();
318
+ return;
319
+ }
320
+ const rows = await this.deps.readRows();
321
+ if (!this.running)
322
+ return;
323
+ this.deps.observeRows(rows);
324
+ const live = new Set(rows.map((row) => row.Id));
325
+ for (const entry of [...this.tracked.values()]) {
326
+ if (!live.has(entry.row.Id) && !entry.attempt)
327
+ this.forget(entry);
328
+ }
329
+ for (const row of rows) {
330
+ if (!this.running)
331
+ return;
332
+ try {
333
+ await this.consider(row);
334
+ }
335
+ catch (error) {
336
+ log('pending join %s: this pass could not handle it; the next one retries: %s', row.Id, errorText(error));
337
+ }
338
+ }
339
+ }
340
+ /** Drop every scheduled attempt; the first pass that finds this machine an owner again schedules afresh. */
341
+ pauseAttempts() {
342
+ for (const entry of this.tracked.values()) {
343
+ this.cancelTimer(entry);
344
+ entry.nextAttemptAt = undefined;
345
+ }
346
+ for (const entry of this.dueQueue)
347
+ entry.queued = false;
348
+ this.dueQueue.length = 0;
349
+ }
350
+ /** Bring one read row into this machine's schedule. */
351
+ async consider(row) {
352
+ const entry = this.adopt(row);
353
+ if (entry.attempt)
354
+ return;
355
+ if (entry.row.Outcome !== null) {
356
+ this.emitIfChanged(entry);
357
+ await this.ageOut(entry);
358
+ return;
359
+ }
360
+ if (entry.joinedHere || this.now() >= entry.row.ExpiresAt) {
361
+ await this.startAttempt(entry);
362
+ return;
363
+ }
364
+ if (entry.timer === undefined && !entry.queued) {
365
+ this.scheduleAttempt(entry, Math.floor(stableFraction(`${this.deps.selfId}|${entry.row.Id}`) * this.baseDelayMs));
366
+ }
367
+ this.emitIfChanged(entry);
368
+ }
369
+ /**
370
+ * The tracked entry for `row`, updated to it. A row asked for again (`RequestedAt` changed)
371
+ * starts this machine's counting over; a finished row stops its schedule.
372
+ */
373
+ adopt(row) {
374
+ const entry = this.tracked.get(row.Id);
375
+ if (!entry) {
376
+ const fresh = { row, failures: 0, queued: false, trying: false, spentOnce: false };
377
+ this.tracked.set(row.Id, fresh);
378
+ return fresh;
379
+ }
380
+ if (entry.row.StampId === row.StampId)
381
+ return entry;
382
+ if (row.Outcome !== null || entry.row.RequestedAt !== row.RequestedAt) {
383
+ this.cancelTimer(entry);
384
+ entry.failures = 0;
385
+ entry.spentOnce = false;
386
+ entry.lastError = undefined;
387
+ entry.nextAttemptAt = undefined;
388
+ }
389
+ if (row.Outcome === 'joined')
390
+ entry.joinedHere = undefined;
391
+ entry.row = row;
392
+ return entry;
393
+ }
394
+ scheduleAttempt(entry, delayMs) {
395
+ if (!this.running)
396
+ return;
397
+ this.cancelTimer(entry);
398
+ const now = this.now();
399
+ // A row already past its expiry keeps the full delay: its due attempt is an outcome write,
400
+ // and clamping to a past expiry would retry a failing write with no wait at all.
401
+ const at = now < entry.row.ExpiresAt ? Math.min(now + delayMs, entry.row.ExpiresAt) : now + delayMs;
402
+ entry.nextAttemptAt = at;
403
+ entry.timer = this.scheduler.setTimeout(() => {
404
+ entry.timer = undefined;
405
+ this.enqueue(entry);
406
+ }, Math.max(0, at - this.now()));
407
+ }
408
+ cancelTimer(entry) {
409
+ if (entry.timer !== undefined) {
410
+ this.scheduler.clearTimeout(entry.timer);
411
+ entry.timer = undefined;
412
+ }
413
+ }
414
+ enqueue(entry) {
415
+ if (!this.running || this.tracked.get(entry.row.Id) !== entry || entry.queued)
416
+ return;
417
+ entry.queued = true;
418
+ this.dueQueue.push(entry);
419
+ this.pump();
420
+ }
421
+ /** Start due attempts while background slots are free. */
422
+ pump() {
423
+ while (this.running && this.backgroundAttempts < MAX_BACKGROUND_ATTEMPTS && this.dueQueue.length > 0) {
424
+ const entry = this.dueQueue.shift();
425
+ entry.queued = false;
426
+ if (entry.attempt || this.tracked.get(entry.row.Id) !== entry)
427
+ continue;
428
+ this.backgroundAttempts++;
429
+ void this.startAttempt(entry).finally(() => {
430
+ this.backgroundAttempts--;
431
+ this.pump();
432
+ });
433
+ }
434
+ }
435
+ /** Run one attempt for `entry`, or join the one already running. Never rejects. */
436
+ startAttempt(entry) {
437
+ if (entry.attempt)
438
+ return entry.attempt;
439
+ this.cancelTimer(entry);
440
+ if (entry.queued) {
441
+ entry.queued = false;
442
+ this.dueQueue.splice(this.dueQueue.indexOf(entry), 1);
443
+ }
444
+ // NOTE: only a join is held for re-writing (`joinedHere`); a failed write of a final failure is
445
+ // retried by attempting again, so a `local` failure whose write failed ends up recorded as
446
+ // `token-spent`. If apps come to branch on that code, hold failures the same way.
447
+ const run = this.attemptOnce(entry)
448
+ .catch((error) => {
449
+ entry.trying = false;
450
+ log('pending join %s: attempt failed on this machine: %s', entry.row.Id, errorText(error));
451
+ if (entry.row.Outcome === null && this.tracked.get(entry.row.Id) === entry) {
452
+ this.retryLater(entry, { code: 'local', reason: errorText(error) });
453
+ }
454
+ })
455
+ .finally(() => {
456
+ entry.attempt = undefined;
457
+ });
458
+ entry.attempt = run;
459
+ return run;
460
+ }
461
+ async attemptOnce(entry) {
462
+ if (entry.row.Outcome !== null)
463
+ return;
464
+ if (entry.joinedHere) {
465
+ await this.writeOutcome(entry, entry.joinedHere);
466
+ return;
467
+ }
468
+ if (this.now() >= entry.row.ExpiresAt) {
469
+ const reason = `No attempt starts after ${new Date(entry.row.ExpiresAt).toISOString()}`;
470
+ await this.writeOutcome(entry, failedFields(entry.row, 'expired', reason, this.now()));
471
+ return;
472
+ }
473
+ entry.trying = true;
474
+ entry.nextAttemptAt = undefined;
475
+ this.emitIfChanged(entry);
476
+ let result;
477
+ try {
478
+ result = await this.deps.attempt(entry.row);
479
+ }
480
+ catch (error) {
481
+ entry.trying = false;
482
+ await this.onAttemptFailed(entry, error);
483
+ return;
484
+ }
485
+ entry.trying = false;
486
+ log('pending join %s: approved, strand %s', entry.row.Id, result.strandId);
487
+ entry.joinedHere = joinedFields(entry.row, result, this.now());
488
+ await this.writeOutcome(entry, entry.joinedHere);
489
+ }
490
+ /** Read a failed attempt: retry, confirm a spent token, or fail the row. */
491
+ async onAttemptFailed(entry, error) {
492
+ const now = this.now();
493
+ if (error instanceof FormationRejectedError && error.code === 'token-spent') {
494
+ await this.onSpent(entry, error);
495
+ }
496
+ else if (error instanceof FormationPostApprovalError) {
497
+ log('pending join %s: approved, then a step on this machine failed; failing the row', entry.row.Id);
498
+ await this.writeOutcome(entry, failedFields(entry.row, 'local', error.message, now));
499
+ }
500
+ else if (error instanceof FormationRejectedError && !error.retryable) {
501
+ log('pending join %s: refused (%s); failing the row', entry.row.Id, error.code);
502
+ await this.writeOutcome(entry, failedFields(entry.row, error.code, error.reason, now));
503
+ }
504
+ else if (error instanceof FormationRejectedError) {
505
+ this.retryLater(entry, { code: error.code, reason: error.reason });
506
+ }
507
+ else if (error instanceof FormationUnreachableError) {
508
+ this.retryLater(entry, { code: 'unreachable', reason: error.message });
509
+ }
510
+ else {
511
+ // Retrying is safe: nothing is attempted after the row's expiry.
512
+ log('pending join %s: attempt threw an unexpected error; retrying: %s', entry.row.Id, errorText(error));
513
+ this.retryLater(entry, { code: 'local', reason: errorText(error) });
514
+ }
515
+ }
516
+ /**
517
+ * A `token-spent` answer may mean another owner machine of this party won the same
518
+ * invitation. Adopt its outcome if the row already shows one; otherwise confirm once, after
519
+ * long enough for that machine's `joined` write to arrive, before failing the row.
520
+ */
521
+ async onSpent(entry, error) {
522
+ const live = await this.deps.readRow(entry.row.Id);
523
+ if (!live) {
524
+ this.forget(entry);
525
+ return;
526
+ }
527
+ this.adopt(live);
528
+ if (live.Outcome !== null) {
529
+ this.emitIfChanged(entry);
530
+ return;
531
+ }
532
+ if (entry.spentOnce) {
533
+ log('pending join %s: token spent again after the confirming wait; failing the row', entry.row.Id);
534
+ await this.writeOutcome(entry, failedFields(entry.row, 'token-spent', error.reason, this.now()));
535
+ return;
536
+ }
537
+ entry.spentOnce = true;
538
+ entry.lastError = { code: 'token-spent', reason: error.reason };
539
+ this.scheduleAttempt(entry, this.spentConfirmDelayMs);
540
+ this.emitIfChanged(entry);
541
+ }
542
+ retryLater(entry, lastError) {
543
+ entry.failures++;
544
+ entry.lastError = lastError;
545
+ this.scheduleAttempt(entry, this.backoffMs(entry.failures));
546
+ this.emitIfChanged(entry);
547
+ }
548
+ /** `min(base × 2^(n−1), max)`, moved by up to ±{@link RETRY_JITTER}. */
549
+ backoffMs(failures) {
550
+ const capped = Math.min(this.baseDelayMs * 2 ** (failures - 1), PENDING_JOIN_MAX_BACKOFF_MS);
551
+ return Math.round(capped * (1 + (this.random() * 2 - 1) * RETRY_JITTER));
552
+ }
553
+ /**
554
+ * Write `next` over the row this machine last read. When another write got there first,
555
+ * re-read and settle by {@link resolveLostWrite}. A row the user dismissed meanwhile is not
556
+ * recreated; a join this machine made is still remembered machine-locally, as every
557
+ * `formStrand` is.
558
+ */
559
+ async writeOutcome(entry, next) {
560
+ let expected = entry.row;
561
+ for (let round = 0; round < MAX_OUTCOME_WRITE_ROUNDS; round++) {
562
+ try {
563
+ const written = await this.deps.replace(expected.StampId, next);
564
+ this.noteWritten(written);
565
+ this.settle(written);
566
+ log('pending join %s: recorded %s%s', next.Id, next.Outcome, next.FailureCode ? ` (${next.FailureCode})` : '');
567
+ return;
568
+ }
569
+ catch (error) {
570
+ if (!(error instanceof PendingJoinChangedError))
571
+ throw error;
572
+ }
573
+ const live = await this.deps.readRow(next.Id);
574
+ const resolution = resolveLostWrite(live, next);
575
+ if (resolution === 'gone') {
576
+ log('pending join %s: dismissed while this machine was recording %s; not recreated', next.Id, next.Outcome);
577
+ this.forget(entry);
578
+ return;
579
+ }
580
+ if (resolution === 'adopt') {
581
+ this.settle(live);
582
+ return;
583
+ }
584
+ expected = live;
585
+ }
586
+ log('pending join %s: another machine kept replacing the row; the next pass decides', next.Id);
587
+ }
588
+ settle(row) {
589
+ const entry = this.adopt(row);
590
+ if (row.Outcome !== null)
591
+ entry.joinedHere = undefined;
592
+ this.emitIfChanged(entry);
593
+ }
594
+ /**
595
+ * Remove a finished row {@link MEMBERSHIP_INVITE_TTL_MS} after its outcome: the membership
596
+ * invitation it carries is dead by then, and an app that was not running has seen the strand
597
+ * through `strand:discovered`. Only while connected, like every write nothing waits on.
598
+ *
599
+ * NOTE: `deletePendingJoin` removes whichever incarnation is live, so a `requestJoin` of the
600
+ * same invitation landing on another machine in the same instant can be removed with it. The
601
+ * user asks again; revisit if that is ever seen.
602
+ */
603
+ async ageOut(entry) {
604
+ const { OutcomeAt } = entry.row;
605
+ if (OutcomeAt === null || this.now() < OutcomeAt + MEMBERSHIP_INVITE_TTL_MS || this.deps.isAlone())
606
+ return;
607
+ await this.deps.remove(entry.row.Id);
608
+ log('pending join %s: removed, %d days after its outcome', entry.row.Id, MEMBERSHIP_INVITE_TTL_MS / (24 * 3600000));
609
+ this.forget(entry);
610
+ }
611
+ forget(entry) {
612
+ this.cancelTimer(entry);
613
+ if (this.tracked.get(entry.row.Id) === entry)
614
+ this.tracked.delete(entry.row.Id);
615
+ }
616
+ emitIfChanged(entry) {
617
+ if (!this.running || this.tracked.get(entry.row.Id) !== entry)
618
+ return;
619
+ const status = this.describe(entry.row, entry);
620
+ const key = JSON.stringify(status);
621
+ if (key === entry.lastEmitted)
622
+ return;
623
+ entry.lastEmitted = key;
624
+ this.deps.emit(status);
625
+ }
626
+ describe(row, entry) {
627
+ const base = {
628
+ id: row.Id,
629
+ sAppId: this.deps.sAppIdOf(row),
630
+ requestedAt: row.RequestedAt,
631
+ expiresAt: row.ExpiresAt,
632
+ };
633
+ if (row.Outcome === 'joined') {
634
+ return { ...base, state: 'joined', strandId: row.StrandId ?? undefined };
635
+ }
636
+ if (row.Outcome === 'failed') {
637
+ return { ...base, state: 'failed', failure: { code: row.FailureCode ?? '', reason: row.FailureReason ?? '' } };
638
+ }
639
+ const local = entry && entry.row.Outcome === null && entry.row.RequestedAt === row.RequestedAt ? entry : undefined;
640
+ if (local?.trying) {
641
+ return { ...base, state: 'trying' };
642
+ }
643
+ if (local?.lastError && local.nextAttemptAt !== undefined) {
644
+ return { ...base, state: 'waiting', nextAttemptAt: local.nextAttemptAt, lastError: local.lastError };
645
+ }
646
+ return { ...base, state: 'pending' };
647
+ }
648
+ }
649
+ //# sourceMappingURL=pending-join-runner.js.map