@real-life/trust-protocol 0.3.1 → 0.4.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.
- package/CHANGELOG.md +32 -0
- package/README.md +187 -78
- package/dist/ack.d.ts +84 -0
- package/dist/ack.d.ts.map +1 -0
- package/dist/ack.js +114 -0
- package/dist/ack.js.map +1 -0
- package/dist/adapters/vti-mediator.d.ts +154 -0
- package/dist/adapters/vti-mediator.d.ts.map +1 -0
- package/dist/adapters/vti-mediator.js +621 -0
- package/dist/adapters/vti-mediator.js.map +1 -0
- package/dist/anchor.d.ts +226 -0
- package/dist/anchor.d.ts.map +1 -0
- package/dist/anchor.js +347 -0
- package/dist/anchor.js.map +1 -0
- package/dist/{holder.d.ts → carrier-holder.d.ts} +1 -1
- package/dist/carrier-holder.d.ts.map +1 -0
- package/dist/{holder.js → carrier-holder.js} +5 -4
- package/dist/carrier-holder.js.map +1 -0
- package/dist/carrier-identity.js +4 -4
- package/dist/carrier-identity.js.map +1 -1
- package/dist/carrier.d.ts.map +1 -1
- package/dist/carrier.js +4 -3
- package/dist/carrier.js.map +1 -1
- package/dist/ceremony.d.ts +3 -2
- package/dist/ceremony.d.ts.map +1 -1
- package/dist/ceremony.js +16 -26
- package/dist/ceremony.js.map +1 -1
- package/dist/codec.d.ts +46 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +410 -0
- package/dist/codec.js.map +1 -0
- package/dist/continuity.d.ts +67 -0
- package/dist/continuity.d.ts.map +1 -0
- package/dist/continuity.js +110 -0
- package/dist/continuity.js.map +1 -0
- package/dist/core.d.ts +4 -0
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js.map +1 -1
- package/dist/crypto.d.ts +23 -7
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +54 -20
- package/dist/crypto.js.map +1 -1
- package/dist/delivery.d.ts +31 -3
- package/dist/delivery.d.ts.map +1 -1
- package/dist/delivery.js +75 -28
- package/dist/delivery.js.map +1 -1
- package/dist/encounter/ceremony.d.ts +37 -0
- package/dist/encounter/ceremony.d.ts.map +1 -0
- package/dist/encounter/ceremony.js +662 -0
- package/dist/encounter/ceremony.js.map +1 -0
- package/dist/encounter.d.ts +91 -1
- package/dist/encounter.d.ts.map +1 -1
- package/dist/encounter.js +191 -12
- package/dist/encounter.js.map +1 -1
- package/dist/engine.d.ts +291 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +1395 -0
- package/dist/engine.js.map +1 -0
- package/dist/entropy.d.ts +28 -0
- package/dist/entropy.d.ts.map +1 -0
- package/dist/entropy.js +96 -0
- package/dist/entropy.js.map +1 -0
- package/dist/events.d.ts +177 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +2 -0
- package/dist/events.js.map +1 -0
- package/dist/fixtures.d.ts +30 -0
- package/dist/fixtures.d.ts.map +1 -0
- package/dist/fixtures.js +155 -0
- package/dist/fixtures.js.map +1 -0
- package/dist/forge.d.ts +47 -0
- package/dist/forge.d.ts.map +1 -0
- package/dist/forge.js +20 -0
- package/dist/forge.js.map +1 -0
- package/dist/group-star.d.ts +251 -0
- package/dist/group-star.d.ts.map +1 -0
- package/dist/group-star.js +348 -0
- package/dist/group-star.js.map +1 -0
- package/dist/holder-api.d.ts +137 -0
- package/dist/holder-api.d.ts.map +1 -0
- package/dist/holder-api.js +132 -0
- package/dist/holder-api.js.map +1 -0
- package/dist/holder-state-v1.d.ts +801 -0
- package/dist/holder-state-v1.d.ts.map +1 -0
- package/dist/holder-state-v1.js +2 -0
- package/dist/holder-state-v1.js.map +1 -0
- package/dist/identity-state.d.ts +43 -0
- package/dist/identity-state.d.ts.map +1 -0
- package/dist/identity-state.js +345 -0
- package/dist/identity-state.js.map +1 -0
- package/dist/identity.d.ts +24 -2
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +70 -11
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +26 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +77 -29
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +36 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +22 -0
- package/dist/internal.js.map +1 -0
- package/dist/introduction/holder.d.ts +18 -0
- package/dist/introduction/holder.d.ts.map +1 -0
- package/dist/introduction/holder.js +757 -0
- package/dist/introduction/holder.js.map +1 -0
- package/dist/introduction.d.ts +101 -0
- package/dist/introduction.d.ts.map +1 -0
- package/dist/introduction.js +132 -0
- package/dist/introduction.js.map +1 -0
- package/dist/invariants.d.ts +43 -0
- package/dist/invariants.d.ts.map +1 -0
- package/dist/invariants.js +883 -0
- package/dist/invariants.js.map +1 -0
- package/dist/keys-seed.d.ts +7 -0
- package/dist/keys-seed.d.ts.map +1 -0
- package/dist/keys-seed.js +133 -0
- package/dist/keys-seed.js.map +1 -0
- package/dist/log-memory.d.ts +12 -0
- package/dist/log-memory.d.ts.map +1 -0
- package/dist/log-memory.js +101 -0
- package/dist/log-memory.js.map +1 -0
- package/dist/membership/holder.d.ts +35 -0
- package/dist/membership/holder.d.ts.map +1 -0
- package/dist/membership/holder.js +852 -0
- package/dist/membership/holder.js.map +1 -0
- package/dist/membership.d.ts +92 -0
- package/dist/membership.d.ts.map +1 -0
- package/dist/membership.js +157 -0
- package/dist/membership.js.map +1 -0
- package/dist/ports.d.ts +237 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +2 -0
- package/dist/ports.js.map +1 -0
- package/dist/profile.d.ts +56 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +193 -0
- package/dist/profile.js.map +1 -0
- package/dist/prompts.d.ts +14 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +69 -0
- package/dist/prompts.js.map +1 -0
- package/dist/receive.d.ts +133 -0
- package/dist/receive.d.ts.map +1 -0
- package/dist/receive.js +298 -0
- package/dist/receive.js.map +1 -0
- package/dist/results.d.ts +158 -0
- package/dist/results.d.ts.map +1 -0
- package/dist/results.js +21 -0
- package/dist/results.js.map +1 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +42 -38
- package/dist/schemas.js.map +1 -1
- package/dist/sha256.d.ts +3 -0
- package/dist/sha256.d.ts.map +1 -0
- package/dist/sha256.js +69 -0
- package/dist/sha256.js.map +1 -0
- package/dist/star.d.ts +31 -0
- package/dist/star.d.ts.map +1 -0
- package/dist/star.js +56 -0
- package/dist/star.js.map +1 -0
- package/dist/state-builder.d.ts +42 -0
- package/dist/state-builder.d.ts.map +1 -0
- package/dist/state-builder.js +100 -0
- package/dist/state-builder.js.map +1 -0
- package/dist/state.d.ts +853 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +36 -0
- package/dist/state.js.map +1 -0
- package/dist/store-contract.d.ts +11 -0
- package/dist/store-contract.d.ts.map +1 -0
- package/dist/store-contract.js +189 -0
- package/dist/store-contract.js.map +1 -0
- package/dist/store-memory.d.ts +8 -0
- package/dist/store-memory.d.ts.map +1 -0
- package/dist/store-memory.js +17 -0
- package/dist/store-memory.js.map +1 -0
- package/dist/testing.d.ts +22 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +21 -0
- package/dist/testing.js.map +1 -0
- package/dist/trust/continuity.d.ts +71 -0
- package/dist/trust/continuity.d.ts.map +1 -0
- package/dist/trust/continuity.js +1216 -0
- package/dist/trust/continuity.js.map +1 -0
- package/dist/trust/duties.d.ts +67 -0
- package/dist/trust/duties.d.ts.map +1 -0
- package/dist/trust/duties.js +179 -0
- package/dist/trust/duties.js.map +1 -0
- package/dist/trust/entries.d.ts +139 -0
- package/dist/trust/entries.d.ts.map +1 -0
- package/dist/trust/entries.js +694 -0
- package/dist/trust/entries.js.map +1 -0
- package/dist/trust/group-star.d.ts +43 -0
- package/dist/trust/group-star.d.ts.map +1 -0
- package/dist/trust/group-star.js +188 -0
- package/dist/trust/group-star.js.map +1 -0
- package/dist/trust/mapping.d.ts +56 -0
- package/dist/trust/mapping.d.ts.map +1 -0
- package/dist/trust/mapping.js +584 -0
- package/dist/trust/mapping.js.map +1 -0
- package/dist/trust/profile.d.ts +25 -0
- package/dist/trust/profile.d.ts.map +1 -0
- package/dist/trust/profile.js +249 -0
- package/dist/trust/profile.js.map +1 -0
- package/dist/trust/star.d.ts +208 -0
- package/dist/trust/star.d.ts.map +1 -0
- package/dist/trust/star.js +588 -0
- package/dist/trust/star.js.map +1 -0
- package/dist/trust/state.d.ts +83 -0
- package/dist/trust/state.d.ts.map +1 -0
- package/dist/trust/state.js +154 -0
- package/dist/trust/state.js.map +1 -0
- package/dist/util-freeze.d.ts +3 -0
- package/dist/util-freeze.d.ts.map +1 -0
- package/dist/util-freeze.js +11 -0
- package/dist/util-freeze.js.map +1 -0
- package/dist/views.d.ts +147 -0
- package/dist/views.d.ts.map +1 -0
- package/dist/views.js +268 -0
- package/dist/views.js.map +1 -0
- package/dist/visibility.d.ts +5 -5
- package/dist/visibility.d.ts.map +1 -1
- package/dist/visibility.js +22 -28
- package/dist/visibility.js.map +1 -1
- package/dist/wire.d.ts +178 -84
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js.map +1 -1
- package/package.json +58 -11
- package/schemas/holder-state-v1.schema.json +3880 -0
- package/dist/holder.d.ts.map +0 -1
- package/dist/holder.js.map +0 -1
- package/dist/probe/acks.d.ts +0 -27
- package/dist/probe/acks.d.ts.map +0 -1
- package/dist/probe/acks.js +0 -152
- package/dist/probe/acks.js.map +0 -1
- package/dist/probe/continuity.d.ts +0 -20
- package/dist/probe/continuity.d.ts.map +0 -1
- package/dist/probe/continuity.js +0 -805
- package/dist/probe/continuity.js.map +0 -1
- package/dist/probe/deps.d.ts +0 -61
- package/dist/probe/deps.d.ts.map +0 -1
- package/dist/probe/deps.js +0 -54
- package/dist/probe/deps.js.map +0 -1
- package/dist/probe/encounter.d.ts +0 -26
- package/dist/probe/encounter.d.ts.map +0 -1
- package/dist/probe/encounter.js +0 -938
- package/dist/probe/encounter.js.map +0 -1
- package/dist/probe/introduce.d.ts +0 -70
- package/dist/probe/introduce.d.ts.map +0 -1
- package/dist/probe/introduce.js +0 -295
- package/dist/probe/introduce.js.map +0 -1
- package/dist/probe/membership.d.ts +0 -90
- package/dist/probe/membership.d.ts.map +0 -1
- package/dist/probe/membership.js +0 -450
- package/dist/probe/membership.js.map +0 -1
- package/dist/probe/trust.d.ts +0 -156
- package/dist/probe/trust.d.ts.map +0 -1
- package/dist/probe/trust.js +0 -1233
- package/dist/probe/trust.js.map +0 -1
- package/dist/probe.d.ts +0 -7
- package/dist/probe.d.ts.map +0 -1
- package/dist/probe.js +0 -42
- package/dist/probe.js.map +0 -1
package/dist/engine.js
ADDED
|
@@ -0,0 +1,1395 @@
|
|
|
1
|
+
// engine — the holder's small engine (lib Stage 2, note §4.1–4.4, 4.7).
|
|
2
|
+
//
|
|
3
|
+
// Modules own their rules as pure transitions and register commands,
|
|
4
|
+
// task handlers and duty handlers here, once, at construction; after
|
|
5
|
+
// that the registries are frozen. No module has a commit path of its
|
|
6
|
+
// own: the engine coordinates snapshot, preparation, check, commit and
|
|
7
|
+
// release, and nothing else.
|
|
8
|
+
//
|
|
9
|
+
// An act runs in four steps (note §4.2), serialized by one in-engine
|
|
10
|
+
// queue:
|
|
11
|
+
// 1. read the snapshot {state, version} from the store and decode it;
|
|
12
|
+
// raise `when` to state.lastWhen (4.7)
|
|
13
|
+
// 2. prepare (async, outside the transaction): crypto, entropy, ports;
|
|
14
|
+
// `prepared` is bound to the version and commitSeq it ran against
|
|
15
|
+
// 3. apply (synchronous, pure, on a deep-frozen snapshot), then one CAS
|
|
16
|
+
// commit of state, outbox, duties, the act journal and commitSeq;
|
|
17
|
+
// on `conflict` back to 1 — the queue serializes this engine, CAS
|
|
18
|
+
// catches every other writer — bounded by retry.commitAttempts
|
|
19
|
+
// 4. only after the commit: events to subscribers, the result returned
|
|
20
|
+
//
|
|
21
|
+
// A commit that throws has an unknown outcome: InfraError
|
|
22
|
+
// 'unknown-commit-outcome'. The engine then settles before its next
|
|
23
|
+
// operation: it re-reads and, if the commit landed (the stored state is
|
|
24
|
+
// the one it wrote, or the journal holds the act at its commitSeq),
|
|
25
|
+
// releases that commit's events late; otherwise drops them. A repeated
|
|
26
|
+
// human act finds its result in the journal (note §4.2).
|
|
27
|
+
//
|
|
28
|
+
// Choices this file makes where the note leaves them open:
|
|
29
|
+
// - snapshots handed to prepare/apply are always deep-frozen (also in
|
|
30
|
+
// production): a mutating module throws a TypeError, nothing commits
|
|
31
|
+
// - every act reads the store afresh; the engine caches no state
|
|
32
|
+
// - eventId is `<commitSeq>.<index>`: unique per holder, no entropy
|
|
33
|
+
// - the input is bound at the public call: a canonical, deep-frozen JSON
|
|
34
|
+
// copy (undefined object members dropped, anything else non-JSON
|
|
35
|
+
// refused); its hash, every preparation and every apply use that copy
|
|
36
|
+
// - a `when` above lastWhen is a state change and commits; a non-human
|
|
37
|
+
// act whose apply returns the identical state object and nothing else
|
|
38
|
+
// commits nothing only when lastWhen stays too. A human act always
|
|
39
|
+
// commits, because its journal entry is the result. Refusals and
|
|
40
|
+
// journal replays commit nothing, lastWhen included
|
|
41
|
+
// - outbound of an act starts at attempt 1 with nextAttemptAt =
|
|
42
|
+
// when + ceil(backoff(1)); resume() re-emits only items that are due
|
|
43
|
+
// and pauses re-emission while the last step is awaiting-transport
|
|
44
|
+
// (the adapter holds the item) and never re-emits an accepted one. It
|
|
45
|
+
// gives up every non-terminal item (submitted, held or accepted) at
|
|
46
|
+
// first submission + horizonMs, never by attempt
|
|
47
|
+
// count; nextDueAt is the earliest due re-emission or horizon expiry
|
|
48
|
+
// - the holder's give-up is failed(expired-by-adapter-policy); a later
|
|
49
|
+
// delivered report is late by that history, whatever the adapter says
|
|
50
|
+
// - replay of a human act returns the journaled events and the outbound
|
|
51
|
+
// of its documents that are not terminal (delivered, failed), at their
|
|
52
|
+
// current attempt. Idempotency is bounded by the journal's retention:
|
|
53
|
+
// max(P90D, retry.horizonMs), the key-retention of Delivery §7 that
|
|
54
|
+
// bounds effects and their acks; after it the same actId is a new act
|
|
55
|
+
// - KeyProvider, Log and Access rejections inside a preparation become
|
|
56
|
+
// InfraError('key-provider' | 'log' | 'access') with the cause. Known
|
|
57
|
+
// limitation: a provider's argument errors (a module passing a label
|
|
58
|
+
// outside the registry, say) are wrapped the same way, so a module
|
|
59
|
+
// programming error surfaces as an infrastructure error; the original
|
|
60
|
+
// error stays in `cause`
|
|
61
|
+
// - events are deep-frozen before listeners or callers see them; a
|
|
62
|
+
// listener's throw or rejection is dropped
|
|
63
|
+
// - the next state is validated by the codec before every commit; an
|
|
64
|
+
// invalid state is a programming error and throws
|
|
65
|
+
// - receive (receive.ts) is a built-in like tick: a failed disposition
|
|
66
|
+
// commits nothing, lastWhen included (like a refusal); duplicate-known
|
|
67
|
+
// commits only a raised lastWhen; a CAS conflict re-reads and runs the
|
|
68
|
+
// stages again, which re-checks the effect cache at stage 4
|
|
69
|
+
// - an ack is stored sealed (StoredAck: envelope, digest, recipient) in
|
|
70
|
+
// its effect, so every re-send is byte-identical down to the envelope.
|
|
71
|
+
// It stays `sent: false` until any report on its digest. Unsent, it has
|
|
72
|
+
// the outbox's retry schedule: attempt 1 at the receive, re-offered by
|
|
73
|
+
// resume when due (attempt + 1, backoff), counted into nextDueAt, and at
|
|
74
|
+
// its horizon (effect commit + horizonMs) marked sent by the horizon
|
|
75
|
+
// (`sentBy: 'horizon'`, no event: acks have no trias). A duplicate's
|
|
76
|
+
// re-send carries attempt + 1 and does not move the schedule (it
|
|
77
|
+
// commits nothing beyond lastWhen)
|
|
78
|
+
// - outbound of one commit, in order: outbox re-emissions, released
|
|
79
|
+
// acks, new documents
|
|
80
|
+
// - resume runs duties after the outbox, ordered by dueAt (absent: due
|
|
81
|
+
// always, counted as 0), then id. An Applied fulfils the duty and the
|
|
82
|
+
// engine removes it in the same commit; 'not-due' and a refusal keep it
|
|
83
|
+
// without a trace. A duty of a registered kind at another version is
|
|
84
|
+
// announced as duty.unsupported on every resume (a commit of its own);
|
|
85
|
+
// a kind no module registers is kept silently. A duty with awaitHead
|
|
86
|
+
// runs only when its handler's awaitHolds says so (none: never). Each
|
|
87
|
+
// planned duty is checked again against the state the duties before it
|
|
88
|
+
// left: still present at the handler's version, due, its awaited head
|
|
89
|
+
// holding; a prepared duty whose data changed since its preparation is
|
|
90
|
+
// deferred to the next resume and prepared afresh there. Its handler's
|
|
91
|
+
// apply re-checks every other state-dependent precondition itself
|
|
92
|
+
// (note §4.2 step 3). nextDueAt comes from the final duty set, the
|
|
93
|
+
// duties created in this resume included: a future dueAt, or `when` for
|
|
94
|
+
// duty work runnable now that did not run; a duty that ran and stayed
|
|
95
|
+
// (not-due, refused) sets no timer while its record is unchanged since
|
|
96
|
+
// that run. A duty whose kind changed since the plan is not run by the
|
|
97
|
+
// planned handler; the next resume dispatches it. New documents of
|
|
98
|
+
// duties count in too. The duties a handler opens are folded into the
|
|
99
|
+
// state before the next handler runs (an id already open is kept), so
|
|
100
|
+
// later handlers' drop rules and dedupe see them; they run on the next
|
|
101
|
+
// resume (the plan is fixed at the snapshot)
|
|
102
|
+
// - every committed operation but resume names the duty work it opened or
|
|
103
|
+
// unblocked as nextDueAt on its result (ActResult, ReceiveResult
|
|
104
|
+
// unique): `when` where a duty that is new, changed, or newly runnable
|
|
105
|
+
// (its awaited head now holding) can run now, else the earliest future
|
|
106
|
+
// dueAt among those; absent where it opened none. It is the app's cue to
|
|
107
|
+
// call resume (or set a timer), as resume's own nextDueAt is. A journal
|
|
108
|
+
// replay of a human act carries none: its duties were announced then
|
|
109
|
+
// - sweeps (Trust 5.4 reconciliation, PR 4b) run in resume after the
|
|
110
|
+
// duties, in registration order, on the state the duties left; their
|
|
111
|
+
// preparations run against resume's snapshot beside the duties'. A
|
|
112
|
+
// piece whose inputs moved since is skipped and makes `when` the
|
|
113
|
+
// nextDueAt, as does sweep work that became pending during the resume
|
|
114
|
+
// itself (a give-up at the horizon, a duty's change) without having
|
|
115
|
+
// been prepared. Their new documents count into nextDueAt like the
|
|
116
|
+
// duties'. Every other committed operation whose state adds a key to
|
|
117
|
+
// a sweep's `pending` names `when` as its nextDueAt; a sweep that
|
|
118
|
+
// declares the state parts it reads (`reads`) is skipped there when the
|
|
119
|
+
// operation left all of them untouched (by reference)
|
|
120
|
+
// - a verified delivery-ack runs the ack handler registered for the
|
|
121
|
+
// acknowledged item's type in its own transaction; a report never does
|
|
122
|
+
// - an outbox item that turns failed (a failed report, or the holder's
|
|
123
|
+
// own give-up at the horizon inside resume) runs the failure handler
|
|
124
|
+
// registered for its type in that same transaction, once, at the
|
|
125
|
+
// transition; duties it opens are folded into the state there (so two
|
|
126
|
+
// failures of one resume cannot open one duty twice) and count into
|
|
127
|
+
// nextDueAt like any new duty. A late delivered after failed runs the
|
|
128
|
+
// ack handler as usual, never the failure handler again
|
|
129
|
+
// - the Encounter latch exception (receive.ts TaskRefusal, Refusal):
|
|
130
|
+
// a refusal carrying `latch` commits exactly that state (checked to
|
|
131
|
+
// shrink encounter.challenges and touch nothing else) and the raised
|
|
132
|
+
// lastWhen, with no journal entry, cache entry, ack, outbound or event;
|
|
133
|
+
// the result stays the refusal (receive: failed at stage 9)
|
|
134
|
+
// - a task handler's refusal commits nothing; its TaskDiscarded (an
|
|
135
|
+
// assembly discarded at a resource limit or rejected, Trust 5365/5366)
|
|
136
|
+
// commits its state and nothing else: disposition `discarded` with the
|
|
137
|
+
// reason, no cache entry, no ack, no event
|
|
138
|
+
// - a transition's `renames` (Applied) are followed in the effect cache by
|
|
139
|
+
// the engine after the transition: each stored ack's recipient.contactId
|
|
140
|
+
// that a rename names moves to its new id; the envelope, sealed to the
|
|
141
|
+
// key agreement, is untouched (an ack re-sent is byte-identical)
|
|
142
|
+
// - a module's ownRetries are task types it re-issues itself: resume
|
|
143
|
+
// never re-emits their outbox items (the horizon still gives them up);
|
|
144
|
+
// its keepKeys name own rkids tick must not tombstone though no live
|
|
145
|
+
// tuple carries them. Both trust the registered modules: no ownership
|
|
146
|
+
// of a type or a key is checked (modules are the library's own, not
|
|
147
|
+
// app plug-ins)
|
|
148
|
+
// - a failure handler gets the retry backoff, so a failure can make work
|
|
149
|
+
// due no earlier than its schedule allows
|
|
150
|
+
// - the timer contract: tick does the time-driven work (expiry,
|
|
151
|
+
// retention, the modules' `retain`), resume the delivery work (retries,
|
|
152
|
+
// due duties, sweeps, acks). nextDueAt names the earliest of both: on
|
|
153
|
+
// resume, the earliest future deadline of the expiry work (prompt
|
|
154
|
+
// expiresAt, effect expiry, the act journal's, a terminal outbox item's
|
|
155
|
+
// and an address key's retention, every module's `deadlines`); on an
|
|
156
|
+
// act or receive, also a deadline the commit newly introduced. The app
|
|
157
|
+
// sets one timer on it and, when it fires, calls tick and then resume
|
|
158
|
+
// (README "The holder")
|
|
159
|
+
// - after its own retention, tick runs every module's `retain` hook on
|
|
160
|
+
// the result (the trust module drops in-flight star deliveries whose
|
|
161
|
+
// outbox items have all been retained away); the address keys' retention
|
|
162
|
+
// comes last, its live set and keepKeys read on what the hooks left, so
|
|
163
|
+
// a key whose last claim (an act's pin, 8290) left in this tick leaves in
|
|
164
|
+
// the same tick and no passed key deadline outlives it
|
|
165
|
+
// - without an Access port, EngineOptions.accessFrom (createHolder: the
|
|
166
|
+
// membership module's localAccess) builds one over each preparation's
|
|
167
|
+
// own snapshot, so it describes exactly the state the commit lands on
|
|
168
|
+
// - EngineOptions.check (createHolder's `invariants: true`) runs on every
|
|
169
|
+
// commit after the codec's validation and before the CAS; its throw
|
|
170
|
+
// aborts the operation, nothing commits
|
|
171
|
+
// - tick retains with the key-retention max(P90D, horizonMs): effects
|
|
172
|
+
// with their acks at expiresAt; terminal outbox items once the
|
|
173
|
+
// retention has passed since their last step, unless a kept journal
|
|
174
|
+
// entry names them; an address key not shown for the retention and
|
|
175
|
+
// the own rkid of no tuple that is not deactivated becomes a tombstone —
|
|
176
|
+
// except a member key (a group/… label): an identity context of
|
|
177
|
+
// Identity §6.1 rather than a delivery address of Delivery §5 (one
|
|
178
|
+
// derivation per group, reused whenever the holder is invited to it
|
|
179
|
+
// again), it leaves `addresses` without a tombstone (a point for
|
|
180
|
+
// Delivery 0.82: addresses and identity contexts)
|
|
181
|
+
import { jcs, b64uOf } from './core.js';
|
|
182
|
+
import { CODEC, decodeState, encodeState } from './codec.js';
|
|
183
|
+
import { emptyState, toOpaque, triasOf } from './state.js';
|
|
184
|
+
import { CryptoUnavailableError, InfraError } from './results.js';
|
|
185
|
+
import { randomEntropy } from './entropy.js';
|
|
186
|
+
import { ED_PKCS8, X_PKCS8 } from './keys-seed.js';
|
|
187
|
+
import { freezeDeep } from './util-freeze.js';
|
|
188
|
+
import { ACK_TYPE } from './ack.js';
|
|
189
|
+
import { challengeAged } from './encounter.js';
|
|
190
|
+
import { ackOutbound, handlerPrepared, isTaskRefusal, markDelivered, prepareReceive } from './receive.js';
|
|
191
|
+
import { isTaskDiscarded } from './receive.js';
|
|
192
|
+
/** The Encounter latch exception (receive.ts TaskRefusal): a latch state
|
|
193
|
+
* differs from its base in encounter.challenges alone; every challenge it
|
|
194
|
+
* keeps is byte-identical, and every one it removes is aged at `when`, the
|
|
195
|
+
* commit's authoritative time (Encounter 5190, 5260). Anything else is a
|
|
196
|
+
* programming error and throws. */
|
|
197
|
+
function checkLatch(base, latch, when) {
|
|
198
|
+
for (const k of Object.keys(base)) {
|
|
199
|
+
if (k === 'encounter')
|
|
200
|
+
continue;
|
|
201
|
+
if (base[k] !== latch[k])
|
|
202
|
+
throw new Error(`engine: an aging latch changed ${k}`);
|
|
203
|
+
}
|
|
204
|
+
const e = base.encounter, l = latch.encounter;
|
|
205
|
+
for (const k of Object.keys(e)) {
|
|
206
|
+
if (k !== 'challenges' && e[k] !== l[k])
|
|
207
|
+
throw new Error(`engine: an aging latch changed encounter.${k}`);
|
|
208
|
+
}
|
|
209
|
+
for (const [v, c] of Object.entries(l.challenges)) {
|
|
210
|
+
if (!Object.hasOwn(e.challenges, v))
|
|
211
|
+
throw new Error('engine: an aging latch added a challenge');
|
|
212
|
+
if (jcs(c) !== jcs(e.challenges[v]))
|
|
213
|
+
throw new Error(`engine: an aging latch changed the challenge ${v}`);
|
|
214
|
+
}
|
|
215
|
+
for (const [v, c] of Object.entries(e.challenges)) {
|
|
216
|
+
if (!Object.hasOwn(l.challenges, v) && !challengeAged(c.tCh, when))
|
|
217
|
+
throw new Error(`engine: an aging latch removed the unaged challenge ${v}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// ── defaults ───────────────────────────────────────────────────────────
|
|
221
|
+
const DAY = 86_400_000;
|
|
222
|
+
/** Defaults of the retry policy and the retention it implies. */
|
|
223
|
+
export const ENGINE_DEFAULTS = Object.freeze({
|
|
224
|
+
/** 30 s, doubling, at most 6 h */
|
|
225
|
+
backoff: (attempt) => Math.min(30_000 * 2 ** Math.max(0, attempt - 1), 6 * 3_600_000),
|
|
226
|
+
/** P7D, inside Delivery's give-up range [P1D, P90D]; the only give-up */
|
|
227
|
+
horizonMs: 7 * DAY,
|
|
228
|
+
commitAttempts: 8,
|
|
229
|
+
/** max(P90D, horizonMs) with the default horizon */
|
|
230
|
+
retentionMs: 90 * DAY,
|
|
231
|
+
});
|
|
232
|
+
/** Delivery's give-up range for horizonMs: [P1D, P90D]. */
|
|
233
|
+
const HORIZON_MIN = DAY;
|
|
234
|
+
const HORIZON_MAX = 90 * DAY;
|
|
235
|
+
const BUILTIN = ['tick', 'resume', 'report', 'receive'];
|
|
236
|
+
const TASK_URI = /^[a-z][a-z0-9+.-]*:\S+\/[0-9]+\.[0-9]+$/;
|
|
237
|
+
// ── helpers ────────────────────────────────────────────────────────────
|
|
238
|
+
const ALGORITHMS = ['Ed25519', 'X25519', 'HKDF', 'HMAC', 'AES-GCM', 'SHA-256'];
|
|
239
|
+
const concat = (a, b) => { const o = new Uint8Array(a.length + b.length); o.set(a); o.set(b, a.length); return o; };
|
|
240
|
+
/** Resolves the WebCrypto handle and probes every algorithm the library uses. */
|
|
241
|
+
async function resolveCrypto(c) {
|
|
242
|
+
const h = c ?? globalThis.crypto;
|
|
243
|
+
const S = h?.subtle;
|
|
244
|
+
if (!S || typeof S.importKey !== 'function' || typeof S.digest !== 'function')
|
|
245
|
+
throw new CryptoUnavailableError(['subtle', ...ALGORITHMS]);
|
|
246
|
+
const k = new Uint8Array(32).fill(1);
|
|
247
|
+
const probes = {
|
|
248
|
+
Ed25519: () => S.importKey('pkcs8', concat(ED_PKCS8, k), { name: 'Ed25519' }, false, ['sign']),
|
|
249
|
+
X25519: () => S.importKey('pkcs8', concat(X_PKCS8, k), { name: 'X25519' }, false, ['deriveBits']),
|
|
250
|
+
HKDF: () => S.importKey('raw', k, 'HKDF', false, ['deriveBits']),
|
|
251
|
+
HMAC: () => S.importKey('raw', k, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']),
|
|
252
|
+
'AES-GCM': () => S.importKey('raw', k, { name: 'AES-GCM' }, false, ['encrypt']),
|
|
253
|
+
'SHA-256': () => S.digest('SHA-256', k),
|
|
254
|
+
};
|
|
255
|
+
const ok = await Promise.all(ALGORITHMS.map(async (a) => { try {
|
|
256
|
+
await probes[a]();
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return false;
|
|
261
|
+
} }));
|
|
262
|
+
const missing = ALGORITHMS.filter((_, i) => !ok[i]);
|
|
263
|
+
if (missing.length)
|
|
264
|
+
throw new CryptoUnavailableError(missing);
|
|
265
|
+
return h;
|
|
266
|
+
}
|
|
267
|
+
const te = new TextEncoder();
|
|
268
|
+
/** The stack's document digest (u + multihash sha2-256 over JCS), on the resolved handle. */
|
|
269
|
+
const digestOf = async (S, x) => 'u' + b64uOf(concat(Uint8Array.from([0x12, 0x20]), new Uint8Array(await S.digest('SHA-256', te.encode(jcs(x))))));
|
|
270
|
+
/** Drops undefined object members (as JSON does) without hiding what jcs refuses. */
|
|
271
|
+
const dropUndefined = (x) => {
|
|
272
|
+
if (x === null || typeof x !== 'object')
|
|
273
|
+
return x;
|
|
274
|
+
if (Object.getOwnPropertySymbols(x).length > 0)
|
|
275
|
+
return x;
|
|
276
|
+
if (Array.isArray(x)) {
|
|
277
|
+
if (Object.getOwnPropertyNames(x).length !== x.length + 1)
|
|
278
|
+
return x;
|
|
279
|
+
for (let i = 0; i < x.length; i++)
|
|
280
|
+
if (!(i in x))
|
|
281
|
+
return x;
|
|
282
|
+
return x.map(dropUndefined);
|
|
283
|
+
}
|
|
284
|
+
const proto = Object.getPrototypeOf(x);
|
|
285
|
+
if (proto !== null && proto !== Object.prototype)
|
|
286
|
+
return x;
|
|
287
|
+
if (Object.getOwnPropertyNames(x).length !== Object.keys(x).length)
|
|
288
|
+
return x;
|
|
289
|
+
// own data properties, never assignment: a "__proto__" key stays a key
|
|
290
|
+
// (assignment would hit the inherited setter and change the prototype)
|
|
291
|
+
const out = {};
|
|
292
|
+
for (const [k, v] of Object.entries(x)) {
|
|
293
|
+
if (v !== undefined)
|
|
294
|
+
Object.defineProperty(out, k, { value: dropUndefined(v), enumerable: true, writable: true, configurable: true });
|
|
295
|
+
}
|
|
296
|
+
return out;
|
|
297
|
+
};
|
|
298
|
+
/** The act input as bound at the call: canonical JSON, deep-frozen. A
|
|
299
|
+
* "__proto__" member is an ordinary key throughout: jcs serializes own
|
|
300
|
+
* keys (Object.keys) and JSON.parse defines own data properties, so it is
|
|
301
|
+
* hashed, journaled and handed to prepare/apply like any other key. */
|
|
302
|
+
const bindInput = (x) => freezeDeep(JSON.parse(jcs(x === undefined ? null : dropUndefined(x))));
|
|
303
|
+
/** when + ceil(delay), clamped to safe integer milliseconds. */
|
|
304
|
+
const dueAt = (when, delay) => {
|
|
305
|
+
const d = Number.isNaN(delay) ? 0 : Math.max(0, Math.ceil(delay));
|
|
306
|
+
return Math.min(when + d, Number.MAX_SAFE_INTEGER);
|
|
307
|
+
};
|
|
308
|
+
/** A port whose rejections become InfraError(code); InfraError passes as is. */
|
|
309
|
+
function guard(port, names, code, wrapResult) {
|
|
310
|
+
const out = {};
|
|
311
|
+
for (const n of names) {
|
|
312
|
+
out[n] = async (...args) => {
|
|
313
|
+
let r;
|
|
314
|
+
try {
|
|
315
|
+
r = await port[n].apply(port, args);
|
|
316
|
+
}
|
|
317
|
+
catch (e) {
|
|
318
|
+
throw e instanceof InfraError ? e : new InfraError(code, `${code}: ${n} failed`, { cause: e });
|
|
319
|
+
}
|
|
320
|
+
return wrapResult ? wrapResult(r) : r;
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
return Object.freeze(out);
|
|
324
|
+
}
|
|
325
|
+
const inc = (d) => (BigInt(d) + 1n).toString();
|
|
326
|
+
const releaseOf = (state) => (rkid) => {
|
|
327
|
+
if (state.addresses.tombstones.includes(rkid))
|
|
328
|
+
return { status: 'retired' };
|
|
329
|
+
if (!Object.hasOwn(state.addresses.keys, rkid))
|
|
330
|
+
return { status: 'unknown' };
|
|
331
|
+
const k = state.addresses.keys[rkid];
|
|
332
|
+
return k.retiredAt !== undefined ? { status: 'retired' } : { status: 'live', label: k.label };
|
|
333
|
+
};
|
|
334
|
+
const isRefusal = (r) => r?.ok === false;
|
|
335
|
+
/** The effect cache with the stored acks' recipients renamed. */
|
|
336
|
+
function renameAcks(effects, renames) {
|
|
337
|
+
let out = null;
|
|
338
|
+
for (const [d, e] of Object.entries(effects)) {
|
|
339
|
+
let a = e.ack;
|
|
340
|
+
if (!a)
|
|
341
|
+
continue;
|
|
342
|
+
for (const r of renames) {
|
|
343
|
+
if (r.from.includes(a.recipient.contactId) && (r.rkids === null || r.rkids.includes(a.recipient.via ?? a.recipient.rkid)))
|
|
344
|
+
a = { ...a, recipient: { ...a.recipient, contactId: r.to } };
|
|
345
|
+
}
|
|
346
|
+
if (a !== e.ack)
|
|
347
|
+
(out ??= { ...effects })[d] = { ...e, ack: a };
|
|
348
|
+
}
|
|
349
|
+
return out ?? effects;
|
|
350
|
+
}
|
|
351
|
+
const checkWhen = (when) => {
|
|
352
|
+
if (!Number.isSafeInteger(when) || when < 0)
|
|
353
|
+
throw new TypeError(`when must be non-negative integer milliseconds, got ${String(when)}`);
|
|
354
|
+
};
|
|
355
|
+
/** A duty's dueAt; absent counts as 0 (due always). */
|
|
356
|
+
const dutyDue = (d) => (Number.isSafeInteger(d.dueAt) ? d.dueAt : 0);
|
|
357
|
+
/** Due, and not waiting on a head its handler's awaitHolds denies (none: waits forever). */
|
|
358
|
+
const dutyRunnable = (d, h, state, when) => {
|
|
359
|
+
if (dutyDue(d) > when)
|
|
360
|
+
return false;
|
|
361
|
+
const awaits = d.awaitHead;
|
|
362
|
+
return typeof awaits !== 'string' || (h.awaitHolds?.(state, d) ?? false);
|
|
363
|
+
};
|
|
364
|
+
const isHalt = (r) => r?.halt === true;
|
|
365
|
+
const actResult = (r) => {
|
|
366
|
+
if (isHalt(r))
|
|
367
|
+
throw new Error('engine: an act does not halt');
|
|
368
|
+
return r.ok ? { ok: true, outbound: r.outbound, events: r.events, ...(r.nextDueAt !== undefined ? { nextDueAt: r.nextDueAt } : {}) } : r;
|
|
369
|
+
};
|
|
370
|
+
// ── the engine ─────────────────────────────────────────────────────────
|
|
371
|
+
class HolderEngine {
|
|
372
|
+
registry;
|
|
373
|
+
#store;
|
|
374
|
+
#ports;
|
|
375
|
+
/** the ports a preparation sees, rejections typed (InfraError) */
|
|
376
|
+
#keys;
|
|
377
|
+
#log;
|
|
378
|
+
#access;
|
|
379
|
+
#crypto;
|
|
380
|
+
#entropy;
|
|
381
|
+
#policy;
|
|
382
|
+
#commands;
|
|
383
|
+
#tasks;
|
|
384
|
+
#duties;
|
|
385
|
+
#sweeps;
|
|
386
|
+
#acks;
|
|
387
|
+
#failures;
|
|
388
|
+
#ownRetries;
|
|
389
|
+
#keepKeys;
|
|
390
|
+
#retain;
|
|
391
|
+
#deadlines;
|
|
392
|
+
#builtins;
|
|
393
|
+
#check;
|
|
394
|
+
#accessFrom;
|
|
395
|
+
#listeners = new Set();
|
|
396
|
+
#chain = Promise.resolve();
|
|
397
|
+
/** a commit whose outcome is unknown, settled before the next operation */
|
|
398
|
+
#unsettled = null;
|
|
399
|
+
constructor(ports, crypto, modules, opts = {}) {
|
|
400
|
+
if (opts.check !== undefined && typeof opts.check !== 'function')
|
|
401
|
+
throw new TypeError('check must be a function');
|
|
402
|
+
this.#check = opts.check;
|
|
403
|
+
if (opts.accessFrom !== undefined && typeof opts.accessFrom !== 'function')
|
|
404
|
+
throw new TypeError('accessFrom must be a function');
|
|
405
|
+
this.#accessFrom = opts.accessFrom;
|
|
406
|
+
this.#store = ports.store;
|
|
407
|
+
this.#ports = ports;
|
|
408
|
+
this.#crypto = crypto;
|
|
409
|
+
this.#entropy = ports.entropy ?? randomEntropy(crypto);
|
|
410
|
+
const r = ports.retry ?? {};
|
|
411
|
+
const horizonMs = r.horizonMs ?? ENGINE_DEFAULTS.horizonMs;
|
|
412
|
+
const backoff = r.backoff ?? ENGINE_DEFAULTS.backoff;
|
|
413
|
+
const commitAttempts = r.commitAttempts ?? ENGINE_DEFAULTS.commitAttempts;
|
|
414
|
+
if (!Number.isSafeInteger(horizonMs) || horizonMs < HORIZON_MIN || horizonMs > HORIZON_MAX)
|
|
415
|
+
throw new RangeError(`retry.horizonMs must be integer ms within [P1D, P90D], got ${horizonMs}`);
|
|
416
|
+
if (typeof backoff !== 'function')
|
|
417
|
+
throw new RangeError('retry.backoff must be a function');
|
|
418
|
+
for (let n = 1; n <= 3; n++) {
|
|
419
|
+
const d = backoff(n);
|
|
420
|
+
if (typeof d !== 'number' || !Number.isFinite(d) || d < 0)
|
|
421
|
+
throw new RangeError(`retry.backoff(${n}) must be a finite non-negative number, got ${d}`);
|
|
422
|
+
}
|
|
423
|
+
if (!Number.isSafeInteger(commitAttempts) || commitAttempts < 1)
|
|
424
|
+
throw new RangeError('retry.commitAttempts must be a positive integer');
|
|
425
|
+
this.#policy = Object.freeze({ backoff, horizonMs, commitAttempts, retentionMs: Math.max(ENGINE_DEFAULTS.retentionMs, horizonMs) });
|
|
426
|
+
const carrierKey = (k) => Object.freeze({ principal: k.principal, sign: guard(k, ['sign'], 'key-provider').sign });
|
|
427
|
+
const base = guard(ports.keys, ['anchorOf', 'sign', 'agree'], 'key-provider');
|
|
428
|
+
this.#keys = Object.freeze({
|
|
429
|
+
anchorOf: base.anchorOf, sign: base.sign, agree: base.agree,
|
|
430
|
+
carrierPrincipal: guard(ports.keys, ['carrierPrincipal'], 'key-provider', carrierKey).carrierPrincipal,
|
|
431
|
+
});
|
|
432
|
+
if (ports.log)
|
|
433
|
+
this.#log = guard(ports.log, ['head', 'at', 'append'], 'log');
|
|
434
|
+
if (ports.access)
|
|
435
|
+
this.#access = guard(ports.access, ['memberships', 'membership'], 'access');
|
|
436
|
+
const commands = new Map();
|
|
437
|
+
const tasks = new Map();
|
|
438
|
+
const duties = new Map();
|
|
439
|
+
const sweeps = new Map();
|
|
440
|
+
const acks = new Map();
|
|
441
|
+
const failures = new Map();
|
|
442
|
+
const ownRetries = new Set();
|
|
443
|
+
const keepKeys = [];
|
|
444
|
+
const retain = [];
|
|
445
|
+
const deadlines = [];
|
|
446
|
+
const names = new Set();
|
|
447
|
+
for (const m of modules) {
|
|
448
|
+
if (!m || typeof m.name !== 'string' || !m.name)
|
|
449
|
+
throw new TypeError('a module needs a name');
|
|
450
|
+
if (names.has(m.name))
|
|
451
|
+
throw new Error(`duplicate module ${m.name}`);
|
|
452
|
+
names.add(m.name);
|
|
453
|
+
for (const [name, def] of Object.entries(m.commands ?? {})) {
|
|
454
|
+
if (commands.has(name) || BUILTIN.includes(name))
|
|
455
|
+
throw new Error(`duplicate command ${name} (module ${m.name})`);
|
|
456
|
+
if (typeof def?.apply !== 'function' || typeof def.human !== 'boolean')
|
|
457
|
+
throw new TypeError(`command ${name} (module ${m.name}) needs human and apply`);
|
|
458
|
+
commands.set(name, Object.freeze({ ...def }));
|
|
459
|
+
}
|
|
460
|
+
for (const [uri, h] of Object.entries(m.tasks ?? {})) {
|
|
461
|
+
if (!TASK_URI.test(uri))
|
|
462
|
+
throw new Error(`not a full task URI with version: ${uri} (module ${m.name})`);
|
|
463
|
+
if (uri === ACK_TYPE)
|
|
464
|
+
throw new Error(`${uri} is built in: acks take the common path (module ${m.name})`);
|
|
465
|
+
if (tasks.has(uri))
|
|
466
|
+
throw new Error(`duplicate task ${uri} (module ${m.name})`);
|
|
467
|
+
if (typeof h?.apply !== 'function' || typeof h.decode !== 'function')
|
|
468
|
+
throw new TypeError(`task ${uri} (module ${m.name}) needs decode and apply`);
|
|
469
|
+
if (h.proof !== 'absent' && h.proof !== 'required')
|
|
470
|
+
throw new TypeError(`task ${uri} (module ${m.name}) needs its proof declaration: 'absent' or 'required'`);
|
|
471
|
+
if (h.channel !== undefined && typeof h.channel !== 'function')
|
|
472
|
+
throw new TypeError(`task ${uri} (module ${m.name}): channel must be a function`);
|
|
473
|
+
if (h.pinned !== undefined && (h.pinned !== true || h.channel !== undefined))
|
|
474
|
+
throw new TypeError(`task ${uri} (module ${m.name}): pinned is true or absent, and only under the tuple binding`);
|
|
475
|
+
tasks.set(uri, Object.freeze({ ...h }));
|
|
476
|
+
}
|
|
477
|
+
for (const [kind, h] of Object.entries(m.duties ?? {})) {
|
|
478
|
+
if (duties.has(kind))
|
|
479
|
+
throw new Error(`duplicate duty ${kind} (module ${m.name})`);
|
|
480
|
+
if (typeof h?.apply !== 'function')
|
|
481
|
+
throw new TypeError(`duty ${kind} (module ${m.name}) needs apply`);
|
|
482
|
+
if (!Number.isSafeInteger(h.v) || h.v < 1)
|
|
483
|
+
throw new TypeError(`duty ${kind} (module ${m.name}) needs its version v, a positive integer`);
|
|
484
|
+
if (h.awaitHolds !== undefined && typeof h.awaitHolds !== 'function')
|
|
485
|
+
throw new TypeError(`duty ${kind} (module ${m.name}): awaitHolds must be a function`);
|
|
486
|
+
duties.set(kind, Object.freeze({ ...h }));
|
|
487
|
+
}
|
|
488
|
+
for (const [name, h] of Object.entries(m.sweeps ?? {})) {
|
|
489
|
+
if (sweeps.has(name))
|
|
490
|
+
throw new Error(`duplicate sweep ${name} (module ${m.name})`);
|
|
491
|
+
if (typeof h?.apply !== 'function' || typeof h.prepare !== 'function' || typeof h.pending !== 'function')
|
|
492
|
+
throw new TypeError(`sweep ${name} (module ${m.name}) needs pending, prepare and apply`);
|
|
493
|
+
sweeps.set(name, Object.freeze({ ...h }));
|
|
494
|
+
}
|
|
495
|
+
for (const [uri, h] of Object.entries(m.acks ?? {})) {
|
|
496
|
+
if (!TASK_URI.test(uri))
|
|
497
|
+
throw new Error(`not a full task URI with version: ${uri} (module ${m.name})`);
|
|
498
|
+
if (acks.has(uri))
|
|
499
|
+
throw new Error(`duplicate ack handler ${uri} (module ${m.name})`);
|
|
500
|
+
if (typeof h !== 'function')
|
|
501
|
+
throw new TypeError(`ack handler ${uri} (module ${m.name}) must be a function`);
|
|
502
|
+
acks.set(uri, h);
|
|
503
|
+
}
|
|
504
|
+
for (const [uri, h] of Object.entries(m.failures ?? {})) {
|
|
505
|
+
if (!TASK_URI.test(uri))
|
|
506
|
+
throw new Error(`not a full task URI with version: ${uri} (module ${m.name})`);
|
|
507
|
+
if (failures.has(uri))
|
|
508
|
+
throw new Error(`duplicate failure handler ${uri} (module ${m.name})`);
|
|
509
|
+
if (typeof h !== 'function')
|
|
510
|
+
throw new TypeError(`failure handler ${uri} (module ${m.name}) must be a function`);
|
|
511
|
+
failures.set(uri, h);
|
|
512
|
+
}
|
|
513
|
+
for (const uri of m.ownRetries ?? []) {
|
|
514
|
+
if (!TASK_URI.test(uri))
|
|
515
|
+
throw new Error(`not a full task URI with version: ${uri} (module ${m.name})`);
|
|
516
|
+
ownRetries.add(uri);
|
|
517
|
+
}
|
|
518
|
+
if (m.keepKeys !== undefined) {
|
|
519
|
+
if (typeof m.keepKeys !== 'function')
|
|
520
|
+
throw new TypeError(`keepKeys (module ${m.name}) must be a function`);
|
|
521
|
+
keepKeys.push(m.keepKeys);
|
|
522
|
+
}
|
|
523
|
+
if (m.retain !== undefined) {
|
|
524
|
+
if (typeof m.retain !== 'function')
|
|
525
|
+
throw new TypeError(`retain (module ${m.name}) must be a function`);
|
|
526
|
+
retain.push(m.retain);
|
|
527
|
+
}
|
|
528
|
+
if (m.deadlines !== undefined) {
|
|
529
|
+
if (typeof m.deadlines !== 'function')
|
|
530
|
+
throw new TypeError(`deadlines (module ${m.name}) must be a function`);
|
|
531
|
+
deadlines.push(m.deadlines);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
this.#commands = commands;
|
|
535
|
+
this.#tasks = tasks;
|
|
536
|
+
this.#duties = duties;
|
|
537
|
+
this.#sweeps = sweeps;
|
|
538
|
+
this.#acks = acks;
|
|
539
|
+
this.#failures = failures;
|
|
540
|
+
this.#ownRetries = ownRetries;
|
|
541
|
+
this.#keepKeys = Object.freeze(keepKeys);
|
|
542
|
+
this.#deadlines = Object.freeze(deadlines);
|
|
543
|
+
this.#retain = Object.freeze(retain);
|
|
544
|
+
this.#builtins = Object.freeze({ tick: this.#tickDef(), resume: this.#resumeDef(), report: this.#reportDef(), receive: this.#receiveDef() });
|
|
545
|
+
this.registry = Object.freeze({
|
|
546
|
+
commands: Object.freeze([...commands.keys()]),
|
|
547
|
+
tasks: Object.freeze([...tasks.keys()]),
|
|
548
|
+
duties: Object.freeze([...duties.keys()]),
|
|
549
|
+
sweeps: Object.freeze([...sweeps.keys()]),
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
// ── loading ──────────────────────────────────────────────────────────
|
|
553
|
+
async #readRaw() {
|
|
554
|
+
try {
|
|
555
|
+
return await this.#store.read();
|
|
556
|
+
}
|
|
557
|
+
catch (e) {
|
|
558
|
+
throw new InfraError('store-read', undefined, { cause: e });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
async #read() {
|
|
562
|
+
const snap = await this.#readRaw();
|
|
563
|
+
if (snap.state === null)
|
|
564
|
+
throw new Error('engine: the store holds no state');
|
|
565
|
+
return { state: freezeDeep(decodeState(snap.state)), version: snap.version };
|
|
566
|
+
}
|
|
567
|
+
/** Initialise an empty store with the empty state, or check the stored one decodes. */
|
|
568
|
+
async load() {
|
|
569
|
+
const snap = await this.#readRaw();
|
|
570
|
+
if (snap.state !== null) {
|
|
571
|
+
decodeState(snap.state);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
let cause;
|
|
575
|
+
try {
|
|
576
|
+
if ((await this.#store.commit(null, toOpaque(emptyState(CODEC)))).ok)
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
catch (e) {
|
|
580
|
+
cause = e;
|
|
581
|
+
}
|
|
582
|
+
const again = await this.#readRaw(); // conflict or unknown outcome: someone (maybe we) wrote
|
|
583
|
+
if (again.state === null)
|
|
584
|
+
throw new InfraError('store-commit', 'could not initialise the store', { cause });
|
|
585
|
+
decodeState(again.state);
|
|
586
|
+
}
|
|
587
|
+
// ── the queue ────────────────────────────────────────────────────────
|
|
588
|
+
#enqueue(fn) {
|
|
589
|
+
const p = this.#chain.then(async () => { await this.#settle(); return fn(); });
|
|
590
|
+
this.#chain = p.then(() => undefined, () => undefined);
|
|
591
|
+
return p;
|
|
592
|
+
}
|
|
593
|
+
async #settle() {
|
|
594
|
+
const u = this.#unsettled;
|
|
595
|
+
if (!u)
|
|
596
|
+
return;
|
|
597
|
+
const { state } = await this.#read();
|
|
598
|
+
const landed = jcs(state) === u.text ||
|
|
599
|
+
(u.actId !== undefined && Object.hasOwn(state.acts, u.actId) && state.acts[u.actId].commitSeq === u.commitSeq);
|
|
600
|
+
this.#unsettled = null;
|
|
601
|
+
if (landed)
|
|
602
|
+
this.#announce(u.commitSeq, u.events);
|
|
603
|
+
}
|
|
604
|
+
#announce(commitSeq, events) {
|
|
605
|
+
for (const l of [...this.#listeners]) {
|
|
606
|
+
// a listener's failure (throw or rejection) neither undoes nor blocks the commit
|
|
607
|
+
try {
|
|
608
|
+
const r = l({ commitSeq, events });
|
|
609
|
+
if (r !== null && typeof r === 'object' && typeof r.then === 'function')
|
|
610
|
+
r.then(undefined, () => undefined);
|
|
611
|
+
}
|
|
612
|
+
catch { /* dropped */ }
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
// ── the four steps ───────────────────────────────────────────────────
|
|
616
|
+
/** `input` is already bound (bindInput); `inputHash` its journal hash for human acts. */
|
|
617
|
+
async #run(def, op, input, when, actId, inputHash) {
|
|
618
|
+
for (let attempt = 1; attempt <= this.#policy.commitAttempts; attempt++) {
|
|
619
|
+
// 1 — snapshot, when
|
|
620
|
+
const { state, version } = await this.#read();
|
|
621
|
+
const at = Math.max(when, state.lastWhen);
|
|
622
|
+
if (def.human && Object.hasOwn(state.acts, actId)) {
|
|
623
|
+
const rec = state.acts[actId];
|
|
624
|
+
if (rec.inputHash !== inputHash)
|
|
625
|
+
return { ok: false, code: 'actid-conflict' };
|
|
626
|
+
const live = rec.outboundDigests.filter((d) => { const t = triasOf(this.#itemOf(state, d)); return t !== 'delivered' && t !== 'failed'; });
|
|
627
|
+
const events = structuredClone(rec.events);
|
|
628
|
+
return { ok: true, outbound: live.map((d) => this.#outboundOf(state, d)), events: freezeDeep(events) };
|
|
629
|
+
}
|
|
630
|
+
// 2 — prepare, bound to this snapshot
|
|
631
|
+
const ctx = Object.freeze({
|
|
632
|
+
snapshot: state, keys: this.#keys, entropy: this.#entropy, access: this.#access ?? this.#accessFrom?.(state), log: this.#log,
|
|
633
|
+
crypto: this.#crypto, when: at, release: releaseOf(state),
|
|
634
|
+
});
|
|
635
|
+
const value = def.prepare ? await def.prepare(ctx, input) : undefined;
|
|
636
|
+
const prepared = Object.freeze({ value, version, commitSeq: state.commitSeq });
|
|
637
|
+
// 3 — apply, assemble, CAS
|
|
638
|
+
const r = def.apply(state, prepared, input, at);
|
|
639
|
+
if (isHalt(r))
|
|
640
|
+
return r;
|
|
641
|
+
if (isRefusal(r)) {
|
|
642
|
+
const refusal = { ok: false, code: r.code, ...(r.ref !== undefined ? { ref: r.ref } : {}) };
|
|
643
|
+
if (!r.latch)
|
|
644
|
+
return refusal;
|
|
645
|
+
// the Encounter latch exception: the latch and lastWhen commit, nothing else
|
|
646
|
+
checkLatch(state, r.latch, at);
|
|
647
|
+
const seq = inc(state.commitSeq);
|
|
648
|
+
const next = { ...r.latch, commitSeq: seq, lastWhen: at };
|
|
649
|
+
const text = encodeState(next);
|
|
650
|
+
if (this.#check)
|
|
651
|
+
this.#check(state, next, op.kind === 'act' ? { ...op, events: [] } : op);
|
|
652
|
+
let res;
|
|
653
|
+
try {
|
|
654
|
+
res = await this.#store.commit(version, toOpaque(next));
|
|
655
|
+
}
|
|
656
|
+
catch (e) {
|
|
657
|
+
this.#unsettled = { text, commitSeq: seq, events: [] };
|
|
658
|
+
throw new InfraError('unknown-commit-outcome', undefined, { cause: e });
|
|
659
|
+
}
|
|
660
|
+
if (!res.ok)
|
|
661
|
+
continue;
|
|
662
|
+
this.#announce(seq, []);
|
|
663
|
+
return refusal;
|
|
664
|
+
}
|
|
665
|
+
const drafts = r.outbound ?? [];
|
|
666
|
+
const eventDrafts = r.events ?? [];
|
|
667
|
+
const newDuties = r.duties ?? [];
|
|
668
|
+
const emit = r.emit ?? [];
|
|
669
|
+
const release = r.release ?? [];
|
|
670
|
+
// resume computes its own timer; every other operation names the duty
|
|
671
|
+
// work it opened or unblocked, the app's cue to call resume
|
|
672
|
+
const wake = r.nextDueAt ?? (def === this.#builtins.resume ? undefined : this.#wake(state, r.state, newDuties, at));
|
|
673
|
+
const extra = { ...(wake !== undefined ? { nextDueAt: wake } : {}), ...(r.detail !== undefined ? { detail: r.detail } : {}) };
|
|
674
|
+
if (!def.human && r.state === state && !r.renames?.length && at === state.lastWhen && !drafts.length && !eventDrafts.length && !newDuties.length && !emit.length) {
|
|
675
|
+
return { ok: true, outbound: [...release], events: [], ...extra };
|
|
676
|
+
}
|
|
677
|
+
const seq = inc(state.commitSeq);
|
|
678
|
+
const events = eventDrafts.map((e, i) => ({ ...e, eventId: `${seq}.${i}`, commitSeq: seq, ...(def.human ? { actId } : {}) }));
|
|
679
|
+
freezeDeep(events);
|
|
680
|
+
const outbound = drafts.map((o) => ({ digest: o.digest, envelope: o.envelope, type: o.type, threadId: o.threadId, recipient: o.recipient, attempt: 1 }));
|
|
681
|
+
const outbox = { ...r.state.outbox };
|
|
682
|
+
for (const o of outbound) {
|
|
683
|
+
if (Object.hasOwn(outbox, o.digest))
|
|
684
|
+
throw new Error(`engine: outbound ${o.digest} is already in the outbox`);
|
|
685
|
+
outbox[o.digest] = {
|
|
686
|
+
type: o.type, threadId: o.threadId, recipient: o.recipient, envelope: o.envelope, attempt: 1,
|
|
687
|
+
history: [{ status: 'submitted', at, attempt: 1 }], nextAttemptAt: dueAt(at, this.#policy.backoff(1)),
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
const duties = { ...r.state.duties };
|
|
691
|
+
for (const d of newDuties) {
|
|
692
|
+
if (Object.hasOwn(duties, d.id))
|
|
693
|
+
throw new Error(`engine: duty ${d.id} already exists`);
|
|
694
|
+
duties[d.id] = d;
|
|
695
|
+
}
|
|
696
|
+
const acts = def.human
|
|
697
|
+
? { ...r.state.acts, [actId]: { actId: actId, inputHash: inputHash, commitSeq: seq, outboundDigests: outbound.map((o) => o.digest), events, at } }
|
|
698
|
+
: r.state.acts;
|
|
699
|
+
const effects = r.renames?.length ? renameAcks(r.state.effects, r.renames) : r.state.effects;
|
|
700
|
+
const next = { ...r.state, effects, commitSeq: seq, lastWhen: at, outbox, duties, acts };
|
|
701
|
+
const text = encodeState(next); // validates; an invalid next state is a programming error
|
|
702
|
+
if (this.#check) {
|
|
703
|
+
const d = (r.detail ?? {});
|
|
704
|
+
const pick = (k) => (d[k] !== undefined ? { [k]: d[k] } : {});
|
|
705
|
+
this.#check(state, next, op.kind === 'receive' ? { kind: 'receive', ...pick('disposition'), ...pick('digest'), ...pick('type'), ...pick('ref'), ...pick('rkid') }
|
|
706
|
+
: op.kind === 'resume' ? { kind: 'resume', fulfilled: d.fulfilled ?? [] }
|
|
707
|
+
: op.kind === 'act' ? { ...op, events } : op);
|
|
708
|
+
}
|
|
709
|
+
let res;
|
|
710
|
+
try {
|
|
711
|
+
res = await this.#store.commit(version, toOpaque(next));
|
|
712
|
+
}
|
|
713
|
+
catch (e) {
|
|
714
|
+
this.#unsettled = { text, commitSeq: seq, events, ...(def.human ? { actId } : {}) };
|
|
715
|
+
throw new InfraError('unknown-commit-outcome', undefined, { cause: e });
|
|
716
|
+
}
|
|
717
|
+
if (!res.ok)
|
|
718
|
+
continue;
|
|
719
|
+
// 4 — release
|
|
720
|
+
this.#announce(seq, events);
|
|
721
|
+
return { ok: true, outbound: [...emit, ...release, ...outbound], events, ...extra };
|
|
722
|
+
}
|
|
723
|
+
throw new InfraError('commit-conflict', `no commit after ${this.#policy.commitAttempts} attempts`);
|
|
724
|
+
}
|
|
725
|
+
/** The work an operation opened or unblocked: its duty work (#dutyWake),
|
|
726
|
+
* or `when` where a sweep's pending keys gained one. */
|
|
727
|
+
/** Every time at which tick's expiry and retention change `state`: the
|
|
728
|
+
* engine's own and the modules' `deadlines` (the timer contract). */
|
|
729
|
+
#expiries(state) {
|
|
730
|
+
const retention = this.#policy.retentionMs;
|
|
731
|
+
const out = [];
|
|
732
|
+
for (const p of Object.values(state.prompts))
|
|
733
|
+
if (p.expiresAt !== undefined)
|
|
734
|
+
out.push(p.expiresAt);
|
|
735
|
+
for (const e of Object.values(state.effects))
|
|
736
|
+
out.push(e.expiresAt);
|
|
737
|
+
for (const a of Object.values(state.acts))
|
|
738
|
+
out.push(a.at + retention);
|
|
739
|
+
for (const it of Object.values(state.outbox)) {
|
|
740
|
+
const t = triasOf(it);
|
|
741
|
+
if ((t === 'delivered' || t === 'failed') && it.history.length)
|
|
742
|
+
out.push(it.history[it.history.length - 1].at + retention);
|
|
743
|
+
}
|
|
744
|
+
for (const k of Object.values(state.addresses.keys))
|
|
745
|
+
out.push(k.lastShown + retention);
|
|
746
|
+
for (const f of this.#deadlines)
|
|
747
|
+
for (const t of f(state))
|
|
748
|
+
out.push(t);
|
|
749
|
+
return out.filter((t) => Number.isSafeInteger(t));
|
|
750
|
+
}
|
|
751
|
+
/** The earliest expiry deadline after `when`, among those `only` admits. */
|
|
752
|
+
#nextExpiry(state, when, only = () => true) {
|
|
753
|
+
let next;
|
|
754
|
+
for (const t of this.#expiries(state))
|
|
755
|
+
if (t > when && only(t) && (next === undefined || t < next))
|
|
756
|
+
next = t;
|
|
757
|
+
return next;
|
|
758
|
+
}
|
|
759
|
+
#wake(before, after, added, when) {
|
|
760
|
+
const d0 = this.#dutyWake(before, after, added, when);
|
|
761
|
+
// a deadline the commit introduced (a prompt opened, an act started, …)
|
|
762
|
+
const had = before === after ? null : new Set(this.#expiries(before));
|
|
763
|
+
const x = had === null ? undefined : this.#nextExpiry(after, when, (t) => !had.has(t));
|
|
764
|
+
const d = d0 === undefined ? x : x === undefined ? d0 : Math.min(d0, x);
|
|
765
|
+
if (d === when || before === after)
|
|
766
|
+
return d;
|
|
767
|
+
for (const sw of this.#sweeps.values()) {
|
|
768
|
+
// a sweep whose declared inputs are untouched (by reference) cannot have new pending keys
|
|
769
|
+
if (sw.reads && sw.reads.every((k) => before[k] === after[k]))
|
|
770
|
+
continue;
|
|
771
|
+
const had = new Set(sw.pending(before, when));
|
|
772
|
+
if (sw.pending(after, when).some((k) => !had.has(k)))
|
|
773
|
+
return when;
|
|
774
|
+
}
|
|
775
|
+
return d;
|
|
776
|
+
}
|
|
777
|
+
/** The duty work an operation opened or unblocked: over the duties that
|
|
778
|
+
* are new, changed, or runnable now but not before, `when` where one
|
|
779
|
+
* is runnable now, else the earliest future dueAt; undefined where none. */
|
|
780
|
+
#dutyWake(before, after, added, when) {
|
|
781
|
+
let wake;
|
|
782
|
+
for (const d of [...Object.values(after.duties), ...added]) {
|
|
783
|
+
const h = this.#duties.get(d.kind);
|
|
784
|
+
if (!h || d.v !== h.v)
|
|
785
|
+
continue;
|
|
786
|
+
const prev = Object.hasOwn(before.duties, d.id) ? before.duties[d.id] : undefined;
|
|
787
|
+
const runnable = dutyRunnable(d, h, after, when);
|
|
788
|
+
if (prev !== undefined && jcs(prev) === jcs(d) && (!runnable || dutyRunnable(prev, h, before, when)))
|
|
789
|
+
continue;
|
|
790
|
+
const t = runnable ? when : dutyDue(d) > when ? dutyDue(d) : undefined;
|
|
791
|
+
if (t !== undefined)
|
|
792
|
+
wake = wake === undefined ? t : Math.min(wake, t);
|
|
793
|
+
}
|
|
794
|
+
return wake;
|
|
795
|
+
}
|
|
796
|
+
/** The failure handlers of the items `digests` (each just turned
|
|
797
|
+
* failed), in order, on `state`; their duties folded into the state. */
|
|
798
|
+
#failed(state, digests, when) {
|
|
799
|
+
let cur = state;
|
|
800
|
+
const events = [];
|
|
801
|
+
for (const digest of digests) {
|
|
802
|
+
const h = this.#failures.get(cur.outbox[digest].type);
|
|
803
|
+
if (!h)
|
|
804
|
+
continue;
|
|
805
|
+
const r = h(cur, digest, when, { backoff: this.#policy.backoff });
|
|
806
|
+
if (r.state.effects !== cur.effects)
|
|
807
|
+
throw new Error(`engine: the failure handler of ${cur.outbox[digest].type} wrote the effect cache`);
|
|
808
|
+
if (r.outbound?.length)
|
|
809
|
+
throw new Error(`engine: the failure handler of ${cur.outbox[digest].type} issued documents; it opens duties`);
|
|
810
|
+
cur = r.state;
|
|
811
|
+
events.push(...(r.events ?? []));
|
|
812
|
+
if (r.duties?.length) {
|
|
813
|
+
const duties = { ...cur.duties };
|
|
814
|
+
for (const d of r.duties)
|
|
815
|
+
if (!Object.hasOwn(duties, d.id))
|
|
816
|
+
duties[d.id] = d;
|
|
817
|
+
cur = { ...cur, duties };
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return { state: cur, events };
|
|
821
|
+
}
|
|
822
|
+
#itemOf(state, digest) {
|
|
823
|
+
if (!Object.hasOwn(state.outbox, digest))
|
|
824
|
+
throw new Error(`engine: journaled outbound ${digest} is missing from the outbox`);
|
|
825
|
+
return state.outbox[digest];
|
|
826
|
+
}
|
|
827
|
+
#outboundOf(state, digest) {
|
|
828
|
+
const it = this.#itemOf(state, digest);
|
|
829
|
+
return structuredClone({ digest, envelope: it.envelope, type: it.type, threadId: it.threadId, recipient: it.recipient, attempt: it.attempt });
|
|
830
|
+
}
|
|
831
|
+
// ── built-ins ────────────────────────────────────────────────────────
|
|
832
|
+
#tickDef() {
|
|
833
|
+
const policy = () => this.#policy;
|
|
834
|
+
const retainers = () => this.#retain;
|
|
835
|
+
const keepers = () => this.#keepKeys;
|
|
836
|
+
return {
|
|
837
|
+
human: false,
|
|
838
|
+
apply(state, _p, _input, when) {
|
|
839
|
+
const retention = policy().retentionMs;
|
|
840
|
+
let dropped = false;
|
|
841
|
+
const keep = (rec, live) => {
|
|
842
|
+
const out = {};
|
|
843
|
+
for (const [k, v] of Object.entries(rec)) {
|
|
844
|
+
if (live(v, k))
|
|
845
|
+
out[k] = v;
|
|
846
|
+
else
|
|
847
|
+
dropped = true;
|
|
848
|
+
}
|
|
849
|
+
return out;
|
|
850
|
+
};
|
|
851
|
+
// effects leave with their acks (Delivery 4.2); prompts at expiry; the
|
|
852
|
+
// act journal after the retention
|
|
853
|
+
const effects = keep(state.effects, (x) => x.expiresAt > when);
|
|
854
|
+
const prompts = keep(state.prompts, (x) => x.expiresAt === undefined || x.expiresAt > when);
|
|
855
|
+
const acts = keep(state.acts, (x) => x.at + retention > when);
|
|
856
|
+
// terminal outbox items, the retention after their last step, unless a
|
|
857
|
+
// kept journal entry still answers with them (replay needs the item)
|
|
858
|
+
const journaled = new Set(Object.values(acts).flatMap((a) => a.outboundDigests));
|
|
859
|
+
const outbox = keep(state.outbox, (it, digest) => {
|
|
860
|
+
const t = triasOf(it);
|
|
861
|
+
if (t !== 'delivered' && t !== 'failed')
|
|
862
|
+
return true;
|
|
863
|
+
const last = it.history.length ? it.history[it.history.length - 1].at : 0;
|
|
864
|
+
return last + retention > when || journaled.has(digest);
|
|
865
|
+
});
|
|
866
|
+
let next = dropped ? { ...state, effects, prompts, acts, outbox } : state;
|
|
867
|
+
// the modules' own retention, on what the engine's left
|
|
868
|
+
for (const r of retainers()) {
|
|
869
|
+
const after = r(next, when);
|
|
870
|
+
if (after.effects !== next.effects)
|
|
871
|
+
throw new Error('engine: a module\'s retain wrote the effect cache');
|
|
872
|
+
next = after;
|
|
873
|
+
}
|
|
874
|
+
// addresses (Delivery §5), on what the retention left, so a key whose
|
|
875
|
+
// last claim left in this tick leaves with it: a key not shown for the
|
|
876
|
+
// retention, and the own rkid of no live tuple, becomes a tombstone —
|
|
877
|
+
// its id alone, kept forever; tombstones never shrink
|
|
878
|
+
const held = next;
|
|
879
|
+
const live = new Set();
|
|
880
|
+
for (const r of Object.values(held.relationships))
|
|
881
|
+
for (const t of r.tuples)
|
|
882
|
+
if (!t.deactivated)
|
|
883
|
+
live.add(t.rkid);
|
|
884
|
+
// and the rkids a module still needs (a deactivated prior whose continuity flow is open)
|
|
885
|
+
for (const k of keepers())
|
|
886
|
+
for (const rkid of k(held))
|
|
887
|
+
live.add(rkid);
|
|
888
|
+
const tombstones = [...held.addresses.tombstones];
|
|
889
|
+
let gone = false;
|
|
890
|
+
const keys = {};
|
|
891
|
+
for (const [rkid, k] of Object.entries(held.addresses.keys)) {
|
|
892
|
+
if (k.lastShown + retention > when || live.has(rkid)) {
|
|
893
|
+
keys[rkid] = k;
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
gone = true;
|
|
897
|
+
// a member key (group/…) is an identity context, not a delivery
|
|
898
|
+
// address of Delivery §5: it leaves without a tombstone, so the same
|
|
899
|
+
// derivation can serve the same group again (a re-invitation)
|
|
900
|
+
if (!k.label.startsWith('group/') && !tombstones.includes(rkid))
|
|
901
|
+
tombstones.push(rkid);
|
|
902
|
+
}
|
|
903
|
+
if (gone)
|
|
904
|
+
next = { ...held, addresses: { keys, tombstones } };
|
|
905
|
+
return { state: next }; // unchanged: the engine still commits a raised lastWhen
|
|
906
|
+
},
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
/** The duties a resume plans to run, in fixed order (dueAt, then id),
|
|
910
|
+
* and the registered kinds met at an unknown version. Each planned
|
|
911
|
+
* duty is checked again against the current state right before its
|
|
912
|
+
* handler runs (#resumeDef). */
|
|
913
|
+
#dutyPlan(state, when) {
|
|
914
|
+
const order = Object.values(state.duties).sort((a, b) => (dutyDue(a) - dutyDue(b)) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
915
|
+
const run = [];
|
|
916
|
+
const unsupported = [];
|
|
917
|
+
for (const duty of order) {
|
|
918
|
+
const h = this.#duties.get(duty.kind);
|
|
919
|
+
if (!h)
|
|
920
|
+
continue; // a kind no module here registers: kept, silent
|
|
921
|
+
if (duty.v !== h.v) {
|
|
922
|
+
unsupported.push(duty);
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
if (dutyRunnable(duty, h, state, when))
|
|
926
|
+
run.push({ duty, h });
|
|
927
|
+
}
|
|
928
|
+
return { run, unsupported };
|
|
929
|
+
}
|
|
930
|
+
#resumeDef() {
|
|
931
|
+
const expiryAfter = (st, w) => this.#nextExpiry(st, w);
|
|
932
|
+
const policy = () => this.#policy;
|
|
933
|
+
const plan = (state, when) => this.#dutyPlan(state, when);
|
|
934
|
+
const handlers = () => this.#duties;
|
|
935
|
+
const sweeps = () => this.#sweeps;
|
|
936
|
+
const failed = (st, digests, when) => this.#failed(st, digests, when);
|
|
937
|
+
const ownRetries = () => this.#ownRetries;
|
|
938
|
+
return {
|
|
939
|
+
human: false,
|
|
940
|
+
// the due duties' and the sweeps' preparations, against the snapshot apply will see
|
|
941
|
+
async prepare(ctx) {
|
|
942
|
+
const values = {};
|
|
943
|
+
for (const { duty, h } of plan(ctx.snapshot, ctx.when).run)
|
|
944
|
+
if (h.prepare)
|
|
945
|
+
values[duty.id] = await h.prepare(ctx, duty);
|
|
946
|
+
const swept = {};
|
|
947
|
+
for (const [name, sw] of sweeps())
|
|
948
|
+
swept[name] = await sw.prepare(ctx);
|
|
949
|
+
return { duties: values, sweeps: swept };
|
|
950
|
+
},
|
|
951
|
+
apply(state, prepared, _input, when) {
|
|
952
|
+
const p = policy();
|
|
953
|
+
const outbox = { ...state.outbox };
|
|
954
|
+
const emit = [];
|
|
955
|
+
const events = [];
|
|
956
|
+
const gaveUp = [];
|
|
957
|
+
let nextDueAt;
|
|
958
|
+
const due = (t) => { nextDueAt = nextDueAt === undefined ? t : Math.min(nextDueAt, t); };
|
|
959
|
+
// 1 — the outbox, by due time, then digest
|
|
960
|
+
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));
|
|
961
|
+
for (const digest of order) {
|
|
962
|
+
const it = state.outbox[digest];
|
|
963
|
+
const trias = triasOf(it);
|
|
964
|
+
if (trias === 'delivered' || trias === 'failed')
|
|
965
|
+
continue;
|
|
966
|
+
// the horizon applies to every non-terminal item: held, accepted or not
|
|
967
|
+
const first = it.history.length ? it.history[0].at : when;
|
|
968
|
+
const expiry = Math.min(first + p.horizonMs, Number.MAX_SAFE_INTEGER);
|
|
969
|
+
if (when >= expiry) {
|
|
970
|
+
const { nextAttemptAt: _, ...rest } = it;
|
|
971
|
+
outbox[digest] = { ...rest, history: [...it.history, { status: 'failed', at: when, reason: 'expired-by-adapter-policy' }] };
|
|
972
|
+
events.push({ type: 'delivery.status', digest, threadId: it.threadId, status: 'failed' });
|
|
973
|
+
gaveUp.push(digest);
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
due(expiry);
|
|
977
|
+
// never re-emitted: an accepted item (the service has it) and one the
|
|
978
|
+
// adapter holds (awaiting-transport, until it reports again)
|
|
979
|
+
if (trias === 'accepted' || it.history[it.history.length - 1]?.status === 'awaiting-transport')
|
|
980
|
+
continue;
|
|
981
|
+
// a type its module re-issues itself is never reproduced (Trust 6710)
|
|
982
|
+
if (ownRetries().has(it.type))
|
|
983
|
+
continue;
|
|
984
|
+
if (it.nextAttemptAt !== undefined && it.nextAttemptAt > when) {
|
|
985
|
+
due(it.nextAttemptAt);
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
const attempt = it.attempt + 1;
|
|
989
|
+
const nextAttemptAt = dueAt(when, p.backoff(attempt));
|
|
990
|
+
outbox[digest] = { ...it, attempt, nextAttemptAt, history: [...it.history, { status: 'submitted', at: when, attempt }] };
|
|
991
|
+
emit.push(structuredClone({ digest, envelope: it.envelope, type: it.type, threadId: it.threadId, recipient: it.recipient, attempt }));
|
|
992
|
+
due(nextAttemptAt);
|
|
993
|
+
}
|
|
994
|
+
// 2 — duties, in the plan's order, each on the state the one before
|
|
995
|
+
// left and checked against it right before its handler: still there
|
|
996
|
+
// at the handler's version, due, its awaited head still holding. A
|
|
997
|
+
// prepared duty whose data changed since its preparation is deferred
|
|
998
|
+
// to the next resume, which prepares it afresh (due at once)
|
|
999
|
+
const { run, unsupported } = plan(state, when);
|
|
1000
|
+
for (const d of unsupported)
|
|
1001
|
+
events.push({ type: 'duty.unsupported', dutyId: d.id, kind: d.kind, v: d.v });
|
|
1002
|
+
// the outbox changed only by a re-emission or a give-up (an event so far)
|
|
1003
|
+
let cur = !emit.length && !events.length ? state : { ...state, outbox };
|
|
1004
|
+
// the failure handlers of the items given up, in the give-up's transaction
|
|
1005
|
+
if (gaveUp.length) {
|
|
1006
|
+
const f = failed(cur, gaveUp, when);
|
|
1007
|
+
cur = f.state;
|
|
1008
|
+
events.push(...f.events);
|
|
1009
|
+
}
|
|
1010
|
+
const drafts = [];
|
|
1011
|
+
const renames = [];
|
|
1012
|
+
// the duties whose handler returned Applied (invariants.ts: fulfilment)
|
|
1013
|
+
const fulfilled = [];
|
|
1014
|
+
// ran and stayed (not-due, refused): id → its record as it ran
|
|
1015
|
+
const kept = new Map();
|
|
1016
|
+
const { duties: values, sweeps: swept } = prepared.value;
|
|
1017
|
+
for (const { duty, h } of run) {
|
|
1018
|
+
if (!Object.hasOwn(cur.duties, duty.id))
|
|
1019
|
+
continue; // fulfilled or dropped meanwhile
|
|
1020
|
+
const now = cur.duties[duty.id];
|
|
1021
|
+
// another kind or version since the plan: the next resume dispatches it
|
|
1022
|
+
if (now.kind !== duty.kind || now.id !== duty.id || now.v !== h.v || !dutyRunnable(now, h, cur, when))
|
|
1023
|
+
continue; // the final pass sets its timer
|
|
1024
|
+
if (h.prepare && jcs(now) !== jcs(duty))
|
|
1025
|
+
continue; // prepared on other data
|
|
1026
|
+
const r = h.apply(cur, now, Object.freeze({ prepared: Object.freeze({ value: values[duty.id], version: prepared.version, commitSeq: prepared.commitSeq }), backoff: p.backoff }), when);
|
|
1027
|
+
if (r === 'not-due' || isRefusal(r)) {
|
|
1028
|
+
kept.set(duty.id, jcs(now));
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
// fulfilled: the duty leaves; what its handler opened is folded into
|
|
1032
|
+
// the state at once (an id already open is kept), so the duties
|
|
1033
|
+
// after it see it — their drop rules and their dedupe included
|
|
1034
|
+
const left = { ...r.state.duties };
|
|
1035
|
+
delete left[duty.id];
|
|
1036
|
+
for (const d of r.duties ?? [])
|
|
1037
|
+
if (!Object.hasOwn(left, d.id))
|
|
1038
|
+
left[d.id] = d;
|
|
1039
|
+
cur = { ...r.state, duties: left };
|
|
1040
|
+
fulfilled.push(duty.id);
|
|
1041
|
+
drafts.push(...(r.outbound ?? []));
|
|
1042
|
+
events.push(...(r.events ?? []));
|
|
1043
|
+
renames.push(...(r.renames ?? []));
|
|
1044
|
+
}
|
|
1045
|
+
// 2b — the sweeps, on the state the duties left; a prepared piece
|
|
1046
|
+
// whose inputs moved is skipped and due again at once
|
|
1047
|
+
for (const [name, sw] of sweeps()) {
|
|
1048
|
+
const r = sw.apply(cur, Object.freeze({ value: swept[name], version: prepared.version, commitSeq: prepared.commitSeq }), when);
|
|
1049
|
+
if (r.state.effects !== cur.effects)
|
|
1050
|
+
throw new Error(`engine: the sweep ${name} wrote the effect cache`);
|
|
1051
|
+
cur = r.state;
|
|
1052
|
+
if (r.duties?.length) {
|
|
1053
|
+
const all = { ...cur.duties };
|
|
1054
|
+
for (const d of r.duties)
|
|
1055
|
+
if (!Object.hasOwn(all, d.id))
|
|
1056
|
+
all[d.id] = d;
|
|
1057
|
+
cur = { ...cur, duties: all };
|
|
1058
|
+
}
|
|
1059
|
+
drafts.push(...(r.outbound ?? []));
|
|
1060
|
+
events.push(...(r.events ?? []));
|
|
1061
|
+
if (r.deferred)
|
|
1062
|
+
due(when);
|
|
1063
|
+
}
|
|
1064
|
+
// work a sweep owes now but did not prepare: a divergence the outbox
|
|
1065
|
+
// step (a give-up at the horizon) or a duty of this resume created —
|
|
1066
|
+
// due at once, prepared by the next resume
|
|
1067
|
+
if (cur !== state) {
|
|
1068
|
+
for (const sw of sweeps().values()) {
|
|
1069
|
+
const had = new Set(sw.pending(state, when));
|
|
1070
|
+
if (sw.pending(cur, when).some((k) => !had.has(k))) {
|
|
1071
|
+
due(when);
|
|
1072
|
+
break;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
// the timer from the final duty set, new duties included: a future
|
|
1077
|
+
// dueAt, or `when` for work runnable now that did not run (deferred,
|
|
1078
|
+
// newly created, newly unblocked); a duty that ran and stayed
|
|
1079
|
+
// (not-due, refused) sets none while its record is unchanged since
|
|
1080
|
+
// its run — changed by a later duty, it counts like any other
|
|
1081
|
+
for (const d of Object.values(cur.duties)) {
|
|
1082
|
+
const h = handlers().get(d.kind);
|
|
1083
|
+
if (!h || d.v !== h.v || kept.get(d.id) === jcs(d))
|
|
1084
|
+
continue;
|
|
1085
|
+
if (dutyDue(d) > when)
|
|
1086
|
+
due(dutyDue(d));
|
|
1087
|
+
else if (dutyRunnable(d, h, cur, when))
|
|
1088
|
+
due(when);
|
|
1089
|
+
}
|
|
1090
|
+
// the duties' new documents enter the outbox in #run, due at backoff(1)
|
|
1091
|
+
if (drafts.length)
|
|
1092
|
+
due(dueAt(when, p.backoff(1)));
|
|
1093
|
+
// 3 — acks still unsent (a restart between the receive commit and the
|
|
1094
|
+
// send, or no report yet): re-offered byte-identical on the retry
|
|
1095
|
+
// policy, by effect digest; at the horizon marked sent by it
|
|
1096
|
+
const release = [];
|
|
1097
|
+
let effects = null;
|
|
1098
|
+
for (const d of Object.keys(cur.effects).sort()) {
|
|
1099
|
+
const a = cur.effects[d].ack;
|
|
1100
|
+
if (!a || a.sent)
|
|
1101
|
+
continue;
|
|
1102
|
+
const expiry = Math.min(a.at + p.horizonMs, Number.MAX_SAFE_INTEGER);
|
|
1103
|
+
effects ??= { ...cur.effects };
|
|
1104
|
+
if (when >= expiry) {
|
|
1105
|
+
const { nextAttemptAt: _, ...rest } = a;
|
|
1106
|
+
effects[d] = { ...cur.effects[d], ack: { ...rest, sent: true, sentBy: 'horizon' } };
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
due(expiry);
|
|
1110
|
+
if (a.nextAttemptAt !== undefined && a.nextAttemptAt > when) {
|
|
1111
|
+
due(a.nextAttemptAt);
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
const attempt = a.attempt + 1;
|
|
1115
|
+
const nextAttemptAt = dueAt(when, p.backoff(attempt));
|
|
1116
|
+
effects[d] = { ...cur.effects[d], ack: { ...a, attempt, nextAttemptAt } };
|
|
1117
|
+
release.push(ackOutbound(a, attempt));
|
|
1118
|
+
due(nextAttemptAt);
|
|
1119
|
+
}
|
|
1120
|
+
if (effects && jcs(effects) !== jcs(cur.effects))
|
|
1121
|
+
cur = { ...cur, effects };
|
|
1122
|
+
// the expiry work tick does (the timer contract, the header)
|
|
1123
|
+
const ex = expiryAfter(cur, when);
|
|
1124
|
+
if (ex !== undefined)
|
|
1125
|
+
due(ex);
|
|
1126
|
+
return {
|
|
1127
|
+
state: cur, emit, events, outbound: drafts, release, detail: { fulfilled },
|
|
1128
|
+
...(renames.length ? { renames } : {}),
|
|
1129
|
+
...(nextDueAt !== undefined ? { nextDueAt } : {}),
|
|
1130
|
+
};
|
|
1131
|
+
},
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
#reportDef() {
|
|
1135
|
+
const failed = (st, digests, when) => this.#failed(st, digests, when);
|
|
1136
|
+
return {
|
|
1137
|
+
human: false,
|
|
1138
|
+
apply(state, _p, report, when) {
|
|
1139
|
+
if (!Object.hasOwn(state.outbox, report.digest)) {
|
|
1140
|
+
// an ack (outbox-less): any report says the app handed it over
|
|
1141
|
+
const eff = Object.keys(state.effects).find((d) => state.effects[d].ack?.digest === report.digest);
|
|
1142
|
+
if (eff === undefined)
|
|
1143
|
+
return { ok: false, code: 'unknown-digest' };
|
|
1144
|
+
const e = state.effects[eff];
|
|
1145
|
+
if (e.ack.sent)
|
|
1146
|
+
return { state: state };
|
|
1147
|
+
const { nextAttemptAt: _, ...ack } = e.ack;
|
|
1148
|
+
return { state: { ...state, effects: { ...state.effects, [eff]: { ...e, ack: { ...ack, sent: true, sentBy: 'report' } } } } };
|
|
1149
|
+
}
|
|
1150
|
+
const it = state.outbox[report.digest];
|
|
1151
|
+
const trias = triasOf(it);
|
|
1152
|
+
const had = (s) => it.history.some((x) => x.status === s);
|
|
1153
|
+
const same = { state: state };
|
|
1154
|
+
let step;
|
|
1155
|
+
switch (report.status) {
|
|
1156
|
+
case 'awaiting-transport': {
|
|
1157
|
+
const last = it.history[it.history.length - 1];
|
|
1158
|
+
if (trias !== 'submitted')
|
|
1159
|
+
return same; // no step back before the trias
|
|
1160
|
+
if (last?.status === 'awaiting-transport' && last.reason === report.reason)
|
|
1161
|
+
return same;
|
|
1162
|
+
step = { status: 'awaiting-transport', at: when, reason: report.reason };
|
|
1163
|
+
break;
|
|
1164
|
+
}
|
|
1165
|
+
case 'accepted':
|
|
1166
|
+
if (had('accepted') || trias !== 'submitted')
|
|
1167
|
+
return same;
|
|
1168
|
+
step = { status: 'accepted', at: when };
|
|
1169
|
+
break;
|
|
1170
|
+
case 'delivered': {
|
|
1171
|
+
// lateness is the holder's own history: delivered after failed
|
|
1172
|
+
// (its own give-up or the adapter's) is late, whatever the flag says
|
|
1173
|
+
const m = markDelivered(state, report.digest, when);
|
|
1174
|
+
return m.event ? { state: { ...state, outbox: m.outbox }, events: [m.event] } : same;
|
|
1175
|
+
}
|
|
1176
|
+
case 'failed':
|
|
1177
|
+
if (had('failed') || trias === 'delivered')
|
|
1178
|
+
return same;
|
|
1179
|
+
step = report.reason === 'rejected-by-receiver'
|
|
1180
|
+
? { status: 'failed', at: when, reason: report.reason, code: report.code }
|
|
1181
|
+
: { status: 'failed', at: when, reason: report.reason };
|
|
1182
|
+
break;
|
|
1183
|
+
default:
|
|
1184
|
+
throw new TypeError(`unknown report status ${String(report.status)}`);
|
|
1185
|
+
}
|
|
1186
|
+
const base = step.status === 'awaiting-transport' ? it : (({ nextAttemptAt: _, ...rest }) => rest)(it);
|
|
1187
|
+
const outbox = { ...state.outbox, [report.digest]: { ...base, history: [...it.history, step] } };
|
|
1188
|
+
const events = step.status === 'awaiting-transport' ? [] : [{
|
|
1189
|
+
type: 'delivery.status', digest: report.digest, threadId: it.threadId, status: step.status,
|
|
1190
|
+
}];
|
|
1191
|
+
if (step.status === 'failed') {
|
|
1192
|
+
const f = failed({ ...state, outbox }, [report.digest], when);
|
|
1193
|
+
return { state: f.state, events: [...events, ...f.events] };
|
|
1194
|
+
}
|
|
1195
|
+
return { state: { ...state, outbox }, events };
|
|
1196
|
+
},
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
#receiveDef() {
|
|
1200
|
+
const tasks = () => this.#tasks;
|
|
1201
|
+
const acks = () => this.#acks;
|
|
1202
|
+
const policy = () => this.#policy;
|
|
1203
|
+
return {
|
|
1204
|
+
human: false,
|
|
1205
|
+
prepare: (ctx, env) => prepareReceive(ctx, env, tasks()),
|
|
1206
|
+
apply(state, prepared, _env, when) {
|
|
1207
|
+
const plan = prepared.value;
|
|
1208
|
+
const cached = (ack) => ({ ack, expiresAt: Math.min(when + policy().retentionMs, Number.MAX_SAFE_INTEGER) });
|
|
1209
|
+
switch (plan.kind) {
|
|
1210
|
+
case 'failed': return { halt: true, detail: plan.result };
|
|
1211
|
+
case 'duplicate': {
|
|
1212
|
+
// the stored ack, byte-identical; a terminal document has none
|
|
1213
|
+
// (its outbound attempt is one past the schedule's, which it does not move)
|
|
1214
|
+
const a = state.effects[plan.digest].ack;
|
|
1215
|
+
return { state: state, release: a ? [ackOutbound(a, a.attempt + 1)] : [], detail: { disposition: 'duplicate-known', digest: plan.digest } };
|
|
1216
|
+
}
|
|
1217
|
+
case 'ack': {
|
|
1218
|
+
// terminal: the sender-status update and its cache entry, no ack;
|
|
1219
|
+
// the issuing module's ack handler runs in the same transaction
|
|
1220
|
+
const m = markDelivered(state, plan.ref, when);
|
|
1221
|
+
let next = { ...state, outbox: m.outbox };
|
|
1222
|
+
const events = m.event ? [m.event] : [];
|
|
1223
|
+
const h = acks().get(state.outbox[plan.ref].type);
|
|
1224
|
+
if (h) {
|
|
1225
|
+
const r = h(next, plan.ref, when);
|
|
1226
|
+
if (r.state.effects !== next.effects)
|
|
1227
|
+
throw new Error(`engine: the ack handler of ${state.outbox[plan.ref].type} wrote the effect cache`);
|
|
1228
|
+
next = r.state;
|
|
1229
|
+
events.push(...(r.events ?? []));
|
|
1230
|
+
}
|
|
1231
|
+
return {
|
|
1232
|
+
state: { ...next, effects: { ...next.effects, [plan.digest]: cached(null) } },
|
|
1233
|
+
events,
|
|
1234
|
+
detail: { disposition: 'unique', digest: plan.digest, type: ACK_TYPE, ref: plan.ref },
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
case 'task': {
|
|
1238
|
+
const h = tasks().get(plan.type);
|
|
1239
|
+
const r = h.apply(state, handlerPrepared(prepared, plan.value), plan.doc, plan.arrival);
|
|
1240
|
+
if (isTaskRefusal(r)) {
|
|
1241
|
+
const failedDetail = { disposition: 'failed', reason: r.reason, stage: 9, digest: plan.digest, ...(r.ref !== undefined ? { ref: r.ref } : {}) };
|
|
1242
|
+
if (!r.latch)
|
|
1243
|
+
return { halt: true, detail: failedDetail };
|
|
1244
|
+
// the Encounter latch exception (receive.ts): only the latch commits
|
|
1245
|
+
checkLatch(state, r.latch, when);
|
|
1246
|
+
return { state: r.latch, detail: failedDetail };
|
|
1247
|
+
}
|
|
1248
|
+
if (isTaskDiscarded(r)) {
|
|
1249
|
+
// a resource-limit discard: its state commits; no cache entry, no ack
|
|
1250
|
+
if (r.state.effects !== state.effects)
|
|
1251
|
+
throw new Error(`engine: the handler of ${plan.type} wrote the effect cache`);
|
|
1252
|
+
return { state: r.state, detail: { disposition: 'discarded', digest: plan.digest, type: plan.type, reason: r.reason, ...(r.ref !== undefined ? { ref: r.ref } : {}) } };
|
|
1253
|
+
}
|
|
1254
|
+
if (r.state.effects !== state.effects)
|
|
1255
|
+
throw new Error(`engine: the handler of ${plan.type} wrote the effect cache; the core writes it`);
|
|
1256
|
+
return {
|
|
1257
|
+
state: { ...r.state, effects: { ...state.effects, [plan.digest]: cached({ ...plan.ack, at: when, attempt: 1, nextAttemptAt: dueAt(when, policy().backoff(1)), sent: false }) } },
|
|
1258
|
+
...(r.outbound ? { outbound: r.outbound } : {}),
|
|
1259
|
+
...(r.events ? { events: r.events } : {}),
|
|
1260
|
+
...(r.duties ? { duties: r.duties } : {}),
|
|
1261
|
+
...(r.renames ? { renames: r.renames } : {}),
|
|
1262
|
+
release: [ackOutbound(plan.ack, 1)],
|
|
1263
|
+
detail: { disposition: 'unique', digest: plan.digest, type: plan.type, rkid: plan.arrival.rkid },
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
},
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
// ── public surface ───────────────────────────────────────────────────
|
|
1271
|
+
act(command, input, when, opts = {}) {
|
|
1272
|
+
let def;
|
|
1273
|
+
let bound;
|
|
1274
|
+
let hash;
|
|
1275
|
+
let actId;
|
|
1276
|
+
try {
|
|
1277
|
+
def = this.#commands.get(command);
|
|
1278
|
+
if (!def)
|
|
1279
|
+
throw new Error(BUILTIN.includes(command) ? `${command} is built in: call engine.${command}()` : `unknown command ${command}`);
|
|
1280
|
+
checkWhen(when);
|
|
1281
|
+
actId = def.human ? opts.actId : undefined;
|
|
1282
|
+
if (def.human && (typeof actId !== 'string' || actId === ''))
|
|
1283
|
+
return Promise.resolve({ ok: false, code: 'actid-missing' });
|
|
1284
|
+
// bound here, synchronously: later changes by the caller reach nothing
|
|
1285
|
+
bound = bindInput(input);
|
|
1286
|
+
if (def.human) {
|
|
1287
|
+
hash = digestOf(this.#crypto.subtle, { command, input: bound });
|
|
1288
|
+
hash.catch(() => undefined); // awaited in the queue; no unhandled rejection meanwhile
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
catch (e) {
|
|
1292
|
+
return Promise.reject(e);
|
|
1293
|
+
}
|
|
1294
|
+
const d = def;
|
|
1295
|
+
return this.#enqueue(async () => {
|
|
1296
|
+
return actResult(await this.#run(d, { kind: 'act', command }, bound, when, actId, hash === undefined ? undefined : await hash));
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
tick(when) {
|
|
1300
|
+
try {
|
|
1301
|
+
checkWhen(when);
|
|
1302
|
+
}
|
|
1303
|
+
catch (e) {
|
|
1304
|
+
return Promise.reject(e);
|
|
1305
|
+
}
|
|
1306
|
+
return this.#enqueue(async () => {
|
|
1307
|
+
return actResult(await this.#run(this.#builtins.tick, { kind: 'tick' }, null, when));
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
resume(when) {
|
|
1311
|
+
try {
|
|
1312
|
+
checkWhen(when);
|
|
1313
|
+
}
|
|
1314
|
+
catch (e) {
|
|
1315
|
+
return Promise.reject(e);
|
|
1316
|
+
}
|
|
1317
|
+
return this.#enqueue(async () => {
|
|
1318
|
+
const r = await this.#run(this.#builtins.resume, { kind: 'resume' }, null, when);
|
|
1319
|
+
if (isHalt(r) || !r.ok)
|
|
1320
|
+
throw new Error('engine: resume does not refuse');
|
|
1321
|
+
return { outbound: r.outbound, events: r.events, ...(r.nextDueAt !== undefined ? { nextDueAt: r.nextDueAt } : {}) };
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
report(report, when) {
|
|
1325
|
+
let bound;
|
|
1326
|
+
try {
|
|
1327
|
+
checkWhen(when);
|
|
1328
|
+
bound = bindInput(report);
|
|
1329
|
+
}
|
|
1330
|
+
catch (e) {
|
|
1331
|
+
return Promise.reject(e);
|
|
1332
|
+
}
|
|
1333
|
+
return this.#enqueue(async () => {
|
|
1334
|
+
return actResult(await this.#run(this.#builtins.report, { kind: 'report', status: bound.status }, bound, when));
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
receive(env, when) {
|
|
1338
|
+
try {
|
|
1339
|
+
checkWhen(when);
|
|
1340
|
+
}
|
|
1341
|
+
catch (e) {
|
|
1342
|
+
return Promise.reject(e);
|
|
1343
|
+
}
|
|
1344
|
+
// bound at the call like any input; what is not JSON is no envelope
|
|
1345
|
+
let bound;
|
|
1346
|
+
try {
|
|
1347
|
+
bound = bindInput(env);
|
|
1348
|
+
}
|
|
1349
|
+
catch {
|
|
1350
|
+
bound = null;
|
|
1351
|
+
}
|
|
1352
|
+
return this.#enqueue(async () => {
|
|
1353
|
+
const r = await this.#run(this.#builtins.receive, { kind: 'receive' }, bound, when);
|
|
1354
|
+
if (isHalt(r))
|
|
1355
|
+
return r.detail;
|
|
1356
|
+
if (!r.ok)
|
|
1357
|
+
throw new Error('engine: receive does not refuse');
|
|
1358
|
+
if (r.detail?.disposition === 'failed')
|
|
1359
|
+
return r.detail; // a latch committed
|
|
1360
|
+
const d = r.detail;
|
|
1361
|
+
if (d.disposition === 'discarded')
|
|
1362
|
+
return { disposition: 'discarded', digest: d.digest, type: d.type, reason: d.reason, ...(d.ref !== undefined ? { ref: d.ref } : {}) };
|
|
1363
|
+
return d.disposition === 'unique'
|
|
1364
|
+
? { disposition: 'unique', digest: d.digest, type: d.type, outbound: r.outbound, events: r.events, ...(r.nextDueAt !== undefined ? { nextDueAt: r.nextDueAt } : {}) }
|
|
1365
|
+
: { disposition: 'duplicate-known', digest: d.digest, outbound: r.outbound };
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
subscribe(listener) {
|
|
1369
|
+
if (typeof listener !== 'function')
|
|
1370
|
+
throw new TypeError('listener must be a function');
|
|
1371
|
+
this.#listeners.add(listener);
|
|
1372
|
+
return () => { this.#listeners.delete(listener); };
|
|
1373
|
+
}
|
|
1374
|
+
snapshot() {
|
|
1375
|
+
return this.#enqueue(async () => {
|
|
1376
|
+
const { state } = await this.#read();
|
|
1377
|
+
return Object.freeze({ state, commitSeq: state.commitSeq });
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Build an engine over the ports and modules: resolves and probes
|
|
1383
|
+
* WebCrypto, freezes the registries (duplicate command names, task URIs
|
|
1384
|
+
* or duty kinds throw), and initialises an empty store with the empty
|
|
1385
|
+
* state at the current codec.
|
|
1386
|
+
*/
|
|
1387
|
+
export async function createEngine(ports, modules, opts = {}) {
|
|
1388
|
+
if (!ports?.store || !ports.keys)
|
|
1389
|
+
throw new TypeError('createEngine needs a store and keys');
|
|
1390
|
+
const crypto = await resolveCrypto(ports.crypto);
|
|
1391
|
+
const engine = new HolderEngine(ports, crypto, modules, opts);
|
|
1392
|
+
await engine.load();
|
|
1393
|
+
return engine;
|
|
1394
|
+
}
|
|
1395
|
+
//# sourceMappingURL=engine.js.map
|