@real-life/trust-protocol 0.5.0 → 0.6.1

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 (44) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +97 -34
  3. package/dist/adapters/vti-mediator.d.ts +18 -5
  4. package/dist/adapters/vti-mediator.d.ts.map +1 -1
  5. package/dist/adapters/vti-mediator.js +143 -17
  6. package/dist/adapters/vti-mediator.js.map +1 -1
  7. package/dist/codec.d.ts +2 -9
  8. package/dist/codec.d.ts.map +1 -1
  9. package/dist/codec.js +61 -96
  10. package/dist/codec.js.map +1 -1
  11. package/dist/engine.d.ts +66 -2
  12. package/dist/engine.d.ts.map +1 -1
  13. package/dist/engine.js +393 -23
  14. package/dist/engine.js.map +1 -1
  15. package/dist/holder-api.d.ts +22 -2
  16. package/dist/holder-api.d.ts.map +1 -1
  17. package/dist/holder-api.js +43 -3
  18. package/dist/holder-api.js.map +1 -1
  19. package/dist/{holder-state-v2.d.ts → holder-state-v3.d.ts} +210 -207
  20. package/dist/holder-state-v3.d.ts.map +1 -0
  21. package/dist/holder-state-v3.js +2 -0
  22. package/dist/holder-state-v3.js.map +1 -0
  23. package/dist/index.d.ts +2 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/internal.d.ts +1 -2
  27. package/dist/internal.d.ts.map +1 -1
  28. package/dist/internal.js.map +1 -1
  29. package/dist/results.d.ts +42 -3
  30. package/dist/results.d.ts.map +1 -1
  31. package/dist/results.js.map +1 -1
  32. package/dist/state.d.ts +22 -9
  33. package/dist/state.d.ts.map +1 -1
  34. package/dist/state.js.map +1 -1
  35. package/package.json +4 -6
  36. package/schemas/{holder-state-v2.schema.json → holder-state-v3.schema.json} +50 -5
  37. package/dist/holder-state-v1.d.ts +0 -801
  38. package/dist/holder-state-v1.d.ts.map +0 -1
  39. package/dist/holder-state-v1.js +0 -2
  40. package/dist/holder-state-v1.js.map +0 -1
  41. package/dist/holder-state-v2.d.ts.map +0 -1
  42. package/dist/holder-state-v2.js +0 -2
  43. package/dist/holder-state-v2.js.map +0 -1
  44. package/schemas/holder-state-v1.schema.json +0 -3880
package/dist/engine.js CHANGED
@@ -42,7 +42,12 @@
42
42
  // that give-up runs first as an operation of its own on the one commit
43
43
  // path (resume's CommitOp, its timer carried to the replay's answer)
44
44
  // - outbound of an act starts at attempt 1 with nextAttemptAt =
45
- // when + ceil(backoff(1)); resume() re-emits only items that are due
45
+ // when + ceil(backoff(1)); the act names that or the document's send
46
+ // deadline, whichever is first, in nextDueAt (a repeat names its live
47
+ // documents' the same way), so an idle holder's timer reaches the
48
+ // retries, the give-up and the journal's end. An act without documents
49
+ // names no journal deadline: the next resume names the end of its entry
50
+ // (a tick does not); resume() re-emits only items that are due
46
51
  // and pauses re-emission while the last step is awaiting-transport
47
52
  // (the adapter holds the item) and never re-emits an accepted one. It
48
53
  // gives up every non-terminal item (submitted, held or accepted) at
@@ -58,7 +63,24 @@
58
63
  // due for the give-up, at their current attempt; one due for it is
59
64
  // given up in a commit of its own first (6115: never handed out again). Idempotency is bounded by the journal's retention:
60
65
  // max(P90D, retry.horizonMs), the key-retention of Delivery §7 that
