@classytic/arc 2.31.0 → 2.33.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 +1 -1
- package/bin/arc.js +58 -17
- package/dist/{BaseController-De7GqHck.mjs → BaseController-CaQC5EXX.mjs} +298 -35
- package/dist/{EventTransport-2uVV34WU.d.mts → EventTransport-BEVP50ka.d.mts} +16 -0
- package/dist/{EventTransport-9mlTgqzE.mjs → EventTransport-tq4CEB-q.mjs} +39 -2
- package/dist/{HookSystem-BcmPyq5I.mjs → HookSystem-DqXkMXkB.mjs} +2 -2
- package/dist/{ResourceRegistry-qq0mn2s9.mjs → ResourceRegistry-DoALUlOO.mjs} +18 -15
- package/dist/{actionPermissions-BE5NwaQk.mjs → actionPermissions-CUeCmpUd.mjs} +1 -1
- package/dist/audit/index.d.mts +2 -2
- package/dist/audit/index.mjs +2 -2
- package/dist/auth/audit.mjs +1 -1
- package/dist/auth/index.d.mts +2 -2
- package/dist/auth/index.mjs +44 -11
- package/dist/auth/redis-session.mjs +1 -1
- package/dist/{authorizationDecision-ChvUQGG1.mjs → authorizationDecision-B3mP_QTy.mjs} +2 -2
- package/dist/{types-B7XeLEgK.d.mts → base-Csjc2oF-.d.mts} +74 -74
- package/dist/{buildHandler-CDrCJzmB.mjs → buildHandler-DLE9xX-V.mjs} +6 -5
- package/dist/cache/index.mjs +4 -4
- package/dist/{caching-BFHA6Scf.mjs → caching-BcGmvnHh.mjs} +2 -2
- package/dist/cleanup/index.d.mts +52 -18
- package/dist/cleanup/index.mjs +281 -32
- package/dist/cli/commands/describe.d.mts +1 -1
- package/dist/cli/commands/describe.mjs +4 -6
- package/dist/cli/commands/docs.mjs +3 -3
- package/dist/cli/commands/doctor.mjs +1 -1
- package/dist/cli/commands/generate.mjs +2 -2
- package/dist/cli/commands/init.mjs +2 -2
- package/dist/cli/commands/introspect.mjs +2 -2
- package/dist/cli/index.mjs +3 -3
- package/dist/{constants-D3Dv4LAV.mjs → constants-BxCoxmiN.mjs} +1 -1
- package/dist/context/index.mjs +3 -3
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.mjs +7 -7
- package/dist/{core-BGpIxINN.mjs → core-dxhcHhUD.mjs} +3 -3
- package/dist/{createActionRouter-CDXEdasl.mjs → createActionRouter-DVpoA0do.mjs} +14 -11
- package/dist/{createAggregationRouter-KrQDzx-k.mjs → createAggregationRouter-B_B48JZ-.mjs} +10 -8
- package/dist/{createApp-Df89BEni.mjs → createApp-CLGegqiG.mjs} +287 -62
- package/dist/{defineAggregation-DbPPS8RI.mjs → defineAggregation-Nv-DwfOf.mjs} +1 -1
- package/dist/{defineResource-Bv6EtN1x.mjs → defineResource-DR9vsUYy.mjs} +318 -35
- package/dist/discovery/index.mjs +1 -1
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +3 -3
- package/dist/{elevation-D2l1mh4g.mjs → elevation-B0tlY8y1.mjs} +4 -4
- package/dist/encryption/index.mjs +5 -4
- package/dist/{errorHandler-C5NeD6bT.mjs → errorHandler-C7xBqHsI.mjs} +42 -5
- package/dist/{errorHandler-CpYUJmPA.d.mts → errorHandler-DI5AN2b5.d.mts} +14 -3
- package/dist/{errors-Cj42AntO.mjs → errors-Cd_yy7aW.mjs} +1 -1
- package/dist/{eventPlugin-GF5nnBIq.d.mts → eventPlugin-DSNEZWfV.d.mts} +29 -1
- package/dist/{eventPlugin-CkffEihm.mjs → eventPlugin-Dar8qRaj.mjs} +68 -11
- package/dist/events/index.d.mts +18 -297
- package/dist/events/index.mjs +7 -758
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +14 -10
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/events/transports/redis.mjs +1 -1
- package/dist/factory/index.d.mts +20 -5
- package/dist/factory/index.mjs +131 -8
- package/dist/fastify-augmentation-DVsTAy6X.d.mts +179 -0
- package/dist/{fields-CB5Cuzk9.mjs → fields-CojeUkS4.mjs} +6 -11
- package/dist/{generate-EcA6ui4i.mjs → generate-DiB0nHPR.mjs} +2 -2
- package/dist/{headers-3zvE0rM3.mjs → headers-CveNO9gR.mjs} +2 -2
- package/dist/{health-DjZYjM7H.mjs → health-CasG2A28.mjs} +1 -1
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +2 -2
- package/dist/idempotency/index.d.mts +45 -1
- package/dist/idempotency/index.mjs +95 -11
- package/dist/idempotency/redis.mjs +1 -1
- package/dist/{index-CEJNZFtX.d.mts → index-B-DvUzsr.d.mts} +82 -3
- package/dist/{index-CpzxqePp.d.mts → index-BcR2bFZQ.d.mts} +707 -373
- package/dist/{index-Bc2ox8_U.d.mts → index-Brin0ODU.d.mts} +4 -4
- package/dist/{index-CFThn1K9.d.mts → index-C5NnMEKF.d.mts} +70 -4
- package/dist/{index-C-481Zf2.d.mts → index-ugr1tQlW.d.mts} +17 -1
- package/dist/index.d.mts +6 -6
- package/dist/index.mjs +11 -11
- package/dist/{init-BEiNZBsJ.mjs → init-mdJ-v4Xp.mjs} +24 -16
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +2 -2
- package/dist/integrations/index.d.mts +2 -2
- package/dist/integrations/index.mjs +1 -1
- package/dist/integrations/jobs/index.d.mts +1 -1
- package/dist/integrations/jobs/index.mjs +100 -98
- package/dist/integrations/mcp/index.d.mts +12 -3
- package/dist/integrations/mcp/index.mjs +2 -2
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +2 -2
- package/dist/integrations/streamline.mjs +3 -3
- package/dist/integrations/webhooks.d.mts +124 -1
- package/dist/integrations/webhooks.mjs +180 -2
- package/dist/integrations/websocket-pushref-redis.mjs +1 -1
- package/dist/integrations/websocket-redis.mjs +1 -1
- package/dist/integrations/websocket.mjs +38 -35
- package/dist/{interface-DYzM0q-u.mjs → interface-DznJrm9R.mjs} +1 -1
- package/dist/{jsonBody-BYbLDi5D.mjs → jsonBody-CfGH-GxO.mjs} +2 -2
- package/dist/{keys-C0_pa1tY.mjs → keys-WeKvY1dE.mjs} +1 -1
- package/dist/{loadResources-yawi9tAE.mjs → loadResources-Bj-aSsXi.mjs} +4 -3
- package/dist/{loadResourcesFromEntry-BAjYZonx.mjs → loadResourcesFromEntry-DwaxNcE2.mjs} +2 -2
- package/dist/logger/index.mjs +1 -1
- package/dist/{mcpPlugin-B_es0hiF.mjs → mcpPlugin-aJg5TqHi.mjs} +76 -36
- package/dist/{memory-DR6wpa9F.mjs → memory-qYSB7rMQ.mjs} +5 -5
- package/dist/{metrics-CUdw_wLB.mjs → metrics-D7uchLjN.mjs} +2 -2
- package/dist/middleware/index.d.mts +1 -1
- package/dist/middleware/index.mjs +3 -3
- package/dist/migrations/index.mjs +3 -3
- package/dist/{module-0pFJA6YW.mjs → module-CwKsgZXp.mjs} +1 -1
- package/dist/{multipartBody-wOgj_fnj.mjs → multipartBody-4w-S3-PU.mjs} +3 -3
- package/dist/{openapi-B2L4js1v.mjs → openapi-BN5cQaO2.mjs} +6 -6
- package/dist/outbox-admin/index.d.mts +85 -0
- package/dist/outbox-admin/index.mjs +115 -0
- package/dist/outbox-module-CXB79bvt.mjs +839 -0
- package/dist/outbox-module-D-VFq37T.d.mts +368 -0
- package/dist/permissions/index.d.mts +3 -3
- package/dist/permissions/index.mjs +6 -6
- package/dist/{permissions-L--QDAir.mjs → permissions-BAfwgBxc.mjs} +120 -8
- package/dist/{pipe-DRDNsI2T.mjs → pipe-BzzoEhHj.mjs} +3 -5
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/pipeline/index.mjs +2 -2
- package/dist/{pipeline-UPQFatit.mjs → pipeline-q69DZzgG.mjs} +17 -15
- package/dist/plugins/index.d.mts +6 -6
- package/dist/plugins/index.mjs +16 -16
- package/dist/plugins/response-cache.mjs +2 -2
- package/dist/plugins/tracing-entry.mjs +2 -2
- package/dist/{pluralize-O0FZgESl.mjs → pluralize-B7xf8qZF.mjs} +1 -1
- package/dist/{preloadResources-Dz6BjzM6.mjs → preloadResources-BkyqeTDz.mjs} +1 -1
- package/dist/{preloadResources-CyoJMSiO.d.mts → preloadResources-g07ldtzh.d.mts} +30 -7
- package/dist/presets/filesUpload.d.mts +2 -2
- package/dist/presets/filesUpload.mjs +6 -6
- package/dist/presets/index.d.mts +2 -2
- package/dist/presets/index.mjs +2 -2
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +3 -3
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +2 -2
- package/dist/{presets-C4-g4_4P.mjs → presets-D-h0uWtP.mjs} +6 -5
- package/dist/projection-BKM0Ui-s.mjs +42 -0
- package/dist/{queryCachePlugin-BZr8v46E.mjs → queryCachePlugin-DhsZ0ZIc.mjs} +50 -8
- package/dist/{rateLimit-CMPY5Qms.mjs → rateLimit-D4-ASwsI.mjs} +2 -2
- package/dist/{rateLimitKey-Dn5JNmkj.mjs → rateLimitKey-XngqVNEV.mjs} +1 -1
- package/dist/{realtime-C5NoxWFY.mjs → realtime-CTkv3O59.mjs} +10 -10
- package/dist/{redis-stream-qN6A-RFv.d.mts → redis-stream-mwLWlfHG.d.mts} +1 -1
- package/dist/{registerRoute-CBN84fc7.mjs → registerRoute-DNnd3vkz.mjs} +10 -8
- package/dist/registry/index.d.mts +3 -3
- package/dist/registry/index.mjs +3 -3
- package/dist/{registry-4213U4zb.mjs → registry-CLxCR5LF.mjs} +2 -2
- package/dist/{renewingLease-Dll8mGgc.mjs → renewingLease-BnWtbA2U.mjs} +1 -1
- package/dist/{replyHelpers-DIiZKGUt.mjs → replyHelpers-BSBVCwL3.mjs} +2 -2
- package/dist/{repositoryFilter-f9eAfq_l.mjs → repositoryFilter-DKny7PDn.mjs} +1 -1
- package/dist/{requestContext-2TxXOJ7T.mjs → requestContext-uYE35JxI.mjs} +1 -1
- package/dist/{rolldown-runtime-CUsjNX9R.mjs → rolldown-runtime-DNg-KNCk.mjs} +1 -1
- package/dist/runtimeCapabilities-Dm88DaTW.mjs +49 -0
- package/dist/{schedules-DdtlICTf.mjs → schedules-CAcZgia0.mjs} +3 -3
- package/dist/{schemaConverter-D--wdJM-.mjs → schemaConverter-XH1RZRyu.mjs} +1 -1
- package/dist/{schemaIR-Bm2enbnh.mjs → schemaIR-CDvclAew.mjs} +2 -2
- package/dist/schemas/index.mjs +1 -1
- package/dist/scim/index.mjs +2 -2
- package/dist/scope/index.mjs +5 -5
- package/dist/{scope-DHx5Fous.mjs → scope-BaWcqBvr.mjs} +4 -4
- package/dist/{simpleEqualityMatcher-DjQJ7FKA.mjs → simpleEqualityMatcher-Se8VrsaP.mjs} +1 -1
- package/dist/{sse-CUTo9y0X.mjs → sse-Cf0jUHsh.mjs} +7 -6
- package/dist/{sseStream-B_5XcVVd.mjs → sseStream-Bvpi-UXQ.mjs} +2 -2
- package/dist/{store-helpers-D1MP_gRG.mjs → store-helpers-B88p99Z2.mjs} +1 -1
- package/dist/{streaming-PSZwi8JV.mjs → streaming-DsT1B5zJ.mjs} +1 -1
- package/dist/{subscribe-helpers-C0Tb2aUp.mjs → subscribe-helpers-CLIOHsOK.mjs} +2 -2
- package/dist/sync/changelog.mjs +1 -1
- package/dist/testing/cleanupStoreContract.d.mts +1 -1
- package/dist/testing/cleanupStoreContract.mjs +12 -6
- package/dist/testing/index.d.mts +52 -13
- package/dist/testing/index.mjs +110 -40
- package/dist/testing/outboxStoreContract.d.mts +11 -2
- package/dist/testing/outboxStoreContract.mjs +110 -21
- package/dist/testing/storageContract.mjs +11 -6
- package/dist/{transactionContext-dAfBVUrk.mjs → transactionContext-BYtWnwAS.mjs} +1 -1
- package/dist/{typeGuards-B0ryPo3I.mjs → typeGuards-jjKJDN13.mjs} +1 -1
- package/dist/types/index.d.mts +3 -3
- package/dist/types/index.mjs +1 -1
- package/dist/types/storage.mjs +1 -1
- package/dist/{types-CBgvkp8O.d.mts → types-BcTumAz_.d.mts} +128 -18
- package/dist/{types-CwcUQ610.d.mts → types-BfC83KsP.d.mts} +15 -1
- package/dist/{types-Cys0ko9Z.mjs → types-CoqIRrkY.mjs} +4 -4
- package/dist/{types-C4QE8qnm.d.mts → types-DVLddu2q.d.mts} +144 -1
- package/dist/usage/index.d.mts +1 -1
- package/dist/usage/index.mjs +0 -0
- package/dist/{userHelpers-ChYJ6CdK.mjs → userHelpers-CWF5pbZD.mjs} +1 -1
- package/dist/utils/index.d.mts +57 -5
- package/dist/utils/index.mjs +12 -11
- package/dist/{utils-vL8BrDE2.mjs → utils-h1V_sT-t.mjs} +38 -7
- package/dist/{validate-_KbifpHF.mjs → validate-CdQlz52F.mjs} +2 -2
- package/dist/{versioning-D_rhHfZm.d.mts → versioning-DEs-_hFi.d.mts} +1 -1
- package/dist/{versioning-CHDTor3P.mjs → versioning-rZbyLVD-.mjs} +4 -4
- package/package.json +31 -20
- package/skills/arc/SKILL.md +96 -4
- package/skills/arc/references/api-reference.md +19 -0
- package/skills/arc/references/production.md +2 -2
- package/dist/fastify-augmentation-zh0_AYHa.d.mts +0 -85
- package/dist/projection-BHw6xlOQ.mjs +0 -18
package/dist/events/index.mjs
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
/*! @classytic/arc v2.
|
|
2
|
-
import {
|
|
3
|
-
import { a as createEventRegistry, i as wrapWithSchema, n as subscribeWithSchema, o as defineEvent, r as wrapWithBoundary, t as subscribeWithBoundary } from "../subscribe-helpers-
|
|
4
|
-
import { i as withRetry, r as createDeadLetterPublisher, t as eventPlugin } from "../eventPlugin-
|
|
5
|
-
import { n as
|
|
6
|
-
import {
|
|
7
|
-
import { InvalidOutboxEventError, OutboxOwnershipError } from "@classytic/primitives/outbox";
|
|
8
|
-
import { update } from "@classytic/repo-core/update";
|
|
1
|
+
/*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
|
|
2
|
+
import { n as MemoryEventTransport } from "../EventTransport-tq4CEB-q.mjs";
|
|
3
|
+
import { a as createEventRegistry, i as wrapWithSchema, n as subscribeWithSchema, o as defineEvent, r as wrapWithBoundary, t as subscribeWithBoundary } from "../subscribe-helpers-CLIOHsOK.mjs";
|
|
4
|
+
import { i as withRetry, r as createDeadLetterPublisher, t as eventPlugin } from "../eventPlugin-Dar8qRaj.mjs";
|
|
5
|
+
import { i as exponentialBackoff, n as EventOutbox, r as repositoryAsOutboxStore, t as createOutboxModule } from "../outbox-module-CXB79bvt.mjs";
|
|
6
|
+
import { MemoryOutboxStore } from "@classytic/primitives/memory-outbox";
|
|
9
7
|
//#region src/events/eventTypes.ts
|
|
10
8
|
/**
|
|
11
9
|
* Event Type Constants and Helpers
|
|
@@ -57,753 +55,4 @@ const CACHE_EVENTS = Object.freeze({
|
|
|
57
55
|
TAG_VERSION_BUMPED: "arc.cache.tag.bumped"
|
|
58
56
|
});
|
|
59
57
|
//#endregion
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* In-memory outbox store — reference implementation for dev / testing.
|
|
63
|
-
*
|
|
64
|
-
* Supports the full `OutboxStore` capability set (claim/lease, fail/retry,
|
|
65
|
-
* dedupe, visibleAt). Production deployments should pass a `repository` to
|
|
66
|
-
* `EventOutbox` instead of using this; the memory store exists for unit
|
|
67
|
-
* tests and single-process dev where durable persistence is noise.
|
|
68
|
-
*/
|
|
69
|
-
const DEFAULT_LEASE_MS$2 = 3e4;
|
|
70
|
-
var MemoryOutboxStore = class {
|
|
71
|
-
entries = [];
|
|
72
|
-
seenDedupeKeys = /* @__PURE__ */ new Set();
|
|
73
|
-
/**
|
|
74
|
-
* Drop every entry (including delivered / failed / dead-lettered rows) and
|
|
75
|
-
* the dedupe-key memory.
|
|
76
|
-
*
|
|
77
|
-
* For tests only — it is the in-memory analogue of truncating the table, and
|
|
78
|
-
* exists so a suite can isolate cases against ONE store instance (the
|
|
79
|
-
* `reset` hook of `runOutboxStoreContract`). Not part of `OutboxStore`; a
|
|
80
|
-
* production store must never expose this.
|
|
81
|
-
*/
|
|
82
|
-
clear() {
|
|
83
|
-
this.entries.length = 0;
|
|
84
|
-
this.seenDedupeKeys.clear();
|
|
85
|
-
}
|
|
86
|
-
async save(event, options) {
|
|
87
|
-
if (!event?.type || typeof event.type !== "string") throw new InvalidOutboxEventError("event.type is required");
|
|
88
|
-
if (!event.meta?.id || typeof event.meta.id !== "string") throw new InvalidOutboxEventError("event.meta.id is required");
|
|
89
|
-
if (options?.dedupeKey) {
|
|
90
|
-
if (this.seenDedupeKeys.has(options.dedupeKey)) return;
|
|
91
|
-
this.seenDedupeKeys.add(options.dedupeKey);
|
|
92
|
-
}
|
|
93
|
-
this.entries.push({
|
|
94
|
-
event,
|
|
95
|
-
status: "pending",
|
|
96
|
-
attempts: 0,
|
|
97
|
-
visibleAt: options?.visibleAt?.getTime() ?? 0,
|
|
98
|
-
leaseOwner: null,
|
|
99
|
-
leaseExpiresAt: 0,
|
|
100
|
-
deliveredAt: null,
|
|
101
|
-
firstFailedAt: null,
|
|
102
|
-
lastFailedAt: null,
|
|
103
|
-
lastError: null,
|
|
104
|
-
dedupeKey: options?.dedupeKey
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
async getPending(limit) {
|
|
108
|
-
const now = Date.now();
|
|
109
|
-
return this.entries.filter((e) => e.status === "pending" && e.visibleAt <= now && (e.leaseOwner === null || e.leaseExpiresAt <= now)).slice(0, limit).map((e) => e.event);
|
|
110
|
-
}
|
|
111
|
-
async claimPending(options) {
|
|
112
|
-
const now = Date.now();
|
|
113
|
-
const limit = options?.limit ?? 100;
|
|
114
|
-
const leaseMs = options?.leaseMs ?? DEFAULT_LEASE_MS$2;
|
|
115
|
-
const consumerId = options?.consumerId ?? "anonymous";
|
|
116
|
-
const typeFilter = options?.types ? new Set(options.types) : null;
|
|
117
|
-
const claimed = [];
|
|
118
|
-
for (const entry of this.entries) {
|
|
119
|
-
if (claimed.length >= limit) break;
|
|
120
|
-
if (entry.status !== "pending") continue;
|
|
121
|
-
if (entry.visibleAt > now) continue;
|
|
122
|
-
if (entry.leaseOwner !== null && entry.leaseExpiresAt > now) continue;
|
|
123
|
-
if (typeFilter && !typeFilter.has(entry.event.type)) continue;
|
|
124
|
-
entry.leaseOwner = consumerId;
|
|
125
|
-
entry.leaseExpiresAt = now + leaseMs;
|
|
126
|
-
entry.attempts++;
|
|
127
|
-
claimed.push(entry.event);
|
|
128
|
-
}
|
|
129
|
-
return claimed;
|
|
130
|
-
}
|
|
131
|
-
async acknowledge(eventId, options) {
|
|
132
|
-
const entry = this.entries.find((e) => e.event.meta.id === eventId);
|
|
133
|
-
if (!entry) return;
|
|
134
|
-
if (entry.status === "delivered") return;
|
|
135
|
-
if (options?.consumerId && entry.leaseOwner && entry.leaseOwner !== options.consumerId) throw new OutboxOwnershipError(eventId, options.consumerId, entry.leaseOwner);
|
|
136
|
-
entry.status = "delivered";
|
|
137
|
-
entry.deliveredAt = Date.now();
|
|
138
|
-
entry.leaseOwner = null;
|
|
139
|
-
}
|
|
140
|
-
async fail(eventId, error, options) {
|
|
141
|
-
const entry = this.entries.find((e) => e.event.meta.id === eventId);
|
|
142
|
-
if (!entry) return;
|
|
143
|
-
if (options?.consumerId && entry.leaseOwner && entry.leaseOwner !== options.consumerId) throw new OutboxOwnershipError(eventId, options.consumerId, entry.leaseOwner);
|
|
144
|
-
const now = Date.now();
|
|
145
|
-
entry.lastError = error;
|
|
146
|
-
entry.leaseOwner = null;
|
|
147
|
-
entry.leaseExpiresAt = 0;
|
|
148
|
-
if (entry.firstFailedAt === null) entry.firstFailedAt = now;
|
|
149
|
-
entry.lastFailedAt = now;
|
|
150
|
-
if (options?.deadLetter) {
|
|
151
|
-
entry.status = "dead_letter";
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
entry.status = "pending";
|
|
155
|
-
entry.visibleAt = options?.retryAt ? options.retryAt.getTime() : 0;
|
|
156
|
-
}
|
|
157
|
-
async getDeadLettered(limit) {
|
|
158
|
-
const out = [];
|
|
159
|
-
for (const entry of this.entries) {
|
|
160
|
-
if (out.length >= limit) break;
|
|
161
|
-
if (entry.status !== "dead_letter") continue;
|
|
162
|
-
out.push({
|
|
163
|
-
event: entry.event,
|
|
164
|
-
error: {
|
|
165
|
-
message: entry.lastError?.message ?? "unknown",
|
|
166
|
-
...entry.lastError?.code !== void 0 ? { code: entry.lastError.code } : {}
|
|
167
|
-
},
|
|
168
|
-
attempts: entry.attempts,
|
|
169
|
-
firstFailedAt: new Date(entry.firstFailedAt ?? entry.lastFailedAt ?? Date.now()),
|
|
170
|
-
lastFailedAt: new Date(entry.lastFailedAt ?? entry.firstFailedAt ?? Date.now())
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
return out;
|
|
174
|
-
}
|
|
175
|
-
async purge(olderThanMs) {
|
|
176
|
-
const cutoff = Date.now() - olderThanMs;
|
|
177
|
-
let purged = 0;
|
|
178
|
-
for (let i = this.entries.length - 1; i >= 0; i--) {
|
|
179
|
-
const entry = this.entries[i];
|
|
180
|
-
if (!entry) continue;
|
|
181
|
-
if (entry.status === "delivered" && entry.deliveredAt !== null && entry.deliveredAt < cutoff) {
|
|
182
|
-
if (entry.dedupeKey) this.seenDedupeKeys.delete(entry.dedupeKey);
|
|
183
|
-
this.entries.splice(i, 1);
|
|
184
|
-
purged++;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
return purged;
|
|
188
|
-
}
|
|
189
|
-
/** Test helper: inspect entry by id */
|
|
190
|
-
_getEntry(eventId) {
|
|
191
|
-
return this.entries.find((e) => e.event.meta.id === eventId);
|
|
192
|
-
}
|
|
193
|
-
};
|
|
194
|
-
//#endregion
|
|
195
|
-
//#region src/events/outbox/backoff.ts
|
|
196
|
-
/**
|
|
197
|
-
* Compute a `retryAt` `Date` using exponential backoff with jitter.
|
|
198
|
-
*
|
|
199
|
-
* This is a convenience helper for store authors implementing
|
|
200
|
-
* {@link OutboxStore.fail}: call it to compute the retry visibility window
|
|
201
|
-
* based on the event's current attempt count.
|
|
202
|
-
*
|
|
203
|
-
* Formula: `delay = min(maxMs, baseMs * 2^(attempt - 1)) * (1 + random * jitter)`
|
|
204
|
-
*
|
|
205
|
-
* @example Basic usage inside a store's `fail()` method
|
|
206
|
-
* ```typescript
|
|
207
|
-
* async fail(eventId, error, options) {
|
|
208
|
-
* const entry = await this.findById(eventId);
|
|
209
|
-
* entry.attempts++;
|
|
210
|
-
* if (entry.attempts >= MAX_ATTEMPTS) {
|
|
211
|
-
* return this.deadLetter(eventId, error);
|
|
212
|
-
* }
|
|
213
|
-
* const retryAt = exponentialBackoff({ attempt: entry.attempts });
|
|
214
|
-
* entry.visibleAt = retryAt;
|
|
215
|
-
* await this.update(entry);
|
|
216
|
-
* }
|
|
217
|
-
* ```
|
|
218
|
-
*
|
|
219
|
-
* @example Tuning for a faster transport
|
|
220
|
-
* ```typescript
|
|
221
|
-
* exponentialBackoff({ attempt: 3, baseMs: 250, maxMs: 10_000, jitter: 0.3 });
|
|
222
|
-
* // attempt=1 → ~250ms ±30%
|
|
223
|
-
* // attempt=2 → ~500ms ±30%
|
|
224
|
-
* // attempt=3 → ~1000ms ±30%
|
|
225
|
-
* // attempt=10 → capped at 10_000ms
|
|
226
|
-
* ```
|
|
227
|
-
*/
|
|
228
|
-
function exponentialBackoff(options) {
|
|
229
|
-
const attempt = Math.max(1, Math.floor(options.attempt));
|
|
230
|
-
const baseMs = options.baseMs ?? 1e3;
|
|
231
|
-
const maxMs = options.maxMs ?? 6e4;
|
|
232
|
-
const jitter = Math.max(0, Math.min(1, options.jitter ?? .2));
|
|
233
|
-
const now = options.now ?? Date.now();
|
|
234
|
-
const exp = baseMs * 2 ** (attempt - 1);
|
|
235
|
-
const capped = Math.min(maxMs, exp);
|
|
236
|
-
const jittered = jitter > 0 ? capped * (1 + Math.random() * jitter) : capped;
|
|
237
|
-
return new Date(now + jittered);
|
|
238
|
-
}
|
|
239
|
-
//#endregion
|
|
240
|
-
//#region src/events/repository-outbox-adapter.ts
|
|
241
|
-
/**
|
|
242
|
-
* RepositoryLike → OutboxStore adapter.
|
|
243
|
-
*
|
|
244
|
-
* Maps the `OutboxStore` vocabulary (save / claimPending / acknowledge /
|
|
245
|
-
* fail / getDeadLettered / purge) onto arc's own `RepositoryLike` primitives
|
|
246
|
-
* (create / getOne / findAll / deleteMany / findOneAndUpdate). `EventOutbox`
|
|
247
|
-
* wraps a passed repository with this helper when you use the
|
|
248
|
-
* `{ repository }` option; the function is also re-exported from
|
|
249
|
-
* `@classytic/arc/events` so consumers can build and decorate the store
|
|
250
|
-
* manually (metrics, tracing, multi-transport fan-out).
|
|
251
|
-
*
|
|
252
|
-
* Portability: filters compose via `@classytic/repo-core/filter` and
|
|
253
|
-
* updates via `@classytic/repo-core/update`. The primary-key column name
|
|
254
|
-
* is read from `repository.idField` — mongokit defaults to `_id`,
|
|
255
|
-
* sqlitekit / pgkit / prismakit to the schema's declared PK. The adapter
|
|
256
|
-
* therefore runs on any kit that implements `StandardRepo.findOneAndUpdate`
|
|
257
|
-
* + `getOne` + `getAll` + `deleteMany` + `create`.
|
|
258
|
-
*
|
|
259
|
-
* `fail()` uses a lease-gated read-then-write pair to preserve
|
|
260
|
-
* `firstFailedAt` across retries without relying on Mongo's aggregation-
|
|
261
|
-
* pipeline `$ifNull`. Leases guarantee single-writer during the failure
|
|
262
|
-
* window (`claimPending` filters out non-owned rows), so the two calls are
|
|
263
|
-
* safe under concurrent relayers.
|
|
264
|
-
*
|
|
265
|
-
* Why we DON'T use `StandardRepo.claim()` (repo-core 0.4+) anywhere in this
|
|
266
|
-
* adapter — design note for the next reader who wants to "modernize" us:
|
|
267
|
-
*
|
|
268
|
-
* • `claimPending` (FIFO claim-next loop): `claim(id, ...)` requires the
|
|
269
|
-
* candidate id upfront, but the relayer doesn't have it — that's the
|
|
270
|
-
* whole point of `findOneAndUpdate({ filter, sort: { createdAt: 1 } })`,
|
|
271
|
-
* which finds + claims in a SINGLE round-trip per claimed event.
|
|
272
|
-
* Replacing with `getOne(filter, sort) → claim(id, ...)` doubles the
|
|
273
|
-
* round-trips, opens a TOCTOU window where the candidate state can
|
|
274
|
-
* change between read and CAS (we'd need a retry loop on null-on-race),
|
|
275
|
-
* and gains nothing — the existing call is already atomic and
|
|
276
|
-
* race-free. Throughput regression with no upside; do not adopt.
|
|
277
|
-
*
|
|
278
|
-
* • `acknowledge` (pending → delivered): id is in scope, but the current
|
|
279
|
-
* filter uses `ne('status', 'delivered')` to allow ack from any
|
|
280
|
-
* non-delivered state (defensive; lease invariant means it's pending
|
|
281
|
-
* in practice). `claim` requires an exact `from` value, so adopting
|
|
282
|
-
* would tighten the source-state predicate from "not delivered" to
|
|
283
|
-
* "exactly pending". That's a behavior change — even if the new
|
|
284
|
-
* behavior is arguably tighter/safer, the constraint here is "no
|
|
285
|
-
* semantic regression". Keep `findOneAndUpdate`.
|
|
286
|
-
*
|
|
287
|
-
* • `fail` (any → pending|dead_letter): the current `baseFilter` is
|
|
288
|
-
* id-only (with optional `leaseOwner` guard) — there's NO source-state
|
|
289
|
-
* predicate at all. Adopting `claim` would ADD `from: 'pending'`,
|
|
290
|
-
* blocking `fail()` calls against rows in any other state. Same "no
|
|
291
|
-
* semantic regression" concern; keep `findOneAndUpdate`. The
|
|
292
|
-
* read-then-write pair also exists here to preserve `firstFailedAt`
|
|
293
|
-
* portably (see paragraph above) — `claim` doesn't simplify that.
|
|
294
|
-
*
|
|
295
|
-
* No kit currently ships a filter-based `claimNext`/`claimPending` (Path C
|
|
296
|
-
* in the v2.11.x evaluation) — checked mongokit 3.13.0 + sqlitekit 0.3.0.
|
|
297
|
-
* If/when one appears, the FIFO loop becomes a 1-call adoption candidate.
|
|
298
|
-
*/
|
|
299
|
-
const DEFAULT_LEASE_MS$1 = 3e4;
|
|
300
|
-
const DEFAULT_CLAIM_LIMIT = 100;
|
|
301
|
-
const DEFAULT_PURGE_BATCH = 500;
|
|
302
|
-
/** Column this adapter reads/writes for the visibility timestamp. */
|
|
303
|
-
const DEFAULT_VISIBLE_AT_FIELD = "visibleAt";
|
|
304
|
-
function repositoryAsOutboxStore(repository, options) {
|
|
305
|
-
const visibleAtField = options?.visibleAtField ?? DEFAULT_VISIBLE_AT_FIELD;
|
|
306
|
-
const missing = [];
|
|
307
|
-
if (typeof repository.create !== "function") missing.push("create");
|
|
308
|
-
if (typeof repository.getOne !== "function") missing.push("getOne");
|
|
309
|
-
if (typeof repository.getAll !== "function") missing.push("getAll");
|
|
310
|
-
if (typeof repository.deleteMany !== "function") missing.push("deleteMany");
|
|
311
|
-
if (typeof repository.findOneAndUpdate !== "function") missing.push("findOneAndUpdate");
|
|
312
|
-
if (missing.length > 0) throw new Error(`EventOutbox: repository is missing required methods: ${missing.join(", ")}. mongokit ≥3.10.2 satisfies all five; other kits must implement them to back the outbox.`);
|
|
313
|
-
const r = repository;
|
|
314
|
-
const idField = repository.idField ?? "_id";
|
|
315
|
-
/**
|
|
316
|
-
* Unwrap mongokit's pagination envelope ({ data, total, ... }) — some
|
|
317
|
-
* kits may return a bare array when pagination is disabled. Handle both.
|
|
318
|
-
*/
|
|
319
|
-
const unwrapDocs = (result) => {
|
|
320
|
-
if (Array.isArray(result)) return result;
|
|
321
|
-
return result?.data ?? [];
|
|
322
|
-
};
|
|
323
|
-
const isDuplicateKeyError = createIsDuplicateKeyError(repository);
|
|
324
|
-
const safeGetOne = createSafeGetOne(repository);
|
|
325
|
-
const isWellFormed = (event) => !!event && typeof event.type === "string" && !!event.meta?.id;
|
|
326
|
-
/**
|
|
327
|
-
* Filter matching every row that's eligible to be claimed by a relayer:
|
|
328
|
-
* status=pending, visible now, and either unleased or under an expired
|
|
329
|
-
* lease. Used by `getPending` and `claimPending` — defined once so the
|
|
330
|
-
* two code paths stay in lockstep.
|
|
331
|
-
*/
|
|
332
|
-
const claimableFilter = (now) => and(eq("status", "pending"), lte(visibleAtField, now), or(eq("leaseOwner", null), lte("leaseExpiresAt", now)));
|
|
333
|
-
return {
|
|
334
|
-
/**
|
|
335
|
-
* Transactional: `save` forwards `options.session` to the backing
|
|
336
|
-
* repository's `create`, so the event row joins the caller's transaction on
|
|
337
|
-
* the SAME connection and commits or rolls back with the domain write.
|
|
338
|
-
*
|
|
339
|
-
* Declared explicitly because `OutboxWriteOptions.session` is best-effort by
|
|
340
|
-
* contract — a caller cannot infer atomicity from the presence of an outbox,
|
|
341
|
-
* only from this flag. See `OutboxStore.transactionalSave` in
|
|
342
|
-
* `@classytic/primitives/outbox`.
|
|
343
|
-
*/
|
|
344
|
-
transactionalSave: true,
|
|
345
|
-
async save(event, options) {
|
|
346
|
-
if (!event?.type || typeof event.type !== "string") throw new InvalidOutboxEventError("event.type is required");
|
|
347
|
-
if (!event.meta?.id || typeof event.meta.id !== "string") throw new InvalidOutboxEventError("event.meta.id is required");
|
|
348
|
-
const now = /* @__PURE__ */ new Date();
|
|
349
|
-
const doc = {
|
|
350
|
-
[idField]: event.meta.id,
|
|
351
|
-
event,
|
|
352
|
-
type: event.type,
|
|
353
|
-
status: "pending",
|
|
354
|
-
attempts: 0,
|
|
355
|
-
[visibleAtField]: options?.visibleAt ?? now,
|
|
356
|
-
leaseOwner: null,
|
|
357
|
-
leaseExpiresAt: null,
|
|
358
|
-
deliveredAt: null,
|
|
359
|
-
firstFailedAt: null,
|
|
360
|
-
lastFailedAt: null,
|
|
361
|
-
lastError: null,
|
|
362
|
-
dedupeKey: options?.dedupeKey ?? null,
|
|
363
|
-
partitionKey: options?.partitionKey ?? null,
|
|
364
|
-
headers: options?.headers ? { ...options.headers } : null,
|
|
365
|
-
createdAt: now
|
|
366
|
-
};
|
|
367
|
-
try {
|
|
368
|
-
await r.create(doc, options?.session ? { session: options.session } : void 0);
|
|
369
|
-
} catch (err) {
|
|
370
|
-
if (isDuplicateKeyError(err)) return;
|
|
371
|
-
throw err;
|
|
372
|
-
}
|
|
373
|
-
},
|
|
374
|
-
async getPending(limit) {
|
|
375
|
-
const now = /* @__PURE__ */ new Date();
|
|
376
|
-
const result = await r.getAll({
|
|
377
|
-
filters: claimableFilter(now),
|
|
378
|
-
sort: { createdAt: 1 },
|
|
379
|
-
page: 1,
|
|
380
|
-
limit
|
|
381
|
-
});
|
|
382
|
-
return unwrapDocs(result).map((d) => d.event).filter(isWellFormed);
|
|
383
|
-
},
|
|
384
|
-
async claimPending(options) {
|
|
385
|
-
const limit = options?.limit ?? DEFAULT_CLAIM_LIMIT;
|
|
386
|
-
const leaseMs = options?.leaseMs ?? DEFAULT_LEASE_MS$1;
|
|
387
|
-
const consumerId = options?.consumerId ?? "anonymous";
|
|
388
|
-
const typeFilter = options?.types?.length ? anyOf("type", options.types) : null;
|
|
389
|
-
const now = /* @__PURE__ */ new Date();
|
|
390
|
-
const candidateFilter = typeFilter ? and(claimableFilter(now), typeFilter) : claimableFilter(now);
|
|
391
|
-
const candidates = unwrapDocs(await r.getAll({
|
|
392
|
-
filters: candidateFilter,
|
|
393
|
-
sort: { createdAt: 1 },
|
|
394
|
-
page: 1,
|
|
395
|
-
limit
|
|
396
|
-
}));
|
|
397
|
-
if (candidates.length === 0) return [];
|
|
398
|
-
return (await Promise.all(candidates.map((candidate) => {
|
|
399
|
-
const claimNow = /* @__PURE__ */ new Date();
|
|
400
|
-
const leaseExpiresAt = new Date(claimNow.getTime() + leaseMs);
|
|
401
|
-
return r.findOneAndUpdate(and(eq(idField, candidate.event.meta.id), claimableFilter(claimNow)), update({
|
|
402
|
-
set: {
|
|
403
|
-
leaseOwner: consumerId,
|
|
404
|
-
leaseExpiresAt
|
|
405
|
-
},
|
|
406
|
-
inc: { attempts: 1 }
|
|
407
|
-
}), { returnDocument: "after" });
|
|
408
|
-
}))).filter((doc) => doc !== null).map((doc) => doc.event).filter(isWellFormed);
|
|
409
|
-
},
|
|
410
|
-
async acknowledge(eventId, options) {
|
|
411
|
-
const now = /* @__PURE__ */ new Date();
|
|
412
|
-
const baseFilter = and(eq(idField, eventId), ne("status", "delivered"));
|
|
413
|
-
const filter = options?.consumerId ? and(baseFilter, eq("leaseOwner", options.consumerId)) : baseFilter;
|
|
414
|
-
if (await r.findOneAndUpdate(filter, update({ set: {
|
|
415
|
-
status: "delivered",
|
|
416
|
-
deliveredAt: now,
|
|
417
|
-
leaseOwner: null,
|
|
418
|
-
leaseExpiresAt: null
|
|
419
|
-
} }), { returnDocument: "after" })) return;
|
|
420
|
-
const current = await safeGetOne(eq(idField, eventId));
|
|
421
|
-
if (!current) return;
|
|
422
|
-
if (current.status === "delivered") return;
|
|
423
|
-
if (options?.consumerId && current.leaseOwner !== options.consumerId) throw new OutboxOwnershipError(eventId, options.consumerId, current.leaseOwner);
|
|
424
|
-
},
|
|
425
|
-
async fail(eventId, error, options) {
|
|
426
|
-
const now = /* @__PURE__ */ new Date();
|
|
427
|
-
const targetStatus = options?.deadLetter ? "dead_letter" : "pending";
|
|
428
|
-
const visibleAt = options?.retryAt ?? now;
|
|
429
|
-
const baseFilter = eq(idField, eventId);
|
|
430
|
-
const filter = options?.consumerId ? and(baseFilter, eq("leaseOwner", options.consumerId)) : baseFilter;
|
|
431
|
-
const current = await safeGetOne(baseFilter);
|
|
432
|
-
if (!current) return;
|
|
433
|
-
if (options?.consumerId && current.leaseOwner !== options.consumerId) throw new OutboxOwnershipError(eventId, options.consumerId, current.leaseOwner);
|
|
434
|
-
const errorInfo = error.code ? {
|
|
435
|
-
message: error.message,
|
|
436
|
-
code: error.code
|
|
437
|
-
} : { message: error.message };
|
|
438
|
-
const firstFailedAt = current.firstFailedAt ?? now;
|
|
439
|
-
if (await r.findOneAndUpdate(filter, update({ set: {
|
|
440
|
-
status: targetStatus,
|
|
441
|
-
[visibleAtField]: visibleAt,
|
|
442
|
-
leaseOwner: null,
|
|
443
|
-
leaseExpiresAt: null,
|
|
444
|
-
lastFailedAt: now,
|
|
445
|
-
lastError: errorInfo,
|
|
446
|
-
firstFailedAt
|
|
447
|
-
} }), { returnDocument: "after" })) return;
|
|
448
|
-
if (options?.consumerId) {
|
|
449
|
-
const after = await safeGetOne(baseFilter);
|
|
450
|
-
if (after && after.leaseOwner !== options.consumerId) throw new OutboxOwnershipError(eventId, options.consumerId, after.leaseOwner);
|
|
451
|
-
}
|
|
452
|
-
},
|
|
453
|
-
async getDeadLettered(limit) {
|
|
454
|
-
const result = await r.getAll({
|
|
455
|
-
filters: eq("status", "dead_letter"),
|
|
456
|
-
sort: { [idField]: 1 },
|
|
457
|
-
page: 1,
|
|
458
|
-
limit
|
|
459
|
-
});
|
|
460
|
-
return unwrapDocs(result).filter((d) => isWellFormed(d.event)).map((d) => ({
|
|
461
|
-
event: d.event,
|
|
462
|
-
error: {
|
|
463
|
-
message: d.lastError?.message ?? "unknown",
|
|
464
|
-
...d.lastError?.code !== void 0 ? { code: d.lastError.code } : {}
|
|
465
|
-
},
|
|
466
|
-
attempts: d.attempts,
|
|
467
|
-
firstFailedAt: d.firstFailedAt ?? d.lastFailedAt ?? d.createdAt,
|
|
468
|
-
lastFailedAt: d.lastFailedAt ?? d.firstFailedAt ?? d.createdAt
|
|
469
|
-
}));
|
|
470
|
-
},
|
|
471
|
-
async purge(olderThanMs) {
|
|
472
|
-
const cutoff = new Date(Date.now() - olderThanMs);
|
|
473
|
-
let totalDeleted = 0;
|
|
474
|
-
for (;;) {
|
|
475
|
-
const result = await r.getAll({
|
|
476
|
-
filters: and(eq("status", "delivered"), lte("deliveredAt", cutoff)),
|
|
477
|
-
sort: { deliveredAt: 1 },
|
|
478
|
-
page: 1,
|
|
479
|
-
limit: DEFAULT_PURGE_BATCH,
|
|
480
|
-
select: idField
|
|
481
|
-
});
|
|
482
|
-
const batch = unwrapDocs(result);
|
|
483
|
-
if (batch.length === 0) break;
|
|
484
|
-
const ids = batch.map((d) => d[idField]);
|
|
485
|
-
const res = await r.deleteMany(anyOf(idField, ids));
|
|
486
|
-
totalDeleted += res.deletedCount ?? 0;
|
|
487
|
-
if (batch.length < DEFAULT_PURGE_BATCH) break;
|
|
488
|
-
}
|
|
489
|
-
return totalDeleted;
|
|
490
|
-
}
|
|
491
|
-
};
|
|
492
|
-
}
|
|
493
|
-
//#endregion
|
|
494
|
-
//#region src/events/outbox/relay.ts
|
|
495
|
-
/** Default outbox retention — delivered events older than this are eligible for purge */
|
|
496
|
-
const DEFAULT_OUTBOX_RETENTION_MS = 10080 * 60 * 1e3;
|
|
497
|
-
const DEFAULT_LEASE_MS = 3e4;
|
|
498
|
-
/**
|
|
499
|
-
* Hard cap on the in-process attempt-counter map. Entries are cleared on
|
|
500
|
-
* local ack and on dead-letter, but an event that fails HERE and is later
|
|
501
|
-
* acked by a DIFFERENT relay worker (lease expiry → reclaim) never drains
|
|
502
|
-
* locally — without a cap the map grows for the life of the process in
|
|
503
|
-
* multi-worker deployments. Eviction is oldest-write-first; evicting an
|
|
504
|
-
* entry only resets its non-authoritative in-process count (the documented
|
|
505
|
-
* contract already says counts reset on restart — durable counts live in
|
|
506
|
-
* the store).
|
|
507
|
-
*/
|
|
508
|
-
const MAX_TRACKED_ATTEMPTS = 1e4;
|
|
509
|
-
var EventOutbox = class {
|
|
510
|
-
_store;
|
|
511
|
-
_transport;
|
|
512
|
-
_batchSize;
|
|
513
|
-
_consumerId;
|
|
514
|
-
_leaseMs;
|
|
515
|
-
_onError;
|
|
516
|
-
_usePublishMany;
|
|
517
|
-
_sessionProvider;
|
|
518
|
-
_traceContextProvider;
|
|
519
|
-
_failurePolicy;
|
|
520
|
-
/**
|
|
521
|
-
* In-process attempt counter per event id. Accurate within this relay
|
|
522
|
-
* process; resets on restart. Populated as failures occur and cleared on
|
|
523
|
-
* successful ack or dead-letter transition; additionally hard-capped at
|
|
524
|
-
* {@link MAX_TRACKED_ATTEMPTS} (oldest-write evicted first) so events
|
|
525
|
-
* resolved by another relay worker can't accumulate forever. For durable
|
|
526
|
-
* authoritative counts, apps can query the store directly inside
|
|
527
|
-
* {@link OutboxFailurePolicy}.
|
|
528
|
-
*/
|
|
529
|
-
_attempts = /* @__PURE__ */ new Map();
|
|
530
|
-
constructor(opts) {
|
|
531
|
-
if (opts.repository) this._store = repositoryAsOutboxStore(opts.repository);
|
|
532
|
-
else if (opts.store) this._store = opts.store;
|
|
533
|
-
else throw new Error("EventOutbox: either `repository` or `store` must be provided. Pass a RepositoryLike (mongokit / prismakit) for the common case, or a concrete OutboxStore (memory / custom) for non-repository backends.");
|
|
534
|
-
this._transport = opts.transport;
|
|
535
|
-
this._batchSize = opts.batchSize ?? 100;
|
|
536
|
-
this._consumerId = opts.consumerId ?? `relay-${Math.random().toString(36).slice(2, 10)}`;
|
|
537
|
-
this._leaseMs = opts.leaseMs ?? DEFAULT_LEASE_MS;
|
|
538
|
-
this._onError = opts.onError;
|
|
539
|
-
this._usePublishMany = opts.usePublishMany ?? true;
|
|
540
|
-
this._failurePolicy = opts.failurePolicy;
|
|
541
|
-
this._sessionProvider = opts.sessionProvider;
|
|
542
|
-
this._traceContextProvider = opts.traceContextProvider;
|
|
543
|
-
}
|
|
544
|
-
/** Unique consumer ID used for lease ownership when the store supports claims */
|
|
545
|
-
get consumerId() {
|
|
546
|
-
return this._consumerId;
|
|
547
|
-
}
|
|
548
|
-
/**
|
|
549
|
-
* Store event in outbox.
|
|
550
|
-
*
|
|
551
|
-
* Validates that `event.type` and `event.meta.id` are present — throws
|
|
552
|
-
* {@link InvalidOutboxEventError} otherwise, so corrupt rows can never
|
|
553
|
-
* be persisted via this API.
|
|
554
|
-
*
|
|
555
|
-
* Pass `options.session` to participate in a host-managed DB transaction
|
|
556
|
-
* (store must support session-aware writes). Other options (`visibleAt`,
|
|
557
|
-
* `dedupeKey`, `partitionKey`, `headers`) are forwarded to stores that
|
|
558
|
-
* implement them and ignored otherwise.
|
|
559
|
-
*/
|
|
560
|
-
async store(event, options) {
|
|
561
|
-
if (!event || typeof event !== "object") throw new InvalidOutboxEventError("event is not an object");
|
|
562
|
-
if (!event.type || typeof event.type !== "string") throw new InvalidOutboxEventError("event.type is required");
|
|
563
|
-
if (!event.meta?.id || typeof event.meta.id !== "string") throw new InvalidOutboxEventError("event.meta.id is required");
|
|
564
|
-
let effective = options;
|
|
565
|
-
if (effective?.session === void 0 && this._sessionProvider) {
|
|
566
|
-
const autoSession = this._sessionProvider();
|
|
567
|
-
if (autoSession !== void 0) effective = {
|
|
568
|
-
...effective ?? {},
|
|
569
|
-
session: autoSession
|
|
570
|
-
};
|
|
571
|
-
}
|
|
572
|
-
if (!effective?.headers?.traceparent && this._traceContextProvider) {
|
|
573
|
-
const traceHeaders = this._traceContextProvider();
|
|
574
|
-
if (traceHeaders && Object.keys(traceHeaders).length > 0) effective = {
|
|
575
|
-
...effective ?? {},
|
|
576
|
-
headers: {
|
|
577
|
-
...traceHeaders,
|
|
578
|
-
...effective?.headers ?? {}
|
|
579
|
-
}
|
|
580
|
-
};
|
|
581
|
-
}
|
|
582
|
-
if (effective?.dedupeKey === void 0 && event.meta.idempotencyKey) effective = {
|
|
583
|
-
...effective ?? {},
|
|
584
|
-
dedupeKey: event.meta.idempotencyKey
|
|
585
|
-
};
|
|
586
|
-
await this._store.save(event, effective);
|
|
587
|
-
}
|
|
588
|
-
_reportError(kind, error, event) {
|
|
589
|
-
if (!this._onError) return;
|
|
590
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
591
|
-
try {
|
|
592
|
-
this._onError({
|
|
593
|
-
kind,
|
|
594
|
-
event,
|
|
595
|
-
error: err
|
|
596
|
-
});
|
|
597
|
-
} catch {}
|
|
598
|
-
}
|
|
599
|
-
/**
|
|
600
|
-
* Relay pending events to transport and return the number of successful
|
|
601
|
-
* publish+acknowledge pairs.
|
|
602
|
-
*
|
|
603
|
-
* For richer observability (per-kind counts, publishMany detection, etc.)
|
|
604
|
-
* use {@link relayBatch} which returns a {@link RelayResult}. This method
|
|
605
|
-
* is the backward-compatible shortcut that returns just the count.
|
|
606
|
-
*
|
|
607
|
-
* @returns Number of successfully published AND acknowledged events
|
|
608
|
-
*/
|
|
609
|
-
async relay() {
|
|
610
|
-
return (await this.relayBatch()).relayed;
|
|
611
|
-
}
|
|
612
|
-
/**
|
|
613
|
-
* Relay a batch of pending events to the transport and return a rich
|
|
614
|
-
* {@link RelayResult} describing the outcome of each event.
|
|
615
|
-
*
|
|
616
|
-
* Behavior summary:
|
|
617
|
-
*
|
|
618
|
-
* - **Claim path**: uses {@link OutboxStore.claimPending} when the store
|
|
619
|
-
* supports it (safe for multi-worker relay) or falls back to
|
|
620
|
-
* {@link OutboxStore.getPending} (single-worker only).
|
|
621
|
-
*
|
|
622
|
-
* - **Publish path**: if the transport implements
|
|
623
|
-
* {@link EventTransport.publishMany} and `usePublishMany` is not disabled,
|
|
624
|
-
* the entire batch is sent in one call. Otherwise each event is published
|
|
625
|
-
* individually. Either way, per-event outcomes are tracked.
|
|
626
|
-
*
|
|
627
|
-
* - **Failure path**: if the store implements `fail`, per-event failures
|
|
628
|
-
* are reported via `store.fail(...)` and the batch continues. Without
|
|
629
|
-
* `fail`, the batch stops on the first failure (legacy behavior).
|
|
630
|
-
*
|
|
631
|
-
* - **Malformed events**: events missing `type` or `meta.id` abort the
|
|
632
|
-
* batch — a well-behaved store must never return them (see
|
|
633
|
-
* {@link OutboxStore} semantics #6). The error is reported via `onError`.
|
|
634
|
-
*
|
|
635
|
-
* - **Ownership mismatches**: if `acknowledge`/`fail` throws
|
|
636
|
-
* {@link OutboxOwnershipError} (our lease expired and another worker
|
|
637
|
-
* claimed the event), the event is NOT counted as relayed. The other
|
|
638
|
-
* worker will re-publish — at-least-once semantics preserved.
|
|
639
|
-
*
|
|
640
|
-
* @returns Per-kind outcome counts for the batch
|
|
641
|
-
*/
|
|
642
|
-
async relayBatch() {
|
|
643
|
-
const empty = {
|
|
644
|
-
relayed: 0,
|
|
645
|
-
attempted: 0,
|
|
646
|
-
publishFailed: 0,
|
|
647
|
-
ackFailed: 0,
|
|
648
|
-
ownershipMismatches: 0,
|
|
649
|
-
malformed: 0,
|
|
650
|
-
failHookErrors: 0,
|
|
651
|
-
deadLettered: 0,
|
|
652
|
-
usedPublishMany: false
|
|
653
|
-
};
|
|
654
|
-
if (!this._transport) return empty;
|
|
655
|
-
const claimStartedAt = Date.now();
|
|
656
|
-
const pending = this._store.claimPending ? await this._store.claimPending({
|
|
657
|
-
limit: this._batchSize,
|
|
658
|
-
consumerId: this._consumerId,
|
|
659
|
-
leaseMs: this._leaseMs
|
|
660
|
-
}) : await this._store.getPending(this._batchSize);
|
|
661
|
-
const claimMs = Date.now() - claimStartedAt;
|
|
662
|
-
const publishStartedAt = Date.now();
|
|
663
|
-
const valid = [];
|
|
664
|
-
let malformed = 0;
|
|
665
|
-
for (const event of pending) {
|
|
666
|
-
if (!event?.type || !event.meta?.id) {
|
|
667
|
-
this._reportError("malformed_event", new InvalidOutboxEventError("store returned event missing type or meta.id — batch aborted"), event);
|
|
668
|
-
malformed++;
|
|
669
|
-
break;
|
|
670
|
-
}
|
|
671
|
-
valid.push(event);
|
|
672
|
-
}
|
|
673
|
-
const counts = {
|
|
674
|
-
relayed: 0,
|
|
675
|
-
publishFailed: 0,
|
|
676
|
-
ackFailed: 0,
|
|
677
|
-
ownershipMismatches: 0,
|
|
678
|
-
failHookErrors: 0,
|
|
679
|
-
deadLettered: 0
|
|
680
|
-
};
|
|
681
|
-
const canPublishMany = this._usePublishMany && typeof this._transport.publishMany === "function";
|
|
682
|
-
const canFail = typeof this._store.fail === "function";
|
|
683
|
-
let publishOutcomes;
|
|
684
|
-
if (canPublishMany && valid.length > 0) try {
|
|
685
|
-
const result = await this._transport.publishMany?.(valid);
|
|
686
|
-
publishOutcomes = new Map(result);
|
|
687
|
-
} catch (batchErr) {
|
|
688
|
-
publishOutcomes = /* @__PURE__ */ new Map();
|
|
689
|
-
const err = batchErr instanceof Error ? batchErr : new Error(String(batchErr));
|
|
690
|
-
for (const ev of valid) publishOutcomes.set(ev.meta.id, err);
|
|
691
|
-
}
|
|
692
|
-
else {
|
|
693
|
-
publishOutcomes = /* @__PURE__ */ new Map();
|
|
694
|
-
for (const event of valid) try {
|
|
695
|
-
await this._transport.publish(event);
|
|
696
|
-
publishOutcomes.set(event.meta.id, null);
|
|
697
|
-
} catch (err) {
|
|
698
|
-
publishOutcomes.set(event.meta.id, err instanceof Error ? err : new Error(String(err)));
|
|
699
|
-
if (!canFail) break;
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
let stopBatch = false;
|
|
703
|
-
for (const event of valid) {
|
|
704
|
-
if (stopBatch) break;
|
|
705
|
-
const publishErr = publishOutcomes.get(event.meta.id);
|
|
706
|
-
if (publishErr instanceof Error) {
|
|
707
|
-
counts.publishFailed++;
|
|
708
|
-
this._reportError("publish_failed", publishErr, event);
|
|
709
|
-
if (!canFail) {
|
|
710
|
-
stopBatch = true;
|
|
711
|
-
continue;
|
|
712
|
-
}
|
|
713
|
-
const attempts = (this._attempts.get(event.meta.id) ?? 0) + 1;
|
|
714
|
-
this._attempts.delete(event.meta.id);
|
|
715
|
-
this._attempts.set(event.meta.id, attempts);
|
|
716
|
-
if (this._attempts.size > MAX_TRACKED_ATTEMPTS) {
|
|
717
|
-
const oldest = this._attempts.keys().next().value;
|
|
718
|
-
if (oldest !== void 0) this._attempts.delete(oldest);
|
|
719
|
-
}
|
|
720
|
-
let failOpts = { consumerId: this._consumerId };
|
|
721
|
-
if (this._failurePolicy) try {
|
|
722
|
-
const decision = await this._failurePolicy({
|
|
723
|
-
event,
|
|
724
|
-
error: publishErr,
|
|
725
|
-
attempts
|
|
726
|
-
});
|
|
727
|
-
failOpts = {
|
|
728
|
-
...failOpts,
|
|
729
|
-
...decision
|
|
730
|
-
};
|
|
731
|
-
} catch (policyErr) {
|
|
732
|
-
this._reportError("fail_failed", policyErr, event);
|
|
733
|
-
}
|
|
734
|
-
try {
|
|
735
|
-
await this._store.fail?.(event.meta.id, normalizeError(publishErr), failOpts);
|
|
736
|
-
if (failOpts.deadLetter) {
|
|
737
|
-
counts.deadLettered++;
|
|
738
|
-
this._attempts.delete(event.meta.id);
|
|
739
|
-
}
|
|
740
|
-
} catch (failErr) {
|
|
741
|
-
if (failErr instanceof OutboxOwnershipError) {
|
|
742
|
-
counts.ownershipMismatches++;
|
|
743
|
-
this._reportError("ownership_mismatch", failErr, event);
|
|
744
|
-
} else {
|
|
745
|
-
counts.failHookErrors++;
|
|
746
|
-
this._reportError("fail_failed", failErr, event);
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
continue;
|
|
750
|
-
}
|
|
751
|
-
try {
|
|
752
|
-
await this._store.acknowledge(event.meta.id, { consumerId: this._consumerId });
|
|
753
|
-
counts.relayed++;
|
|
754
|
-
this._attempts.delete(event.meta.id);
|
|
755
|
-
} catch (ackErr) {
|
|
756
|
-
counts.ackFailed++;
|
|
757
|
-
if (ackErr instanceof OutboxOwnershipError) {
|
|
758
|
-
counts.ownershipMismatches++;
|
|
759
|
-
this._reportError("ownership_mismatch", ackErr, event);
|
|
760
|
-
} else this._reportError("acknowledge_failed", ackErr, event);
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
return {
|
|
764
|
-
relayed: counts.relayed,
|
|
765
|
-
attempted: valid.length,
|
|
766
|
-
publishFailed: counts.publishFailed,
|
|
767
|
-
ackFailed: counts.ackFailed,
|
|
768
|
-
ownershipMismatches: counts.ownershipMismatches,
|
|
769
|
-
malformed,
|
|
770
|
-
failHookErrors: counts.failHookErrors,
|
|
771
|
-
deadLettered: counts.deadLettered,
|
|
772
|
-
usedPublishMany: canPublishMany && valid.length > 0,
|
|
773
|
-
claimMs,
|
|
774
|
-
publishMs: Date.now() - publishStartedAt
|
|
775
|
-
};
|
|
776
|
-
}
|
|
777
|
-
/**
|
|
778
|
-
* Fetch current dead-lettered events as typed {@link DeadLetteredEvent}
|
|
779
|
-
* envelopes. Delegates to {@link OutboxStore.getDeadLettered} — returns
|
|
780
|
-
* `[]` when the store doesn't implement it.
|
|
781
|
-
*
|
|
782
|
-
* Pairs with {@link OutboxFailurePolicy}: apps set a policy that routes to
|
|
783
|
-
* `deadLetter: true` after N attempts, then read back with this to alert,
|
|
784
|
-
* replay, or archive.
|
|
785
|
-
*/
|
|
786
|
-
async getDeadLettered(limit = 100) {
|
|
787
|
-
if (!this._store.getDeadLettered) return [];
|
|
788
|
-
return this._store.getDeadLettered(limit);
|
|
789
|
-
}
|
|
790
|
-
/**
|
|
791
|
-
* Purge old **delivered** events from the outbox store.
|
|
792
|
-
* Delegates to `store.purge()` if implemented; no-op otherwise.
|
|
793
|
-
* @param olderThanMs - Remove events delivered more than this many ms ago (default: 7 days)
|
|
794
|
-
* @returns Number of purged events, or 0 if store doesn't support purge
|
|
795
|
-
*/
|
|
796
|
-
async purge(olderThanMs = DEFAULT_OUTBOX_RETENTION_MS) {
|
|
797
|
-
if (!this._store.purge) return 0;
|
|
798
|
-
return this._store.purge(olderThanMs);
|
|
799
|
-
}
|
|
800
|
-
};
|
|
801
|
-
function normalizeError(err) {
|
|
802
|
-
if (err instanceof Error) return {
|
|
803
|
-
message: err.message,
|
|
804
|
-
code: err.code
|
|
805
|
-
};
|
|
806
|
-
return { message: String(err) };
|
|
807
|
-
}
|
|
808
|
-
//#endregion
|
|
809
|
-
export { ARC_LIFECYCLE_EVENTS, CACHE_EVENTS, CRUD_EVENT_SUFFIXES, EventOutbox, MemoryEventTransport, MemoryOutboxStore, createDeadLetterPublisher, createEventRegistry, crudEventType, defineEvent, eventPlugin, exponentialBackoff, repositoryAsOutboxStore, subscribeWithBoundary, subscribeWithSchema, withRetry, wrapWithBoundary, wrapWithSchema };
|
|
58
|
+
export { ARC_LIFECYCLE_EVENTS, CACHE_EVENTS, CRUD_EVENT_SUFFIXES, EventOutbox, MemoryEventTransport, MemoryOutboxStore, createDeadLetterPublisher, createEventRegistry, createOutboxModule, crudEventType, defineEvent, eventPlugin, exponentialBackoff, repositoryAsOutboxStore, subscribeWithBoundary, subscribeWithSchema, withRetry, wrapWithBoundary, wrapWithSchema };
|