@real-life/trust-protocol 0.5.0 → 0.6.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 (44) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +79 -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 +62 -1
  12. package/dist/engine.d.ts.map +1 -1
  13. package/dist/engine.js +289 -15
  14. package/dist/engine.js.map +1 -1
  15. package/dist/holder-api.d.ts +15 -1
  16. package/dist/holder-api.d.ts.map +1 -1
  17. package/dist/holder-api.js +28 -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 +1 -1
  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 +5 -1
  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
@@ -75,13 +75,48 @@
75
75
  // stages again, which re-checks the effect cache at stage 4
76
76
  // - an ack is stored sealed (StoredAck: envelope, digest, recipient) in
77
77
  // 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)
78
+ // It stays `sent: false` until a service takes it (report accepted or
79
+ // delivered: `sentBy: 'accepted'`), the adapter reports it failed
80
+ // (`'failed'`), or its horizon passes (effect commit + horizonMs:
81
+ // `'horizon'`, no event: acks have no trias) — Delivery RLTP-DEL-6330.
82
+ // An awaiting-transport report keeps it unsent and records `held`; a
83
+ // held ack is not re-offered by resume (the adapter has it) and stays
84
+ // in pendingTransport. Unsent and not held, it has the outbox's retry
85
+ // schedule: attempt 1 at the receive, re-offered by resume when due
86
+ // (attempt + 1, backoff), counted into nextDueAt. Sent or not, it stays
87
+ // with its effect until the effect expires (RLTP-DEL-6335). A
88
+ // duplicate's re-send carries attempt + 1 and does not move the
89
+ // schedule; it commits the answer it makes due (6330), else nothing
90
+ // beyond lastWhen
91
+ // - custody (Delivery RLTP-DEL-6120 to 6135): pendingTransport(when) is
92
+ // the set the holder keeps for transport — every outbox item neither
93
+ // terminal nor accepted, held ones included, and every unsent ack —
94
+ // each with its deadline (notAfter: the horizon from the first
95
+ // submission, or the type's sender lifetime if earlier) and without
96
+ // those whose deadline has passed at `when`, whether or not a resume
97
+ // has recorded the give-up yet. It answers only after a resume of this
98
+ // engine has completed (a resume that throws withdraws that again), and
99
+ // its entries are issued to this engine: mayHandOver(entry, when)
100
+ // accepts only an entry this engine issued, re-reads the store and says
101
+ // yes only while the engine is open and the entry is still pending
102
+ // there with the same envelope before its deadline. handOver(entry,
103
+ // when, start) is the same check with the deposit's start inside the
104
+ // queue: nothing of this engine runs between the check and the start.
105
+ // A duplicate makes an answer due, stored with the ack (answers,
106
+ // answerDue: kept across a lock): a `resend` entry numbered by it,
107
+ // beside the ack's retries and independent of their state, deadline the
108
+ // effect's retention; the begun hand-over ends it (a commit): it is one
109
+ // hand-over with no retries (Delivery 6330), so a deposit the wire
110
+ // refuses is not tried again — the sender's next redelivery asks anew;
111
+ // a duplicate while one is due is answered by it (the same bytes); a resume that throws, its settling
112
+ // included, withdraws the authority until one completes. The check is
113
+ // this engine's: a second engine over the same store commits through
114
+ // CAS but is not serialized with this one's hand-overs, so one opening
115
+ // per store at a time is the app's to keep (its writer lock). close() ends the
116
+ // engine: every later operation rejects with InfraError('closed'),
117
+ // mayHandOver answers false, and no entry issued before authorizes
118
+ // anything. An operation already running when close() is called
119
+ // completes
85
120
  // - outbound of one commit, in order: outbox re-emissions, released
86
121
  // acks, new documents
87
122
  // - resume runs duties after the outbox, ordered by dueAt (absent: due
@@ -355,6 +390,7 @@ function renameAcks(effects, renames) {
355
390
  }
356
391
  return out ?? effects;
357
392
  }