61
- // bounds effects and their acks; after it the same actId is a new act
66
+ // bounds effects and their acks, counted from the act's commit and
67
+ // from the last step of each of its documents, whichever is later (a
68
+ // document not yet terminal keeps the act): the act's result stays as
69
+ // long as its documents' completion does; after it the same actId is
70
+ // a new act. The retention runs in holder time (an entry leaves at the
71
+ // tick at or after its end, `when` raised to lastWhen): a clock set
72
+ // ahead by more than the retention ends it early in real time. The
73
+ // bound is one of time, not of count: one entry per human act (its
74
+ // events and document digests, the documents themselves kept beside
75
+ // it), removed by the tick that is due. A replay
76
+ // answers from the journal alone, before any preparation: no key, no
77
+ // entropy, no new document; the time it reads at changes nothing in it
78
+ // - replay(actId, when) is the journal read without the act: in the
79
+ // queue (after the settling of an unsettled commit), on the stored
80
+ // state, committing nothing, lastWhen included; it names which of the
81
+ // act's documents a repeat at max(when, lastWhen) would hand out, and
82
+ // gives none up. unknown-act: no entry under the id (never committed,
83
+ // or past the retention)
62
84
  // - KeyProvider, Log and Access rejections inside a preparation become
63
85
  // InfraError('key-provider' | 'log' | 'access') with the cause. Known
64
86
  // limitation: a provider's argument errors (a module passing a label
@@ -75,13 +97,48 @@
75
97
  // stages again, which re-checks the effect cache at stage 4
76
98
  // - an ack is stored sealed (StoredAck: envelope, digest, recipient) in
77
99
  // its effect, so every re-send is byte-identical down to the envelope.
78
- // It stays `sent: false` until any report on its digest. Unsent, it has
79
- // the outbox's retry schedule: attempt 1 at the receive, re-offered by
80
- // resume when due (attempt + 1, backoff), counted into nextDueAt, and at
81
- // its horizon (effect commit + horizonMs) marked sent by the horizon
82
- // (`sentBy: 'horizon'`, no event: acks have no trias). A duplicate's
83
- // re-send carries attempt + 1 and does not move the schedule (it
84
- // commits nothing beyond lastWhen)
100
+ // It stays `sent: false` until a service takes it (report accepted or
101
+ // delivered: `sentBy: 'accepted'`), the adapter reports it failed
102
+ // (`'failed'`), or its horizon passes (effect commit + horizonMs:
103
+ // `'horizon'`, no event: acks have no trias) — Delivery RLTP-DEL-6330.
104
+ // An awaiting-transport report keeps it unsent and records `held`; a
105
+ // held ack is not re-offered by resume (the adapter has it) and stays
106
+ // in pendingTransport. Unsent and not held, it has the outbox's retry
107
+ // schedule: attempt 1 at the receive, re-offered by resume when due
108
+ // (attempt + 1, backoff), counted into nextDueAt. Sent or not, it stays
109
+ // with its effect until the effect expires (RLTP-DEL-6335). A
110
+ // duplicate's re-send carries attempt + 1 and does not move the
111
+ // schedule; it commits the answer it makes due (6330), else nothing
112
+ // beyond lastWhen
113
+ // - custody (Delivery RLTP-DEL-6120 to 6135): pendingTransport(when) is
114
+ // the set the holder keeps for transport — every outbox item neither
115
+ // terminal nor accepted, held ones included, and every unsent ack —
116
+ // each with its deadline (notAfter: the horizon from the first
117
+ // submission, or the type's sender lifetime if earlier) and without
118
+ // those whose deadline has passed at `when`, whether or not a resume
119
+ // has recorded the give-up yet. It answers only after a resume of this
120
+ // engine has completed (a resume that throws withdraws that again), and
121
+ // its entries are issued to this engine: mayHandOver(entry, when)
122
+ // accepts only an entry this engine issued, re-reads the store and says
123
+ // yes only while the engine is open and the entry is still pending
124
+ // there with the same envelope before its deadline. handOver(entry,
125
+ // when, start) is the same check with the deposit's start inside the
126
+ // queue: nothing of this engine runs between the check and the start.
127
+ // A duplicate makes an answer due, stored with the ack (answers,
128
+ // answerDue: kept across a lock): a `resend` entry numbered by it,
129
+ // beside the ack's retries and independent of their state, deadline the
130
+ // effect's retention; the begun hand-over ends it (a commit): it is one
131
+ // hand-over with no retries (Delivery 6330), so a deposit the wire
132
+ // refuses is not tried again — the sender's next redelivery asks anew;
133
+ // a duplicate while one is due is answered by it (the same bytes); a resume that throws, its settling
134
+ // included, withdraws the authority until one completes. The check is
135
+ // this engine's: a second engine over the same store commits through
136
+ // CAS but is not serialized with this one's hand-overs, so one opening
137
+ // per store at a time is the app's to keep (its writer lock). close() ends the
138
+ // engine: every later operation rejects with InfraError('closed'),
139
+ // mayHandOver answers false, and no entry issued before authorizes
140
+ // anything. An operation already running when close() is called
141
+ // completes
85
142
  // - outbound of one commit, in order: outbox re-emissions, released
