@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
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
/*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
|
|
2
|
+
import { i as resolveEventTransport } from "./EventTransport-tq4CEB-q.mjs";
|
|
3
|
+
import { n as createSafeGetOne, t as createIsDuplicateKeyError } from "./store-helpers-B88p99Z2.mjs";
|
|
4
|
+
import { and, anyOf, eq, lte, ne, or } from "@classytic/repo-core/filter";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { InvalidOutboxEventError, OutboxOwnershipError } from "@classytic/primitives/outbox";
|
|
7
|
+
import { update } from "@classytic/repo-core/update";
|
|
8
|
+
import { hostname } from "node:os";
|
|
9
|
+
//#region src/events/outbox/backoff.ts
|
|
10
|
+
/**
|
|
11
|
+
* Compute a `retryAt` `Date` using exponential backoff with jitter.
|
|
12
|
+
*
|
|
13
|
+
* This is a convenience helper for store authors implementing
|
|
14
|
+
* {@link OutboxStore.fail}: call it to compute the retry visibility window
|
|
15
|
+
* based on the event's current attempt count.
|
|
16
|
+
*
|
|
17
|
+
* Formula: `delay = min(maxMs, baseMs * 2^(attempt - 1)) * (1 + random * jitter)`
|
|
18
|
+
*
|
|
19
|
+
* @example Basic usage inside a store's `fail()` method
|
|
20
|
+
* ```typescript
|
|
21
|
+
* async fail(eventId, error, options) {
|
|
22
|
+
* const entry = await this.findById(eventId);
|
|
23
|
+
* entry.attempts++;
|
|
24
|
+
* if (entry.attempts >= MAX_ATTEMPTS) {
|
|
25
|
+
* return this.deadLetter(eventId, error);
|
|
26
|
+
* }
|
|
27
|
+
* const retryAt = exponentialBackoff({ attempt: entry.attempts });
|
|
28
|
+
* entry.visibleAt = retryAt;
|
|
29
|
+
* await this.update(entry);
|
|
30
|
+
* }
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* @example Tuning for a faster transport
|
|
34
|
+
* ```typescript
|
|
35
|
+
* exponentialBackoff({ attempt: 3, baseMs: 250, maxMs: 10_000, jitter: 0.3 });
|
|
36
|
+
* // attempt=1 → ~250ms ±30%
|
|
37
|
+
* // attempt=2 → ~500ms ±30%
|
|
38
|
+
* // attempt=3 → ~1000ms ±30%
|
|
39
|
+
* // attempt=10 → capped at 10_000ms
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
function exponentialBackoff(options) {
|
|
43
|
+
const attempt = Math.max(1, Math.floor(options.attempt));
|
|
44
|
+
const baseMs = options.baseMs ?? 1e3;
|
|
45
|
+
const maxMs = options.maxMs ?? 6e4;
|
|
46
|
+
const jitter = Math.max(0, Math.min(1, options.jitter ?? .2));
|
|
47
|
+
const now = options.now ?? Date.now();
|
|
48
|
+
const exp = baseMs * 2 ** (attempt - 1);
|
|
49
|
+
const capped = Math.min(maxMs, exp);
|
|
50
|
+
const jittered = jitter > 0 ? capped * (1 + Math.random() * jitter) : capped;
|
|
51
|
+
return new Date(now + jittered);
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/events/repository-outbox-adapter.ts
|
|
55
|
+
const DEFAULT_LEASE_MS$1 = 3e4;
|
|
56
|
+
const DEFAULT_CLAIM_LIMIT = 100;
|
|
57
|
+
const DEFAULT_PURGE_BATCH = 500;
|
|
58
|
+
/** Column this adapter reads/writes for the visibility timestamp. */
|
|
59
|
+
const DEFAULT_VISIBLE_AT_FIELD = "visibleAt";
|
|
60
|
+
function repositoryAsOutboxStore(repository, options) {
|
|
61
|
+
const visibleAtField = options?.visibleAtField ?? DEFAULT_VISIBLE_AT_FIELD;
|
|
62
|
+
const missing = [];
|
|
63
|
+
if (typeof repository.create !== "function") missing.push("create");
|
|
64
|
+
if (typeof repository.getOne !== "function") missing.push("getOne");
|
|
65
|
+
if (typeof repository.getAll !== "function") missing.push("getAll");
|
|
66
|
+
if (typeof repository.deleteMany !== "function") missing.push("deleteMany");
|
|
67
|
+
if (typeof repository.findOneAndUpdate !== "function") missing.push("findOneAndUpdate");
|
|
68
|
+
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.`);
|
|
69
|
+
const r = repository;
|
|
70
|
+
const idField = repository.idField ?? "_id";
|
|
71
|
+
/**
|
|
72
|
+
* Unwrap mongokit's pagination envelope ({ data, total, ... }) — some
|
|
73
|
+
* kits may return a bare array when pagination is disabled. Handle both.
|
|
74
|
+
*/
|
|
75
|
+
const unwrapDocs = (result) => {
|
|
76
|
+
if (Array.isArray(result)) return result;
|
|
77
|
+
return result?.data ?? [];
|
|
78
|
+
};
|
|
79
|
+
const isDuplicateKeyError = createIsDuplicateKeyError(repository);
|
|
80
|
+
const safeGetOne = createSafeGetOne(repository);
|
|
81
|
+
const isWellFormed = (event) => !!event && typeof event.type === "string" && !!event.meta?.id;
|
|
82
|
+
/**
|
|
83
|
+
* Filter matching every row that's eligible to be claimed by a relayer:
|
|
84
|
+
* status=pending, visible now, and either unleased or under an expired
|
|
85
|
+
* lease. Used by `getPending` and `claimPending` — defined once so the
|
|
86
|
+
* two code paths stay in lockstep.
|
|
87
|
+
*/
|
|
88
|
+
const claimableFilter = (now) => and(eq("status", "pending"), lte(visibleAtField, now), or(eq("leaseOwner", null), lte("leaseExpiresAt", now)));
|
|
89
|
+
/**
|
|
90
|
+
* FENCED claim — the ONE claim implementation (`claimPending` delegates).
|
|
91
|
+
* The token rides the SAME CAS that claims the row: `inc: { fenceToken: 1 }`
|
|
92
|
+
* next to the attempts bump, so a takeover after lease expiry mints a
|
|
93
|
+
* strictly greater token in the very statement that takes ownership —
|
|
94
|
+
* no second round trip, no window. Minted by the store; a process cannot
|
|
95
|
+
* fence itself.
|
|
96
|
+
*/
|
|
97
|
+
const claimPendingFenced = async (options) => {
|
|
98
|
+
const limit = options?.limit ?? DEFAULT_CLAIM_LIMIT;
|
|
99
|
+
const leaseMs = options?.leaseMs ?? DEFAULT_LEASE_MS$1;
|
|
100
|
+
const consumerId = options?.consumerId ?? "anonymous";
|
|
101
|
+
const typeFilter = options?.types?.length ? anyOf("type", options.types) : null;
|
|
102
|
+
const now = /* @__PURE__ */ new Date();
|
|
103
|
+
const candidateFilter = typeFilter ? and(claimableFilter(now), typeFilter) : claimableFilter(now);
|
|
104
|
+
const candidates = unwrapDocs(await r.getAll({
|
|
105
|
+
filters: candidateFilter,
|
|
106
|
+
sort: { createdAt: 1 },
|
|
107
|
+
page: 1,
|
|
108
|
+
limit
|
|
109
|
+
}));
|
|
110
|
+
if (candidates.length === 0) return [];
|
|
111
|
+
return (await Promise.all(candidates.map((candidate) => {
|
|
112
|
+
const claimNow = /* @__PURE__ */ new Date();
|
|
113
|
+
const leaseExpiresAt = new Date(claimNow.getTime() + leaseMs);
|
|
114
|
+
return r.findOneAndUpdate(and(eq(idField, candidate.event.meta.id), claimableFilter(claimNow)), update({
|
|
115
|
+
set: {
|
|
116
|
+
leaseOwner: consumerId,
|
|
117
|
+
leaseExpiresAt
|
|
118
|
+
},
|
|
119
|
+
inc: {
|
|
120
|
+
attempts: 1,
|
|
121
|
+
fenceToken: 1
|
|
122
|
+
}
|
|
123
|
+
}), { returnDocument: "after" });
|
|
124
|
+
}))).filter((doc) => doc !== null).filter((doc) => isWellFormed(doc.event)).map((doc) => ({
|
|
125
|
+
event: doc.event,
|
|
126
|
+
fencingToken: doc.fenceToken ?? 0
|
|
127
|
+
}));
|
|
128
|
+
};
|
|
129
|
+
return {
|
|
130
|
+
/**
|
|
131
|
+
* Transactional: `save` forwards `options.session` to the backing
|
|
132
|
+
* repository's `create`, so the event row joins the caller's transaction on
|
|
133
|
+
* the SAME connection and commits or rolls back with the domain write.
|
|
134
|
+
*
|
|
135
|
+
* Declared explicitly because `OutboxWriteOptions.session` is best-effort by
|
|
136
|
+
* contract — a caller cannot infer atomicity from the presence of an outbox,
|
|
137
|
+
* only from this flag. See `OutboxStore.transactionalSave` in
|
|
138
|
+
* `@classytic/primitives/outbox`.
|
|
139
|
+
*/
|
|
140
|
+
transactionalSave: true,
|
|
141
|
+
async save(event, options) {
|
|
142
|
+
if (!event?.type || typeof event.type !== "string") throw new InvalidOutboxEventError("event.type is required");
|
|
143
|
+
if (!event.meta?.id || typeof event.meta.id !== "string") throw new InvalidOutboxEventError("event.meta.id is required");
|
|
144
|
+
const now = /* @__PURE__ */ new Date();
|
|
145
|
+
const doc = {
|
|
146
|
+
[idField]: event.meta.id,
|
|
147
|
+
event,
|
|
148
|
+
type: event.type,
|
|
149
|
+
status: "pending",
|
|
150
|
+
attempts: 0,
|
|
151
|
+
[visibleAtField]: options?.visibleAt ?? now,
|
|
152
|
+
leaseOwner: null,
|
|
153
|
+
leaseExpiresAt: null,
|
|
154
|
+
deliveredAt: null,
|
|
155
|
+
firstFailedAt: null,
|
|
156
|
+
lastFailedAt: null,
|
|
157
|
+
lastError: null,
|
|
158
|
+
dedupeKey: options?.dedupeKey ?? null,
|
|
159
|
+
partitionKey: options?.partitionKey ?? null,
|
|
160
|
+
headers: options?.headers ? { ...options.headers } : null,
|
|
161
|
+
createdAt: now
|
|
162
|
+
};
|
|
163
|
+
try {
|
|
164
|
+
await r.create(doc, options?.session ? { session: options.session } : void 0);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (isDuplicateKeyError(err)) return;
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
async getPending(limit) {
|
|
171
|
+
const now = /* @__PURE__ */ new Date();
|
|
172
|
+
const result = await r.getAll({
|
|
173
|
+
filters: claimableFilter(now),
|
|
174
|
+
sort: { createdAt: 1 },
|
|
175
|
+
page: 1,
|
|
176
|
+
limit
|
|
177
|
+
});
|
|
178
|
+
return unwrapDocs(result).map((d) => d.event).filter(isWellFormed);
|
|
179
|
+
},
|
|
180
|
+
async claimPending(options) {
|
|
181
|
+
return (await claimPendingFenced(options)).map((c) => c.event);
|
|
182
|
+
},
|
|
183
|
+
claimPendingFenced,
|
|
184
|
+
async acknowledge(eventId, options) {
|
|
185
|
+
const now = /* @__PURE__ */ new Date();
|
|
186
|
+
const baseFilter = and(eq(idField, eventId), ne("status", "delivered"));
|
|
187
|
+
let filter = options?.consumerId ? and(baseFilter, eq("leaseOwner", options.consumerId)) : baseFilter;
|
|
188
|
+
if (options?.fencingToken !== void 0) filter = and(filter, eq("fenceToken", options.fencingToken));
|
|
189
|
+
if (await r.findOneAndUpdate(filter, update({ set: {
|
|
190
|
+
status: "delivered",
|
|
191
|
+
deliveredAt: now,
|
|
192
|
+
leaseOwner: null,
|
|
193
|
+
leaseExpiresAt: null
|
|
194
|
+
} }), { returnDocument: "after" })) return;
|
|
195
|
+
const current = await safeGetOne(eq(idField, eventId));
|
|
196
|
+
if (!current) return;
|
|
197
|
+
if (current.status === "delivered") return;
|
|
198
|
+
if (options?.consumerId && current.leaseOwner !== options.consumerId) throw new OutboxOwnershipError(eventId, options.consumerId, current.leaseOwner);
|
|
199
|
+
if (options?.fencingToken !== void 0 && current.fenceToken !== options.fencingToken) throw new OutboxOwnershipError(eventId, `token ${options.fencingToken}`, `token ${current.fenceToken}`);
|
|
200
|
+
},
|
|
201
|
+
async fail(eventId, error, options) {
|
|
202
|
+
const now = /* @__PURE__ */ new Date();
|
|
203
|
+
const targetStatus = options?.deadLetter ? "dead_letter" : "pending";
|
|
204
|
+
const visibleAt = options?.retryAt ?? now;
|
|
205
|
+
const baseFilter = eq(idField, eventId);
|
|
206
|
+
let filter = options?.consumerId ? and(baseFilter, eq("leaseOwner", options.consumerId)) : baseFilter;
|
|
207
|
+
if (options?.fencingToken !== void 0) filter = and(filter, eq("fenceToken", options.fencingToken));
|
|
208
|
+
const current = await safeGetOne(baseFilter);
|
|
209
|
+
if (!current) return;
|
|
210
|
+
if (options?.consumerId && current.leaseOwner !== options.consumerId) throw new OutboxOwnershipError(eventId, options.consumerId, current.leaseOwner);
|
|
211
|
+
const errorInfo = error.code ? {
|
|
212
|
+
message: error.message,
|
|
213
|
+
code: error.code
|
|
214
|
+
} : { message: error.message };
|
|
215
|
+
const firstFailedAt = current.firstFailedAt ?? now;
|
|
216
|
+
if (await r.findOneAndUpdate(filter, update({ set: {
|
|
217
|
+
status: targetStatus,
|
|
218
|
+
[visibleAtField]: visibleAt,
|
|
219
|
+
leaseOwner: null,
|
|
220
|
+
leaseExpiresAt: null,
|
|
221
|
+
lastFailedAt: now,
|
|
222
|
+
lastError: errorInfo,
|
|
223
|
+
firstFailedAt
|
|
224
|
+
} }), { returnDocument: "after" })) return;
|
|
225
|
+
if (options?.consumerId) {
|
|
226
|
+
const after = await safeGetOne(baseFilter);
|
|
227
|
+
if (after && after.leaseOwner !== options.consumerId) throw new OutboxOwnershipError(eventId, options.consumerId, after.leaseOwner);
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
async getDeadLettered(limit) {
|
|
231
|
+
const result = await r.getAll({
|
|
232
|
+
filters: eq("status", "dead_letter"),
|
|
233
|
+
sort: { [idField]: 1 },
|
|
234
|
+
page: 1,
|
|
235
|
+
limit
|
|
236
|
+
});
|
|
237
|
+
return unwrapDocs(result).filter((d) => isWellFormed(d.event)).map((d) => ({
|
|
238
|
+
event: d.event,
|
|
239
|
+
error: {
|
|
240
|
+
message: d.lastError?.message ?? "unknown",
|
|
241
|
+
...d.lastError?.code !== void 0 ? { code: d.lastError.code } : {}
|
|
242
|
+
},
|
|
243
|
+
attempts: d.attempts,
|
|
244
|
+
firstFailedAt: d.firstFailedAt ?? d.lastFailedAt ?? d.createdAt,
|
|
245
|
+
lastFailedAt: d.lastFailedAt ?? d.firstFailedAt ?? d.createdAt
|
|
246
|
+
}));
|
|
247
|
+
},
|
|
248
|
+
async purge(olderThanMs) {
|
|
249
|
+
const cutoff = new Date(Date.now() - olderThanMs);
|
|
250
|
+
let totalDeleted = 0;
|
|
251
|
+
for (;;) {
|
|
252
|
+
const result = await r.getAll({
|
|
253
|
+
filters: and(eq("status", "delivered"), lte("deliveredAt", cutoff)),
|
|
254
|
+
sort: { deliveredAt: 1 },
|
|
255
|
+
page: 1,
|
|
256
|
+
limit: DEFAULT_PURGE_BATCH,
|
|
257
|
+
select: idField
|
|
258
|
+
});
|
|
259
|
+
const batch = unwrapDocs(result);
|
|
260
|
+
if (batch.length === 0) break;
|
|
261
|
+
const ids = batch.map((d) => d[idField]);
|
|
262
|
+
const res = await r.deleteMany(anyOf(idField, ids));
|
|
263
|
+
totalDeleted += res.deletedCount ?? 0;
|
|
264
|
+
if (batch.length < DEFAULT_PURGE_BATCH) break;
|
|
265
|
+
}
|
|
266
|
+
return totalDeleted;
|
|
267
|
+
},
|
|
268
|
+
/**
|
|
269
|
+
* Re-drive one dead-lettered event — the other half of `fail({ deadLetter: true })`.
|
|
270
|
+
*
|
|
271
|
+
* Filtered on `status: 'dead_letter'` as well as the id, so this is a no-op on a row
|
|
272
|
+
* that is merely retrying. Requeuing a backing-off row would zero its attempt count
|
|
273
|
+
* and defeat the failure policy, and the id alone cannot distinguish the two.
|
|
274
|
+
*
|
|
275
|
+
* `attempts` resets because the operator's whole claim is that the CAUSE is fixed:
|
|
276
|
+
* carrying the old count forward would dead-letter the retry again after one more
|
|
277
|
+
* blip, which reads as "the fix did not work".
|
|
278
|
+
*/
|
|
279
|
+
async requeue(eventId) {
|
|
280
|
+
return await r.findOneAndUpdate(and(eq(idField, eventId), eq("status", "dead_letter")), update({ set: {
|
|
281
|
+
status: "pending",
|
|
282
|
+
attempts: 0,
|
|
283
|
+
leaseOwner: null,
|
|
284
|
+
leaseExpiresAt: null,
|
|
285
|
+
[visibleAtField]: /* @__PURE__ */ new Date(),
|
|
286
|
+
lastError: null
|
|
287
|
+
} }), { returnDocument: "after" }) != null;
|
|
288
|
+
},
|
|
289
|
+
/** Rows in `status` — the count behind an operator dashboard or a dead-letter alert. */
|
|
290
|
+
async countByStatus(status) {
|
|
291
|
+
const filters = eq("status", status);
|
|
292
|
+
const counter = repository.count;
|
|
293
|
+
if (typeof counter === "function") return counter.call(repository, filters);
|
|
294
|
+
/**
|
|
295
|
+
* Fallback for a kit without `count`. Two shapes, and the difference decides the
|
|
296
|
+
* query — getting it wrong here produces a number that LIES, which is worse than
|
|
297
|
+
* no number at all: the operator reads a healthy dashboard and stops looking.
|
|
298
|
+
*
|
|
299
|
+
* A paginated kit reports the match count in its envelope, so one row is enough
|
|
300
|
+
* to read it — deliberately not a hydrate-and-length, since a `delivered` count
|
|
301
|
+
* would drag the whole retention window into memory for one integer.
|
|
302
|
+
*
|
|
303
|
+
* A kit that returns a BARE ARRAY has no envelope to read, so the rows ARE the
|
|
304
|
+
* count and every match has to come back. `unwrapDocs` documents that shape as
|
|
305
|
+
* real, so it is not hypothetical. `select: idField` keeps it to one column.
|
|
306
|
+
*/
|
|
307
|
+
const probe = await r.getAll({
|
|
308
|
+
filters,
|
|
309
|
+
page: 1,
|
|
310
|
+
limit: 1,
|
|
311
|
+
select: idField
|
|
312
|
+
});
|
|
313
|
+
if (Array.isArray(probe)) {
|
|
314
|
+
const all = await r.getAll({
|
|
315
|
+
filters,
|
|
316
|
+
select: idField
|
|
317
|
+
});
|
|
318
|
+
return unwrapDocs(all).length;
|
|
319
|
+
}
|
|
320
|
+
const total = probe?.total ?? probe?.meta?.total;
|
|
321
|
+
if (typeof total === "number") return total;
|
|
322
|
+
/**
|
|
323
|
+
* An envelope in a shape neither branch recognises. Throwing beats returning 0:
|
|
324
|
+
* a zero is indistinguishable from a healthy queue, so it would silence exactly
|
|
325
|
+
* the alert this method exists to raise.
|
|
326
|
+
*/
|
|
327
|
+
throw new Error("repositoryAsOutboxStore.countByStatus: the repository's `getAll` returned an envelope with no `total` (and is not a bare array), so the count cannot be read. Implement `count(filter)` on the repository — every kit that has it answers this in one query.");
|
|
328
|
+
},
|
|
329
|
+
/**
|
|
330
|
+
* Age of the oldest PENDING row, or `null` when the queue is empty.
|
|
331
|
+
*
|
|
332
|
+
* The signal a pending count cannot give: a count of 40 looks the same whether the
|
|
333
|
+
* relay is draining briskly or has been wedged for an hour behind one poison row.
|
|
334
|
+
*
|
|
335
|
+
* Measured from `createdAt`, not the last attempt — the question is how long the
|
|
336
|
+
* event has gone undelivered, and restarting the clock on every retry would make a
|
|
337
|
+
* permanently-failing row look permanently fresh, which is the exact row an alert
|
|
338
|
+
* exists to surface.
|
|
339
|
+
*/
|
|
340
|
+
async oldestPendingAgeMs() {
|
|
341
|
+
const result = await r.getAll({
|
|
342
|
+
filters: eq("status", "pending"),
|
|
343
|
+
sort: { createdAt: 1 },
|
|
344
|
+
page: 1,
|
|
345
|
+
limit: 1
|
|
346
|
+
});
|
|
347
|
+
const [oldest] = unwrapDocs(result);
|
|
348
|
+
if (oldest?.createdAt == null) return null;
|
|
349
|
+
return Date.now() - new Date(oldest.createdAt).getTime();
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
//#endregion
|
|
354
|
+
//#region src/events/outbox/relay.ts
|
|
355
|
+
/** Default outbox retention — delivered events older than this are eligible for purge */
|
|
356
|
+
const DEFAULT_OUTBOX_RETENTION_MS = 6048e5;
|
|
357
|
+
const DEFAULT_LEASE_MS = 3e4;
|
|
358
|
+
/**
|
|
359
|
+
* Hard cap on the in-process attempt-counter map. Entries are cleared on
|
|
360
|
+
* local ack and on dead-letter, but an event that fails HERE and is later
|
|
361
|
+
* acked by a DIFFERENT relay worker (lease expiry → reclaim) never drains
|
|
362
|
+
* locally — without a cap the map grows for the life of the process in
|
|
363
|
+
* multi-worker deployments. Eviction is oldest-write-first; evicting an
|
|
364
|
+
* entry only resets its non-authoritative in-process count (the documented
|
|
365
|
+
* contract already says counts reset on restart — durable counts live in
|
|
366
|
+
* the store).
|
|
367
|
+
*/
|
|
368
|
+
const MAX_TRACKED_ATTEMPTS = 1e4;
|
|
369
|
+
var EventOutbox = class {
|
|
370
|
+
_store;
|
|
371
|
+
_transport;
|
|
372
|
+
_batchSize;
|
|
373
|
+
_consumerId;
|
|
374
|
+
_leaseMs;
|
|
375
|
+
_onError;
|
|
376
|
+
_usePublishMany;
|
|
377
|
+
_sessionProvider;
|
|
378
|
+
_traceContextProvider;
|
|
379
|
+
_failurePolicy;
|
|
380
|
+
/**
|
|
381
|
+
* In-process attempt counter per event id. Accurate within this relay
|
|
382
|
+
* process; resets on restart. Populated as failures occur and cleared on
|
|
383
|
+
* successful ack or dead-letter transition; additionally hard-capped at
|
|
384
|
+
* {@link MAX_TRACKED_ATTEMPTS} (oldest-write evicted first) so events
|
|
385
|
+
* resolved by another relay worker can't accumulate forever. For durable
|
|
386
|
+
* authoritative counts, apps can query the store directly inside
|
|
387
|
+
* {@link OutboxFailurePolicy}.
|
|
388
|
+
*/
|
|
389
|
+
_attempts = /* @__PURE__ */ new Map();
|
|
390
|
+
constructor(opts) {
|
|
391
|
+
if (opts.repository) this._store = repositoryAsOutboxStore(opts.repository);
|
|
392
|
+
else if (opts.store) this._store = opts.store;
|
|
393
|
+
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.");
|
|
394
|
+
this._transport = opts.transport;
|
|
395
|
+
this._batchSize = opts.batchSize ?? 100;
|
|
396
|
+
this._consumerId = opts.consumerId ?? `relay-${Math.random().toString(36).slice(2, 10)}`;
|
|
397
|
+
this._leaseMs = opts.leaseMs ?? DEFAULT_LEASE_MS;
|
|
398
|
+
this._onError = opts.onError;
|
|
399
|
+
this._usePublishMany = opts.usePublishMany ?? true;
|
|
400
|
+
this._failurePolicy = opts.failurePolicy;
|
|
401
|
+
this._sessionProvider = opts.sessionProvider;
|
|
402
|
+
this._traceContextProvider = opts.traceContextProvider;
|
|
403
|
+
}
|
|
404
|
+
/** Unique consumer ID used for lease ownership when the store supports claims */
|
|
405
|
+
get consumerId() {
|
|
406
|
+
return this._consumerId;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Store event in outbox.
|
|
410
|
+
*
|
|
411
|
+
* Validates that `event.type` and `event.meta.id` are present — throws
|
|
412
|
+
* {@link InvalidOutboxEventError} otherwise, so corrupt rows can never
|
|
413
|
+
* be persisted via this API.
|
|
414
|
+
*
|
|
415
|
+
* Pass `options.session` to participate in a host-managed DB transaction
|
|
416
|
+
* (store must support session-aware writes). Other options (`visibleAt`,
|
|
417
|
+
* `dedupeKey`, `partitionKey`, `headers`) are forwarded to stores that
|
|
418
|
+
* implement them and ignored otherwise.
|
|
419
|
+
*/
|
|
420
|
+
async store(event, options) {
|
|
421
|
+
if (!event || typeof event !== "object") throw new InvalidOutboxEventError("event is not an object");
|
|
422
|
+
if (!event.type || typeof event.type !== "string") throw new InvalidOutboxEventError("event.type is required");
|
|
423
|
+
if (!event.meta?.id || typeof event.meta.id !== "string") throw new InvalidOutboxEventError("event.meta.id is required");
|
|
424
|
+
let effective = options;
|
|
425
|
+
if (effective?.session === void 0 && this._sessionProvider) {
|
|
426
|
+
const autoSession = this._sessionProvider();
|
|
427
|
+
if (autoSession !== void 0) effective = {
|
|
428
|
+
...effective ?? {},
|
|
429
|
+
session: autoSession
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
if (!effective?.headers?.traceparent && this._traceContextProvider) {
|
|
433
|
+
const traceHeaders = this._traceContextProvider();
|
|
434
|
+
if (traceHeaders && Object.keys(traceHeaders).length > 0) effective = {
|
|
435
|
+
...effective ?? {},
|
|
436
|
+
headers: {
|
|
437
|
+
...traceHeaders,
|
|
438
|
+
...effective?.headers ?? {}
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
if (effective?.dedupeKey === void 0 && event.meta.idempotencyKey) effective = {
|
|
443
|
+
...effective ?? {},
|
|
444
|
+
dedupeKey: event.meta.idempotencyKey
|
|
445
|
+
};
|
|
446
|
+
await this._store.save(event, effective);
|
|
447
|
+
}
|
|
448
|
+
_reportError(kind, error, event) {
|
|
449
|
+
if (!this._onError) return;
|
|
450
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
451
|
+
try {
|
|
452
|
+
this._onError({
|
|
453
|
+
kind,
|
|
454
|
+
event,
|
|
455
|
+
error: err
|
|
456
|
+
});
|
|
457
|
+
} catch {}
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Relay pending events to transport and return the number of successful
|
|
461
|
+
* publish+acknowledge pairs.
|
|
462
|
+
*
|
|
463
|
+
* For richer observability (per-kind counts, publishMany detection, etc.)
|
|
464
|
+
* use {@link relayBatch} which returns a {@link RelayResult}. This method
|
|
465
|
+
* is the backward-compatible shortcut that returns just the count.
|
|
466
|
+
*
|
|
467
|
+
* @returns Number of successfully published AND acknowledged events
|
|
468
|
+
*/
|
|
469
|
+
async relay() {
|
|
470
|
+
return (await this.relayBatch()).relayed;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Relay a batch of pending events to the transport and return a rich
|
|
474
|
+
* {@link RelayResult} describing the outcome of each event.
|
|
475
|
+
*
|
|
476
|
+
* Behavior summary:
|
|
477
|
+
*
|
|
478
|
+
* - **Claim path**: uses {@link OutboxStore.claimPending} when the store
|
|
479
|
+
* supports it (safe for multi-worker relay) or falls back to
|
|
480
|
+
* {@link OutboxStore.getPending} (single-worker only).
|
|
481
|
+
*
|
|
482
|
+
* - **Publish path**: if the transport implements
|
|
483
|
+
* {@link EventTransport.publishMany} and `usePublishMany` is not disabled,
|
|
484
|
+
* the entire batch is sent in one call. Otherwise each event is published
|
|
485
|
+
* individually. Either way, per-event outcomes are tracked.
|
|
486
|
+
*
|
|
487
|
+
* - **Failure path**: if the store implements `fail`, per-event failures
|
|
488
|
+
* are reported via `store.fail(...)` and the batch continues. Without
|
|
489
|
+
* `fail`, the batch stops on the first failure (legacy behavior).
|
|
490
|
+
*
|
|
491
|
+
* - **Malformed events**: events missing `type` or `meta.id` abort the
|
|
492
|
+
* batch — a well-behaved store must never return them (see
|
|
493
|
+
* {@link OutboxStore} semantics #6). The error is reported via `onError`.
|
|
494
|
+
*
|
|
495
|
+
* - **Ownership mismatches**: if `acknowledge`/`fail` throws
|
|
496
|
+
* {@link OutboxOwnershipError} (our lease expired and another worker
|
|
497
|
+
* claimed the event), the event is NOT counted as relayed. The other
|
|
498
|
+
* worker will re-publish — at-least-once semantics preserved.
|
|
499
|
+
*
|
|
500
|
+
* @returns Per-kind outcome counts for the batch
|
|
501
|
+
*/
|
|
502
|
+
async relayBatch() {
|
|
503
|
+
const empty = {
|
|
504
|
+
relayed: 0,
|
|
505
|
+
attempted: 0,
|
|
506
|
+
publishFailed: 0,
|
|
507
|
+
ackFailed: 0,
|
|
508
|
+
ownershipMismatches: 0,
|
|
509
|
+
malformed: 0,
|
|
510
|
+
failHookErrors: 0,
|
|
511
|
+
deadLettered: 0,
|
|
512
|
+
usedPublishMany: false
|
|
513
|
+
};
|
|
514
|
+
if (!this._transport) return empty;
|
|
515
|
+
const claimStartedAt = Date.now();
|
|
516
|
+
const fenceTokens = /* @__PURE__ */ new Map();
|
|
517
|
+
let pending;
|
|
518
|
+
if (typeof this._store.claimPendingFenced === "function") pending = (await this._store.claimPendingFenced({
|
|
519
|
+
limit: this._batchSize,
|
|
520
|
+
consumerId: this._consumerId,
|
|
521
|
+
leaseMs: this._leaseMs
|
|
522
|
+
})).map((c) => {
|
|
523
|
+
if (c.event?.meta?.id) fenceTokens.set(c.event.meta.id, c.fencingToken);
|
|
524
|
+
return c.event;
|
|
525
|
+
});
|
|
526
|
+
else if (this._store.claimPending) pending = await this._store.claimPending({
|
|
527
|
+
limit: this._batchSize,
|
|
528
|
+
consumerId: this._consumerId,
|
|
529
|
+
leaseMs: this._leaseMs
|
|
530
|
+
});
|
|
531
|
+
else pending = await this._store.getPending(this._batchSize);
|
|
532
|
+
const claimMs = Date.now() - claimStartedAt;
|
|
533
|
+
const publishStartedAt = Date.now();
|
|
534
|
+
const valid = [];
|
|
535
|
+
let malformed = 0;
|
|
536
|
+
for (const event of pending) {
|
|
537
|
+
if (!event?.type || !event.meta?.id) {
|
|
538
|
+
this._reportError("malformed_event", new InvalidOutboxEventError("store returned event missing type or meta.id — batch aborted"), event);
|
|
539
|
+
malformed++;
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
valid.push(event);
|
|
543
|
+
}
|
|
544
|
+
const counts = {
|
|
545
|
+
relayed: 0,
|
|
546
|
+
publishFailed: 0,
|
|
547
|
+
ackFailed: 0,
|
|
548
|
+
ownershipMismatches: 0,
|
|
549
|
+
failHookErrors: 0,
|
|
550
|
+
deadLettered: 0
|
|
551
|
+
};
|
|
552
|
+
const canPublishMany = this._usePublishMany && typeof this._transport.publishMany === "function";
|
|
553
|
+
const canFail = typeof this._store.fail === "function";
|
|
554
|
+
let publishOutcomes;
|
|
555
|
+
if (canPublishMany && valid.length > 0) try {
|
|
556
|
+
const result = await this._transport.publishMany?.(valid);
|
|
557
|
+
publishOutcomes = new Map(result);
|
|
558
|
+
} catch (batchErr) {
|
|
559
|
+
publishOutcomes = /* @__PURE__ */ new Map();
|
|
560
|
+
const err = batchErr instanceof Error ? batchErr : new Error(String(batchErr));
|
|
561
|
+
for (const ev of valid) publishOutcomes.set(ev.meta.id, err);
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
publishOutcomes = /* @__PURE__ */ new Map();
|
|
565
|
+
for (const event of valid) try {
|
|
566
|
+
await this._transport.publish(event);
|
|
567
|
+
publishOutcomes.set(event.meta.id, null);
|
|
568
|
+
} catch (err) {
|
|
569
|
+
publishOutcomes.set(event.meta.id, err instanceof Error ? err : new Error(String(err)));
|
|
570
|
+
if (!canFail) break;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
let stopBatch = false;
|
|
574
|
+
for (const event of valid) {
|
|
575
|
+
if (stopBatch) break;
|
|
576
|
+
const publishErr = publishOutcomes.get(event.meta.id);
|
|
577
|
+
if (publishErr instanceof Error) {
|
|
578
|
+
counts.publishFailed++;
|
|
579
|
+
this._reportError("publish_failed", publishErr, event);
|
|
580
|
+
if (!canFail) {
|
|
581
|
+
stopBatch = true;
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
const attempts = (this._attempts.get(event.meta.id) ?? 0) + 1;
|
|
585
|
+
this._attempts.delete(event.meta.id);
|
|
586
|
+
this._attempts.set(event.meta.id, attempts);
|
|
587
|
+
if (this._attempts.size > MAX_TRACKED_ATTEMPTS) {
|
|
588
|
+
const oldest = this._attempts.keys().next().value;
|
|
589
|
+
if (oldest !== void 0) this._attempts.delete(oldest);
|
|
590
|
+
}
|
|
591
|
+
const fenceToken = fenceTokens.get(event.meta.id);
|
|
592
|
+
let failOpts = {
|
|
593
|
+
consumerId: this._consumerId,
|
|
594
|
+
...fenceToken !== void 0 ? { fencingToken: fenceToken } : {}
|
|
595
|
+
};
|
|
596
|
+
if (this._failurePolicy) try {
|
|
597
|
+
const decision = await this._failurePolicy({
|
|
598
|
+
event,
|
|
599
|
+
error: publishErr,
|
|
600
|
+
attempts
|
|
601
|
+
});
|
|
602
|
+
failOpts = {
|
|
603
|
+
...failOpts,
|
|
604
|
+
...decision
|
|
605
|
+
};
|
|
606
|
+
} catch (policyErr) {
|
|
607
|
+
this._reportError("fail_failed", policyErr, event);
|
|
608
|
+
}
|
|
609
|
+
try {
|
|
610
|
+
await this._store.fail?.(event.meta.id, normalizeError(publishErr), failOpts);
|
|
611
|
+
if (failOpts.deadLetter) {
|
|
612
|
+
counts.deadLettered++;
|
|
613
|
+
this._attempts.delete(event.meta.id);
|
|
614
|
+
}
|
|
615
|
+
} catch (failErr) {
|
|
616
|
+
if (failErr instanceof OutboxOwnershipError) {
|
|
617
|
+
counts.ownershipMismatches++;
|
|
618
|
+
this._reportError("ownership_mismatch", failErr, event);
|
|
619
|
+
} else {
|
|
620
|
+
counts.failHookErrors++;
|
|
621
|
+
this._reportError("fail_failed", failErr, event);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
try {
|
|
627
|
+
const ackFenceToken = fenceTokens.get(event.meta.id);
|
|
628
|
+
await this._store.acknowledge(event.meta.id, {
|
|
629
|
+
consumerId: this._consumerId,
|
|
630
|
+
...ackFenceToken !== void 0 ? { fencingToken: ackFenceToken } : {}
|
|
631
|
+
});
|
|
632
|
+
counts.relayed++;
|
|
633
|
+
this._attempts.delete(event.meta.id);
|
|
634
|
+
} catch (ackErr) {
|
|
635
|
+
counts.ackFailed++;
|
|
636
|
+
if (ackErr instanceof OutboxOwnershipError) {
|
|
637
|
+
counts.ownershipMismatches++;
|
|
638
|
+
this._reportError("ownership_mismatch", ackErr, event);
|
|
639
|
+
} else this._reportError("acknowledge_failed", ackErr, event);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return {
|
|
643
|
+
relayed: counts.relayed,
|
|
644
|
+
attempted: valid.length,
|
|
645
|
+
publishFailed: counts.publishFailed,
|
|
646
|
+
ackFailed: counts.ackFailed,
|
|
647
|
+
ownershipMismatches: counts.ownershipMismatches,
|
|
648
|
+
malformed,
|
|
649
|
+
failHookErrors: counts.failHookErrors,
|
|
650
|
+
deadLettered: counts.deadLettered,
|
|
651
|
+
usedPublishMany: canPublishMany && valid.length > 0,
|
|
652
|
+
claimMs,
|
|
653
|
+
publishMs: Date.now() - publishStartedAt
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Fetch current dead-lettered events as typed {@link DeadLetteredEvent}
|
|
658
|
+
* envelopes. Delegates to {@link OutboxStore.getDeadLettered} — returns
|
|
659
|
+
* `[]` when the store doesn't implement it.
|
|
660
|
+
*
|
|
661
|
+
* Pairs with {@link OutboxFailurePolicy}: apps set a policy that routes to
|
|
662
|
+
* `deadLetter: true` after N attempts, then read back with this to alert,
|
|
663
|
+
* replay, or archive.
|
|
664
|
+
*/
|
|
665
|
+
async getDeadLettered(limit = 100) {
|
|
666
|
+
if (!this._store.getDeadLettered) return [];
|
|
667
|
+
return this._store.getDeadLettered(limit);
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Purge old **delivered** events from the outbox store.
|
|
671
|
+
* Delegates to `store.purge()` if implemented; no-op otherwise.
|
|
672
|
+
* @param olderThanMs - Remove events delivered more than this many ms ago (default: 7 days)
|
|
673
|
+
* @returns Number of purged events, or 0 if store doesn't support purge
|
|
674
|
+
*/
|
|
675
|
+
async purge(olderThanMs = DEFAULT_OUTBOX_RETENTION_MS) {
|
|
676
|
+
if (!this._store.purge) return 0;
|
|
677
|
+
return this._store.purge(olderThanMs);
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
function normalizeError(err) {
|
|
681
|
+
if (err instanceof Error) return {
|
|
682
|
+
message: err.message,
|
|
683
|
+
code: err.code
|
|
684
|
+
};
|
|
685
|
+
return { message: String(err) };
|
|
686
|
+
}
|
|
687
|
+
//#endregion
|
|
688
|
+
//#region src/events/outbox-module.ts
|
|
689
|
+
/**
|
|
690
|
+
* The OUTBOX as a module — so the store is a SLOT other modules depend on, not a value the host
|
|
691
|
+
* threads by hand.
|
|
692
|
+
*
|
|
693
|
+
* ## The failure this exists to make impossible
|
|
694
|
+
*
|
|
695
|
+
* A durable store passed as a constructor argument to N consumers will eventually miss one, and
|
|
696
|
+
* the miss is silent. Observed in two independent ERP hosts on the same fleet:
|
|
697
|
+
*
|
|
698
|
+
* - one passed an outbox to its revenue-attach seam and **not** to `createOrder`, so
|
|
699
|
+
* `order:created` published fire-and-forget — a failed publish discarded the revenue trigger
|
|
700
|
+
* entirely: no attach, no dead-letter, no log. Cash collected, nothing recorded.
|
|
701
|
+
* - the other had the identical gap, written independently.
|
|
702
|
+
*
|
|
703
|
+
* Six consumers had to be wired by hand in each host (order kernel, revenue kernel, access
|
|
704
|
+
* kernel, revenue-attach durability, line-activation dead letters, promo-commit dead letters).
|
|
705
|
+
* That is not a discipline problem; it is a design problem.
|
|
706
|
+
*
|
|
707
|
+
* As a module the store is registered ONCE and read from the registry. A consumer declares
|
|
708
|
+
* `dependsOn: ['outbox']` and arc refuses to boot without it — the omission becomes a startup
|
|
709
|
+
* failure instead of a quiet data-loss window.
|
|
710
|
+
*
|
|
711
|
+
* ## What this module does NOT do
|
|
712
|
+
*
|
|
713
|
+
* It does not build a store. Store construction is store-specific (a mongo model, a SQL table),
|
|
714
|
+
* and arc is deliberately storage-agnostic — `repositoryAsOutboxStore` already adapts any
|
|
715
|
+
* repository, and kits own their own schema (e.g. `@classytic/mongokit/outbox`). This module owns
|
|
716
|
+
* the three things that are genuinely arc's: the module SLOT, the relay SCHEDULE, and shutdown.
|
|
717
|
+
*/
|
|
718
|
+
/**
|
|
719
|
+
* The store contract is `@classytic/primitives/outbox`'s, not arc's — arc adapts to it rather
|
|
720
|
+
* than owning it, which is why a kit (mongokit) can supply a store with no arc dependency.
|
|
721
|
+
*/
|
|
722
|
+
/** Default relay cadence. Frequent enough that a retry is not a user-visible delay. */
|
|
723
|
+
const DEFAULT_RELAY_EVERY_MS = 5e3;
|
|
724
|
+
/**
|
|
725
|
+
* One relay identity per RELAY INSTANCE — `<name>:<hostname>:<pid>:<random>`.
|
|
726
|
+
*
|
|
727
|
+
* A lease identifies an independently executing CLAIMANT, not a crash domain.
|
|
728
|
+
* Arc lets two outbox modules coexist (`name` is the `dependsOn` key), each
|
|
729
|
+
* with its own schedule arm, and nothing stops two of them pointing at the
|
|
730
|
+
* same store: they tick independently, so they are two claimants and must be
|
|
731
|
+
* two identities. Scoping this per-process instead would have made the module
|
|
732
|
+
* WEAKER than the primitive it wraps — `EventOutbox`'s own fallback is already
|
|
733
|
+
* per-instance — and would leave the store unable to tell the two apart after
|
|
734
|
+
* a lease expiry, which is exactly the stale-owner hole this replaced.
|
|
735
|
+
*
|
|
736
|
+
* Every component earns its place: `name` distinguishes co-resident modules,
|
|
737
|
+
* `hostname:pid` makes a lease row legible in ops tooling, and the random
|
|
738
|
+
* suffix breaks the tie a container restart creates — same hostname, often the
|
|
739
|
+
* same pid, and the restarted process must not inherit a predecessor's
|
|
740
|
+
* identity while that predecessor's lease is still live.
|
|
741
|
+
*
|
|
742
|
+
* Called once per `bootstrap()`, so the id is stable across every tick of the
|
|
743
|
+
* relay it belongs to — which the lease depends on.
|
|
744
|
+
*/
|
|
745
|
+
function mintConsumerId(name) {
|
|
746
|
+
return `${name}:${hostname()}:${process.pid}:${randomBytes(4).toString("hex")}`;
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Compose the outbox as a module.
|
|
750
|
+
*
|
|
751
|
+
* ```ts
|
|
752
|
+
* // host
|
|
753
|
+
* const model = createOutboxModel(connection, { visibleAtField: 'nextVisibleAt' });
|
|
754
|
+
* createApp({ modules: [
|
|
755
|
+
* createOutboxModule({
|
|
756
|
+
* store: repositoryAsOutboxStore(new Repository(model), { visibleAtField: 'nextVisibleAt' }),
|
|
757
|
+
* onError: (err, ctx) => app.log.error({ err, ...ctx }, 'outbox relay'),
|
|
758
|
+
* }),
|
|
759
|
+
* …
|
|
760
|
+
* ]});
|
|
761
|
+
*
|
|
762
|
+
* // consumer module
|
|
763
|
+
* defineModule({
|
|
764
|
+
* name: 'order',
|
|
765
|
+
* dependsOn: ['outbox'],
|
|
766
|
+
* bootstrap: (f) => createOrder({
|
|
767
|
+
* outbox: getModuleExports<OutboxModuleExports>(f, 'outbox').store,
|
|
768
|
+
* }),
|
|
769
|
+
* });
|
|
770
|
+
* ```
|
|
771
|
+
*
|
|
772
|
+
* `dependsOn` is doing real work there: it both orders the bootstraps and makes a missing outbox a
|
|
773
|
+
* boot error naming the module that needed it.
|
|
774
|
+
*/
|
|
775
|
+
function createOutboxModule(options) {
|
|
776
|
+
const name = options.name ?? "outbox";
|
|
777
|
+
const relayEveryMs = options.relayEveryMs ?? DEFAULT_RELAY_EVERY_MS;
|
|
778
|
+
/**
|
|
779
|
+
* Built at bootstrap and reused by the schedule.
|
|
780
|
+
*
|
|
781
|
+
* Not rebuilt per tick: `EventOutbox` carries the consumer id the lease is keyed on, and a fresh
|
|
782
|
+
* instance per tick would be a fresh claimant every few seconds — which defeats the lease it
|
|
783
|
+
* depends on for exactly-once publishing.
|
|
784
|
+
*/
|
|
785
|
+
let exports;
|
|
786
|
+
return {
|
|
787
|
+
name,
|
|
788
|
+
bootstrap: (fastify) => {
|
|
789
|
+
const store = typeof options.store === "function" ? options.store(fastify) : options.store;
|
|
790
|
+
/**
|
|
791
|
+
* The RAW transport, never `fastify.events`.
|
|
792
|
+
*
|
|
793
|
+
* This read used to be `(fastify as { events?: EventTransport }).events` — a cast across
|
|
794
|
+
* two INCOMPATIBLE shapes. The facade's `publish(type, payload, meta?)` took the relayed
|
|
795
|
+
* `DomainEvent` as its `type` argument and failed the non-empty-string guard, so the
|
|
796
|
+
* documented default path published NOTHING: subscribers saw nothing and every tick
|
|
797
|
+
* reported `publishFailed: 1` until the row dead-lettered. The cast is what let two
|
|
798
|
+
* mismatched signatures typecheck.
|
|
799
|
+
*/
|
|
800
|
+
const transport = options.transport === void 0 ? resolveEventTransport(fastify) : typeof options.transport === "function" ? options.transport(fastify) : options.transport;
|
|
801
|
+
/**
|
|
802
|
+
* No transport is BOOT-FATAL, because the alternative is invisible:
|
|
803
|
+
* `EventOutbox.relayBatch()` returns an empty result when it has none, so the
|
|
804
|
+
* module's schedule would tick forever, publish nothing, and let the store grow —
|
|
805
|
+
* a durable backlog nobody is draining, reported as healthy. The failure this
|
|
806
|
+
* module exists to prevent, arrived at from the other side.
|
|
807
|
+
*/
|
|
808
|
+
if (transport === void 0) throw new Error(`Outbox module "${name}" could not resolve an event transport, so its relay would tick forever without publishing while the store grows. Register arc's events plugin (it is on by default — check \`arcPlugins.events !== false\`), or pass an explicit \`transport\` to \`createOutboxModule\`. Note the outbox needs the RAW EventTransport, not the \`fastify.events\` facade: the facade takes \`(type, payload, meta?)\` and fails open, which would acknowledge rows whose publish actually failed.`);
|
|
809
|
+
exports = {
|
|
810
|
+
store,
|
|
811
|
+
relay: new EventOutbox({
|
|
812
|
+
store,
|
|
813
|
+
transport,
|
|
814
|
+
...options.batchSize !== void 0 ? { batchSize: options.batchSize } : {},
|
|
815
|
+
...options.leaseMs !== void 0 ? { leaseMs: options.leaseMs } : {},
|
|
816
|
+
consumerId: options.consumerId ?? mintConsumerId(name),
|
|
817
|
+
...options.onError !== void 0 ? { onError: options.onError } : {},
|
|
818
|
+
...options.failurePolicy !== void 0 ? { failurePolicy: options.failurePolicy } : {}
|
|
819
|
+
})
|
|
820
|
+
};
|
|
821
|
+
return exports;
|
|
822
|
+
},
|
|
823
|
+
/**
|
|
824
|
+
* The relay runs on the module's OWN schedule arm, so the host stops owning the tick — and
|
|
825
|
+
* a host that forgets to start one cannot exist.
|
|
826
|
+
*/
|
|
827
|
+
scheduledJobs: () => [{
|
|
828
|
+
name: `${name}.relay`,
|
|
829
|
+
kind: "relay",
|
|
830
|
+
every: relayEveryMs,
|
|
831
|
+
runOnStart: options.runOnStart ?? true,
|
|
832
|
+
handler: async () => {
|
|
833
|
+
await exports?.relay.relay();
|
|
834
|
+
}
|
|
835
|
+
}]
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
//#endregion
|
|
839
|
+
export { exponentialBackoff as i, EventOutbox as n, repositoryAsOutboxStore as r, createOutboxModule as t };
|