@onlyne/lib 3.0.0 → 3.2.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/README.md +6 -3
- package/dist/awaited.d.ts +43 -0
- package/dist/awaited.d.ts.map +1 -0
- package/dist/awaited.js +82 -0
- package/dist/awaited.js.map +1 -0
- package/dist/client.d.ts +140 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1772 -0
- package/dist/client.js.map +1 -0
- package/dist/core.d.ts +36 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +122 -0
- package/dist/core.js.map +1 -0
- package/dist/delivery.d.ts +120 -0
- package/dist/delivery.d.ts.map +1 -0
- package/dist/delivery.js +117 -0
- package/dist/delivery.js.map +1 -0
- package/dist/discovery.d.ts +35 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +306 -0
- package/dist/discovery.js.map +1 -0
- package/dist/domains.d.ts +29 -0
- package/dist/domains.d.ts.map +1 -0
- package/dist/domains.js +267 -0
- package/dist/domains.js.map +1 -0
- package/dist/history.d.ts +13 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +143 -0
- package/dist/history.js.map +1 -0
- package/dist/http.d.ts +20 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +88 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +97 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +679 -0
- package/dist/keys.js.map +1 -0
- package/dist/memory.d.ts +9 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +35 -0
- package/dist/memory.js.map +1 -0
- package/dist/messages.d.ts +64 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +480 -0
- package/dist/messages.js.map +1 -0
- package/dist/node.d.ts +26 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +59 -0
- package/dist/node.js.map +1 -0
- package/dist/resolve.d.ts +18 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +37 -0
- package/dist/resolve.js.map +1 -0
- package/dist/resources.d.ts +25 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +782 -0
- package/dist/resources.js.map +1 -0
- package/dist/routes/domains.d.ts +4 -0
- package/dist/routes/domains.d.ts.map +1 -0
- package/dist/routes/domains.js +42 -0
- package/dist/routes/domains.js.map +1 -0
- package/dist/routes/grants.d.ts +8 -0
- package/dist/routes/grants.d.ts.map +1 -0
- package/dist/routes/grants.js +32 -0
- package/dist/routes/grants.js.map +1 -0
- package/dist/routes/public.d.ts +4 -0
- package/dist/routes/public.d.ts.map +1 -0
- package/dist/routes/public.js +62 -0
- package/dist/routes/public.js.map +1 -0
- package/dist/routes/resources.d.ts +8 -0
- package/dist/routes/resources.d.ts.map +1 -0
- package/dist/routes/resources.js +87 -0
- package/dist/routes/resources.js.map +1 -0
- package/dist/routes/shared.d.ts +77 -0
- package/dist/routes/shared.d.ts.map +1 -0
- package/dist/routes/shared.js +349 -0
- package/dist/routes/shared.js.map +1 -0
- package/dist/routes/threads.d.ts +8 -0
- package/dist/routes/threads.d.ts.map +1 -0
- package/dist/routes/threads.js +113 -0
- package/dist/routes/threads.js.map +1 -0
- package/dist/routes/trust.d.ts +4 -0
- package/dist/routes/trust.d.ts.map +1 -0
- package/dist/routes/trust.js +36 -0
- package/dist/routes/trust.js.map +1 -0
- package/dist/stream.d.ts +20 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +141 -0
- package/dist/stream.js.map +1 -0
- package/dist/threads.d.ts +79 -0
- package/dist/threads.d.ts.map +1 -0
- package/dist/threads.js +480 -0
- package/dist/threads.js.map +1 -0
- package/dist/transport.d.ts +45 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +175 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +809 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/package.json +16 -8
- package/src/client.ts +0 -1549
- package/src/core.ts +0 -114
- package/src/discovery.ts +0 -267
- package/src/domains.ts +0 -210
- package/src/history.ts +0 -129
- package/src/http.ts +0 -841
- package/src/index.ts +0 -18
- package/src/keys.ts +0 -632
- package/src/memory.ts +0 -34
- package/src/messages.ts +0 -398
- package/src/node.ts +0 -56
- package/src/resources.ts +0 -745
- package/src/threads.ts +0 -350
- package/src/transport.ts +0 -204
- package/src/types.ts +0 -383
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
import { OnlyneError, decimal, fail, key, page, requireValue, same, utc } from "./core.js";
|
|
2
|
+
/**
|
|
3
|
+
* RFC 0006 §1: `read`, `describe` and `list_events` are read-only queries. The endpoint answers
|
|
4
|
+
* them from its own state, so they never reach the driver and never claim an operation id.
|
|
5
|
+
*/
|
|
6
|
+
const QUERY = { read: true, describe: true, list_events: true };
|
|
7
|
+
/** How long one caller waits on the driver before answering `unknown`; the external call keeps running. */
|
|
8
|
+
const DRIVER_WAIT_MS = 30_000;
|
|
9
|
+
const resourceKey = (ref) => key('resource', ref.owner, ref.id);
|
|
10
|
+
const operationKey = (ref, id) => key('resource', ref.owner, ref.id, 'operation', id);
|
|
11
|
+
const eventKey = (ref, id) => key('resource', ref.owner, ref.id, 'event', id);
|
|
12
|
+
const sourceKey = (ref, source) => key('resource', ref.owner, ref.id, 'source', source);
|
|
13
|
+
const handoverKey = (ref, id) => key('resource', ref.owner, ref.id, 'handover', id);
|
|
14
|
+
const probeKey = (id) => key('probe', id);
|
|
15
|
+
const operationsPrefix = (ref) => key('resource', ref.owner, ref.id, 'operation');
|
|
16
|
+
const eventsPrefix = (ref) => key('resource', ref.owner, ref.id, 'event');
|
|
17
|
+
/** Idempotency is scoped per resource: the same platform event or operation ID in two resources must not collide. */
|
|
18
|
+
const resourceScope = (ref) => `${ref.owner}/${ref.id}`;
|
|
19
|
+
const EXTENSIONS = {
|
|
20
|
+
operation: 'operation', event: 'event', source: 'source', handover: 'handover',
|
|
21
|
+
};
|
|
22
|
+
/** `key` encodes each part and joins with literal slashes, so rows are recognised by decoding parts, never by matching an encoded fragment. */
|
|
23
|
+
function parseRow(path) {
|
|
24
|
+
const parts = path.split('/');
|
|
25
|
+
if (parts[0] !== 'resource' || parts.length < 3 || parts.length > 5)
|
|
26
|
+
return undefined;
|
|
27
|
+
const ref = { owner: decodeURIComponent(parts[1]), id: decodeURIComponent(parts[2]) };
|
|
28
|
+
if (parts.length === 3)
|
|
29
|
+
return { ref, kind: 'resource' };
|
|
30
|
+
const kind = EXTENSIONS[decodeURIComponent(parts[3])];
|
|
31
|
+
if (kind === undefined)
|
|
32
|
+
return undefined;
|
|
33
|
+
return parts.length === 5 ? { ref, kind } : undefined;
|
|
34
|
+
}
|
|
35
|
+
/** Rows of one kind with decoded keys: the resource namespace also holds operations, events and handovers. */
|
|
36
|
+
function rowsOf(entries, kind) {
|
|
37
|
+
const found = [];
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
const row = parseRow(entry.key);
|
|
40
|
+
if (row !== undefined && row.kind === kind)
|
|
41
|
+
found.push({ row, value: entry.value });
|
|
42
|
+
}
|
|
43
|
+
return found;
|
|
44
|
+
}
|
|
45
|
+
/** Event and operation cursors are decimal strings; the lexical default would order `10` before `2`. */
|
|
46
|
+
const bySequence = (a, b) => {
|
|
47
|
+
const left = decimal(a);
|
|
48
|
+
const right = decimal(b);
|
|
49
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
50
|
+
};
|
|
51
|
+
/** `accepted_at` orders an operation list; padding keeps the millisecond string monotone under the lexical cursor. */
|
|
52
|
+
const anchor = (record) => `${String(utc(record.accepted_at)).padStart(15, '0')}:${record.request.operation_id}`;
|
|
53
|
+
const byAnchor = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
54
|
+
/**
|
|
55
|
+
* RFC 0006 §6: the only two read authorities. The binding grants the current handler its
|
|
56
|
+
* working view; `log_readers` is a standing grant that a handover must not rewrite.
|
|
57
|
+
*/
|
|
58
|
+
function mayRead(resource, actor) {
|
|
59
|
+
return resource.binding.handler === actor || resource.log_readers.includes(actor);
|
|
60
|
+
}
|
|
61
|
+
/** Every operation except a query belongs to the current handler; there is no second operator (RFC 0006 §3). */
|
|
62
|
+
function mayOperate(resource, actor, operation) {
|
|
63
|
+
if (QUERY[operation])
|
|
64
|
+
return mayRead(resource, actor);
|
|
65
|
+
return resource.binding.handler === actor;
|
|
66
|
+
}
|
|
67
|
+
/** A target's answer is only an answer if it names a position or a reason; anything else is a broken peer. */
|
|
68
|
+
function probeAnswerOf(value) {
|
|
69
|
+
if (typeof value !== 'object' || value === null)
|
|
70
|
+
fail('unavailable', 'The target answered a probe without a body.');
|
|
71
|
+
const record = value;
|
|
72
|
+
if (record.answer === 'accept') {
|
|
73
|
+
if (typeof record.cursor !== 'string')
|
|
74
|
+
fail('unavailable', 'The target accepted a probe without a cursor.');
|
|
75
|
+
decimal(record.cursor);
|
|
76
|
+
return { answer: 'accept', cursor: record.cursor };
|
|
77
|
+
}
|
|
78
|
+
if (record.answer === 'reject') {
|
|
79
|
+
if (typeof record.reason !== 'string' || record.reason.length === 0)
|
|
80
|
+
fail('unavailable', 'The target rejected a probe without a reason.');
|
|
81
|
+
return { answer: 'reject', reason: record.reason };
|
|
82
|
+
}
|
|
83
|
+
fail('unavailable', 'The target answered a probe with an answer the protocol does not define.');
|
|
84
|
+
}
|
|
85
|
+
export function createResourceService(ctx, transport) {
|
|
86
|
+
const drivers = ctx.options.plugins.resources;
|
|
87
|
+
/**
|
|
88
|
+
* A driver that mirrors event ids in an index of its own — the IM bridge keeps the platform
|
|
89
|
+
* reply handles that way — is told exactly which events left retention. Handing over the ids
|
|
90
|
+
* keeps the driver's own clock out of it: a retained event keeps its handle, and only the rows
|
|
91
|
+
* whose event is gone are dropped.
|
|
92
|
+
*/
|
|
93
|
+
const retireDriver = drivers;
|
|
94
|
+
/**
|
|
95
|
+
* Every request for one operation ID must be identical, actor included: the same caller retries, nobody takes over
|
|
96
|
+
* the slot. The retry envelope stays out of it, so a retry may refresh its window without becoming another request.
|
|
97
|
+
*/
|
|
98
|
+
function digestOf(actor, ref, request) {
|
|
99
|
+
return JSON.stringify({
|
|
100
|
+
actor, resource_ref: ref, operation: request.operation,
|
|
101
|
+
expected_revision: request.expected_revision, data: request.data,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/** Registering and receiving are endpoint actions: the resource owner is the endpoint, not whichever agent asks. */
|
|
105
|
+
function requireEndpoint(auth) {
|
|
106
|
+
if (auth.actor !== ctx.node)
|
|
107
|
+
fail('forbidden', 'Only the resource endpoint registers and receives here.');
|
|
108
|
+
}
|
|
109
|
+
const fetchResource = (ref) => ctx.storage.transaction(async (tx) => tx.get(resourceKey(ref)));
|
|
110
|
+
const live = async (ref) => {
|
|
111
|
+
const resource = requireValue(await fetchResource(ref));
|
|
112
|
+
if (!same(resource.resource_ref, ref))
|
|
113
|
+
fail('internal', 'The stored resource does not match its key.');
|
|
114
|
+
return resource;
|
|
115
|
+
};
|
|
116
|
+
const queryOutside = async (resource, id) => {
|
|
117
|
+
if (drivers === undefined)
|
|
118
|
+
return undefined;
|
|
119
|
+
try {
|
|
120
|
+
return await drivers.query?.(resource, id);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const intentOf = (ref, id) => ctx.storage.transaction(async (tx) => tx.get(operationKey(ref, id)));
|
|
127
|
+
function nextRevision(resource) {
|
|
128
|
+
if (!resource.revision)
|
|
129
|
+
fail('internal', 'The resource revision is missing.');
|
|
130
|
+
const revision = ctx.id();
|
|
131
|
+
if (revision === resource.revision)
|
|
132
|
+
fail('internal', 'The id source reused the current resource revision.');
|
|
133
|
+
return revision;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Admission and the claim share one transaction: a concurrent caller either finds the intent already written and only
|
|
137
|
+
* reports it, or writes it itself and owns the single external effect for this operation ID.
|
|
138
|
+
*/
|
|
139
|
+
async function claimOperation(ref, auth, request) {
|
|
140
|
+
if (!auth.actor)
|
|
141
|
+
fail('unauthenticated', 'Authentication is required.');
|
|
142
|
+
return ctx.storage.transaction(async (tx) => {
|
|
143
|
+
const existing = await tx.get(operationKey(ref, request.operation_id));
|
|
144
|
+
if (existing) {
|
|
145
|
+
if (existing.record.digest !== digestOf(auth.actor, ref, request))
|
|
146
|
+
fail('id_conflict', 'The operation ID is bound to another request.');
|
|
147
|
+
return { record: existing.record, claimed: false };
|
|
148
|
+
}
|
|
149
|
+
ctx.deadline(request.retry_until);
|
|
150
|
+
const fresh = requireValue(await tx.get(resourceKey(ref)));
|
|
151
|
+
if (!mayOperate(fresh, auth.actor, request.operation))
|
|
152
|
+
fail('forbidden', 'The authenticated caller may not perform this operation.');
|
|
153
|
+
if (request.expected_revision !== undefined && request.expected_revision !== fresh.revision) {
|
|
154
|
+
throw new OnlyneError('revision_conflict', 'The resource binding changed.', { current_revision: fresh.revision });
|
|
155
|
+
}
|
|
156
|
+
const record = {
|
|
157
|
+
actor: auth.actor, request, digest: digestOf(auth.actor, ref, request),
|
|
158
|
+
result: { operation_id: request.operation_id, state: 'pending' },
|
|
159
|
+
resource_ref: ref, accepted_at: new Date(ctx.now()).toISOString(), authorized_revision: fresh.revision,
|
|
160
|
+
};
|
|
161
|
+
await tx.put(operationKey(ref, request.operation_id), { record, settle: true });
|
|
162
|
+
return { record, claimed: true };
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* `replaceUnknown` lets a late or recovered driver outcome correct a record only the local wait gave up on; a
|
|
167
|
+
* committed or aborted result is never overwritten.
|
|
168
|
+
*/
|
|
169
|
+
async function settleWith(tx, ref, id, outcome, replaceUnknown = false) {
|
|
170
|
+
const intent = requireValue(await tx.get(operationKey(ref, id)), 'not_found');
|
|
171
|
+
const current = intent.record.result.state;
|
|
172
|
+
if (current !== 'pending' && !(replaceUnknown && current === 'unknown'))
|
|
173
|
+
return intent.record.result;
|
|
174
|
+
const result = {
|
|
175
|
+
operation_id: id, state: outcome.state,
|
|
176
|
+
...(outcome.data === undefined ? {} : { data: outcome.data }),
|
|
177
|
+
...(outcome.error === undefined ? {} : { error: outcome.error }),
|
|
178
|
+
};
|
|
179
|
+
// `unknown` is a state to keep asking about, not a settlement: the record stays owed to `recover`.
|
|
180
|
+
await tx.put(operationKey(ref, id), { record: { ...intent.record, result }, settle: result.state === 'unknown' });
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
async function settle(ref, id, outcome, replaceUnknown = false) {
|
|
184
|
+
return ctx.storage.transaction(async (tx) => settleWith(tx, ref, id, outcome, replaceUnknown));
|
|
185
|
+
}
|
|
186
|
+
/** The operation's recorded result, for a path that finds the handover already settled. */
|
|
187
|
+
async function resultOf(tx, ref, id) {
|
|
188
|
+
return requireValue(await tx.get(operationKey(ref, id)), 'not_found').record.result;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Retention gives every event a terminal disposition. Expiry is written onto the event row
|
|
192
|
+
* itself; the log keeps the content and the id is never reused (RFC 0006 §2).
|
|
193
|
+
*
|
|
194
|
+
* The source row is an index over that log, not the log: while the event is retained it is the
|
|
195
|
+
* deduplication point, and once the event expires it can never deduplicate again, so keeping it
|
|
196
|
+
* would grow the store for nothing. The row doubles as the marker for the announcement — it is
|
|
197
|
+
* deleted only after the driver acknowledged the retirement, so a failed pass leaves it in
|
|
198
|
+
* place and the next one repeats the announcement instead of losing it.
|
|
199
|
+
*/
|
|
200
|
+
async function expireEvents() {
|
|
201
|
+
const now = ctx.now();
|
|
202
|
+
const retiring = await ctx.storage.transaction(async (tx) => {
|
|
203
|
+
// One scan answers both questions: which events are due, and whether a dedup row still
|
|
204
|
+
// names one of them. A per-event read would turn a sweep into one query per expired event.
|
|
205
|
+
const rows = await tx.scan(key('resource'));
|
|
206
|
+
const sources = new Map();
|
|
207
|
+
const events = [];
|
|
208
|
+
for (const entry of rows) {
|
|
209
|
+
const row = parseRow(entry.key);
|
|
210
|
+
if (row?.kind === 'event')
|
|
211
|
+
events.push(entry.value);
|
|
212
|
+
else if (row?.kind === 'source')
|
|
213
|
+
sources.set(entry.key, entry.value);
|
|
214
|
+
}
|
|
215
|
+
const pending = [];
|
|
216
|
+
for (const value of events) {
|
|
217
|
+
if (utc(value.expires_at) > now)
|
|
218
|
+
continue;
|
|
219
|
+
if (!value.expired) {
|
|
220
|
+
await tx.put(eventKey(value.resource_ref, value.event_id), { ...value, expired: true });
|
|
221
|
+
}
|
|
222
|
+
if (sources.get(sourceKey(value.resource_ref, value.source_id)) === value.event_id)
|
|
223
|
+
pending.push(value);
|
|
224
|
+
}
|
|
225
|
+
return pending;
|
|
226
|
+
});
|
|
227
|
+
if (retiring.length === 0)
|
|
228
|
+
return;
|
|
229
|
+
await retireDriver?.retire?.(retiring);
|
|
230
|
+
await ctx.storage.transaction(async (tx) => {
|
|
231
|
+
for (const event of retiring) {
|
|
232
|
+
const source = sourceKey(event.resource_ref, event.source_id);
|
|
233
|
+
if (await tx.get(source) === event.event_id)
|
|
234
|
+
await tx.delete(source);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
/** One event, filtered by the caller's rights (RFC 0006 §1 query semantics). */
|
|
239
|
+
async function readEvent(auth, ref, id) {
|
|
240
|
+
ctx.owner(ref.owner);
|
|
241
|
+
const resource = await live(ref);
|
|
242
|
+
if (!mayRead(resource, auth.actor))
|
|
243
|
+
fail('not_found', 'The requested resource is unavailable.');
|
|
244
|
+
return requireValue(await ctx.storage.transaction(async (tx) => tx.get(eventKey(ref, id))));
|
|
245
|
+
}
|
|
246
|
+
/** The append-only log, read by the current handler or a standing log reader (RFC 0006 §6). */
|
|
247
|
+
async function readLog(auth, ref, options) {
|
|
248
|
+
ctx.owner(ref.owner);
|
|
249
|
+
const resource = await live(ref);
|
|
250
|
+
if (!mayRead(resource, auth.actor))
|
|
251
|
+
fail('not_found', 'The requested resource is unavailable.');
|
|
252
|
+
const since = options?.since;
|
|
253
|
+
if (since !== undefined)
|
|
254
|
+
decimal(since);
|
|
255
|
+
const events = (await ctx.storage.transaction(async (tx) => tx.scan(eventsPrefix(ref))))
|
|
256
|
+
.map(entry => entry.value)
|
|
257
|
+
.filter(event => since === undefined || bySequence(event.event_id, since) > 0)
|
|
258
|
+
.sort((a, b) => bySequence(a.event_id, b.event_id));
|
|
259
|
+
return page(events, options ?? {}, { kind: 'log', owner: ref.owner, id: ref.id, actor: auth.actor, since }, event => event.event_id, bySequence);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* A query operation answers from this endpoint's own state. It records no operation: a read
|
|
263
|
+
* that fails must not consume an idempotency slot, and RFC 0006 §1 keeps queries out of the
|
|
264
|
+
* effect path entirely.
|
|
265
|
+
*/
|
|
266
|
+
async function query(auth, ref, resource, request) {
|
|
267
|
+
const id = request.operation_id;
|
|
268
|
+
// A query result travels as wire JSON: the caller reads the same bytes an HTTP response carries.
|
|
269
|
+
const wire = (value) => JSON.parse(JSON.stringify(value));
|
|
270
|
+
if (request.operation === 'describe')
|
|
271
|
+
return { operation_id: id, state: 'committed', data: wire(resource) };
|
|
272
|
+
const data = (request.data ?? {});
|
|
273
|
+
if (request.operation === 'read') {
|
|
274
|
+
const eventId = data.event_id;
|
|
275
|
+
if (typeof eventId !== 'string' || eventId.length === 0)
|
|
276
|
+
fail('invalid_request', 'read names the event_id to return.');
|
|
277
|
+
return { operation_id: id, state: 'committed', data: wire(await readEvent(auth, ref, eventId)) };
|
|
278
|
+
}
|
|
279
|
+
const options = {};
|
|
280
|
+
if (typeof data.cursor === 'string')
|
|
281
|
+
options.cursor = data.cursor;
|
|
282
|
+
if (typeof data.page_size === 'number')
|
|
283
|
+
options.page_size = data.page_size;
|
|
284
|
+
if (typeof data.since === 'string')
|
|
285
|
+
options.since = data.since;
|
|
286
|
+
return { operation_id: id, state: 'committed', data: wire(await readLog(auth, ref, options)) };
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* RFC 0006 §4.4: the single point where a handover takes effect. Deadline, revision and
|
|
290
|
+
* initiator are rechecked inside this transaction, so a binding that moved under the probe
|
|
291
|
+
* cannot be overwritten and only one of two racing handovers switches.
|
|
292
|
+
*/
|
|
293
|
+
async function commitHandover(ref, id, cursor) {
|
|
294
|
+
return ctx.storage.transaction(async (tx) => {
|
|
295
|
+
const intent = requireValue(await tx.get(handoverKey(ref, id)), 'not_found');
|
|
296
|
+
if (intent.state !== 'open')
|
|
297
|
+
return resultOf(tx, ref, id);
|
|
298
|
+
if (utc(intent.deadline) <= ctx.now())
|
|
299
|
+
return abortWith(tx, intent, 'deadline_passed');
|
|
300
|
+
const resource = requireValue(await tx.get(resourceKey(ref)));
|
|
301
|
+
if (resource.revision !== intent.expected_revision) {
|
|
302
|
+
return abortWith(tx, intent, 'revision_changed', {
|
|
303
|
+
code: 'revision_conflict', message: 'The resource binding changed before the handover could commit.', retryable: false,
|
|
304
|
+
details: { current_revision: resource.revision },
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
if (resource.binding.handler !== intent.initiator) {
|
|
308
|
+
return abortWith(tx, intent, 'initiator_released', {
|
|
309
|
+
code: 'forbidden', message: 'The initiator no longer holds this resource.', retryable: false,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
// An ack during the freeze moved the cursor forward; the switch must not push it back,
|
|
313
|
+
// because a cursor that moves backwards re-delivers events the handler already consumed.
|
|
314
|
+
const position = bySequence(cursor, resource.binding.cursor) > 0 ? cursor : resource.binding.cursor;
|
|
315
|
+
const moved = { ...resource, binding: { handler: intent.target, cursor: position }, revision: nextRevision(resource) };
|
|
316
|
+
await tx.put(resourceKey(ref), moved);
|
|
317
|
+
await tx.put(handoverKey(ref, id), { ...intent, state: 'committed' });
|
|
318
|
+
const result = {
|
|
319
|
+
operation_id: id, state: 'committed',
|
|
320
|
+
data: { handler: moved.binding.handler, cursor: moved.binding.cursor, revision: moved.revision },
|
|
321
|
+
};
|
|
322
|
+
const record = requireValue(await tx.get(operationKey(ref, id)), 'not_found');
|
|
323
|
+
await tx.put(operationKey(ref, id), { record: { ...record.record, result }, settle: false });
|
|
324
|
+
return result;
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
/** Aborting leaves the binding exactly as it was: the frozen events stay owed to the original handler. */
|
|
328
|
+
async function abortWith(tx, intent, reason, error) {
|
|
329
|
+
const ref = intent.resource_ref;
|
|
330
|
+
const id = intent.operation_id;
|
|
331
|
+
if (intent.state !== 'open')
|
|
332
|
+
return resultOf(tx, ref, id);
|
|
333
|
+
await tx.put(handoverKey(ref, id), { ...intent, state: 'aborted' });
|
|
334
|
+
const record = requireValue(await tx.get(operationKey(ref, id)), 'not_found');
|
|
335
|
+
const result = {
|
|
336
|
+
operation_id: id, state: 'aborted', data: { reason },
|
|
337
|
+
...(error === undefined ? {} : { error }),
|
|
338
|
+
};
|
|
339
|
+
await tx.put(operationKey(ref, id), { record: { ...record.record, result }, settle: false });
|
|
340
|
+
return result;
|
|
341
|
+
}
|
|
342
|
+
const abortHandover = (ref, id, reason, error) => ctx.storage.transaction(async (tx) => abortWith(tx, requireValue(await tx.get(handoverKey(ref, id)), 'not_found'), reason, error));
|
|
343
|
+
/**
|
|
344
|
+
* Why a probe never reached its target. A request the transport refuses to sign is not an
|
|
345
|
+
* outage: this endpoint holds no usable pairing with that node, and only its holder can
|
|
346
|
+
* create one. Reporting that as `unreachable` sends the operator looking for a network fault
|
|
347
|
+
* that does not exist.
|
|
348
|
+
*/
|
|
349
|
+
function probeFailure(error) {
|
|
350
|
+
if (!(error instanceof OnlyneError))
|
|
351
|
+
return { reason: 'unreachable' };
|
|
352
|
+
const details = error.error.details;
|
|
353
|
+
return { reason: details?.required_action === undefined ? 'unreachable' : 'untrusted_target', error: error.error };
|
|
354
|
+
}
|
|
355
|
+
/** How long the caller waits on the probe: the local window, never past the handshake deadline. */
|
|
356
|
+
function waitWindow(deadline) {
|
|
357
|
+
const window = ctx.options.wait_ms ?? DRIVER_WAIT_MS;
|
|
358
|
+
return Math.max(0, Math.min(window, utc(deadline) - ctx.now()));
|
|
359
|
+
}
|
|
360
|
+
/** The probe is a signed peer request like any other: it rides the node's transport, never a bare fetch. */
|
|
361
|
+
async function probeTarget(intent) {
|
|
362
|
+
const body = {
|
|
363
|
+
operation_id: intent.operation_id, resource: intent.resource_ref, kind: intent.kind,
|
|
364
|
+
operations: intent.operations, ...(intent.metadata === undefined ? {} : { metadata: intent.metadata }),
|
|
365
|
+
initiator: intent.initiator, cursor: intent.cursor, deadline: intent.deadline,
|
|
366
|
+
};
|
|
367
|
+
const call = transport.request(intent.target, 'POST', '/handover/probes', body);
|
|
368
|
+
const waited = await waitFor(call, waitWindow(intent.deadline));
|
|
369
|
+
if (waited.kind === 'timeout')
|
|
370
|
+
return { kind: 'timeout' };
|
|
371
|
+
if (waited.kind === 'failed')
|
|
372
|
+
return { kind: 'failed', error: waited.error };
|
|
373
|
+
try {
|
|
374
|
+
return { kind: 'answer', value: probeAnswerOf(waited.value) };
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
return { kind: 'failed' };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/** The answer the target recorded, read back without creating one; `not_found` means the probe never landed. */
|
|
381
|
+
async function recordedAnswer(target, operationId, deadline) {
|
|
382
|
+
const path = `/handover/probes/${encodeURIComponent(operationId)}`;
|
|
383
|
+
const waited = await waitFor(transport.request(target, 'GET', path), waitWindow(deadline));
|
|
384
|
+
if (waited.kind === 'timeout')
|
|
385
|
+
return { kind: 'timeout' };
|
|
386
|
+
// RFC 0006 §4.5: an answer the target never recorded ends the handover, while an unreachable
|
|
387
|
+
// target says nothing and leaves the intent open until its deadline.
|
|
388
|
+
if (waited.kind === 'failed') {
|
|
389
|
+
return waited.error instanceof OnlyneError && waited.error.error.code === 'not_found' ? { kind: 'missing' } : { kind: 'failed' };
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
return { kind: 'answer', value: probeAnswerOf(waited.value) };
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return { kind: 'failed' };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/** A settlement that cannot be written is still answered; the record stays owed to `recover`. */
|
|
399
|
+
async function report(ref, id, outcome, replaceUnknown = false) {
|
|
400
|
+
try {
|
|
401
|
+
return await settle(ref, id, outcome, replaceUnknown);
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
return {
|
|
405
|
+
operation_id: id,
|
|
406
|
+
state: outcome.state === 'committed' ? 'committed' : 'unknown',
|
|
407
|
+
...(outcome.data === undefined ? {} : { data: outcome.data }),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Local intent is durable before any external effect; the driver call is the only place the effect can happen. When
|
|
413
|
+
* the caller stops waiting the answer is `unknown` and the external call keeps running: a later outcome may settle
|
|
414
|
+
* the record, and nothing is ever executed a second time.
|
|
415
|
+
*/
|
|
416
|
+
async function execute(resource, request, signal) {
|
|
417
|
+
const ref = resource.resource_ref;
|
|
418
|
+
const id = request.operation_id;
|
|
419
|
+
if (drivers === undefined)
|
|
420
|
+
return report(ref, id, { state: 'unknown', error: unknownOutcome });
|
|
421
|
+
let call;
|
|
422
|
+
try {
|
|
423
|
+
call = drivers.execute(resource, request, signal);
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
return report(ref, id, { state: 'unknown', error: unknownOutcome });
|
|
427
|
+
}
|
|
428
|
+
const waited = await waitFor(call, ctx.options.wait_ms ?? DRIVER_WAIT_MS);
|
|
429
|
+
if (waited.kind === 'timeout') {
|
|
430
|
+
void call.then(value => report(ref, id, value, true), () => report(ref, id, { state: 'unknown', error: unknownOutcome }, true)).catch(() => undefined);
|
|
431
|
+
return report(ref, id, { state: 'unknown', error: waitOutcome });
|
|
432
|
+
}
|
|
433
|
+
if (waited.kind === 'failed')
|
|
434
|
+
return report(ref, id, { state: 'unknown', error: unknownOutcome });
|
|
435
|
+
return report(ref, id, waited.value);
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* RFC 0006 §4: acceptance writes the intent and freezes the cursor before the probe leaves.
|
|
439
|
+
* The probe decides, and its answer settles the operation in the same call; only a probe
|
|
440
|
+
* that outlives the local wait is left to `recover`.
|
|
441
|
+
*/
|
|
442
|
+
async function initiate(auth, ref, resource, request) {
|
|
443
|
+
const data = (request.data ?? {});
|
|
444
|
+
const target = data.target;
|
|
445
|
+
const deadline = data.handover_deadline;
|
|
446
|
+
if (typeof target !== 'string' || target.length === 0)
|
|
447
|
+
fail('invalid_request', 'A handover names its target.');
|
|
448
|
+
if (typeof deadline !== 'string')
|
|
449
|
+
fail('invalid_request', 'A handover carries a handover_deadline.');
|
|
450
|
+
utc(deadline);
|
|
451
|
+
const digest = digestOf(auth.actor, ref, request);
|
|
452
|
+
const created = await ctx.storage.transaction(async (tx) => {
|
|
453
|
+
const existing = await tx.get(handoverKey(ref, request.operation_id));
|
|
454
|
+
if (existing !== undefined) {
|
|
455
|
+
if (existing.digest !== digest)
|
|
456
|
+
fail('id_conflict', 'The operation ID is bound to another request.');
|
|
457
|
+
return { intent: existing, claimed: false };
|
|
458
|
+
}
|
|
459
|
+
ctx.deadline(request.retry_until);
|
|
460
|
+
const fresh = requireValue(await tx.get(resourceKey(ref)));
|
|
461
|
+
if (fresh.revision !== resource.revision || (request.expected_revision !== undefined && request.expected_revision !== fresh.revision)) {
|
|
462
|
+
throw new OnlyneError('revision_conflict', 'The resource binding changed.', { current_revision: fresh.revision });
|
|
463
|
+
}
|
|
464
|
+
if (fresh.binding.handler !== auth.actor)
|
|
465
|
+
fail('forbidden', 'Only the current handler starts a handover.');
|
|
466
|
+
const record = {
|
|
467
|
+
actor: auth.actor, request, digest,
|
|
468
|
+
// There is no `pending` handover (RFC 0006 §4.5): until the probe answers, the caller
|
|
469
|
+
// has no certain outcome, and that is exactly what `unknown` records.
|
|
470
|
+
result: { operation_id: request.operation_id, state: 'unknown', error: probeWaitOutcome },
|
|
471
|
+
resource_ref: ref, accepted_at: new Date(ctx.now()).toISOString(), authorized_revision: fresh.revision,
|
|
472
|
+
};
|
|
473
|
+
const intent = {
|
|
474
|
+
operation_id: request.operation_id, resource_ref: ref, initiator: auth.actor, target,
|
|
475
|
+
kind: fresh.kind, operations: fresh.operations.slice(),
|
|
476
|
+
...(data.metadata === undefined ? {} : { metadata: data.metadata }),
|
|
477
|
+
expected_revision: fresh.revision, deadline, cursor: fresh.binding.cursor, digest, state: 'open',
|
|
478
|
+
};
|
|
479
|
+
await tx.put(operationKey(ref, request.operation_id), { record, settle: false });
|
|
480
|
+
await tx.put(handoverKey(ref, request.operation_id), intent);
|
|
481
|
+
return { intent, claimed: true };
|
|
482
|
+
});
|
|
483
|
+
if (!created.claimed)
|
|
484
|
+
return requireValue(await intentOf(ref, request.operation_id)).record.result;
|
|
485
|
+
const intent = created.intent;
|
|
486
|
+
if (utc(intent.deadline) <= ctx.now())
|
|
487
|
+
return abortHandover(ref, intent.operation_id, 'deadline_passed');
|
|
488
|
+
const answer = await probeTarget(intent);
|
|
489
|
+
if (answer.kind === 'timeout')
|
|
490
|
+
return requireValue(await intentOf(ref, intent.operation_id)).record.result;
|
|
491
|
+
if (answer.kind === 'failed') {
|
|
492
|
+
const failure = probeFailure(answer.error);
|
|
493
|
+
return abortHandover(ref, intent.operation_id, failure.reason, failure.error);
|
|
494
|
+
}
|
|
495
|
+
if (answer.value.answer === 'reject')
|
|
496
|
+
return abortHandover(ref, intent.operation_id, answer.value.reason);
|
|
497
|
+
return commitHandover(ref, intent.operation_id, answer.value.cursor);
|
|
498
|
+
}
|
|
499
|
+
return {
|
|
500
|
+
/**
|
|
501
|
+
* The endpoint declares the resources it serves, so registration is its own action: the caller is this node and the
|
|
502
|
+
* resource is one this node owns. Repeating a registration returns the stored resource, never a second definition.
|
|
503
|
+
*/
|
|
504
|
+
async register(auth, resource) {
|
|
505
|
+
requireEndpoint(auth);
|
|
506
|
+
if (resource.resource_ref.owner !== ctx.node)
|
|
507
|
+
fail('forbidden', 'This node does not serve the requested owner.');
|
|
508
|
+
const binding = resource.binding;
|
|
509
|
+
if (typeof binding?.handler !== 'string' || binding.handler.length === 0)
|
|
510
|
+
fail('invalid_request', 'A resource registration carries its handler.');
|
|
511
|
+
if (typeof binding.cursor !== 'string')
|
|
512
|
+
fail('invalid_request', 'A resource registration carries its cursor.');
|
|
513
|
+
decimal(binding.cursor);
|
|
514
|
+
if (!Array.isArray(resource.log_readers))
|
|
515
|
+
fail('invalid_request', 'A resource registration carries its log readers.');
|
|
516
|
+
decimal(resource.event_seq);
|
|
517
|
+
if (!Number.isInteger(resource.retention_ms) || resource.retention_ms < 0)
|
|
518
|
+
fail('invalid_request', 'retention_ms must be a non-negative integer.');
|
|
519
|
+
return ctx.storage.transaction(async (tx) => {
|
|
520
|
+
const stored = await tx.get(resourceKey(resource.resource_ref));
|
|
521
|
+
if (stored) {
|
|
522
|
+
if (!same(stored, resource))
|
|
523
|
+
fail('id_conflict', 'The resource is already registered with another definition.');
|
|
524
|
+
return stored;
|
|
525
|
+
}
|
|
526
|
+
await tx.put(resourceKey(resource.resource_ref), resource);
|
|
527
|
+
return resource;
|
|
528
|
+
});
|
|
529
|
+
},
|
|
530
|
+
async list(auth, options) {
|
|
531
|
+
const items = rowsOf(await ctx.storage.transaction(async (tx) => tx.scan(key('resource'))), 'resource')
|
|
532
|
+
.map(entry => entry.value)
|
|
533
|
+
.filter(resource => resource.resource_ref.owner === ctx.node && mayRead(resource, auth.actor));
|
|
534
|
+
items.sort((a, b) => a.resource_ref.id < b.resource_ref.id ? -1 : a.resource_ref.id > b.resource_ref.id ? 1 : 0);
|
|
535
|
+
return page(items, options ?? {}, { kind: 'resources', owner: ctx.node, actor: auth.actor }, item => item.resource_ref.id);
|
|
536
|
+
},
|
|
537
|
+
async describe(auth, ref) {
|
|
538
|
+
ctx.owner(ref.owner);
|
|
539
|
+
const resource = await live(ref);
|
|
540
|
+
if (!mayRead(resource, auth.actor))
|
|
541
|
+
fail('not_found', 'The requested resource is unavailable.');
|
|
542
|
+
return resource;
|
|
543
|
+
},
|
|
544
|
+
async operate(auth, ref, request, signal) {
|
|
545
|
+
ctx.owner(ref.owner);
|
|
546
|
+
const prior = await intentOf(ref, request.operation_id);
|
|
547
|
+
if (prior !== undefined)
|
|
548
|
+
return resume(ref, auth, request, prior.record);
|
|
549
|
+
const resource = await live(ref);
|
|
550
|
+
if (!mayRead(resource, auth.actor))
|
|
551
|
+
fail('not_found', 'The requested resource is unavailable.');
|
|
552
|
+
if (!resource.operations.includes(request.operation))
|
|
553
|
+
fail('unsupported_delivery', 'The resource does not declare this operation.');
|
|
554
|
+
// RFC 0006 §4.1 names the initiator first: a handover belongs to the current handler alone.
|
|
555
|
+
const handover = request.operation === 'handover';
|
|
556
|
+
if (handover && resource.binding.handler !== auth.actor)
|
|
557
|
+
fail('forbidden', 'Only the current handler starts a handover.');
|
|
558
|
+
// RFC 0006 §7: the revision is the send fence, so a former handler learns the binding moved
|
|
559
|
+
// instead of a generic refusal. A handover has already answered its initiator above.
|
|
560
|
+
if (request.expected_revision !== undefined && request.expected_revision !== resource.revision) {
|
|
561
|
+
throw new OnlyneError('revision_conflict', 'The resource binding changed.', { current_revision: resource.revision });
|
|
562
|
+
}
|
|
563
|
+
if (!handover && !mayOperate(resource, auth.actor, request.operation))
|
|
564
|
+
fail('forbidden', 'The authenticated caller may not perform this operation.');
|
|
565
|
+
if (handover)
|
|
566
|
+
return initiate(auth, ref, resource, request);
|
|
567
|
+
if (QUERY[request.operation])
|
|
568
|
+
return query(auth, ref, resource, request);
|
|
569
|
+
if (drivers === undefined)
|
|
570
|
+
fail('unsupported_delivery', 'This node declares no resource driver, so it cannot validate or execute the operation.');
|
|
571
|
+
// Local validation runs before the claim: a request the driver rejects must not hold an operation ID.
|
|
572
|
+
await drivers.validate(resource, request.operation, request.data);
|
|
573
|
+
const claim = await claimOperation(ref, auth, request);
|
|
574
|
+
if (!claim.claimed)
|
|
575
|
+
return resume(ref, auth, request, claim.record);
|
|
576
|
+
return execute(resource, request, signal);
|
|
577
|
+
},
|
|
578
|
+
async operation(auth, ref, id) {
|
|
579
|
+
ctx.owner(ref.owner);
|
|
580
|
+
const resource = await live(ref);
|
|
581
|
+
const intent = requireValue(await intentOf(ref, id));
|
|
582
|
+
if (intent.record.actor !== auth.actor && !mayRead(resource, auth.actor))
|
|
583
|
+
fail('not_found', 'The requested operation is unavailable.');
|
|
584
|
+
return intent.record;
|
|
585
|
+
},
|
|
586
|
+
async operations(auth, ref, options) {
|
|
587
|
+
ctx.owner(ref.owner);
|
|
588
|
+
const resource = await live(ref);
|
|
589
|
+
if (!mayRead(resource, auth.actor))
|
|
590
|
+
fail('not_found', 'The requested resource is unavailable.');
|
|
591
|
+
// An operation record carries its requester's request body, so the list is the caller's own history.
|
|
592
|
+
const records = (await ctx.storage.transaction(async (tx) => tx.scan(operationsPrefix(ref))))
|
|
593
|
+
.map(entry => entry.value.record)
|
|
594
|
+
.filter(record => record.actor === auth.actor);
|
|
595
|
+
records.sort((a, b) => byAnchor(anchor(a), anchor(b)));
|
|
596
|
+
return page(records, options ?? {}, { kind: 'operations', owner: ref.owner, id: ref.id, actor: auth.actor }, anchor);
|
|
597
|
+
},
|
|
598
|
+
async receive(auth, ref, input) {
|
|
599
|
+
ctx.owner(ref.owner);
|
|
600
|
+
requireEndpoint(auth);
|
|
601
|
+
if (!input.source_id)
|
|
602
|
+
fail('invalid_request', 'An inbound event needs its platform identifier.');
|
|
603
|
+
return ctx.storage.transaction(async (tx) => {
|
|
604
|
+
const resource = requireValue(await tx.get(resourceKey(ref)));
|
|
605
|
+
// A platform event ID may repeat only within its own resource, so the source row is the deduplication point.
|
|
606
|
+
const duplicated = await tx.get(sourceKey(ref, input.source_id));
|
|
607
|
+
if (duplicated)
|
|
608
|
+
return requireValue(await tx.get(eventKey(ref, duplicated)));
|
|
609
|
+
// `event_seq` is the last assigned ID, so the next event is one past it and an ID is never reused (0006 §5).
|
|
610
|
+
const eventId = (decimal(resource.event_seq) + 1n).toString();
|
|
611
|
+
const event = {
|
|
612
|
+
resource_ref: ref, event_id: eventId, source_id: input.source_id,
|
|
613
|
+
data: input.data, at: new Date(ctx.now()).toISOString(),
|
|
614
|
+
expires_at: input.expires_at ?? new Date(ctx.now() + resource.retention_ms).toISOString(),
|
|
615
|
+
expired: false,
|
|
616
|
+
};
|
|
617
|
+
utc(event.expires_at);
|
|
618
|
+
await tx.put(resourceKey(ref), { ...resource, event_seq: eventId });
|
|
619
|
+
await tx.put(eventKey(ref, eventId), event);
|
|
620
|
+
await tx.put(sourceKey(ref, input.source_id), eventId);
|
|
621
|
+
return event;
|
|
622
|
+
});
|
|
623
|
+
},
|
|
624
|
+
async log(auth, ref, options) {
|
|
625
|
+
return readLog(auth, ref, options);
|
|
626
|
+
},
|
|
627
|
+
async event(auth, ref, id) {
|
|
628
|
+
return readEvent(auth, ref, id);
|
|
629
|
+
},
|
|
630
|
+
/**
|
|
631
|
+
* RFC 0006 §5: an ack advances the handler's cursor, nothing else. It is idempotent because
|
|
632
|
+
* the cursor only moves forward, and an id that is already behind it leaves the position alone.
|
|
633
|
+
*/
|
|
634
|
+
async ack(auth, ref, id, mutation) {
|
|
635
|
+
ctx.owner(ref.owner);
|
|
636
|
+
return ctx.mutate(auth, `resource_ack:${resourceScope(ref)}`, mutation, { id }, async (tx) => {
|
|
637
|
+
const resource = requireValue(await tx.get(resourceKey(ref)));
|
|
638
|
+
if (resource.binding.handler !== auth.actor)
|
|
639
|
+
fail('forbidden', 'Only the current handler acknowledges events.');
|
|
640
|
+
requireValue(await tx.get(eventKey(ref, id)));
|
|
641
|
+
if (bySequence(id, resource.binding.cursor) <= 0)
|
|
642
|
+
return { resource_ref: ref, cursor: resource.binding.cursor };
|
|
643
|
+
const moved = { ...resource, binding: { ...resource.binding, cursor: id } };
|
|
644
|
+
await tx.put(resourceKey(ref), moved);
|
|
645
|
+
return { resource_ref: ref, cursor: id };
|
|
646
|
+
});
|
|
647
|
+
},
|
|
648
|
+
async probe(auth, probe) {
|
|
649
|
+
if (!auth.actor)
|
|
650
|
+
fail('unauthenticated', 'Authentication is required.');
|
|
651
|
+
if (!probe.operation_id)
|
|
652
|
+
fail('invalid_request', 'A probe carries its operation id.');
|
|
653
|
+
if (typeof probe.resource?.owner !== 'string' || typeof probe.resource.id !== 'string') {
|
|
654
|
+
fail('invalid_request', 'A probe carries the resource reference.');
|
|
655
|
+
}
|
|
656
|
+
decimal(probe.cursor);
|
|
657
|
+
const deadline = utc(probe.deadline);
|
|
658
|
+
const digest = JSON.stringify(probe);
|
|
659
|
+
const existing = await ctx.storage.transaction(async (tx) => tx.get(probeKey(probe.operation_id)));
|
|
660
|
+
if (existing) {
|
|
661
|
+
if (existing.sender !== auth.actor || existing.digest !== digest)
|
|
662
|
+
fail('id_conflict', 'The operation ID is bound to another probe.');
|
|
663
|
+
return existing.answer;
|
|
664
|
+
}
|
|
665
|
+
// The policy runs outside the transaction: a host hook that reads storage would deadlock here.
|
|
666
|
+
const answer = await decideProbe(probe, deadline, auth.actor);
|
|
667
|
+
return ctx.storage.transaction(async (tx) => {
|
|
668
|
+
const raced = await tx.get(probeKey(probe.operation_id));
|
|
669
|
+
if (raced) {
|
|
670
|
+
if (raced.sender !== auth.actor || raced.digest !== digest)
|
|
671
|
+
fail('id_conflict', 'The operation ID is bound to another probe.');
|
|
672
|
+
return raced.answer;
|
|
673
|
+
}
|
|
674
|
+
await tx.put(probeKey(probe.operation_id), { sender: auth.actor, digest, answer });
|
|
675
|
+
return answer;
|
|
676
|
+
});
|
|
677
|
+
},
|
|
678
|
+
async probeAnswer(auth, operationId) {
|
|
679
|
+
const record = requireValue(await ctx.storage.transaction(async (tx) => tx.get(probeKey(operationId))));
|
|
680
|
+
// A probe answer names a resource and its position: only the endpoint that asked may read it.
|
|
681
|
+
if (record.sender !== auth.actor)
|
|
682
|
+
fail('not_found', 'The requested probe is unavailable.');
|
|
683
|
+
return record.answer;
|
|
684
|
+
},
|
|
685
|
+
async recover() {
|
|
686
|
+
// Every record settles only while unresolved, so a repeated pass is harmless; a durable marker would leave a
|
|
687
|
+
// crash between two settlements permanently unrecovered.
|
|
688
|
+
await expireEvents();
|
|
689
|
+
const handovers = rowsOf(await ctx.storage.transaction(async (tx) => tx.scan(key('resource'))), 'handover');
|
|
690
|
+
for (const { row, value } of handovers) {
|
|
691
|
+
if (value.state !== 'open')
|
|
692
|
+
continue;
|
|
693
|
+
const ref = row.ref;
|
|
694
|
+
// RFC 0006 §4.5: a handshake that outlived its deadline never commits.
|
|
695
|
+
if (utc(value.deadline) <= ctx.now()) {
|
|
696
|
+
await abortHandover(ref, value.operation_id, 'deadline_passed');
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
const asked = await recordedAnswer(value.target, value.operation_id, value.deadline);
|
|
700
|
+
if (asked.kind === 'missing') {
|
|
701
|
+
await abortHandover(ref, value.operation_id, 'no_answer');
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
// An unreachable target is not an answer: the intent stays open until the deadline or a later pass.
|
|
705
|
+
if (asked.kind !== 'answer')
|
|
706
|
+
continue;
|
|
707
|
+
if (asked.value.answer === 'accept')
|
|
708
|
+
await commitHandover(ref, value.operation_id, asked.value.cursor);
|
|
709
|
+
else
|
|
710
|
+
await abortHandover(ref, value.operation_id, asked.value.reason);
|
|
711
|
+
}
|
|
712
|
+
const intents = rowsOf(await ctx.storage.transaction(async (tx) => tx.scan(key('resource'))), 'operation');
|
|
713
|
+
for (const { row, value } of intents) {
|
|
714
|
+
if (!value.settle)
|
|
715
|
+
continue;
|
|
716
|
+
const state = value.record.result.state;
|
|
717
|
+
if (state !== 'pending' && state !== 'unknown')
|
|
718
|
+
continue;
|
|
719
|
+
const id = value.record.request.operation_id;
|
|
720
|
+
const resource = await fetchResource(row.ref);
|
|
721
|
+
const queried = resource === undefined ? undefined : await queryOutside(resource, id);
|
|
722
|
+
const late = state === 'unknown';
|
|
723
|
+
if (queried !== undefined && queried.state !== 'pending')
|
|
724
|
+
await settle(row.ref, id, queried, late);
|
|
725
|
+
else if (resource === undefined)
|
|
726
|
+
await settle(row.ref, id, { state: 'unknown', error: unknownOutcome }, late);
|
|
727
|
+
}
|
|
728
|
+
},
|
|
729
|
+
};
|
|
730
|
+
/**
|
|
731
|
+
* The target's own policy decides, and its answer is final for this operation id. A probe that
|
|
732
|
+
* arrives past its deadline is refused rather than accepted late (RFC 0006 §4.3).
|
|
733
|
+
*/
|
|
734
|
+
async function decideProbe(probe, deadline, sender) {
|
|
735
|
+
if (deadline <= ctx.now())
|
|
736
|
+
return { answer: 'reject', reason: 'deadline_passed' };
|
|
737
|
+
const policy = ctx.options.plugins.handoverProbe;
|
|
738
|
+
if (policy === undefined)
|
|
739
|
+
return { answer: 'accept', cursor: probe.cursor };
|
|
740
|
+
const answer = await policy(probe, sender);
|
|
741
|
+
if (answer.answer === 'accept') {
|
|
742
|
+
decimal(answer.cursor);
|
|
743
|
+
return { answer: 'accept', cursor: answer.cursor };
|
|
744
|
+
}
|
|
745
|
+
if (answer.answer === 'reject' && typeof answer.reason === 'string' && answer.reason.length > 0) {
|
|
746
|
+
return { answer: 'reject', reason: answer.reason };
|
|
747
|
+
}
|
|
748
|
+
fail('internal', 'The handover policy returned an answer the protocol does not define.');
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* A retried operation never executes twice: the driver is asked for the admitted one's outcome, and silence keeps the
|
|
752
|
+
* recorded state instead of manufacturing a second effect or overwriting a live claim.
|
|
753
|
+
*/
|
|
754
|
+
async function resume(ref, auth, request, record) {
|
|
755
|
+
if (record.digest !== digestOf(auth.actor, ref, request))
|
|
756
|
+
fail('id_conflict', 'The operation ID is bound to another request.');
|
|
757
|
+
if (record.result.state !== 'pending')
|
|
758
|
+
return record.result;
|
|
759
|
+
const resource = await live(ref);
|
|
760
|
+
const queried = await queryOutside(resource, request.operation_id);
|
|
761
|
+
if (queried !== undefined && queried.state !== 'pending')
|
|
762
|
+
return settle(ref, request.operation_id, queried);
|
|
763
|
+
return (await intentOf(ref, request.operation_id))?.record.result ?? record.result;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const unknownOutcome = {
|
|
767
|
+
code: 'unavailable', message: 'The operation outcome is unknown; the resource driver did not report one.', retryable: true,
|
|
768
|
+
};
|
|
769
|
+
const waitOutcome = {
|
|
770
|
+
code: 'unavailable', message: 'The local wait for the resource driver expired; the external outcome is unknown.', retryable: true,
|
|
771
|
+
};
|
|
772
|
+
const probeWaitOutcome = {
|
|
773
|
+
code: 'unavailable', message: 'The handover probe has not answered within the local wait; query the operation by id.', retryable: true,
|
|
774
|
+
};
|
|
775
|
+
/** Bounded local wait: it answers, it never cancels the promise it stopped waiting for. */
|
|
776
|
+
function waitFor(work, ms) {
|
|
777
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
778
|
+
const timer = setTimeout(() => resolve({ kind: 'timeout' }), ms);
|
|
779
|
+
work.then(value => { clearTimeout(timer); resolve({ kind: 'value', value }); }, error => { clearTimeout(timer); resolve({ kind: 'failed', error }); });
|
|
780
|
+
return promise;
|
|
781
|
+
}
|
|
782
|
+
//# sourceMappingURL=resources.js.map
|