86
143
  // acks, new documents
87
144
  // - resume runs duties after the outbox, ordered by dueAt (absent: due
@@ -355,6 +412,7 @@ function renameAcks(effects, renames) {
355
412
  }
356
413
  return out ?? effects;
357
414
  }
415
+ const closedError = () => new InfraError('closed', 'the holder was closed');
358
416
  const checkWhen = (when) => {
359
417
  if (!Number.isSafeInteger(when) || when < 0)
360
418
  throw new TypeError(`when must be non-negative integer milliseconds, got ${String(when)}`);
@@ -404,6 +462,12 @@ class HolderEngine {
404
462
  #chain = Promise.resolve();
405
463
  /** a commit whose outcome is unknown, settled before the next operation */
406
464
  #unsettled = null;
465
+ /** close() was called: nothing more runs, nothing issued authorizes */
466
+ #closed = false;
467
+ /** a resume of this engine completed (and none threw since) */
468
+ #resumed = false;
469
+ /** the transport entries this engine issued (RLTP-DEL-6130) */
470
+ #issued = new WeakSet();
407
471
  constructor(ports, crypto, modules, opts = {}) {
408
472
  if (opts.check !== undefined && typeof opts.check !== 'function')
409
473
  throw new TypeError('check must be a function');
@@ -601,7 +665,14 @@ class HolderEngine {
601
665
  }
602
666
  // ── the queue ────────────────────────────────────────────────────────
603
667
  #enqueue(fn) {
604
- const p = this.#chain.then(async () => { await this.#settle(); return fn(); });
668
+ if (this.#closed)
669
+ return Promise.reject(closedError());
670
+ const p = this.#chain.then(async () => {
671
+ if (this.#closed)
672
+ throw closedError();
673
+ await this.#settle();
674
+ return fn();
675
+ });
605
676
  this.#chain = p.then(() => undefined, () => undefined);
606
677
  return p;
607
678
  }
@@ -610,8 +681,11 @@ class HolderEngine {
610
681
  if (!u)
611
682
  return;
612
683
  const { state } = await this.#read();
684
+ // the journal entry at that commitSeq with these very events: another
685
+ // opening's commit of the same act at the same commitSeq is not this one
613
686
  const landed = jcs(state) === u.text ||
614
- (u.actId !== undefined && Object.hasOwn(state.acts, u.actId) && state.acts[u.actId].commitSeq === u.commitSeq);
687
+ (u.actId !== undefined && Object.hasOwn(state.acts, u.actId) && state.acts[u.actId].commitSeq === u.commitSeq &&
688
+ jcs(state.acts[u.actId].events) === jcs(u.events));
615
689
  this.#unsettled = null;
616
690
  if (landed)
617
691
  this.#announce(u.commitSeq, u.events);
@@ -671,6 +745,15 @@ class HolderEngine {
671
745
  attempt--;
672
746
  continue;
673
747
  }
748
+ // the timer as the act named it: the next attempt of its live documents
749
+ // (and a give-up's own timer), so a repeat answers as the act did
750
+ const soon = (t) => { if (t !== undefined && t !== null)
751
+ replayWake = replayWake === undefined ? t : Math.min(replayWake, t); };
752
+ for (const d of live) {
753
+ const it = this.#itemOf(state, d);
754
+ soon(it.nextAttemptAt);
755
+ soon(notAfterOf(it, this.#policy.horizonMs));
756
+ }
674
757
  return { ok: true, outbound: live.map((d) => this.#outboundOf(state, d)), events: freezeDeep(events), ...(replayWake !== undefined ? { nextDueAt: replayWake } : {}) };
675
758
  }
676
759
  // 2 — prepare, bound to this snapshot
@@ -749,6 +832,20 @@ class HolderEngine {
749
832
  const effects = r.renames?.length ? renameAcks(r.state.effects, r.renames) : r.state.effects;
750
833
  const next = { ...r.state, effects, commitSeq: seq, lastWhen: at, outbox, duties, acts };
751
834
  const text = encodeState(next); // validates; an invalid next state is a programming error
835
+ // the timer for the documents this commit issues: each one's first
836
+ // retry or its send deadline, whichever comes first, beside the duty
837
+ // work the module's state opened
838
+ let full = wake;
839
+ if (r.nextDueAt === undefined && def !== this.#builtins.resume) {
840
+ const soon = (t) => { if (t !== undefined && t !== null)
841
+ full = full === undefined ? t : Math.min(full, t); };
842
+ // each document's first retry or its send deadline, whichever comes first
843
+ for (const o of outbound) {
844
+ soon(outbox[o.digest].nextAttemptAt);
845
+ soon(notAfterOf(outbox[o.digest], this.#policy.horizonMs));
846
+ }
847
+ }
848
+ const fullExtra = { ...(full !== undefined ? { nextDueAt: full } : {}), ...(r.detail !== undefined ? { detail: r.detail } : {}) };
752
849
  if (this.#check) {
753
850
  const d = (r.detail ?? {});
754
851
  const pick = (k) => (d[k] !== undefined ? { [k]: d[k] } : {});
@@ -768,7 +865,7 @@ class HolderEngine {
768
865
  continue;
769
866
  // 4 — release
770
867
  this.#announce(seq, events);
771
- return { ok: true, outbound: [...emit, ...release, ...outbound], events, ...extra };
868
+ return { ok: true, outbound: [...emit, ...release, ...outbound], events, ...fullExtra };
772
869
  }
773
870
  throw new InfraError('commit-conflict', `no commit after ${this.#policy.commitAttempts} attempts`);
774
871
  }
@@ -784,8 +881,11 @@ class HolderEngine {
784
881
  out.push(p.expiresAt);
785
882
  for (const e of Object.values(state.effects))
786
883
  out.push(e.expiresAt);
787
- for (const a of Object.values(state.acts))
788
- out.push(a.at + retention);
884
+ for (const a of Object.values(state.acts)) {
885
+ const end = actEnd(state, a, retention);
886
+ if (end !== Infinity)
887
+ out.push(end);
888
+ }
789
889
  for (const it of Object.values(state.outbox)) {
790
890
  const t = triasOf(it);
791
891
  if ((t === 'delivered' || t === 'failed') && it.history.length)
@@ -934,9 +1034,10 @@ class HolderEngine {
934
1034
  let dropped = false;
935
1035
  const keep = (rec, live) => {
936
1036
  const out = {};
1037
+ // defined, not assigned: a key such as `__proto__` (an app's actId) stays an own member
937
1038
  for (const [k, v] of Object.entries(rec)) {
938
1039
  if (live(v, k))
939
- out[k] = v;
1040
+ Object.defineProperty(out, k, { value: v, enumerable: true, writable: true, configurable: true });
940
1041
  else
941
1042
  dropped = true;
942
1043
  }
@@ -946,7 +1047,7 @@ class HolderEngine {
946
1047
  // act journal after the retention
947
1048
  const effects = keep(state.effects, (x) => x.expiresAt > when);
948
1049
  const prompts = keep(state.prompts, (x) => x.expiresAt === undefined || x.expiresAt > when);
949
- const acts = keep(state.acts, (x) => x.at + retention > when);
1050
+ const acts = keep(state.acts, (x) => actEnd(state, x, retention) > when);
950
1051
  // terminal outbox items, the retention after their last step, unless a
951
1052
  // kept journal entry still answers with them (replay needs the item)
952
1053
  const journaled = new Set(Object.values(acts).flatMap((a) => a.outboundDigests));
@@ -1201,11 +1302,14 @@ class HolderEngine {
1201
1302
  const expiry = Math.min(a.at + p.horizonMs, Number.MAX_SAFE_INTEGER);
1202
1303
  effects ??= { ...cur.effects };
1203
1304
  if (when >= expiry) {
1204
- const { nextAttemptAt: _, ...rest } = a;
1305
+ const { nextAttemptAt: _, held: _h, ...rest } = a;
1205
1306
  effects[d] = { ...cur.effects[d], ack: { ...rest, sent: true, sentBy: 'horizon' } };
1206
1307
  continue;
1207
1308
  }
1208
1309
  due(expiry);
1310
+ // held: the adapter has it (awaiting-transport); pendingTransport offers it
1311
+ if (a.held !== undefined)
1312
+ continue;
1209
1313
  if (a.nextAttemptAt !== undefined && a.nextAttemptAt > when) {
1210
1314
  due(a.nextAttemptAt);
1211
1315
  continue;
@@ -1230,21 +1334,57 @@ class HolderEngine {
1230
1334
  },
1231
1335
  };
1232
1336
  }
1337
+ /** an answer's hand-over began: it is no longer due (handOver) */
1338
+ #answeredDef() {
1339
+ return {
1340
+ human: false,
1341
+ apply(state, _p, input) {
1342
+ const eff = Object.keys(state.effects).find((d) => state.effects[d].ack?.digest === input.digest);
1343
+ if (eff === undefined)
1344
+ return { state: state };
1345
+ const a = state.effects[eff].ack;
1346
+ if (a.answerDue !== true || a.answers !== input.n)
1347
+ return { state: state };
1348
+ const { answerDue: _d, ...rest } = a;
1349
+ return { state: { ...state, effects: { ...state.effects, [eff]: { ...state.effects[eff], ack: rest } } } };
1350
+ },
1351
+ };
1352
+ }
1233
1353
  #reportDef() {
1234
1354
  const failed = (st, digests, when) => this.#failed(st, digests, when);
1235
1355
  return {
1236
1356
  human: false,
1237
1357
  apply(state, _p, report, when) {
1238
1358
  if (!Object.hasOwn(state.outbox, report.digest)) {
1239
- // an ack (outbox-less): any report says the app handed it over
1359
+ // an ack (outbox-less, no trias; Delivery RLTP-DEL-6330): sent once
1360
+ // a service took it or the adapter gave it up; awaiting-transport
1361
+ // records that the adapter holds it and ends nothing
1240
1362
  const eff = Object.keys(state.effects).find((d) => state.effects[d].ack?.digest === report.digest);
1241
1363
  if (eff === undefined)
1242
1364
  return { ok: false, code: 'unknown-digest' };
1243
1365
  const e = state.effects[eff];
1244
- if (e.ack.sent)
1366
+ const a = e.ack;
1367
+ if (a.sent)
1245
1368
  return { state: state };
1246
- const { nextAttemptAt: _, ...ack } = e.ack;
1247
- return { state: { ...state, effects: { ...state.effects, [eff]: { ...e, ack: { ...ack, sent: true, sentBy: 'report' } } } } };
1369
+ const { nextAttemptAt: _, held: _h, ...rest } = a;
1370
+ let ack;
1371
+ switch (report.status) {
1372
+ case 'awaiting-transport':
1373
+ if (a.held === report.reason)
1374
+ return { state: state };
1375
+ ack = { ...rest, held: report.reason };
1376
+ break;
1377
+ case 'accepted':
1378
+ case 'delivered':
1379
+ ack = { ...rest, sent: true, sentBy: 'accepted' };
1380
+ break;
1381
+ case 'failed':
1382
+ ack = { ...rest, sent: true, sentBy: 'failed' };
1383
+ break;
1384
+ default:
1385
+ throw new TypeError(`unknown report status ${String(report.status)}`);
1386
+ }
1387
+ return { state: { ...state, effects: { ...state.effects, [eff]: { ...e, ack } } } };
1248
1388
  }
1249
1389
  const it = state.outbox[report.digest];
1250
1390
  const trias = triasOf(it);
@@ -1311,8 +1451,14 @@ class HolderEngine {
1311
1451
  case 'duplicate': {
1312
1452
  // the stored ack, byte-identical; a terminal document has none
1313
1453
  // (its outbound attempt is one past the schedule's, which it does not move)
1454
+ // and an answer due (Delivery RLTP-DEL-6330): a hand-over of its
1455
+ // own, kept across a lock until it begins; while one is due, a
1456
+ // further duplicate is answered by it (the same bytes)
1314
1457
  const a = state.effects[plan.digest].ack;
1315
- return { state: state, release: a ? [ackOutbound(a, a.attempt + 1)] : [], detail: { disposition: 'duplicate-known', digest: plan.digest } };
1458
+ const next = a && a.answerDue === undefined
1459
+ ? { ...state, effects: { ...state.effects, [plan.digest]: { ...state.effects[plan.digest], ack: { ...a, answers: (a.answers ?? 0) + 1, answerDue: true } } } }
1460
+ : state;
1461
+ return { state: next, release: a ? [ackOutbound(a, a.attempt + 1)] : [], detail: { disposition: 'duplicate-known', digest: plan.digest } };
1316
1462
  }
1317
1463
  case 'ack': {
1318
1464
  // terminal: the sender-status update and its cache entry, no ack;
@@ -1428,12 +1574,16 @@ class HolderEngine {
1428
1574
  catch (e) {
1429
1575
  return Promise.reject(e);
1430
1576
  }
1577
+ // fail closed: a resume that throws anywhere — the settling of an
1578
+ // earlier commit included — leaves nothing to go out until one completes
1431
1579
  return this.#enqueue(async () => {
1580
+ this.#resumed = false;
1432
1581
  const r = await this.#run(this.#builtins.resume, { kind: 'resume' }, null, when);
1433
1582
  if (isHalt(r) || !r.ok)
1434
1583
  throw new Error('engine: resume does not refuse');
1584
+ this.#resumed = true;
1435
1585
  return { outbound: r.outbound, events: r.events, ...(r.nextDueAt !== undefined ? { nextDueAt: r.nextDueAt } : {}) };
1436
- });
1586
+ }).catch((e) => { this.#resumed = false; throw e; });
1437
1587
  }
1438
1588
  report(report, when) {
1439
1589
  let bound;
@@ -1491,6 +1641,226 @@ class HolderEngine {
1491
1641
  return Object.freeze({ state, commitSeq: state.commitSeq });
1492
1642
  });
1493
1643
  }
1644
+ replay(actId, when) {
1645
+ try {
1646
+ checkWhen(when);
1647
+ }
1648
+ catch (e) {
1649
+ return Promise.reject(e);
1650
+ }
1651
+ if (typeof actId !== 'string' || actId === '')
1652
+ return Promise.resolve(Object.freeze({ ok: false, code: 'actid-missing' }));
1653
+ return this.#enqueue(async () => {
1654
+ const { state } = await this.#read();
1655
+ if (!Object.hasOwn(state.acts, actId))
1656
+ return Object.freeze({ ok: false, code: 'unknown-act' });
1657
+ const rec = state.acts[actId];
1658
+ // as a repeat of the act would see them at max(when, lastWhen), without
1659
+ // the give-up it would commit first: a pure read
1660
+ const at = Math.max(when, state.lastWhen);
1661
+ const outbound = rec.outboundDigests.map((digest) => {
1662
+ const it = Object.hasOwn(state.outbox, digest) ? state.outbox[digest] : undefined;
1663
+ const t = it ? triasOf(it) : undefined;
1664
+ return { digest, live: it !== undefined && t !== 'delivered' && t !== 'failed' && this.#giveUp(it, at) === null };
1665
+ });
1666
+ return freezeDeep({ ok: true, result: { actId: rec.actId, commitSeq: rec.commitSeq, at: rec.at, events: structuredClone(rec.events), outbound } });
1667
+ });
1668
+ }
1669
+ pendingTransport(when) {
1670
+ try {
1671
+ checkWhen(when);
1672
+ }
1673
+ catch (e) {
1674
+ return Promise.reject(e);
1675
+ }
1676
+ return this.#enqueue(async () => {
1677
+ if (!this.#resumed)
1678
+ return Object.freeze({ ok: false, code: 'not-resumed' });
1679
+ const { state } = await this.#read();
1680
+ // the holder's time never runs back: an earlier `when` reads at lastWhen
1681
+ const at = Math.max(when, state.lastWhen);
1682
+ const entries = transportEntries(state, at, this.#policy.horizonMs).map((e) => {
1683
+ const f = freezeDeep(e);
1684
+ this.#issued.add(f);
1685
+ return f;
1686
+ });
1687
+ return Object.freeze({ ok: true, commitSeq: state.commitSeq, at, entries: Object.freeze(entries) });
1688
+ });
1689
+ }
1690
+ mayHandOver(entry, when) {
1691
+ try {
1692
+ checkWhen(when);
1693
+ }
1694
+ catch (e) {
1695
+ return Promise.reject(e);
1696
+ }
1697
+ if (this.#closed || entry === null || typeof entry !== 'object' || !this.#issued.has(entry))
1698
+ return Promise.resolve(false);
1699
+ return this.#enqueue(() => this.#mayGo(entry, when)).catch((e) => {
1700
+ if (e instanceof InfraError && e.code === 'closed')
1701
+ return false;
1702
+ throw e;
1703
+ });
1704
+ }
1705
+ handOver(entry, when, start) {
1706
+ try {
1707
+ checkWhen(when);
1708
+ }
1709
+ catch (e) {
1710
+ return Promise.reject(e);
1711
+ }
1712
+ if (typeof start !== 'function')
1713
+ return Promise.reject(new TypeError('handOver needs a start function'));
1714
+ if (this.#closed || entry === null || typeof entry !== 'object' || !this.#issued.has(entry))
1715
+ return Promise.resolve(false);
1716
+ return this.#enqueue(async () => {
1717
+ if (!this.#resumed)
1718
+ return false;
1719
+ const { state } = await this.#read();
1720
+ // no await from here to start(): close() and every other operation
1721
+ // of this engine come before the check or after the start
1722
+ if (!this.#passes(state, entry, when))
1723
+ return false;
1724
+ if (start() !== true)
1725
+ return false;
1726
+ // an answer's hand-over has begun: it is no longer due (a later
1727
+ // duplicate grants the next); a commit that fails leaves it due, and
1728
+ // it may go out once more — the receiver absorbs it
1729
+ if (entry.resend !== undefined) {
1730
+ try {
1731
+ await this.#run(this.#answeredDef(), { kind: 'report', status: 'answered' }, { digest: entry.digest, n: entry.resend }, Math.max(when, state.lastWhen));
1732
+ }
1733
+ catch { /* stays due */ }
1734
+ }
1735
+ return true;
1736
+ }).catch((e) => {
1737
+ if (e instanceof InfraError && e.code === 'closed')
1738
+ return false;
1739
+ throw e;
1740
+ });
1741
+ }
1742
+ /** the gate's check, inside the queue: open, resumed, still pending in
1743
+ * the store as issued, before its deadline */
1744
+ async #mayGo(entry, when) {
1745
+ if (!this.#resumed)
1746
+ return false;
1747
+ const { state } = await this.#read();
1748
+ return this.#passes(state, entry, when);
1749
+ }
1750
+ /** synchronous: open, resumed, the entry pending in `state` as issued,
1751
+ * before its deadline at max(when, lastWhen) */
1752
+ #passes(state, entry, when) {
1753
+ if (this.#closed || !this.#resumed)
1754
+ return false;
1755
+ const now = transportEntryOf(state, entry.kind, entry.digest, Math.max(when, state.lastWhen), this.#policy.horizonMs, entry.resend);
1756
+ return now !== null && now.resend === entry.resend && jcs(now.envelope) === jcs(entry.envelope);
1757
+ }
1758
+ close() {
1759
+ this.#closed = true;
1760
+ return this.#chain.then(() => undefined, () => undefined);
1761
+ }
1762
+ get closed() { return this.#closed; }
1763
+ }
1764
+ /** When the journal entry of an act may leave (tick): the retention after
1765
+ * its commit and after the last step of each of its documents that is
1766
+ * terminal — the act's result stays as long as the completion of its
1767
+ * documents does; Infinity while one of them is not terminal yet (its
1768
+ * give-up makes it terminal, and the deadline follows then). */
1769
+ const actEnd = (state, a, retention) => {
1770
+ let end = a.at;
1771
+ for (const d of a.outboundDigests) {
1772
+ if (!Object.hasOwn(state.outbox, d))
1773
+ continue;
1774
+ const it = state.outbox[d];
1775
+ const t = triasOf(it);
1776
+ if (t !== 'delivered' && t !== 'failed')
1777
+ return Infinity;
1778
+ if (it.history.length)
1779
+ end = Math.max(end, it.history[it.history.length - 1].at);
1780
+ }
1781
+ return end + retention;
1782
+ };
1783
+ /** The send deadline of an outbox item: the horizon from its first
1784
+ * submission, or the sender lifetime its type declared if that is
1785
+ * earlier (#giveUp's two bounds as one instant). */
1786
+ const notAfterOf = (it, horizonMs) => {
1787
+ if (!it.history.length)
1788
+ return null;
1789
+ const expiry = Math.min(it.history[0].at + horizonMs, Number.MAX_SAFE_INTEGER);
1790
+ return it.expiresAt !== undefined ? Math.min(it.expiresAt, expiry) : expiry;
1791
+ };
1792
+ /** The transport entry of one pending document or ack at `when`, or null:
1793
+ * terminal, accepted, sent, past its deadline, or absent (RLTP-DEL-6135:
1794
+ * absent means stop). */
1795
+ function transportEntryOf(state, kind, digest, when, horizonMs, resend) {
1796
+ if (kind === 'document') {
1797
+ if (!Object.hasOwn(state.outbox, digest))
1798
+ return null;
1799
+ const it = state.outbox[digest];
1800
+ const trias = triasOf(it);
1801
+ if (trias !== 'submitted')
1802
+ return null;
1803
+ const notAfter = notAfterOf(it, horizonMs);
1804
+ if (notAfter === null || when >= notAfter)
1805
+ return null;
1806
+ const last = it.history[it.history.length - 1];
1807
+ return structuredClone({
1808
+ digest, kind, envelope: it.envelope, type: it.type, threadId: it.threadId, recipient: it.recipient, attempt: it.attempt, since: it.history[0].at, notAfter,
1809
+ ...(last.status === 'awaiting-transport' ? { held: last.reason } : it.nextAttemptAt !== undefined ? { nextAttemptAt: it.nextAttemptAt } : {}),
1810
+ });
1811
+ }
1812
+ for (const d of Object.keys(state.effects)) {
1813
+ const a = state.effects[d].ack;
1814
+ if (a === null || a.digest !== digest)
1815
+ continue;
1816
+ if (resend !== undefined) {
1817
+ // a duplicate's answer: its own hand-over, apart from the retries and
1818
+ // whatever state they are in, while due and the effect keeps the ack (6335)
1819
+ if (a.answerDue !== true || a.answers !== resend || when >= state.effects[d].expiresAt)
1820
+ return null;
1821
+ return structuredClone({
1822
+ digest, kind, envelope: a.envelope, type: ACK_TYPE, threadId: a.threadId, recipient: a.recipient, attempt: a.attempt, since: a.at,
1823
+ notAfter: state.effects[d].expiresAt, resend,
1824
+ });
1825
+ }
1826
+ const notAfter = Math.min(a.at + horizonMs, Number.MAX_SAFE_INTEGER);
1827
+ if (a.sent || when >= notAfter)
1828
+ return null;
1829
+ return structuredClone({
1830
+ digest, kind, envelope: a.envelope, type: ACK_TYPE, threadId: a.threadId, recipient: a.recipient, attempt: a.attempt, since: a.at, notAfter,
1831
+ ...(a.held !== undefined ? { held: a.held } : a.nextAttemptAt !== undefined ? { nextAttemptAt: a.nextAttemptAt } : {}),
1832
+ });
1833
+ }
1834
+ return null;
1835
+ }
1836
+ /** Everything the holder keeps for transport at `when`: the outbox items
1837
+ * by due time, then digest, then the unsent acks by effect digest. */
1838
+ function transportEntries(state, when, horizonMs) {
1839
+ const out = [];
1840
+ const order = Object.keys(state.outbox).sort((a, b) => ((state.outbox[a].nextAttemptAt ?? 0) - (state.outbox[b].nextAttemptAt ?? 0)) || (a < b ? -1 : a > b ? 1 : 0));
1841
+ for (const d of order) {
1842
+ const e = transportEntryOf(state, 'document', d, when, horizonMs);
1843
+ if (e)
1844
+ out.push(e);
1845
+ }
1846
+ for (const d of Object.keys(state.effects).sort()) {
1847
+ const a = state.effects[d].ack;
1848
+ if (a === null)
1849
+ continue;
1850
+ const e = transportEntryOf(state, 'ack', a.digest, when, horizonMs);
1851
+ if (e)
1852
+ out.push(e);
1853
+ }
1854
+ // the duplicates' answers due, each its own entry beside the retries
1855
+ for (const d of Object.keys(state.effects).sort()) {
1856
+ const a = state.effects[d].ack;
1857
+ if (a === null || a.answerDue !== true)
1858
+ continue;
1859
+ const e = transportEntryOf(state, 'ack', a.digest, when, horizonMs, a.answers);
1860
+ if (e)
1861
+ out.push(e);
1862
+ }
1863
+ return out;
1494
1864
  }
1495
1865
  /**
1496
1866
  * Build an engine over the ports and modules: resolves and probes