@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.
- package/CHANGELOG.md +119 -0
- package/README.md +97 -34
- package/dist/adapters/vti-mediator.d.ts +18 -5
- package/dist/adapters/vti-mediator.d.ts.map +1 -1
- package/dist/adapters/vti-mediator.js +143 -17
- package/dist/adapters/vti-mediator.js.map +1 -1
- package/dist/codec.d.ts +2 -9
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +61 -96
- package/dist/codec.js.map +1 -1
- package/dist/engine.d.ts +66 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +393 -23
- package/dist/engine.js.map +1 -1
- package/dist/holder-api.d.ts +22 -2
- package/dist/holder-api.d.ts.map +1 -1
- package/dist/holder-api.js +43 -3
- package/dist/holder-api.js.map +1 -1
- package/dist/{holder-state-v2.d.ts → holder-state-v3.d.ts} +210 -207
- package/dist/holder-state-v3.d.ts.map +1 -0
- package/dist/holder-state-v3.js +2 -0
- package/dist/holder-state-v3.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -2
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js.map +1 -1
- package/dist/results.d.ts +42 -3
- package/dist/results.d.ts.map +1 -1
- package/dist/results.js.map +1 -1
- package/dist/state.d.ts +22 -9
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js.map +1 -1
- package/package.json +4 -6
- package/schemas/{holder-state-v2.schema.json → holder-state-v3.schema.json} +50 -5
- package/dist/holder-state-v1.d.ts +0 -801
- package/dist/holder-state-v1.d.ts.map +0 -1
- package/dist/holder-state-v1.js +0 -2
- package/dist/holder-state-v1.js.map +0 -1
- package/dist/holder-state-v2.d.ts.map +0 -1
- package/dist/holder-state-v2.js +0 -2
- package/dist/holder-state-v2.js.map +0 -1
- 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));
|
|
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
|
|
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
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
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
|
-
|
|
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, ...
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1366
|
+
const a = e.ack;
|
|
1367
|
+
if (a.sent)
|
|
1245
1368
|
return { state: state };
|
|
1246
|
-
const { nextAttemptAt: _, ...
|
|
1247
|
-
|
|
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
|
-
|
|
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
|