393
+ const closedError = () => new InfraError('closed', 'the holder was closed');
358
394
  const checkWhen = (when) => {
359
395
  if (!Number.isSafeInteger(when) || when < 0)
360
396
  throw new TypeError(`when must be non-negative integer milliseconds, got ${String(when)}`);
@@ -404,6 +440,12 @@ class HolderEngine {
404
440
  #chain = Promise.resolve();
405
441
  /** a commit whose outcome is unknown, settled before the next operation */
406
442
  #unsettled = null;
443
+ /** close() was called: nothing more runs, nothing issued authorizes */
444
+ #closed = false;
445
+ /** a resume of this engine completed (and none threw since) */
446
+ #resumed = false;
447
+ /** the transport entries this engine issued (RLTP-DEL-6130) */
448
+ #issued = new WeakSet();
407
449
  constructor(ports, crypto, modules, opts = {}) {
408
450
  if (opts.check !== undefined && typeof opts.check !== 'function')
409
451
  throw new TypeError('check must be a function');
@@ -601,7 +643,14 @@ class HolderEngine {
601
643
  }
602
644
  // ── the queue ────────────────────────────────────────────────────────
603
645
  #enqueue(fn) {
604
- const p = this.#chain.then(async () => { await this.#settle(); return fn(); });
646
+ if (this.#closed)
647
+ return Promise.reject(closedError());
648
+ const p = this.#chain.then(async () => {
649
+ if (this.#closed)
650
+ throw closedError();
651
+ await this.#settle();
652
+ return fn();
653
+ });
605
654
  this.#chain = p.then(() => undefined, () => undefined);
606
655
  return p;
607
656
  }
@@ -1201,11 +1250,14 @@ class HolderEngine {
1201
1250
  const expiry = Math.min(a.at + p.horizonMs, Number.MAX_SAFE_INTEGER);
1202
1251
  effects ??= { ...cur.effects };
1203
1252
  if (when >= expiry) {
1204
- const { nextAttemptAt: _, ...rest } = a;
1253
+ const { nextAttemptAt: _, held: _h, ...rest } = a;
1205
1254
  effects[d] = { ...cur.effects[d], ack: { ...rest, sent: true, sentBy: 'horizon' } };
1206
1255
  continue;
1207
1256
  }
1208
1257
  due(expiry);
1258
+ // held: the adapter has it (awaiting-transport); pendingTransport offers it
1259
+ if (a.held !== undefined)
1260
+ continue;
1209
1261
  if (a.nextAttemptAt !== undefined && a.nextAttemptAt > when) {
1210
1262
  due(a.nextAttemptAt);
1211
1263
  continue;
@@ -1230,21 +1282,57 @@ class HolderEngine {
1230
1282
  },
1231
1283
  };
1232
1284
  }
1285
+ /** an answer's hand-over began: it is no longer due (handOver) */
1286
+ #answeredDef() {
1287
+ return {
1288
+ human: false,
1289
+ apply(state, _p, input) {
1290
+ const eff = Object.keys(state.effects).find((d) => state.effects[d].ack?.digest === input.digest);
1291
+ if (eff === undefined)
1292
+ return { state: state };
1293
+ const a = state.effects[eff].ack;
1294
+ if (a.answerDue !== true || a.answers !== input.n)
1295
+ return { state: state };
1296
+ const { answerDue: _d, ...rest } = a;
1297
+ return { state: { ...state, effects: { ...state.effects, [eff]: { ...state.effects[eff], ack: rest } } } };
1298
+ },
1299
+ };
1300
+ }
1233
1301
  #reportDef() {
1234
1302
  const failed = (st, digests, when) => this.#failed(st, digests, when);
1235
1303
  return {
1236
1304
  human: false,
1237
1305
  apply(state, _p, report, when) {
1238
1306
  if (!Object.hasOwn(state.outbox, report.digest)) {
1239
- // an ack (outbox-less): any report says the app handed it over
1307
+ // an ack (outbox-less, no trias; Delivery RLTP-DEL-6330): sent once
1308
+ // a service took it or the adapter gave it up; awaiting-transport
1309
+ // records that the adapter holds it and ends nothing
1240
1310
  const eff = Object.keys(state.effects).find((d) => state.effects[d].ack?.digest === report.digest);
1241
1311
  if (eff === undefined)
1242
1312
  return { ok: false, code: 'unknown-digest' };
1243
1313
  const e = state.effects[eff];
1244
- if (e.ack.sent)
1314
+ const a = e.ack;
1315
+ if (a.sent)
1245
1316
  return { state: state };
1246
- const { nextAttemptAt: _, ...ack } = e.ack;
1247
- return { state: { ...state, effects: { ...state.effects, [eff]: { ...e, ack: { ...ack, sent: true, sentBy: 'report' } } } } };
1317
+ const { nextAttemptAt: _, held: _h, ...rest } = a;
1318
+ let ack;
1319
+ switch (report.status) {
1320
+ case 'awaiting-transport':
1321
+ if (a.held === report.reason)
1322
+ return { state: state };
1323
+ ack = { ...rest, held: report.reason };
1324
+ break;
1325
+ case 'accepted':
1326
+ case 'delivered':
1327
+ ack = { ...rest, sent: true, sentBy: 'accepted' };
1328
+ break;
1329
+ case 'failed':
1330
+ ack = { ...rest, sent: true, sentBy: 'failed' };
1331
+ break;
1332
+ default:
1333
+ throw new TypeError(`unknown report status ${String(report.status)}`);
1334
+ }
1335
+ return { state: { ...state, effects: { ...state.effects, [eff]: { ...e, ack } } } };
1248
1336
  }
1249
1337
  const it = state.outbox[report.digest];
1250
1338
  const trias = triasOf(it);
@@ -1311,8 +1399,14 @@ class HolderEngine {
1311
1399
  case 'duplicate': {
1312
1400
  // the stored ack, byte-identical; a terminal document has none
1313
1401
  // (its outbound attempt is one past the schedule's, which it does not move)
1402
+ // and an answer due (Delivery RLTP-DEL-6330): a hand-over of its
1403
+ // own, kept across a lock until it begins; while one is due, a
1404
+ // further duplicate is answered by it (the same bytes)
1314
1405
  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 } };
1406
+ const next = a && a.answerDue === undefined
1407
+ ? { ...state, effects: { ...state.effects, [plan.digest]: { ...state.effects[plan.digest], ack: { ...a, answers: (a.answers ?? 0) + 1, answerDue: true } } } }
1408
+ : state;
1409
+ return { state: next, release: a ? [ackOutbound(a, a.attempt + 1)] : [], detail: { disposition: 'duplicate-known', digest: plan.digest } };
1316
1410
  }
1317
1411
  case 'ack': {
1318
1412
  // terminal: the sender-status update and its cache entry, no ack;
@@ -1428,12 +1522,16 @@ class HolderEngine {
1428
1522
  catch (e) {
1429
1523
  return Promise.reject(e);
1430
1524
  }
1525
+ // fail closed: a resume that throws anywhere — the settling of an
1526
+ // earlier commit included — leaves nothing to go out until one completes
1431
1527
  return this.#enqueue(async () => {
1528
+ this.#resumed = false;
1432
1529
  const r = await this.#run(this.#builtins.resume, { kind: 'resume' }, null, when);
1433
1530
  if (isHalt(r) || !r.ok)
1434
1531
  throw new Error('engine: resume does not refuse');
1532
+ this.#resumed = true;
1435
1533
  return { outbound: r.outbound, events: r.events, ...(r.nextDueAt !== undefined ? { nextDueAt: r.nextDueAt } : {}) };
1436
- });
1534
+ }).catch((e) => { this.#resumed = false; throw e; });
1437
1535
  }
1438
1536
  report(report, when) {
1439
1537
  let bound;
@@ -1491,6 +1589,182 @@ class HolderEngine {
1491
1589
  return Object.freeze({ state, commitSeq: state.commitSeq });
1492
1590
  });
1493
1591
  }
