@classytic/arc 2.11.3 → 2.11.4
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 +16 -11
- package/dist/EventTransport-BFQjw9pB.mjs +133 -0
- package/dist/{QueryCache-DOBNHBE0.d.mts → QueryCache-D41bfdBB.d.mts} +1 -1
- package/dist/adapters/index.d.mts +3 -3
- package/dist/adapters/index.mjs +2 -2
- package/dist/{adapters-D0tT2Tyo.mjs → adapters-DUUiiimH.mjs} +17 -2
- package/dist/audit/index.d.mts +2 -2
- package/dist/auth/index.d.mts +4 -4
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/cache/index.d.mts +3 -3
- package/dist/cli/commands/docs.mjs +1 -1
- package/dist/cli/commands/generate.mjs +1 -1
- package/dist/cli/commands/init.mjs +125 -43
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.mjs +1 -1
- package/dist/{core-DnUsRpuX.mjs → core-CbcQRIch.mjs} +15 -10
- package/dist/{createActionRouter-u3ql2EDo.mjs → createActionRouter-CIKOcNA7.mjs} +1 -1
- package/dist/{createApp-BFxtdKy6.mjs → createApp-C9bRrqlX.mjs} +4 -6
- package/dist/defineEvent-D1Ky9M1D.mjs +188 -0
- package/dist/docs/index.d.mts +2 -2
- package/dist/docs/index.mjs +1 -1
- package/dist/{eventPlugin-KrFIQ097.mjs → eventPlugin-Cts2-Tfj.mjs} +8 -134
- package/dist/{eventPlugin-CUNjYYRY.d.mts → eventPlugin-DDJoNEPL.d.mts} +34 -7
- package/dist/events/index.d.mts +164 -5
- package/dist/events/index.mjs +128 -180
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +204 -31
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/factory/index.d.mts +1 -1
- package/dist/factory/index.mjs +1 -1
- package/dist/{fields-C8Y0XLAu.d.mts → fields-BRjxOAFp.d.mts} +1 -1
- package/dist/hooks/index.d.mts +1 -1
- package/dist/idempotency/index.d.mts +3 -3
- package/dist/idempotency/index.mjs +1 -1
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/{index-6u4_Gg6G.d.mts → index-CXXRbnf8.d.mts} +51 -5
- package/dist/{index-DdQ3O9Pg.d.mts → index-D9t1KNaB.d.mts} +2 -2
- package/dist/{index-BbMrcvGp.d.mts → index-Rg8axYPz.d.mts} +12 -4
- package/dist/{index-BdXnTPRj.d.mts → index-m8mOOlFW.d.mts} +3 -3
- package/dist/{index-BYCqHCVu.d.mts → index-rHjXmJar.d.mts} +3 -3
- package/dist/index.d.mts +7 -7
- package/dist/index.mjs +3 -3
- package/dist/integrations/event-gateway.d.mts +2 -2
- package/dist/integrations/index.d.mts +2 -2
- package/dist/integrations/mcp/index.d.mts +2 -2
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket.d.mts +1 -1
- package/dist/middleware/index.d.mts +1 -1
- package/dist/{openapi-BGUn7Ki1.mjs → openapi-D7G1V7ex.mjs} +1 -1
- package/dist/org/index.d.mts +2 -2
- package/dist/permissions/index.d.mts +2 -2
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/plugins/index.d.mts +5 -5
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/presets/filesUpload.d.mts +4 -4
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/search.d.mts +2 -2
- package/dist/{queryCachePlugin-BUXBSm4F.d.mts → queryCachePlugin-CqMdLI2-.d.mts} +2 -2
- package/dist/{redis-Cm1gnRDf.d.mts → redis-DiMkdHEl.d.mts} +1 -1
- package/dist/redis-stream-xTGxB2bm.d.mts +232 -0
- package/dist/registry/index.d.mts +1 -1
- package/dist/{resourceToTools-ByZpgjeH.mjs → resourceToTools-CxNmI6xF.mjs} +2 -2
- package/dist/scope/index.d.mts +2 -2
- package/dist/testing/index.d.mts +2 -2
- package/dist/testing/index.mjs +1 -1
- package/dist/testing/storageContract.d.mts +1 -1
- package/dist/types/index.d.mts +4 -4
- package/dist/types/storage.d.mts +1 -1
- package/dist/{types-9beEMe25.d.mts → types-BQ9TJQNy.d.mts} +1 -1
- package/dist/{types-BH7dEGvU.d.mts → types-D7KpfiL1.d.mts} +10 -10
- package/dist/utils/index.d.mts +1 -1
- package/dist/{versioning-M9lNLhO8.d.mts → versioning-DsglKfM_.d.mts} +1 -1
- package/package.json +1 -1
- package/skills/arc/SKILL.md +409 -769
- package/dist/redis-stream-CM8TXTix.d.mts +0 -110
- /package/dist/{EventTransport-CfVEGaEl.d.mts → EventTransport-CYNUXdCJ.d.mts} +0 -0
- /package/dist/{elevation-s5ykdNHr.d.mts → elevation-BQQXZ_VR.d.mts} +0 -0
- /package/dist/{errorHandler-Co3lnVmJ.d.mts → errorHandler-DEWmGWPz.d.mts} +0 -0
- /package/dist/{externalPaths-Bapitwvd.d.mts → externalPaths-BD5nw6St.d.mts} +0 -0
- /package/dist/{interface-CkkWm5uR.d.mts → interface-DfLGcus7.d.mts} +0 -0
- /package/dist/{interface-Da0r7Lna.d.mts → interface-beEtJyWM.d.mts} +0 -0
- /package/dist/{pluralize-BneOJkpi.mjs → pluralize-CWP6MB39.mjs} +0 -0
- /package/dist/{schemaIR-BlG9bY7v.mjs → schemaIR-Dy2p4MxS.mjs} +0 -0
- /package/dist/{sessionManager-D-oNWHz3.d.mts → sessionManager-C4Le_UB3.d.mts} +0 -0
- /package/dist/{storage-BwGQXUpd.d.mts → storage-Dfzt4VTl.d.mts} +0 -0
- /package/dist/{store-helpers-BhrzxvyQ.mjs → store-helpers-Cp4uKC1U.mjs} +0 -0
- /package/dist/{tracing-DokiEsuz.d.mts → tracing-QJVprktp.d.mts} +0 -0
- /package/dist/{types-tgR4Pt8F.d.mts → types-DDyTPc6y.d.mts} +0 -0
- /package/dist/{websocket-CyJ1VIFI.d.mts → websocket-ChC2rqe1.d.mts} +0 -0
package/dist/events/index.mjs
CHANGED
|
@@ -1,184 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { n as
|
|
1
|
+
import { n as createChildEvent, r as createEvent, t as MemoryEventTransport } from "../EventTransport-BFQjw9pB.mjs";
|
|
2
|
+
import { n as defineEvent, t as createEventRegistry } from "../defineEvent-D1Ky9M1D.mjs";
|
|
3
|
+
import { i as withRetry, r as createDeadLetterPublisher, t as eventPlugin } from "../eventPlugin-Cts2-Tfj.mjs";
|
|
4
|
+
import { n as createSafeGetOne, t as createIsDuplicateKeyError } from "../store-helpers-Cp4uKC1U.mjs";
|
|
3
5
|
import { and, anyOf, eq, lte, ne, or } from "@classytic/repo-core/filter";
|
|
4
6
|
import { update } from "@classytic/repo-core/update";
|
|
5
|
-
//#region src/events/defineEvent.ts
|
|
6
|
-
/**
|
|
7
|
-
* defineEvent — Typed Event Definitions with Optional Schema Validation
|
|
8
|
-
*
|
|
9
|
-
* Provides:
|
|
10
|
-
* 1. defineEvent() — declare an event with name, schema, version, description
|
|
11
|
-
* 2. EventRegistry — catalog of all known events + payload validation
|
|
12
|
-
* 3. .create() helper — build DomainEvent with auto-generated metadata
|
|
13
|
-
*
|
|
14
|
-
* The built-in validator checks: object type, required fields, and top-level
|
|
15
|
-
* property types. It does NOT recurse into nested objects, validate arrays,
|
|
16
|
-
* enums, patterns, formats, or $ref. This is intentional — it's a lightweight
|
|
17
|
-
* guard, not a full JSON Schema engine.
|
|
18
|
-
*
|
|
19
|
-
* For full validation, pass a custom `validate` function to `createEventRegistry()`:
|
|
20
|
-
*
|
|
21
|
-
* @example
|
|
22
|
-
* ```typescript
|
|
23
|
-
* import Ajv from 'ajv';
|
|
24
|
-
* const ajv = new Ajv();
|
|
25
|
-
*
|
|
26
|
-
* const registry = createEventRegistry({
|
|
27
|
-
* validate: (schema, payload) => {
|
|
28
|
-
* const valid = ajv.validate(schema, payload);
|
|
29
|
-
* return valid
|
|
30
|
-
* ? { valid: true }
|
|
31
|
-
* : { valid: false, errors: ajv.errorsText().split(', ') };
|
|
32
|
-
* },
|
|
33
|
-
* });
|
|
34
|
-
* ```
|
|
35
|
-
*
|
|
36
|
-
* @example
|
|
37
|
-
* ```typescript
|
|
38
|
-
* import { defineEvent, createEventRegistry } from '@classytic/arc/events';
|
|
39
|
-
*
|
|
40
|
-
* const OrderCreated = defineEvent({
|
|
41
|
-
* name: 'order.created',
|
|
42
|
-
* version: 1,
|
|
43
|
-
* schema: {
|
|
44
|
-
* type: 'object',
|
|
45
|
-
* properties: {
|
|
46
|
-
* orderId: { type: 'string' },
|
|
47
|
-
* total: { type: 'number' },
|
|
48
|
-
* },
|
|
49
|
-
* required: ['orderId', 'total'],
|
|
50
|
-
* },
|
|
51
|
-
* });
|
|
52
|
-
*
|
|
53
|
-
* // Type-safe event creation
|
|
54
|
-
* const event = OrderCreated.create({ orderId: 'o-1', total: 100 });
|
|
55
|
-
* await fastify.events.publish(event.type, event.payload, event.meta);
|
|
56
|
-
*
|
|
57
|
-
* // Registry for introspection + validation
|
|
58
|
-
* const registry = createEventRegistry();
|
|
59
|
-
* registry.register(OrderCreated);
|
|
60
|
-
* const result = registry.validate('order.created', payload);
|
|
61
|
-
* ```
|
|
62
|
-
*/
|
|
63
|
-
/**
|
|
64
|
-
* Define a typed event with optional schema validation.
|
|
65
|
-
*
|
|
66
|
-
* @example
|
|
67
|
-
* const OrderCreated = defineEvent({
|
|
68
|
-
* name: 'order.created',
|
|
69
|
-
* schema: { type: 'object', properties: { orderId: { type: 'string' } }, required: ['orderId'] },
|
|
70
|
-
* });
|
|
71
|
-
*
|
|
72
|
-
* const event = OrderCreated.create({ orderId: '123' });
|
|
73
|
-
*/
|
|
74
|
-
function defineEvent(input) {
|
|
75
|
-
const { name, schema, version = 1, description } = input;
|
|
76
|
-
return {
|
|
77
|
-
name,
|
|
78
|
-
schema,
|
|
79
|
-
version,
|
|
80
|
-
description,
|
|
81
|
-
create(payload, meta) {
|
|
82
|
-
return createEvent(name, payload, meta);
|
|
83
|
-
}
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
/**
|
|
87
|
-
* Create an event registry for cataloging and validating events.
|
|
88
|
-
*
|
|
89
|
-
* The registry is opt-in — unregistered events pass validation.
|
|
90
|
-
* This allows gradual adoption without breaking existing code.
|
|
91
|
-
*
|
|
92
|
-
* @param options.validate - Custom validator replacing the built-in minimal validator.
|
|
93
|
-
* The built-in validator only checks top-level object structure (type, required, property types).
|
|
94
|
-
* For nested objects, arrays, enums, patterns, or $ref, provide AJV or similar.
|
|
95
|
-
*/
|
|
96
|
-
function createEventRegistry(options) {
|
|
97
|
-
const customValidator = options?.validate;
|
|
98
|
-
const definitions = /* @__PURE__ */ new Map();
|
|
99
|
-
function registryKey(name, version) {
|
|
100
|
-
return `${name}:v${version}`;
|
|
101
|
-
}
|
|
102
|
-
return {
|
|
103
|
-
register(definition) {
|
|
104
|
-
const key = registryKey(definition.name, definition.version);
|
|
105
|
-
if (definitions.has(key)) throw new Error(`Event '${definition.name}' v${definition.version} is already registered. Use a different version number for schema evolution.`);
|
|
106
|
-
definitions.set(key, definition);
|
|
107
|
-
},
|
|
108
|
-
get(name, version) {
|
|
109
|
-
if (version !== void 0) return definitions.get(registryKey(name, version));
|
|
110
|
-
let latest;
|
|
111
|
-
let latestVersion = -1;
|
|
112
|
-
for (const def of definitions.values()) if (def.name === name && def.version > latestVersion) {
|
|
113
|
-
latest = def;
|
|
114
|
-
latestVersion = def.version;
|
|
115
|
-
}
|
|
116
|
-
return latest;
|
|
117
|
-
},
|
|
118
|
-
catalog() {
|
|
119
|
-
return Array.from(definitions.values()).map((def) => ({
|
|
120
|
-
name: def.name,
|
|
121
|
-
version: def.version,
|
|
122
|
-
description: def.description,
|
|
123
|
-
schema: def.schema
|
|
124
|
-
}));
|
|
125
|
-
},
|
|
126
|
-
validate(name, payload) {
|
|
127
|
-
let latest;
|
|
128
|
-
let latestVersion = -1;
|
|
129
|
-
for (const def of definitions.values()) if (def.name === name && def.version > latestVersion) {
|
|
130
|
-
latest = def;
|
|
131
|
-
latestVersion = def.version;
|
|
132
|
-
}
|
|
133
|
-
if (!latest) return { valid: true };
|
|
134
|
-
if (!latest.schema) return { valid: true };
|
|
135
|
-
if (customValidator) return customValidator(latest.schema, payload);
|
|
136
|
-
return validatePayload(payload, latest.schema);
|
|
137
|
-
}
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
/**
|
|
141
|
-
* Built-in minimal validator — lightweight guard, NOT a full JSON Schema engine.
|
|
142
|
-
*
|
|
143
|
-
* Checks:
|
|
144
|
-
* - payload is an object (not null, not array)
|
|
145
|
-
* - required fields are present
|
|
146
|
-
* - top-level property types match (string, number, boolean, array, object)
|
|
147
|
-
*
|
|
148
|
-
* Does NOT check:
|
|
149
|
-
* - nested object properties
|
|
150
|
-
* - array item types
|
|
151
|
-
* - enum, pattern, format, minLength, minimum, $ref
|
|
152
|
-
*
|
|
153
|
-
* For full validation, pass a custom `validate` function to `createEventRegistry()`.
|
|
154
|
-
*/
|
|
155
|
-
function validatePayload(payload, schema) {
|
|
156
|
-
const errors = [];
|
|
157
|
-
if (schema.type === "object") {
|
|
158
|
-
if (payload === null || payload === void 0 || typeof payload !== "object" || Array.isArray(payload)) return {
|
|
159
|
-
valid: false,
|
|
160
|
-
errors: ["Payload must be an object"]
|
|
161
|
-
};
|
|
162
|
-
const record = payload;
|
|
163
|
-
if (schema.required) {
|
|
164
|
-
for (const field of schema.required) if (!(field in record) || record[field] === void 0) errors.push(`Missing required field: '${field}'`);
|
|
165
|
-
}
|
|
166
|
-
if (schema.properties) {
|
|
167
|
-
for (const [key, propSchema] of Object.entries(schema.properties)) if (key in record && record[key] !== void 0 && record[key] !== null) {
|
|
168
|
-
const expectedType = propSchema.type;
|
|
169
|
-
if (expectedType) {
|
|
170
|
-
const actualType = Array.isArray(record[key]) ? "array" : typeof record[key];
|
|
171
|
-
if (expectedType !== actualType) errors.push(`Field '${key}': expected ${expectedType}, got ${actualType}`);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
return errors.length === 0 ? { valid: true } : {
|
|
177
|
-
valid: false,
|
|
178
|
-
errors
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
//#endregion
|
|
182
7
|
//#region src/events/eventTypes.ts
|
|
183
8
|
/**
|
|
184
9
|
* Event Type Constants and Helpers
|
|
@@ -886,4 +711,127 @@ function exponentialBackoff(options) {
|
|
|
886
711
|
return new Date(now + jittered);
|
|
887
712
|
}
|
|
888
713
|
//#endregion
|
|
889
|
-
|
|
714
|
+
//#region src/events/subscribe-helpers.ts
|
|
715
|
+
/**
|
|
716
|
+
* Pure handler wrapper — returns a new `EventHandler` that validates
|
|
717
|
+
* `event.payload` against the definition's schema before invoking the handler.
|
|
718
|
+
*
|
|
719
|
+
* The returned handler's input is `DomainEvent<unknown>` (since the transport
|
|
720
|
+
* delivers untyped events) but the inner `handler` receives `DomainEvent<T>`.
|
|
721
|
+
* No cast at the call site.
|
|
722
|
+
*
|
|
723
|
+
* @example
|
|
724
|
+
* ```ts
|
|
725
|
+
* await fastify.events.subscribe(
|
|
726
|
+
* OrderPaid.name,
|
|
727
|
+
* wrapWithSchema(OrderPaid, async (event) => {
|
|
728
|
+
* // event.payload is typed via the registered schema — no cast.
|
|
729
|
+
* await postSalesEntry(event.payload.orderId, event.payload.total);
|
|
730
|
+
* }),
|
|
731
|
+
* );
|
|
732
|
+
* ```
|
|
733
|
+
*/
|
|
734
|
+
function wrapWithSchema(definition, handler, options = {}) {
|
|
735
|
+
const { validate, registry, onInvalid, logger = console, name } = options;
|
|
736
|
+
const label = name ?? definition.name;
|
|
737
|
+
return async (event) => {
|
|
738
|
+
const eventVersion = typeof event.meta?.schemaVersion === "number" ? event.meta.schemaVersion : definition.version;
|
|
739
|
+
let result;
|
|
740
|
+
if (validate && definition.schema) result = validate(definition.schema, event.payload);
|
|
741
|
+
else if (registry) result = registry.validate(definition.name, event.payload, eventVersion);
|
|
742
|
+
else if (definition.schema) {
|
|
743
|
+
const { createEventRegistry } = await import("../defineEvent-D1Ky9M1D.mjs").then((n) => n.r);
|
|
744
|
+
const adhoc = createEventRegistry();
|
|
745
|
+
adhoc.register(definition);
|
|
746
|
+
result = adhoc.validate(definition.name, event.payload);
|
|
747
|
+
} else result = { valid: true };
|
|
748
|
+
if (!result.valid) {
|
|
749
|
+
const errors = result.errors ?? ["payload failed validation"];
|
|
750
|
+
if (onInvalid) try {
|
|
751
|
+
await onInvalid(event, errors);
|
|
752
|
+
} catch (cbErr) {
|
|
753
|
+
logger.error(`[Arc Events] '${label}' onInvalid callback threw:`, cbErr);
|
|
754
|
+
}
|
|
755
|
+
else logger.warn(`[Arc Events] '${label}' skipped event ${event.meta?.id ?? "<no-id>"} — payload failed validation: ${errors.join("; ")}`);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
await handler(event);
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Convenience: validate + subscribe in one call. Equivalent to
|
|
763
|
+
* `fastify.events.subscribe(definition.name, wrapWithSchema(definition, handler, options))`.
|
|
764
|
+
*
|
|
765
|
+
* Returns the unsubscribe function from the underlying transport.
|
|
766
|
+
*
|
|
767
|
+
* @example
|
|
768
|
+
* ```ts
|
|
769
|
+
* await subscribeWithSchema(fastify, OrderPaid, async (event) => {
|
|
770
|
+
* await postSalesEntry(event.payload.orderId, event.payload.total);
|
|
771
|
+
* });
|
|
772
|
+
*
|
|
773
|
+
* // Compose with withRetry — schema validation runs FIRST, then retry on
|
|
774
|
+
* // handler failure. Invalid payloads skip without burning retry attempts.
|
|
775
|
+
* await subscribeWithSchema(
|
|
776
|
+
* fastify,
|
|
777
|
+
* OrderPaid,
|
|
778
|
+
* withRetry(handler, { maxRetries: 3 }),
|
|
779
|
+
* );
|
|
780
|
+
* ```
|
|
781
|
+
*/
|
|
782
|
+
async function subscribeWithSchema(fastify, definition, handler, options) {
|
|
783
|
+
return fastify.events.subscribe(definition.name, wrapWithSchema(definition, handler, options));
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Pure handler wrapper — returns a new `EventHandler` that catches handler
|
|
787
|
+
* exceptions and routes them to `onError` (or logs and swallows). For
|
|
788
|
+
* projection / cache-invalidation / fire-and-forget handlers where retry
|
|
789
|
+
* would just delay the next-event resync, and where one bad event must NOT
|
|
790
|
+
* stop processing of subsequent events.
|
|
791
|
+
*
|
|
792
|
+
* Lighter than `withRetry`: no exponential backoff, no DLQ. Composes with
|
|
793
|
+
* `withRetry` if you want both ("retry, then log if exhausted, never throw").
|
|
794
|
+
*
|
|
795
|
+
* @example
|
|
796
|
+
* ```ts
|
|
797
|
+
* await fastify.events.subscribe(
|
|
798
|
+
* 'product:variants.changed',
|
|
799
|
+
* wrapWithBoundary(async (event) => {
|
|
800
|
+
* cache.invalidate(event.payload.productId);
|
|
801
|
+
* }),
|
|
802
|
+
* );
|
|
803
|
+
* ```
|
|
804
|
+
*/
|
|
805
|
+
function wrapWithBoundary(handler, options = {}) {
|
|
806
|
+
const { onError, logger = console, name } = options;
|
|
807
|
+
const label = name ?? handler.name ?? "anonymous";
|
|
808
|
+
return async (event) => {
|
|
809
|
+
try {
|
|
810
|
+
await handler(event);
|
|
811
|
+
} catch (err) {
|
|
812
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
813
|
+
if (onError) try {
|
|
814
|
+
await onError(error, event);
|
|
815
|
+
} catch (cbErr) {
|
|
816
|
+
logger.error(`[Arc Events] '${label}' onError callback threw:`, cbErr);
|
|
817
|
+
}
|
|
818
|
+
else logger.error(`[Arc Events] '${label}' threw on ${event.type} — swallowed (boundary): ${error.message}`, {
|
|
819
|
+
err: error,
|
|
820
|
+
event: event.type,
|
|
821
|
+
eventId: event.meta?.id,
|
|
822
|
+
handler: label
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Convenience: subscribe + error-boundary in one call. Equivalent to
|
|
829
|
+
* `fastify.events.subscribe(pattern, wrapWithBoundary(handler, options))`.
|
|
830
|
+
*
|
|
831
|
+
* Returns the unsubscribe function from the underlying transport.
|
|
832
|
+
*/
|
|
833
|
+
async function subscribeWithBoundary(fastify, pattern, handler, options) {
|
|
834
|
+
return fastify.events.subscribe(pattern, wrapWithBoundary(handler, options));
|
|
835
|
+
}
|
|
836
|
+
//#endregion
|
|
837
|
+
export { ARC_LIFECYCLE_EVENTS, CACHE_EVENTS, CRUD_EVENT_SUFFIXES, EventOutbox, InvalidOutboxEventError, MemoryEventTransport, MemoryOutboxStore, OutboxOwnershipError, createChildEvent, createDeadLetterPublisher, createEvent, createEventRegistry, crudEventType, defineEvent, eventPlugin, exponentialBackoff, repositoryAsOutboxStore, subscribeWithBoundary, subscribeWithSchema, withRetry, wrapWithBoundary, wrapWithSchema };
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as RedisStreamTransport, r as RedisStreamTransportOptions, t as RedisStreamLike } from "../../redis-stream-
|
|
1
|
+
import { n as RedisStreamTransport, r as RedisStreamTransportOptions, t as RedisStreamLike } from "../../redis-stream-xTGxB2bm.mjs";
|
|
2
2
|
export { type RedisStreamLike, RedisStreamTransport, type RedisStreamTransportOptions };
|
|
@@ -13,10 +13,34 @@ var RedisStreamTransport = class {
|
|
|
13
13
|
maxLen;
|
|
14
14
|
maxPayloadBytes;
|
|
15
15
|
logger;
|
|
16
|
+
/** Tracks the lifecycle policy — set in constructor, read in close(). */
|
|
17
|
+
externalLifecycle;
|
|
18
|
+
closeTimeoutMs;
|
|
16
19
|
handlers = /* @__PURE__ */ new Map();
|
|
17
20
|
running = false;
|
|
18
21
|
pollPromise = null;
|
|
22
|
+
/**
|
|
23
|
+
* Monotonic counter bumped every time the poll loop should stop —
|
|
24
|
+
* `unsubscribe` (last handler removed) and `close()` increment it. Each
|
|
25
|
+
* `pollLoop` instance captures its generation at start and exits when
|
|
26
|
+
* `this.generation` no longer matches. Prevents the
|
|
27
|
+
* subscribe → unsubscribe → fast-resubscribe race where the old loop
|
|
28
|
+
* would still be in `XREADGROUP BLOCK` while a new loop started, leading
|
|
29
|
+
* to two concurrent poll loops on the same consumer name.
|
|
30
|
+
*/
|
|
31
|
+
generation = 0;
|
|
19
32
|
groupCreated = false;
|
|
33
|
+
/**
|
|
34
|
+
* Last-seen failure context per message id, populated when an in-process
|
|
35
|
+
* handler throws in {@link processEntry}. Consumed (and cleared) by
|
|
36
|
+
* {@link moveToDlq} so the dead-letter envelope carries the actual error
|
|
37
|
+
* message instead of opaque "reclaimed without context". Bounded by
|
|
38
|
+
* `maxRetries × consumer-throughput` — entries are deleted on ack and
|
|
39
|
+
* on DLQ write, so the map naturally drains.
|
|
40
|
+
*/
|
|
41
|
+
failureContext = /* @__PURE__ */ new Map();
|
|
42
|
+
/** One-shot guard so the "client lacks xrange" warning fires once per process. */
|
|
43
|
+
xrangeWarningEmitted = false;
|
|
20
44
|
constructor(redis, options = {}) {
|
|
21
45
|
this.redis = redis;
|
|
22
46
|
this.stream = options.stream ?? "arc:events";
|
|
@@ -29,6 +53,8 @@ var RedisStreamTransport = class {
|
|
|
29
53
|
this.deadLetterStream = options.deadLetterStream ?? "arc:events:dlq";
|
|
30
54
|
this.maxLen = options.maxLen ?? 1e4;
|
|
31
55
|
this.maxPayloadBytes = options.maxPayloadBytes ?? 1e6;
|
|
56
|
+
this.externalLifecycle = options.externalLifecycle ?? false;
|
|
57
|
+
this.closeTimeoutMs = options.closeTimeoutMs ?? 1e3;
|
|
32
58
|
this.logger = options.logger ?? console;
|
|
33
59
|
}
|
|
34
60
|
async publish(event) {
|
|
@@ -55,9 +81,10 @@ var RedisStreamTransport = class {
|
|
|
55
81
|
if (!this.running) {
|
|
56
82
|
await this.ensureGroup();
|
|
57
83
|
this.running = true;
|
|
58
|
-
|
|
84
|
+
const myGen = ++this.generation;
|
|
85
|
+
this.pollPromise = this.pollLoop(myGen).catch((err) => {
|
|
59
86
|
this.logger.error("[RedisStreamTransport] Poll loop crashed:", err);
|
|
60
|
-
this.running = false;
|
|
87
|
+
if (this.generation === myGen) this.running = false;
|
|
61
88
|
});
|
|
62
89
|
}
|
|
63
90
|
return () => {
|
|
@@ -66,16 +93,59 @@ var RedisStreamTransport = class {
|
|
|
66
93
|
set.delete(handler);
|
|
67
94
|
if (set.size === 0) this.handlers.delete(pattern);
|
|
68
95
|
}
|
|
69
|
-
if (this.handlers.size === 0 && this.running)
|
|
96
|
+
if (this.handlers.size === 0 && this.running) {
|
|
97
|
+
this.running = false;
|
|
98
|
+
this.generation++;
|
|
99
|
+
}
|
|
70
100
|
};
|
|
71
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Stop polling and release transport state.
|
|
104
|
+
*
|
|
105
|
+
* **Two close contracts** — pick the one that matches your deployment:
|
|
106
|
+
*
|
|
107
|
+
* 1. **Default (`externalLifecycle: false`) — strict bounded close.**
|
|
108
|
+
* `close()` waits up to `closeTimeoutMs` for the in-flight
|
|
109
|
+
* `XREADGROUP BLOCK` to drain. On timeout it calls `redis.disconnect()`
|
|
110
|
+
* (or `quit()` if the client lacks `disconnect`) to break the BLOCK
|
|
111
|
+
* immediately, then awaits the loop's exit. After `close()` returns
|
|
112
|
+
* the transport is fully closed and the connection is released.
|
|
113
|
+
*
|
|
114
|
+
* 2. **`externalLifecycle: true` — bounded RETURN, background drain.**
|
|
115
|
+
* Arc must NOT touch a connection it doesn't own. `close()` returns
|
|
116
|
+
* within `closeTimeoutMs`, but the poll loop is left to drain on its
|
|
117
|
+
* own when its outstanding `XREADGROUP BLOCK` returns (up to
|
|
118
|
+
* `blockTimeMs`). Arc silently absorbs the loop's eventual completion
|
|
119
|
+
* so the host doesn't see unhandled rejections / log spam against a
|
|
120
|
+
* transport it considers closed. The host's own `redis.quit()` /
|
|
121
|
+
* process exit is what ultimately tears the connection down.
|
|
122
|
+
*
|
|
123
|
+
* Practical implication: under `externalLifecycle: true`, set
|
|
124
|
+
* `blockTimeMs` low (e.g. 500ms) so the background drain window is
|
|
125
|
+
* short. The transport is "closed enough" to stop dispatching to
|
|
126
|
+
* handlers (handlers map is cleared and generation is bumped) but is
|
|
127
|
+
* not "fully closed" in the connection-lifecycle sense until the host
|
|
128
|
+
* closes the underlying client.
|
|
129
|
+
*
|
|
130
|
+
* In both modes the generation counter is bumped, so a follow-up
|
|
131
|
+
* `subscribe()` spawns a fresh poll loop with a new generation — the
|
|
132
|
+
* stale loop exits on its next iteration and never overlaps the new one.
|
|
133
|
+
*/
|
|
72
134
|
async close() {
|
|
73
135
|
this.running = false;
|
|
136
|
+
this.generation++;
|
|
74
137
|
this.handlers.clear();
|
|
75
138
|
if (this.pollPromise) {
|
|
76
|
-
await this.pollPromise
|
|
139
|
+
if (await Promise.race([this.pollPromise.then(() => "drained"), this.sleep(this.closeTimeoutMs).then(() => "timeout")]) === "timeout") if (!this.externalLifecycle) {
|
|
140
|
+
if (typeof this.redis.disconnect === "function") this.redis.disconnect();
|
|
141
|
+
else await this.redis.quit().catch((err) => {
|
|
142
|
+
this.logger.error("[RedisStreamTransport] quit() during close raced:", err);
|
|
143
|
+
});
|
|
144
|
+
await this.pollPromise.catch(() => void 0);
|
|
145
|
+
} else this.pollPromise.catch(() => void 0);
|
|
77
146
|
this.pollPromise = null;
|
|
78
147
|
}
|
|
148
|
+
if (!this.externalLifecycle) await this.redis.quit().catch(() => void 0);
|
|
79
149
|
}
|
|
80
150
|
async ensureGroup() {
|
|
81
151
|
if (this.groupCreated) return;
|
|
@@ -86,12 +156,12 @@ var RedisStreamTransport = class {
|
|
|
86
156
|
}
|
|
87
157
|
this.groupCreated = true;
|
|
88
158
|
}
|
|
89
|
-
async pollLoop() {
|
|
90
|
-
while (this.running) try {
|
|
159
|
+
async pollLoop(myGen) {
|
|
160
|
+
while (this.running && this.generation === myGen) try {
|
|
91
161
|
await this.claimPending();
|
|
92
162
|
await this.readNewMessages();
|
|
93
163
|
} catch (err) {
|
|
94
|
-
if (this.running) {
|
|
164
|
+
if (this.running && this.generation === myGen) {
|
|
95
165
|
this.logger.error("[RedisStreamTransport] Poll error:", err);
|
|
96
166
|
await this.sleep(1e3);
|
|
97
167
|
}
|
|
@@ -123,39 +193,38 @@ var RedisStreamTransport = class {
|
|
|
123
193
|
}
|
|
124
194
|
}
|
|
125
195
|
async processEntry(messageId, fields) {
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const rawData = fieldMap.get("data");
|
|
130
|
-
if (!eventType || !rawData) {
|
|
131
|
-
await this.redis.xack(this.stream, this.group, messageId);
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
let event;
|
|
135
|
-
try {
|
|
136
|
-
const parsed = JSON.parse(rawData, (key, value) => {
|
|
137
|
-
if (key === "timestamp" && typeof value === "string") return new Date(value);
|
|
138
|
-
return value;
|
|
139
|
-
});
|
|
140
|
-
if (!parsed || typeof parsed !== "object" || typeof parsed.type !== "string" || !parsed.meta?.id) {
|
|
141
|
-
this.logger.warn("[RedisStreamTransport] Malformed event — missing type or meta.id, acking and skipping");
|
|
142
|
-
await this.redis.xack(this.stream, this.group, messageId);
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
event = parsed;
|
|
146
|
-
} catch {
|
|
196
|
+
const event = parseStreamFields(fields);
|
|
197
|
+
if (!event) {
|
|
198
|
+
this.logger.warn(`[RedisStreamTransport] Malformed entry ${messageId} — missing type/data or invalid JSON, acking and skipping`);
|
|
147
199
|
await this.redis.xack(this.stream, this.group, messageId);
|
|
148
200
|
return;
|
|
149
201
|
}
|
|
150
202
|
const matchingHandlers = this.getMatchingHandlers(event.type);
|
|
151
203
|
let allSucceeded = true;
|
|
204
|
+
let lastError;
|
|
205
|
+
let lastHandlerName;
|
|
152
206
|
for (const handler of matchingHandlers) try {
|
|
153
207
|
await handler(event);
|
|
154
208
|
} catch (err) {
|
|
155
209
|
allSucceeded = false;
|
|
210
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
211
|
+
lastHandlerName = handler.name || lastHandlerName;
|
|
156
212
|
this.logger.error(`[RedisStreamTransport] Handler error for ${event.type}:`, err);
|
|
157
213
|
}
|
|
158
|
-
if (allSucceeded)
|
|
214
|
+
if (allSucceeded) {
|
|
215
|
+
await this.redis.xack(this.stream, this.group, messageId);
|
|
216
|
+
this.failureContext.delete(messageId);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const now = /* @__PURE__ */ new Date();
|
|
220
|
+
const prior = this.failureContext.get(messageId);
|
|
221
|
+
this.failureContext.set(messageId, {
|
|
222
|
+
error: lastError ? toErrorRecord(lastError) : { message: "handler returned without acking — no error captured" },
|
|
223
|
+
firstFailedAt: prior?.firstFailedAt ?? now,
|
|
224
|
+
lastFailedAt: now,
|
|
225
|
+
attempts: (prior?.attempts ?? 0) + 1,
|
|
226
|
+
handlerName: lastHandlerName ?? prior?.handlerName
|
|
227
|
+
});
|
|
159
228
|
}
|
|
160
229
|
getMatchingHandlers(eventType) {
|
|
161
230
|
const matched = [];
|
|
@@ -173,19 +242,123 @@ var RedisStreamTransport = class {
|
|
|
173
242
|
}
|
|
174
243
|
async moveToDlq(ids) {
|
|
175
244
|
if (this.deadLetterStream === false) {
|
|
176
|
-
for (const id of ids)
|
|
245
|
+
for (const id of ids) {
|
|
246
|
+
await this.redis.xack(this.stream, this.group, id);
|
|
247
|
+
this.failureContext.delete(id);
|
|
248
|
+
}
|
|
177
249
|
return;
|
|
178
250
|
}
|
|
179
251
|
for (const id of ids) try {
|
|
180
|
-
|
|
252
|
+
const envelope = await this.buildDlqEnvelope(id);
|
|
253
|
+
if (!envelope) {
|
|
254
|
+
this.logger.error(`[RedisStreamTransport] DLQ for ${id}: source entry missing AND no failure context — acking to drop`);
|
|
255
|
+
await this.redis.xack(this.stream, this.group, id);
|
|
256
|
+
this.failureContext.delete(id);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
await this.redis.xadd(this.deadLetterStream, "*", "type", envelope.event.type, "originalStream", this.stream, "originalId", id, "group", this.group, "data", JSON.stringify(envelope));
|
|
181
260
|
await this.redis.xack(this.stream, this.group, id);
|
|
261
|
+
this.failureContext.delete(id);
|
|
182
262
|
} catch (err) {
|
|
183
263
|
this.logger.error(`[RedisStreamTransport] DLQ write failed for ${id}:`, err);
|
|
184
264
|
}
|
|
185
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* Reconstruct a `DeadLetteredEvent` for a message id. Reads the original
|
|
268
|
+
* entry via `xrange` (when the client supports it) and merges in any
|
|
269
|
+
* in-process failure context. Returns `null` only when BOTH sources are
|
|
270
|
+
* missing — callers ack-and-drop rather than re-queuing a ghost.
|
|
271
|
+
*
|
|
272
|
+
* Graceful degradation paths:
|
|
273
|
+
* - Client lacks `xrange` (older custom wrappers) → log once, build the
|
|
274
|
+
* envelope from `failureContext` alone. Payload is absent but the
|
|
275
|
+
* error reason + attempt accounting still survive.
|
|
276
|
+
* - `xrange` throws (network blip, ACL) → same fallback.
|
|
277
|
+
* - Source entry trimmed before DLQ write → same fallback.
|
|
278
|
+
*/
|
|
279
|
+
async buildDlqEnvelope(id) {
|
|
280
|
+
const ctx = this.failureContext.get(id);
|
|
281
|
+
let event = null;
|
|
282
|
+
if (typeof this.redis.xrange === "function") try {
|
|
283
|
+
const fields = (await this.redis.xrange(this.stream, id, id))[0]?.[1];
|
|
284
|
+
if (fields) {
|
|
285
|
+
const parsed = parseStreamFields(fields);
|
|
286
|
+
if (parsed) event = parsed;
|
|
287
|
+
}
|
|
288
|
+
} catch (err) {
|
|
289
|
+
this.logger.error(`[RedisStreamTransport] xrange for DLQ source ${id} failed:`, err);
|
|
290
|
+
}
|
|
291
|
+
else if (!this.xrangeWarningEmitted) {
|
|
292
|
+
this.xrangeWarningEmitted = true;
|
|
293
|
+
this.logger.warn("[RedisStreamTransport] Redis client lacks xrange() — DLQ envelopes will not include the original event payload. Upgrade your client (ioredis ≥4 supports it) or use a wrapper that proxies xrange to enable replay.");
|
|
294
|
+
}
|
|
295
|
+
if (!event && !ctx) return null;
|
|
296
|
+
const fallbackTime = /* @__PURE__ */ new Date();
|
|
297
|
+
return {
|
|
298
|
+
event: event ?? {
|
|
299
|
+
type: "<unknown>",
|
|
300
|
+
payload: null,
|
|
301
|
+
meta: {
|
|
302
|
+
id,
|
|
303
|
+
timestamp: fallbackTime
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
error: ctx?.error ?? { message: "exhausted retries — failure occurred on a different consumer; error context not preserved across consumer-group failover" },
|
|
307
|
+
attempts: ctx?.attempts ?? this.maxRetries,
|
|
308
|
+
firstFailedAt: ctx?.firstFailedAt ?? fallbackTime,
|
|
309
|
+
lastFailedAt: ctx?.lastFailedAt ?? fallbackTime,
|
|
310
|
+
...ctx?.handlerName ? { handlerName: ctx.handlerName } : {}
|
|
311
|
+
};
|
|
312
|
+
}
|
|
186
313
|
sleep(ms) {
|
|
187
314
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
188
315
|
}
|
|
189
316
|
};
|
|
317
|
+
/**
|
|
318
|
+
* Convert a thrown value into the `DeadLetteredEvent.error` shape — message
|
|
319
|
+
* always present, optional `code` (string only) and `stack`. Centralised so
|
|
320
|
+
* the failure-context tracker and the DLQ envelope writer agree.
|
|
321
|
+
*/
|
|
322
|
+
function toErrorRecord(err) {
|
|
323
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
324
|
+
const code = e.code;
|
|
325
|
+
return {
|
|
326
|
+
message: e.message,
|
|
327
|
+
...typeof code === "string" ? { code } : {},
|
|
328
|
+
...e.stack ? { stack: e.stack } : {}
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Parse a Redis Stream entry's flat `[key, value, key, value, ...]` field
|
|
333
|
+
* array into a typed `DomainEvent`, or `null` when the entry is malformed
|
|
334
|
+
* (missing `type` / `data`, unparseable JSON, or missing required event
|
|
335
|
+
* structure).
|
|
336
|
+
*
|
|
337
|
+
* Pure on purpose — used by both `processEntry` (the live consumer path)
|
|
338
|
+
* and `buildDlqEnvelope` (the dead-letter writer). Keeping the parse logic
|
|
339
|
+
* in one place avoids the silent drift class that produced the original
|
|
340
|
+
* "DLQ has no payload" bug.
|
|
341
|
+
*/
|
|
342
|
+
function parseStreamFields(fields) {
|
|
343
|
+
let eventType;
|
|
344
|
+
let rawData;
|
|
345
|
+
for (let i = 0; i < fields.length; i += 2) {
|
|
346
|
+
const key = fields[i];
|
|
347
|
+
const value = fields[i + 1];
|
|
348
|
+
if (key === "type") eventType = value;
|
|
349
|
+
else if (key === "data") rawData = value;
|
|
350
|
+
}
|
|
351
|
+
if (!eventType || !rawData) return null;
|
|
352
|
+
try {
|
|
353
|
+
const parsed = JSON.parse(rawData, (key, value) => {
|
|
354
|
+
if (key === "timestamp" && typeof value === "string") return new Date(value);
|
|
355
|
+
return value;
|
|
356
|
+
});
|
|
357
|
+
if (!parsed || typeof parsed !== "object" || typeof parsed.type !== "string" || !parsed.meta?.id) return null;
|
|
358
|
+
return parsed;
|
|
359
|
+
} catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
190
363
|
//#endregion
|
|
191
364
|
export { RedisStreamTransport };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { i as EventLogger, n as DomainEvent, o as EventTransport, r as EventHandler } from "../../EventTransport-
|
|
1
|
+
import { i as EventLogger, n as DomainEvent, o as EventTransport, r as EventHandler } from "../../EventTransport-CYNUXdCJ.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/events/transports/redis.d.ts
|
|
4
4
|
interface RedisLike {
|
package/dist/factory/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as CustomPluginAuthOption, c as RawBodyOptions, d as ResourceLike, f as ResourceModule, i as CustomAuthenticatorOption, l as UnderPressureOptions, n as BetterAuthOption, o as JwtAuthOption, p as loadResources, r as CreateAppOptions, s as MultipartOptions, t as AuthOption, u as LoadResourcesOptions } from "../types-
|
|
1
|
+
import { a as CustomPluginAuthOption, c as RawBodyOptions, d as ResourceLike, f as ResourceModule, i as CustomAuthenticatorOption, l as UnderPressureOptions, n as BetterAuthOption, o as JwtAuthOption, p as loadResources, r as CreateAppOptions, s as MultipartOptions, t as AuthOption, u as LoadResourcesOptions } from "../types-D7KpfiL1.mjs";
|
|
2
2
|
import { FastifyInstance } from "fastify";
|
|
3
3
|
|
|
4
4
|
//#region src/factory/createApp.d.ts
|
package/dist/factory/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as edgePreset, c as testingPreset, i as developmentPreset, n as createApp, o as getPreset, s as productionPreset, t as ArcFactory } from "../createApp-
|
|
1
|
+
import { a as edgePreset, c as testingPreset, i as developmentPreset, n as createApp, o as getPreset, s as productionPreset, t as ArcFactory } from "../createApp-C9bRrqlX.mjs";
|
|
2
2
|
import { t as loadResources } from "../loadResources-CPpkyKfM.mjs";
|
|
3
3
|
//#region src/factory/edge.ts
|
|
4
4
|
/**
|
package/dist/hooks/index.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { Cn as beforeUpdate, Sn as beforeDelete, Tn as defineHook, _n as HookSystemOptions, bn as afterUpdate, dn as HookContext, fn as HookHandler, gn as HookSystem, hn as HookRegistration, mn as HookPhase, pn as HookOperation, un as DefineHookOptions, vn as afterCreate, wn as createHookSystem, xn as beforeCreate, yn as afterDelete } from "../index-
|
|
1
|
+
import { Cn as beforeUpdate, Sn as beforeDelete, Tn as defineHook, _n as HookSystemOptions, bn as afterUpdate, dn as HookContext, fn as HookHandler, gn as HookSystem, hn as HookRegistration, mn as HookPhase, pn as HookOperation, un as DefineHookOptions, vn as afterCreate, wn as createHookSystem, xn as beforeCreate, yn as afterDelete } from "../index-CXXRbnf8.mjs";
|
|
2
2
|
export { type DefineHookOptions, type HookContext, type HookHandler, type HookOperation, type HookPhase, type HookRegistration, HookSystem, type HookSystemOptions, afterCreate, afterDelete, afterUpdate, beforeCreate, beforeDelete, beforeUpdate, createHookSystem, defineHook };
|