@xyo-network/event-kit-node 1.0.1 → 2.1.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/dist/node/handshake.mjs +12 -0
- package/dist/node/handshake.mjs.map +2 -2
- package/dist/node/handshakeClients.d.ts +5 -1
- package/dist/node/handshakeClients.d.ts.map +1 -1
- package/dist/node/handshakeMutationIntent.d.ts +128 -66
- package/dist/node/handshakeMutationIntent.d.ts.map +1 -1
- package/dist/node/index.mjs +277 -93
- package/dist/node/index.mjs.map +4 -4
- package/dist/node/runtime/handshakeDiscovery.d.ts +4 -0
- package/dist/node/runtime/handshakeDiscovery.d.ts.map +1 -1
- package/dist/node/runtime/publisherDeliveryState.d.ts +107 -0
- package/dist/node/runtime/publisherDeliveryState.d.ts.map +1 -0
- package/dist/node/runtime/publisherRuntime.d.ts +17 -1
- package/dist/node/runtime/publisherRuntime.d.ts.map +1 -1
- package/dist/node/runtime/sourceSigner.d.ts +1 -0
- package/dist/node/runtime/sourceSigner.d.ts.map +1 -1
- package/dist/node/runtime/sqliteHandshakeStores.d.ts +3 -1
- package/dist/node/runtime/sqliteHandshakeStores.d.ts.map +1 -1
- package/dist/node/runtime.mjs +265 -93
- package/dist/node/runtime.mjs.map +4 -4
- package/package.json +8 -8
package/dist/node/runtime.mjs
CHANGED
|
@@ -141,6 +141,7 @@ function createBoundedPublisherEthersTransport(options = {}) {
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
// src/runtime/handshakeDiscovery.ts
|
|
144
|
+
import { isDeepStrictEqual } from "node:util";
|
|
144
145
|
import { decryptWakeSubscription, sourceWakeFilterHash } from "@xyo-network/event-kit-protocol";
|
|
145
146
|
import { EndpointChallengeResponseZod } from "@xyo-network/event-kit-schemas";
|
|
146
147
|
function createPublisherHandshakeDiscovery(options) {
|
|
@@ -156,6 +157,9 @@ function createPublisherHandshakeDiscovery(options) {
|
|
|
156
157
|
rejectedSubscriptions: 0,
|
|
157
158
|
challengeFailures: 0
|
|
158
159
|
};
|
|
160
|
+
function selectedTuple(tuple) {
|
|
161
|
+
return (options.serviceHash === void 0 || tuple.service.objectHash === options.serviceHash) && (options.deliveryMode === void 0 || tuple.service.body.deliveryMode === options.deliveryMode) && (options.deliveryMode === void 0 || tuple.subscription.body.deliveryMode === options.deliveryMode) && (options.deliveryMode === void 0 || isDeepStrictEqual(tuple.service.body.bestEffort, options.bestEffort));
|
|
162
|
+
}
|
|
159
163
|
async function activeView(signal) {
|
|
160
164
|
signal.throwIfAborted();
|
|
161
165
|
viewing ??= (async () => {
|
|
@@ -173,7 +177,7 @@ function createPublisherHandshakeDiscovery(options) {
|
|
|
173
177
|
...status,
|
|
174
178
|
finalizedHead: view.finalizedHead,
|
|
175
179
|
indexedThrough: view.indexedThrough,
|
|
176
|
-
pendingSubscriptions: view.subscriptions.filter((item) => item.state === "pending").length,
|
|
180
|
+
pendingSubscriptions: view.subscriptions.filter((item) => item.state === "pending" && item.tuples.some(selectedTuple)).length,
|
|
177
181
|
rejectedSubscriptions: view.rejections.length
|
|
178
182
|
};
|
|
179
183
|
return view;
|
|
@@ -238,7 +242,7 @@ function createPublisherHandshakeDiscovery(options) {
|
|
|
238
242
|
if (!found.ok)
|
|
239
243
|
throw new Error("Publisher acceptance candidates are unavailable");
|
|
240
244
|
const offset = options.state.get("challenge-rate", "offset") ?? 0;
|
|
241
|
-
const candidates = found.candidates;
|
|
245
|
+
const candidates = found.candidates.filter((candidate) => selectedTuple(candidate.tuple));
|
|
242
246
|
let attempted = 0;
|
|
243
247
|
for (let index = 0; index < Math.min(candidates.length, 128) && attempted < 4; index += 1) {
|
|
244
248
|
const candidate = candidates[(offset + index) % candidates.length];
|
|
@@ -254,30 +258,40 @@ function createPublisherHandshakeDiscovery(options) {
|
|
|
254
258
|
await acceptCandidate(candidate.subscriber, candidate.tuple.subscription.objectHash, signal);
|
|
255
259
|
}
|
|
256
260
|
}
|
|
261
|
+
async function loadScopedKeys(view, active) {
|
|
262
|
+
const scopedKeys = active.flatMap((item) => item.tuples.filter((tuple) => tuple.subscription.objectHash === item.selectedSubscription).map((tuple) => ({
|
|
263
|
+
encryptionKey: tuple.subscription.body.encryptionKey,
|
|
264
|
+
keyId: tuple.subscription.body.keyId,
|
|
265
|
+
publisher: options.publisher
|
|
266
|
+
})));
|
|
267
|
+
if (view.hasCurrentAdvertisement && (options.serviceHash === void 0 || view.service.objectHash === options.serviceHash) && (options.deliveryMode === void 0 || view.service.body.deliveryMode === options.deliveryMode)) {
|
|
268
|
+
scopedKeys.push({
|
|
269
|
+
encryptionKey: view.key.objectHash,
|
|
270
|
+
keyId: view.key.body.keyId,
|
|
271
|
+
publisher: options.publisher
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
const loadedKeys = await Promise.all(scopedKeys.map(async (key) => await options.keys.getPrivateKey(key)));
|
|
275
|
+
status = {
|
|
276
|
+
...status,
|
|
277
|
+
keyLoaded: scopedKeys.length > 0 && loadedKeys.every((key) => key !== void 0),
|
|
278
|
+
activeKeyIds: [...new Set(scopedKeys.map((key) => key.keyId))]
|
|
279
|
+
};
|
|
280
|
+
}
|
|
257
281
|
return {
|
|
258
282
|
async discover(signal) {
|
|
259
283
|
const view = await activeView(signal);
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
encryptionKey: key.objectHash,
|
|
263
|
-
keyId: key.body.keyId,
|
|
264
|
-
publisher: options.publisher
|
|
265
|
-
});
|
|
266
|
-
status = {
|
|
267
|
-
...status,
|
|
268
|
-
keyLoaded: loaded !== void 0,
|
|
269
|
-
activeKeyIds: key === void 0 ? [] : [key.body.keyId]
|
|
270
|
-
};
|
|
271
|
-
const subscriptions = [];
|
|
272
|
-
const active = view.subscriptions.filter((item) => item.state === "active");
|
|
284
|
+
const active = view.subscriptions.filter((item) => item.state === "active" && item.tuples.some((tuple) => tuple.subscription.objectHash === item.selectedSubscription && selectedTuple(tuple)));
|
|
285
|
+
await loadScopedKeys(view, active);
|
|
273
286
|
if (active.length > (options.maxSubscriptions ?? 64))
|
|
274
287
|
throw new Error("Publisher active subscription limit exceeded");
|
|
288
|
+
const subscriptions = [];
|
|
275
289
|
for (const lifecycle of active) {
|
|
276
290
|
if (lifecycle.state !== "active" || lifecycle.activeFromBlock === void 0)
|
|
277
291
|
continue;
|
|
278
292
|
try {
|
|
279
293
|
const tuple = lifecycle.tuples.find((item) => item.subscription.objectHash === lifecycle.selectedSubscription);
|
|
280
|
-
if (tuple === void 0)
|
|
294
|
+
if (tuple === void 0 || !selectedTuple(tuple))
|
|
281
295
|
throw new Error("Active publisher subscription has no selected tuple");
|
|
282
296
|
const encrypted = tuple.subscription.body;
|
|
283
297
|
if (encrypted.chainId !== options.authorizationChainId)
|
|
@@ -297,6 +311,9 @@ function createPublisherHandshakeDiscovery(options) {
|
|
|
297
311
|
options.transport.assertAllowed(plaintext.endpoint);
|
|
298
312
|
subscriptions.push({
|
|
299
313
|
subscriptionHash: tuple.subscription.objectHash,
|
|
314
|
+
serviceHash: tuple.service.objectHash,
|
|
315
|
+
deliveryMode: plaintext.filter.deliveryMode,
|
|
316
|
+
...plaintext.filter.bestEffort === void 0 ? {} : { bestEffort: plaintext.filter.bestEffort },
|
|
300
317
|
authorizationChainId: options.authorizationChainId,
|
|
301
318
|
controller: tuple.subscriber,
|
|
302
319
|
audience: plaintext.audience,
|
|
@@ -316,7 +333,7 @@ function createPublisherHandshakeDiscovery(options) {
|
|
|
316
333
|
},
|
|
317
334
|
async isActive(subscription, signal) {
|
|
318
335
|
const view = await activeView(signal);
|
|
319
|
-
return view.subscriptions.some((item) => item.state === "active" && item.selectedSubscription === subscription.subscriptionHash && item.tuples.some((tuple) => tuple.subscription.objectHash === subscription.subscriptionHash && tuple.subscriber === subscription.controller));
|
|
336
|
+
return view.subscriptions.some((item) => item.state === "active" && item.selectedSubscription === subscription.subscriptionHash && item.tuples.some((tuple) => tuple.subscription.objectHash === subscription.subscriptionHash && tuple.subscriber === subscription.controller && selectedTuple(tuple)));
|
|
320
337
|
},
|
|
321
338
|
async acceptPending(signal) {
|
|
322
339
|
if (accepting === void 0) {
|
|
@@ -642,18 +659,128 @@ function createPublisherHttpsTransport(input) {
|
|
|
642
659
|
}
|
|
643
660
|
|
|
644
661
|
// src/runtime/publisherRuntime.ts
|
|
645
|
-
import {
|
|
646
|
-
import { isDeepStrictEqual } from "node:util";
|
|
662
|
+
import { isDeepStrictEqual as isDeepStrictEqual3 } from "node:util";
|
|
647
663
|
import { createMemoryLocator, PeriodicActor } from "@ariestools/actor";
|
|
648
664
|
import { creatable } from "@ariestools/sdk";
|
|
649
|
-
import {
|
|
665
|
+
import { BestEffortAdmissionAcknowledgmentZod } from "@xyo-network/event-kit-protocol";
|
|
666
|
+
import { BestEffortDeliveryPolicyZod, WakeDeliveryModeZod } from "@xyo-network/event-kit-schemas";
|
|
650
667
|
import { z as z2 } from "zod";
|
|
651
668
|
|
|
652
|
-
// src/runtime/
|
|
669
|
+
// src/runtime/publisherDeliveryState.ts
|
|
653
670
|
import { randomBytes } from "node:crypto";
|
|
671
|
+
import { isDeepStrictEqual as isDeepStrictEqual2 } from "node:util";
|
|
672
|
+
import { WakeEnvelopeSchema, WakeEnvelopeZod } from "@xyo-network/event-kit-schemas";
|
|
673
|
+
function assertDeliveryState(record, subscription) {
|
|
674
|
+
if (!isDeepStrictEqual2(record.subscription, subscription))
|
|
675
|
+
throw new Error("Subscription revision changed without a new identity");
|
|
676
|
+
if (!Number.isSafeInteger(record.cursor) || record.cursor < subscription.fromPosition - 1 || [record.delivered, record.failures, record.nextAttemptMs].some((value) => !(Number.isSafeInteger(value) && value >= 0)) || [record.attempts, record.superseded, record.abandoned, record.totalAttempts, record.lastHintAt, record.lastNotifiedAt, record.lastHintTarget].some((value) => value !== void 0 && !(Number.isSafeInteger(value) && value >= 0)) || !["active", "blocked", "cancelled"].includes(record.state))
|
|
677
|
+
throw new Error("Persisted publisher delivery state is invalid");
|
|
678
|
+
if (record.pending === void 0) return;
|
|
679
|
+
const pending = WakeEnvelopeZod.parse(record.pending);
|
|
680
|
+
if (subscription.deliveryMode === "durable" && pending.fromPosition !== record.cursor + 1 || pending.deliveryMode !== subscription.deliveryMode || pending.toPosition - pending.fromPosition + 1 > subscription.maxPositionsPerWake || pending.authorizationChainId !== subscription.authorizationChainId || pending.deploymentId !== subscription.deploymentId || pending.queueId !== subscription.queueId || pending.filterHash !== subscription.filterHash || !isDeepStrictEqual2(pending.source, subscription.source))
|
|
681
|
+
throw new Error("Persisted publisher outbox binding is invalid");
|
|
682
|
+
}
|
|
683
|
+
function makeEvent(subscription, fromPosition, toPosition, now, wakeId = randomBytes(16).toString("hex")) {
|
|
684
|
+
return WakeEnvelopeZod.parse({
|
|
685
|
+
schema: WakeEnvelopeSchema,
|
|
686
|
+
authorizationChainId: subscription.authorizationChainId,
|
|
687
|
+
deliveryMode: subscription.deliveryMode,
|
|
688
|
+
deploymentId: subscription.deploymentId,
|
|
689
|
+
filterHash: subscription.filterHash,
|
|
690
|
+
fromPosition,
|
|
691
|
+
issuedAt: now,
|
|
692
|
+
source: subscription.source,
|
|
693
|
+
toPosition,
|
|
694
|
+
wakeId,
|
|
695
|
+
...subscription.queueId === void 0 ? {} : { queueId: subscription.queueId }
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
var DeliveryResponseError = class extends Error {
|
|
699
|
+
retryAfterMs;
|
|
700
|
+
status;
|
|
701
|
+
constructor(status, retryAfterMs) {
|
|
702
|
+
super("Publisher admission failed");
|
|
703
|
+
this.status = status;
|
|
704
|
+
this.retryAfterMs = retryAfterMs;
|
|
705
|
+
}
|
|
706
|
+
get retryable() {
|
|
707
|
+
return this.status === 408 || this.status === 429 || this.status >= 500;
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
function prepareBestEffortDelivery(options) {
|
|
711
|
+
const {
|
|
712
|
+
bestEffort,
|
|
713
|
+
first,
|
|
714
|
+
heads,
|
|
715
|
+
now,
|
|
716
|
+
record,
|
|
717
|
+
state
|
|
718
|
+
} = options;
|
|
719
|
+
const subscription = record.subscription;
|
|
720
|
+
const head = heads.get(publisherSourceKey(subscription.source));
|
|
721
|
+
const prior = record.pending;
|
|
722
|
+
const expired = isHintExpired(record, now, bestEffort);
|
|
723
|
+
if (head === void 0 || head instanceof Error || head.position < subscription.fromPosition) {
|
|
724
|
+
if (prior !== void 0 && !expired) return { ...record, pending: prior };
|
|
725
|
+
if (prior !== void 0) state.set("best-effort-deliveries", subscription.subscriptionHash, {
|
|
726
|
+
...record,
|
|
727
|
+
pending: void 0,
|
|
728
|
+
abandoned: (record.abandoned ?? 0) + 1
|
|
729
|
+
});
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
const refresh = refreshDue(record, now, bestEffort);
|
|
733
|
+
if (freshHintRequired(record, head.position, first, refresh, expired)) {
|
|
734
|
+
const pending = makeEvent(subscription, head.position, head.position, now);
|
|
735
|
+
const prepared = {
|
|
736
|
+
...record,
|
|
737
|
+
pending,
|
|
738
|
+
attempts: 0,
|
|
739
|
+
lastHintAt: now,
|
|
740
|
+
lastHintTarget: head.position,
|
|
741
|
+
superseded: (record.superseded ?? 0) + (prior !== void 0 && !expired ? 1 : 0),
|
|
742
|
+
abandoned: (record.abandoned ?? 0) + (expired ? 1 : 0),
|
|
743
|
+
nextAttemptMs: 0,
|
|
744
|
+
state: "active"
|
|
745
|
+
};
|
|
746
|
+
state.set("best-effort-deliveries", subscription.subscriptionHash, prepared);
|
|
747
|
+
return prepared;
|
|
748
|
+
}
|
|
749
|
+
if (expired) state.set("best-effort-deliveries", subscription.subscriptionHash, {
|
|
750
|
+
...record,
|
|
751
|
+
pending: void 0,
|
|
752
|
+
abandoned: (record.abandoned ?? 0) + 1
|
|
753
|
+
});
|
|
754
|
+
return prior === void 0 || expired ? void 0 : { ...record, pending: prior };
|
|
755
|
+
}
|
|
756
|
+
function isHintExpired(record, now, policy) {
|
|
757
|
+
return record.pending !== void 0 && (now > record.pending.issuedAt + policy.maxHintAgeSeconds || (record.attempts ?? 0) >= policy.maxAttempts);
|
|
758
|
+
}
|
|
759
|
+
function refreshDue(record, now, policy) {
|
|
760
|
+
return now - (record.lastHintAt ?? record.lastNotifiedAt ?? 0) >= policy.refreshIntervalSeconds;
|
|
761
|
+
}
|
|
762
|
+
function freshHintRequired(record, head, first, refresh, expired) {
|
|
763
|
+
return first || head > (record.pending?.toPosition ?? record.lastHintTarget ?? record.cursor) || refresh && (record.pending === void 0 || expired);
|
|
764
|
+
}
|
|
765
|
+
function failedDelivery(record, current, mode, error, now) {
|
|
766
|
+
const bestEffort = mode === "best-effort";
|
|
767
|
+
const wait = error instanceof DeliveryResponseError && error.retryAfterMs !== void 0 ? error.retryAfterMs : Math.min(3e5, 1e3 * 2 ** Math.min(record.failures, 8));
|
|
768
|
+
return {
|
|
769
|
+
...record,
|
|
770
|
+
failures: record.failures + 1,
|
|
771
|
+
totalAttempts: bestEffort ? current?.totalAttempts ?? record.totalAttempts ?? 0 : record.totalAttempts,
|
|
772
|
+
attempts: bestEffort ? current?.attempts ?? record.attempts ?? 0 : record.attempts,
|
|
773
|
+
state: !bestEffort && error instanceof DeliveryResponseError && !error.retryable ? "blocked" : "active",
|
|
774
|
+
nextAttemptMs: now * 1e3 + Math.min(36e5, wait),
|
|
775
|
+
lastError: "delivery-failed"
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// src/runtime/sourceSigner.ts
|
|
780
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
654
781
|
import {
|
|
655
782
|
NormalizedAddressZod,
|
|
656
|
-
WakeEnvelopeZod,
|
|
783
|
+
WakeEnvelopeZod as WakeEnvelopeZod2,
|
|
657
784
|
WakeJwtSchema
|
|
658
785
|
} from "@xyo-network/event-kit-schemas";
|
|
659
786
|
import { PayloadBuilder as PayloadBuilder2 } from "@xyo-network/sdk";
|
|
@@ -663,14 +790,14 @@ function createAccountSourceWakeSigner(options) {
|
|
|
663
790
|
throw new Error("Publisher signing account mismatch");
|
|
664
791
|
return {
|
|
665
792
|
async sign(input, audience) {
|
|
666
|
-
const event =
|
|
793
|
+
const event = WakeEnvelopeZod2.parse(input);
|
|
667
794
|
const eventHash = await PayloadBuilder2.dataHash(event);
|
|
668
795
|
const now = options.nowSeconds?.() ?? Math.floor(Date.now() / 1e3);
|
|
669
796
|
if (!Number.isSafeInteger(now) || now < 0)
|
|
670
797
|
throw new Error("Publisher signing clock is invalid");
|
|
671
798
|
const { token: jwt } = await options.account.signJwt({
|
|
672
799
|
audience,
|
|
673
|
-
claims: { eventHash, jti:
|
|
800
|
+
claims: { eventHash, jti: randomBytes2(16).toString("hex") },
|
|
674
801
|
now,
|
|
675
802
|
schema: WakeJwtSchema,
|
|
676
803
|
ttl: 60
|
|
@@ -689,30 +816,6 @@ function createAccountSourceWakeSigner(options) {
|
|
|
689
816
|
function activationReady(status) {
|
|
690
817
|
return (status?.keyLoaded ?? true) && (status?.pendingSubscriptions ?? 0) === 0;
|
|
691
818
|
}
|
|
692
|
-
function assertDeliveryState(record, subscription) {
|
|
693
|
-
if (!isDeepStrictEqual(record.subscription, subscription))
|
|
694
|
-
throw new Error("Subscription revision changed without a new identity");
|
|
695
|
-
if (!Number.isSafeInteger(record.cursor) || record.cursor < subscription.fromPosition - 1 || [record.delivered, record.failures, record.nextAttemptMs].some((value) => !(Number.isSafeInteger(value) && value >= 0)) || !["active", "blocked", "cancelled"].includes(record.state))
|
|
696
|
-
throw new Error("Persisted publisher delivery state is invalid");
|
|
697
|
-
if (record.pending === void 0) return;
|
|
698
|
-
const pending = WakeEnvelopeZod2.parse(record.pending);
|
|
699
|
-
if (pending.fromPosition !== record.cursor + 1 || pending.toPosition - pending.fromPosition + 1 > subscription.maxPositionsPerWake || pending.authorizationChainId !== subscription.authorizationChainId || pending.deploymentId !== subscription.deploymentId || pending.queueId !== subscription.queueId || pending.filterHash !== subscription.filterHash || !isDeepStrictEqual(pending.source, subscription.source))
|
|
700
|
-
throw new Error("Persisted publisher outbox binding is invalid");
|
|
701
|
-
}
|
|
702
|
-
function makeEvent(subscription, fromPosition, toPosition, now, wakeId = randomBytes2(16).toString("hex")) {
|
|
703
|
-
return WakeEnvelopeZod2.parse({
|
|
704
|
-
schema: WakeEnvelopeSchema,
|
|
705
|
-
authorizationChainId: subscription.authorizationChainId,
|
|
706
|
-
deploymentId: subscription.deploymentId,
|
|
707
|
-
filterHash: subscription.filterHash,
|
|
708
|
-
fromPosition,
|
|
709
|
-
issuedAt: now,
|
|
710
|
-
source: subscription.source,
|
|
711
|
-
toPosition,
|
|
712
|
-
wakeId,
|
|
713
|
-
...subscription.queueId === void 0 ? {} : { queueId: subscription.queueId }
|
|
714
|
-
});
|
|
715
|
-
}
|
|
716
819
|
var PublisherRuntimeActor = class extends PeriodicActor {
|
|
717
820
|
async runPass(context) {
|
|
718
821
|
await this.params.tick(context.signal);
|
|
@@ -727,20 +830,24 @@ var TimingZod = z2.object({
|
|
|
727
830
|
maxSubscriptions: z2.number().int().min(1).max(1024).default(64),
|
|
728
831
|
requestTimeoutMs: z2.number().int().min(100).max(6e4).default(1e4)
|
|
729
832
|
});
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
};
|
|
833
|
+
function runtimeSettings(options) {
|
|
834
|
+
const deliveryMode = WakeDeliveryModeZod.parse(options.deliveryMode);
|
|
835
|
+
const bestEffort = options.bestEffort === void 0 ? void 0 : BestEffortDeliveryPolicyZod.parse(options.bestEffort);
|
|
836
|
+
if (deliveryMode === "best-effort" !== (bestEffort !== void 0)) throw new TypeError("Explicit delivery mode requires its exact policy");
|
|
837
|
+
return {
|
|
838
|
+
bestEffort,
|
|
839
|
+
deliveryMode,
|
|
840
|
+
deliveryNamespace: deliveryMode === "best-effort" ? "best-effort-deliveries" : "deliveries",
|
|
841
|
+
timing: TimingZod.parse(options)
|
|
842
|
+
};
|
|
843
|
+
}
|
|
742
844
|
async function createPersistentEventPublisherRuntime(options) {
|
|
743
|
-
const
|
|
845
|
+
const {
|
|
846
|
+
bestEffort,
|
|
847
|
+
deliveryMode,
|
|
848
|
+
deliveryNamespace,
|
|
849
|
+
timing
|
|
850
|
+
} = runtimeSettings(options);
|
|
744
851
|
if (options.sources.length < 1 || options.sources.length > 16)
|
|
745
852
|
throw new Error("Publisher requires between one and sixteen bounded sources");
|
|
746
853
|
const clock = options.nowSeconds ?? (() => Math.floor(Date.now() / 1e3));
|
|
@@ -752,7 +859,12 @@ async function createPersistentEventPublisherRuntime(options) {
|
|
|
752
859
|
const sourceReaders = new Map(options.sources.map((reader) => [publisherSourceKey(reader.source), reader]));
|
|
753
860
|
if (sourceReaders.size !== options.sources.length)
|
|
754
861
|
throw new Error("Duplicate publisher source identity");
|
|
862
|
+
const state = options.state;
|
|
863
|
+
const existingMode = state.get("metadata", "runtime-delivery-mode");
|
|
864
|
+
if (existingMode !== void 0 && existingMode !== deliveryMode) throw new Error("Publisher state delivery mode mismatch; preserve custody and use an explicitly qualified state binding");
|
|
865
|
+
state.set("metadata", "runtime-delivery-mode", deliveryMode);
|
|
755
866
|
const abort = new AbortController();
|
|
867
|
+
const seenThisProcess = /* @__PURE__ */ new Set();
|
|
756
868
|
let running = false;
|
|
757
869
|
let ready = false;
|
|
758
870
|
let clockHealthy = true;
|
|
@@ -760,11 +872,16 @@ async function createPersistentEventPublisherRuntime(options) {
|
|
|
760
872
|
let lastError;
|
|
761
873
|
let sourcesHealthy = false;
|
|
762
874
|
let stop;
|
|
763
|
-
const state = options.state;
|
|
764
875
|
const timed = (promise, signal) => boundedPublisherOperation(promise, signal, timing.requestTimeoutMs);
|
|
765
876
|
function loadDelivery(subscription, now) {
|
|
877
|
+
if (subscription.deliveryMode !== deliveryMode || !isDeepStrictEqual3(subscription.bestEffort, bestEffort))
|
|
878
|
+
throw new Error("Subscription delivery mode or policy disagrees with runtime");
|
|
879
|
+
if (deliveryMode === "best-effort" && (subscription.maxPositionsPerWake !== 1 || subscription.serviceHash === void 0)) throw new Error("Best Effort requires point range");
|
|
880
|
+
if (options.serviceHash !== void 0 && subscription.serviceHash !== options.serviceHash)
|
|
881
|
+
throw new Error("Subscription does not match the configured exact service descriptor");
|
|
766
882
|
const key = subscription.subscriptionHash;
|
|
767
|
-
|
|
883
|
+
if (deliveryMode === "best-effort" && state.get(deliveryNamespace, key) === void 0 && state.entries(deliveryNamespace).length >= timing.maxSubscriptions) throw new Error("Best Effort state capacity exceeded; live records are not evicted");
|
|
884
|
+
let record = state.get(deliveryNamespace, key);
|
|
768
885
|
if (record === void 0) {
|
|
769
886
|
makeEvent(subscription, subscription.fromPosition, subscription.fromPosition, now, "0".repeat(32));
|
|
770
887
|
if (!Number.isSafeInteger(subscription.maxPositionsPerWake) || subscription.maxPositionsPerWake < 1 || subscription.maxPositionsPerWake > 4096) {
|
|
@@ -778,12 +895,26 @@ async function createPersistentEventPublisherRuntime(options) {
|
|
|
778
895
|
nextAttemptMs: 0,
|
|
779
896
|
state: "active"
|
|
780
897
|
};
|
|
781
|
-
state.set(
|
|
898
|
+
state.set(deliveryNamespace, key, record);
|
|
782
899
|
}
|
|
783
900
|
assertDeliveryState(record, subscription);
|
|
784
901
|
return record;
|
|
785
902
|
}
|
|
786
903
|
function prepareDelivery(record, now, heads) {
|
|
904
|
+
if (deliveryMode === "best-effort" && bestEffort !== void 0) {
|
|
905
|
+
const first = !seenThisProcess.has(record.subscription.subscriptionHash);
|
|
906
|
+
const head2 = heads.get(publisherSourceKey(record.subscription.source));
|
|
907
|
+
if (head2 !== void 0 && !(head2 instanceof Error) && head2.position >= record.subscription.fromPosition)
|
|
908
|
+
seenThisProcess.add(record.subscription.subscriptionHash);
|
|
909
|
+
return prepareBestEffortDelivery({
|
|
910
|
+
bestEffort,
|
|
911
|
+
first,
|
|
912
|
+
heads,
|
|
913
|
+
now,
|
|
914
|
+
record,
|
|
915
|
+
state
|
|
916
|
+
});
|
|
917
|
+
}
|
|
787
918
|
if (record.pending !== void 0) return { ...record, pending: record.pending };
|
|
788
919
|
const subscription = record.subscription;
|
|
789
920
|
const head = heads.get(publisherSourceKey(subscription.source));
|
|
@@ -795,43 +926,74 @@ async function createPersistentEventPublisherRuntime(options) {
|
|
|
795
926
|
state.transaction(() => {
|
|
796
927
|
const logicalKey = JSON.stringify([subscription.subscriptionHash, pending.fromPosition, pending.toPosition]);
|
|
797
928
|
if (!state.insert("delivery-events", logicalKey, pending)) throw new Error("Publisher logical range already exists outside its contiguous cursor");
|
|
798
|
-
state.set(
|
|
929
|
+
state.set(deliveryNamespace, subscription.subscriptionHash, prepared);
|
|
799
930
|
});
|
|
800
931
|
return prepared;
|
|
801
932
|
}
|
|
802
933
|
async function stillActive(record, signal) {
|
|
803
934
|
if (await timed(options.discovery.isActive(record.subscription, signal), signal)) return true;
|
|
804
|
-
state.set(
|
|
935
|
+
state.set(deliveryNamespace, record.subscription.subscriptionHash, {
|
|
805
936
|
...record,
|
|
806
937
|
pending: void 0,
|
|
807
938
|
state: "cancelled"
|
|
808
939
|
});
|
|
809
940
|
return false;
|
|
810
941
|
}
|
|
942
|
+
function acknowledgeBestEffort(record, body, eventHash, signal, now) {
|
|
943
|
+
const { pending, subscription } = record;
|
|
944
|
+
const acknowledgment = BestEffortAdmissionAcknowledgmentZod.safeParse(body);
|
|
945
|
+
if (!acknowledgment.success || acknowledgment.data.eventHash !== eventHash || acknowledgment.data.wakeId !== pending.wakeId || acknowledgment.data.expiresAt !== pending.issuedAt + (bestEffort?.maxHintAgeSeconds ?? 0) + 60 || acknowledgment.data.acceptedAt < pending.issuedAt - 60 || acknowledgment.data.acceptedAt > now + 60)
|
|
946
|
+
throw new Error("Publisher did not receive a matching finite acknowledgment");
|
|
947
|
+
signal.throwIfAborted();
|
|
948
|
+
state.set(deliveryNamespace, subscription.subscriptionHash, {
|
|
949
|
+
subscription,
|
|
950
|
+
cursor: pending.toPosition,
|
|
951
|
+
delivered: record.delivered + 1,
|
|
952
|
+
failures: 0,
|
|
953
|
+
attempts: 0,
|
|
954
|
+
superseded: record.superseded ?? 0,
|
|
955
|
+
abandoned: record.abandoned ?? 0,
|
|
956
|
+
totalAttempts: (record.totalAttempts ?? 0) + 1,
|
|
957
|
+
lastNotifiedAt: now,
|
|
958
|
+
lastHintAt: pending.issuedAt,
|
|
959
|
+
lastHintTarget: pending.toPosition,
|
|
960
|
+
nextAttemptMs: 0,
|
|
961
|
+
state: "active"
|
|
962
|
+
});
|
|
963
|
+
}
|
|
811
964
|
async function sendDelivery(record, signal, now) {
|
|
812
965
|
const { pending, subscription } = record;
|
|
813
966
|
signal.throwIfAborted();
|
|
814
967
|
const signed = await signer.sign(pending, subscription.audience);
|
|
815
968
|
signal.throwIfAborted();
|
|
816
|
-
const attempt = state.get("delivery-attempts", pending.wakeId)?.count ?? 0;
|
|
817
|
-
state.set(
|
|
969
|
+
const attempt = deliveryMode === "best-effort" ? record.attempts ?? 0 : state.get("delivery-attempts", pending.wakeId)?.count ?? 0;
|
|
970
|
+
if (deliveryMode === "best-effort") state.set(deliveryNamespace, subscription.subscriptionHash, {
|
|
971
|
+
...record,
|
|
972
|
+
attempts: attempt + 1,
|
|
973
|
+
totalAttempts: (record.totalAttempts ?? 0) + 1
|
|
974
|
+
});
|
|
975
|
+
else state.set("delivery-attempts", pending.wakeId, {
|
|
818
976
|
count: attempt + 1,
|
|
819
977
|
startedAt: now,
|
|
820
978
|
status: "in-flight"
|
|
821
979
|
});
|
|
822
980
|
const response = await options.transport.post(subscription.endpoint, signed.event, signal, signed.jwt);
|
|
823
|
-
state.set("delivery-attempts", pending.wakeId, {
|
|
981
|
+
if (deliveryMode === "durable") state.set("delivery-attempts", pending.wakeId, {
|
|
824
982
|
count: attempt + 1,
|
|
825
983
|
startedAt: now,
|
|
826
984
|
status: response.status
|
|
827
985
|
});
|
|
828
986
|
if (response.status !== 200 && response.status !== 202) throw new DeliveryResponseError(response.status, response.retryAfterMs);
|
|
987
|
+
if (deliveryMode === "best-effort") {
|
|
988
|
+
acknowledgeBestEffort(record, response.body, signed.eventHash, signal, now);
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
829
991
|
const receipt = z2.object({ receiptId: z2.string().min(1), wakeId: z2.literal(pending.wakeId) }).safeParse(response.body);
|
|
830
992
|
if (!receipt.success) throw new Error("Publisher admission did not return a matching durable receipt");
|
|
831
993
|
signal.throwIfAborted();
|
|
832
994
|
state.transaction(() => {
|
|
833
995
|
state.set("delivery-receipts", pending.wakeId, { receiptId: receipt.data.receiptId, receivedAt: now });
|
|
834
|
-
state.set(
|
|
996
|
+
state.set(deliveryNamespace, subscription.subscriptionHash, {
|
|
835
997
|
subscription,
|
|
836
998
|
cursor: pending.toPosition,
|
|
837
999
|
delivered: record.delivered + 1,
|
|
@@ -843,7 +1005,7 @@ async function createPersistentEventPublisherRuntime(options) {
|
|
|
843
1005
|
}
|
|
844
1006
|
async function deliver(subscription, signal, now, heads) {
|
|
845
1007
|
let record = loadDelivery(subscription, now);
|
|
846
|
-
if (record.state !== "active" || record.nextAttemptMs > now * 1e3) return;
|
|
1008
|
+
if (deliveryMode === "durable" && (record.state !== "active" || record.nextAttemptMs > now * 1e3)) return;
|
|
847
1009
|
try {
|
|
848
1010
|
if (!await stillActive(record, signal)) return;
|
|
849
1011
|
options.transport.assertAllowed(subscription.endpoint);
|
|
@@ -851,17 +1013,16 @@ async function createPersistentEventPublisherRuntime(options) {
|
|
|
851
1013
|
const prepared = prepareDelivery(record, now, heads);
|
|
852
1014
|
if (prepared === void 0) return;
|
|
853
1015
|
record = prepared;
|
|
1016
|
+
if (record.nextAttemptMs > now * 1e3) return;
|
|
854
1017
|
if (!await stillActive(record, signal)) return;
|
|
855
1018
|
await sendDelivery(prepared, signal, now);
|
|
856
1019
|
} catch (error) {
|
|
857
1020
|
if (signal.aborted) return;
|
|
858
|
-
state.set(
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
state
|
|
862
|
-
|
|
863
|
-
lastError: "delivery-failed"
|
|
864
|
-
});
|
|
1021
|
+
state.set(
|
|
1022
|
+
deliveryNamespace,
|
|
1023
|
+
subscription.subscriptionHash,
|
|
1024
|
+
failedDelivery(record, state.get(deliveryNamespace, subscription.subscriptionHash), deliveryMode, error, now)
|
|
1025
|
+
);
|
|
865
1026
|
}
|
|
866
1027
|
}
|
|
867
1028
|
async function readSources(signal) {
|
|
@@ -891,13 +1052,13 @@ async function createPersistentEventPublisherRuntime(options) {
|
|
|
891
1052
|
if (subscriptions.length > timing.maxSubscriptions) throw new Error("Publisher subscription limit exceeded");
|
|
892
1053
|
const active = new Set(subscriptions.map((subscription) => subscription.subscriptionHash));
|
|
893
1054
|
if (active.size !== subscriptions.length) throw new Error("Duplicate subscription identity");
|
|
894
|
-
const unavailable = state.entries(
|
|
1055
|
+
const unavailable = state.entries(deliveryNamespace).filter((item) => !active.has(item.key) && item.value.state !== "cancelled");
|
|
895
1056
|
let available = true;
|
|
896
1057
|
for (let offset = 0; offset < unavailable.length; offset += timing.concurrency) {
|
|
897
1058
|
signal.throwIfAborted();
|
|
898
1059
|
const results = await Promise.allSettled(unavailable.slice(offset, offset + timing.concurrency).map(async ({ key, value }) => {
|
|
899
1060
|
const stillAuthorized = await timed(options.discovery.isActive(value.subscription, signal), signal);
|
|
900
|
-
if (!stillAuthorized) state.set(
|
|
1061
|
+
if (!stillAuthorized) state.set(deliveryNamespace, key, {
|
|
901
1062
|
...value,
|
|
902
1063
|
pending: void 0,
|
|
903
1064
|
state: "cancelled"
|
|
@@ -991,9 +1152,14 @@ async function createPersistentEventPublisherRuntime(options) {
|
|
|
991
1152
|
throw new AggregateError(failures, "Publisher runtime shutdown failed");
|
|
992
1153
|
})(),
|
|
993
1154
|
status() {
|
|
994
|
-
const deliveries = state.entries(
|
|
1155
|
+
const deliveries = state.entries(deliveryNamespace).map((item) => item.value);
|
|
995
1156
|
return {
|
|
996
1157
|
running,
|
|
1158
|
+
deliveryMode,
|
|
1159
|
+
...bestEffort === void 0 ? {} : { bestEffort: Object.freeze({ ...bestEffort }) },
|
|
1160
|
+
superseded: deliveries.reduce((sum, item) => sum + (item.superseded ?? 0), 0),
|
|
1161
|
+
abandoned: deliveries.reduce((sum, item) => sum + (item.abandoned ?? 0), 0),
|
|
1162
|
+
totalAttempts: deliveries.reduce((sum, item) => sum + (item.totalAttempts ?? 0), 0),
|
|
997
1163
|
ready: running && ready,
|
|
998
1164
|
clockHealthy,
|
|
999
1165
|
degraded: !ready || deliveries.some((item) => item.state !== "cancelled" && item.failures > 0),
|
|
@@ -1005,10 +1171,15 @@ async function createPersistentEventPublisherRuntime(options) {
|
|
|
1005
1171
|
passes,
|
|
1006
1172
|
...lastError === void 0 ? {} : { lastError },
|
|
1007
1173
|
...options.discovery.status === void 0 ? {} : { handshake: options.discovery.status() },
|
|
1008
|
-
sources: state.entries("source-heads").map((item) => ({ id: item.key, ...item.value })),
|
|
1009
|
-
subscriptions: state.entries(
|
|
1174
|
+
sources: state.entries("source-heads").filter((item) => sourceReaders.has(item.key)).map((item) => ({ id: item.key, ...item.value })),
|
|
1175
|
+
subscriptions: state.entries(deliveryNamespace).map((item) => ({
|
|
1010
1176
|
id: item.key,
|
|
1011
1177
|
cursor: item.value.cursor,
|
|
1178
|
+
...deliveryMode === "best-effort" ? {
|
|
1179
|
+
...item.value.cursor < item.value.subscription.fromPosition ? {} : { lastAdmissionTarget: item.value.cursor },
|
|
1180
|
+
attempts: item.value.attempts ?? 0,
|
|
1181
|
+
lastHintAt: item.value.lastHintAt
|
|
1182
|
+
} : {},
|
|
1012
1183
|
state: item.value.state,
|
|
1013
1184
|
pending: item.value.pending !== void 0,
|
|
1014
1185
|
failures: item.value.failures
|
|
@@ -1099,7 +1270,7 @@ async function openPublisherDataLake(options) {
|
|
|
1099
1270
|
}
|
|
1100
1271
|
|
|
1101
1272
|
// src/runtime/sqliteHandshakeStores.ts
|
|
1102
|
-
import { isDeepStrictEqual as
|
|
1273
|
+
import { isDeepStrictEqual as isDeepStrictEqual4 } from "node:util";
|
|
1103
1274
|
import { endpointChallengeHash } from "@xyo-network/event-kit-protocol";
|
|
1104
1275
|
import {
|
|
1105
1276
|
Base64Url32BytesZod as Base64Url32BytesZod2,
|
|
@@ -1330,7 +1501,7 @@ function requireTime(value) {
|
|
|
1330
1501
|
if (!Number.isSafeInteger(value) || value < 0) throw new Error("Publisher clock is invalid");
|
|
1331
1502
|
}
|
|
1332
1503
|
function samePublicationIntent(left, right) {
|
|
1333
|
-
return left.chainId === right.chainId && left.source === right.source && left.operationId === right.operationId &&
|
|
1504
|
+
return left.chainId === right.chainId && left.source === right.source && left.operationId === right.operationId && isDeepStrictEqual4(left.eventHashes, right.eventHashes) && isDeepStrictEqual4(left.attachmentHashes, right.attachmentHashes);
|
|
1334
1505
|
}
|
|
1335
1506
|
function publicationKey(intent) {
|
|
1336
1507
|
if (!/^[0-9a-f]{40}$/u.test(intent.chainId) || !/^[0-9a-f]{40}$/u.test(intent.source) || intent.operationId.length === 0 || intent.operationId.length > 256 || [...intent.eventHashes, ...intent.attachmentHashes].some((hash) => !HashZod2.safeParse(hash).success)) {
|
|
@@ -1338,11 +1509,12 @@ function publicationKey(intent) {
|
|
|
1338
1509
|
}
|
|
1339
1510
|
return JSON.stringify([intent.chainId, intent.source, intent.operationId]);
|
|
1340
1511
|
}
|
|
1341
|
-
function createSqlitePublisherHandshakeStores(state) {
|
|
1512
|
+
function createSqlitePublisherHandshakeStores(state, options = {}) {
|
|
1513
|
+
const replayKey = options.replayNamespace === void 0 ? "checkpoint" : `checkpoint:${z4.string().min(1).max(128).regex(/^[A-Za-z0-9_-]+$/u).parse(options.replayNamespace)}`;
|
|
1342
1514
|
const replay = {
|
|
1343
|
-
load: async () => state.get("replay",
|
|
1515
|
+
load: async () => state.get("replay", replayKey),
|
|
1344
1516
|
async save(checkpoint) {
|
|
1345
|
-
state.set("replay",
|
|
1517
|
+
state.set("replay", replayKey, checkpoint);
|
|
1346
1518
|
}
|
|
1347
1519
|
};
|
|
1348
1520
|
const intents = {
|
|
@@ -1380,7 +1552,7 @@ function createSqlitePublisherHandshakeStores(state) {
|
|
|
1380
1552
|
const key = `${record.publisher}:${record.keyId}`;
|
|
1381
1553
|
const prior = state.get("encryption-keys", key);
|
|
1382
1554
|
if (prior !== void 0) {
|
|
1383
|
-
if (!
|
|
1555
|
+
if (!isDeepStrictEqual4(prior, record)) throw new Error("Encryption key collision");
|
|
1384
1556
|
return "duplicate";
|
|
1385
1557
|
}
|
|
1386
1558
|
state.set("encryption-keys", key, record);
|
|
@@ -1400,7 +1572,7 @@ function createSqlitePublisherHandshakeStores(state) {
|
|
|
1400
1572
|
const priorNonce = state.get("challenge-nonces", challenge.nonce);
|
|
1401
1573
|
if (priorNonce !== void 0 && priorNonce !== hash) return { kind: "collision" };
|
|
1402
1574
|
const prior = current(hash);
|
|
1403
|
-
if (prior !== void 0) return
|
|
1575
|
+
if (prior !== void 0) return isDeepStrictEqual4(prior.challenge, challenge) ? { kind: "duplicate", record: prior } : { kind: "collision" };
|
|
1404
1576
|
const record = {
|
|
1405
1577
|
challenge,
|
|
1406
1578
|
challengeHash: hash,
|
|
@@ -1453,7 +1625,7 @@ function createSqlitePublisherHandshakeStores(state) {
|
|
|
1453
1625
|
for (const phase of phases) {
|
|
1454
1626
|
const record = state.get(`publication:${phase}`, key);
|
|
1455
1627
|
if (record === void 0) continue;
|
|
1456
|
-
if (record.phase !== phase || !samePublicationIntent(record, intent) || !
|
|
1628
|
+
if (record.phase !== phase || !samePublicationIntent(record, intent) || !isDeepStrictEqual4(record.evidence, signed.evidence)) {
|
|
1457
1629
|
throw new Error("Publication evidence collision");
|
|
1458
1630
|
}
|
|
1459
1631
|
latest = record;
|
|
@@ -1467,12 +1639,12 @@ function createSqlitePublisherHandshakeStores(state) {
|
|
|
1467
1639
|
state.transaction(() => {
|
|
1468
1640
|
const signed = state.get("publication:signed", key);
|
|
1469
1641
|
if (signed === void 0 && record.phase !== "signed") throw new Error("Publication must bind signed evidence first");
|
|
1470
|
-
if (signed !== void 0 && (!samePublicationIntent(signed, record) || !
|
|
1642
|
+
if (signed !== void 0 && (!samePublicationIntent(signed, record) || !isDeepStrictEqual4(signed.evidence, record.evidence))) {
|
|
1471
1643
|
throw new Error("Publication intent or evidence collision");
|
|
1472
1644
|
}
|
|
1473
1645
|
const namespace = `publication:${record.phase}`;
|
|
1474
1646
|
const prior = state.get(namespace, key);
|
|
1475
|
-
if (prior !== void 0 && !
|
|
1647
|
+
if (prior !== void 0 && !isDeepStrictEqual4(prior, record)) throw new Error("Publication phase collision");
|
|
1476
1648
|
if (prior === void 0) state.set(namespace, key, record);
|
|
1477
1649
|
});
|
|
1478
1650
|
}
|