1592
+ pendingTransport(when) {
1593
+ try {
1594
+ checkWhen(when);
1595
+ }
1596
+ catch (e) {
1597
+ return Promise.reject(e);
1598
+ }
1599
+ return this.#enqueue(async () => {
1600
+ if (!this.#resumed)
1601
+ return Object.freeze({ ok: false, code: 'not-resumed' });
1602
+ const { state } = await this.#read();
1603
+ // the holder's time never runs back: an earlier `when` reads at lastWhen
1604
+ const at = Math.max(when, state.lastWhen);
1605
+ const entries = transportEntries(state, at, this.#policy.horizonMs).map((e) => {
1606
+ const f = freezeDeep(e);
1607
+ this.#issued.add(f);
1608
+ return f;
1609
+ });
1610
+ return Object.freeze({ ok: true, commitSeq: state.commitSeq, at, entries: Object.freeze(entries) });
1611
+ });
1612
+ }
1613
+ mayHandOver(entry, when) {
1614
+ try {
1615
+ checkWhen(when);
1616
+ }
1617
+ catch (e) {
1618
+ return Promise.reject(e);
1619
+ }
1620
+ if (this.#closed || entry === null || typeof entry !== 'object' || !this.#issued.has(entry))
1621
+ return Promise.resolve(false);
1622
+ return this.#enqueue(() => this.#mayGo(entry, when)).catch((e) => {
1623
+ if (e instanceof InfraError && e.code === 'closed')
1624
+ return false;
1625
+ throw e;
1626
+ });
1627
+ }
1628
+ handOver(entry, when, start) {
1629
+ try {
1630
+ checkWhen(when);
1631
+ }
1632
+ catch (e) {
1633
+ return Promise.reject(e);
1634
+ }
1635
+ if (typeof start !== 'function')
1636
+ return Promise.reject(new TypeError('handOver needs a start function'));
1637
+ if (this.#closed || entry === null || typeof entry !== 'object' || !this.#issued.has(entry))
1638
+ return Promise.resolve(false);
1639
+ return this.#enqueue(async () => {
1640
+ if (!this.#resumed)
1641
+ return false;
1642
+ const { state } = await this.#read();
1643
+ // no await from here to start(): close() and every other operation
1644
+ // of this engine come before the check or after the start
1645
+ if (!this.#passes(state, entry, when))
1646
+ return false;
1647
+ if (start() !== true)
1648
+ return false;
1649
+ // an answer's hand-over has begun: it is no longer due (a later
1650
+ // duplicate grants the next); a commit that fails leaves it due, and
1651
+ // it may go out once more — the receiver absorbs it
1652
+ if (entry.resend !== undefined) {
1653
+ try {
1654
+ await this.#run(this.#answeredDef(), { kind: 'report', status: 'answered' }, { digest: entry.digest, n: entry.resend }, Math.max(when, state.lastWhen));
1655
+ }
1656
+ catch { /* stays due */ }
1657
+ }
1658
+ return true;
1659
+ }).catch((e) => {
1660
+ if (e instanceof InfraError && e.code === 'closed')
1661
+ return false;
1662
+ throw e;
1663
+ });
1664
+ }
1665
+ /** the gate's check, inside the queue: open, resumed, still pending in
1666
+ * the store as issued, before its deadline */
1667
+ async #mayGo(entry, when) {
1668
+ if (!this.#resumed)
1669
+ return false;
1670
+ const { state } = await this.#read();
1671
+ return this.#passes(state, entry, when);
1672
+ }
1673
+ /** synchronous: open, resumed, the entry pending in `state` as issued,
1674
+ * before its deadline at max(when, lastWhen) */
1675
+ #passes(state, entry, when) {
1676
+ if (this.#closed || !this.#resumed)
1677
+ return false;
1678
+ const now = transportEntryOf(state, entry.kind, entry.digest, Math.max(when, state.lastWhen), this.#policy.horizonMs, entry.resend);
1679
+ return now !== null && now.resend === entry.resend && jcs(now.envelope) === jcs(entry.envelope);
1680
+ }
1681
+ close() {
1682
+ this.#closed = true;
1683
+ return this.#chain.then(() => undefined, () => undefined);
1684
+ }
1685
+ get closed() { return this.#closed; }
1686
+ }
1687
+ /** The send deadline of an outbox item: the horizon from its first
1688
+ * submission, or the sender lifetime its type declared if that is
1689
+ * earlier (#giveUp's two bounds as one instant). */
1690
+ const notAfterOf = (it, horizonMs) => {
1691
+ if (!it.history.length)
1692
+ return null;
1693
+ const expiry = Math.min(it.history[0].at + horizonMs, Number.MAX_SAFE_INTEGER);
1694
+ return it.expiresAt !== undefined ? Math.min(it.expiresAt, expiry) : expiry;
1695
+ };
1696
+ /** The transport entry of one pending document or ack at `when`, or null:
1697
+ * terminal, accepted, sent, past its deadline, or absent (RLTP-DEL-6135:
1698
+ * absent means stop). */
1699
+ function transportEntryOf(state, kind, digest, when, horizonMs, resend) {
1700
+ if (kind === 'document') {
1701
+ if (!Object.hasOwn(state.outbox, digest))
1702
+ return null;
1703
+ const it = state.outbox[digest];
1704
+ const trias = triasOf(it);
1705
+ if (trias !== 'submitted')
1706
+ return null;
1707
+ const notAfter = notAfterOf(it, horizonMs);
1708
+ if (notAfter === null || when >= notAfter)
1709
+ return null;
1710
+ const last = it.history[it.history.length - 1];
1711
+ return structuredClone({
1712
+ digest, kind, envelope: it.envelope, type: it.type, threadId: it.threadId, recipient: it.recipient, attempt: it.attempt, since: it.history[0].at, notAfter,
1713
+ ...(last.status === 'awaiting-transport' ? { held: last.reason } : it.nextAttemptAt !== undefined ? { nextAttemptAt: it.nextAttemptAt } : {}),
1714
+ });
1715
+ }
1716
+ for (const d of Object.keys(state.effects)) {
1717
+ const a = state.effects[d].ack;
1718
+ if (a === null || a.digest !== digest)
1719
+ continue;
1720
+ if (resend !== undefined) {
1721
+ // a duplicate's answer: its own hand-over, apart from the retries and
1722
+ // whatever state they are in, while due and the effect keeps the ack (6335)
1723
+ if (a.answerDue !== true || a.answers !== resend || when >= state.effects[d].expiresAt)
1724
+ return null;
1725
+ return structuredClone({
1726
+ digest, kind, envelope: a.envelope, type: ACK_TYPE, threadId: a.threadId, recipient: a.recipient, attempt: a.attempt, since: a.at,
1727
+ notAfter: state.effects[d].expiresAt, resend,
1728
+ });
1729
+ }
1730
+ const notAfter = Math.min(a.at + horizonMs, Number.MAX_SAFE_INTEGER);
1731
+ if (a.sent || when >= notAfter)
1732
+ return null;
1733
+ return structuredClone({
1734
+ digest, kind, envelope: a.envelope, type: ACK_TYPE, threadId: a.threadId, recipient: a.recipient, attempt: a.attempt, since: a.at, notAfter,
1735
+ ...(a.held !== undefined ? { held: a.held } : a.nextAttemptAt !== undefined ? { nextAttemptAt: a.nextAttemptAt } : {}),
1736
+ });
1737
+ }
1738
+ return null;
1739
+ }
1740
+ /** Everything the holder keeps for transport at `when`: the outbox items
1741
+ * by due time, then digest, then the unsent acks by effect digest. */
1742
+ function transportEntries(state, when, horizonMs) {
1743
+ const out = [];
1744
+ 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));
1745
+ for (const d of order) {
1746
+ const e = transportEntryOf(state, 'document', d, when, horizonMs);
1747
+ if (e)
1748
+ out.push(e);
1749
+ }
1750
+ for (const d of Object.keys(state.effects).sort()) {
1751
+ const a = state.effects[d].ack;
1752
+ if (a === null)
1753
+ continue;
1754
+ const e = transportEntryOf(state, 'ack', a.digest, when, horizonMs);
1755
+ if (e)
1756
+ out.push(e);
1757
+ }
1758
+ // the duplicates' answers due, each its own entry beside the retries
1759
+ for (const d of Object.keys(state.effects).sort()) {
1760
+ const a = state.effects[d].ack;
1761
+ if (a === null || a.answerDue !== true)
1762
+ continue;
1763
+ const e = transportEntryOf(state, 'ack', a.digest, when, horizonMs, a.answers);
1764
+ if (e)
1765
+ out.push(e);
1766
+ }
1767
+ return out;
1494
1768
  }
1495
1769
  /**
1496
1770
  * Build an engine over the ports and modules: resolves and probes