@cullet/erp-core 1.0.11 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/KIT_CONTEXT.md +1 -0
  2. package/README.md +128 -1
  3. package/dist/app-error.d.ts +108 -0
  4. package/dist/application/index.d.ts +2 -0
  5. package/dist/application/index.js +3 -0
  6. package/dist/domain/index.d.ts +3 -0
  7. package/dist/domain/index.js +3 -0
  8. package/dist/domain-event-contracts.d.ts +27 -0
  9. package/dist/domain-event-contracts.js +22 -0
  10. package/dist/domain-event-contracts.js.map +1 -0
  11. package/dist/domain-exception.d.ts +7 -0
  12. package/dist/domain-exception.js +12 -0
  13. package/dist/domain-exception.js.map +1 -0
  14. package/dist/errors/index.d.ts +2 -1
  15. package/dist/errors/index.js +4 -2
  16. package/dist/exceptions/index.d.ts +5 -0
  17. package/dist/exceptions/index.js +7 -0
  18. package/dist/gate-engine-registry.d.ts +81 -0
  19. package/dist/gate-engine-registry.js +2 -1
  20. package/dist/gate-engine-registry.js.map +1 -1
  21. package/dist/gate-types.d.ts +3 -139
  22. package/dist/gate-v1-payload.schema.js +3 -143
  23. package/dist/gate-v1-payload.schema.js.map +1 -1
  24. package/dist/hashing.js +49 -0
  25. package/dist/hashing.js.map +1 -0
  26. package/dist/immutable.d.ts +6 -0
  27. package/dist/immutable.js +54 -0
  28. package/dist/immutable.js.map +1 -0
  29. package/dist/index.d.ts +21 -200
  30. package/dist/index.js +21 -251
  31. package/dist/index.js.map +1 -1
  32. package/dist/invalid-state-transition-exception.js +30 -0
  33. package/dist/invalid-state-transition-exception.js.map +1 -0
  34. package/dist/invariant-violation-exception.js +11 -0
  35. package/dist/invariant-violation-exception.js.map +1 -0
  36. package/dist/not-found-error.js +54 -0
  37. package/dist/not-found-error.js.map +1 -0
  38. package/dist/outcome.d.ts +140 -0
  39. package/dist/outcome.js +1 -1
  40. package/dist/parse-gate-payload.d.ts +4 -78
  41. package/dist/path.d.ts +90 -0
  42. package/dist/plugin.d.ts +85 -0
  43. package/dist/plugin.js +74 -0
  44. package/dist/plugin.js.map +1 -0
  45. package/dist/plugins/index.d.ts +2 -0
  46. package/dist/plugins/index.js +2 -0
  47. package/dist/policies/engines/index.d.ts +3 -2
  48. package/dist/policies/engines/v1/gate/index.d.ts +2 -1
  49. package/dist/policies/engines/v1/gate/index.js +2 -1
  50. package/dist/policies/engines/v1/gate/index.js.map +1 -1
  51. package/dist/policies/index.d.ts +6 -3
  52. package/dist/policies/index.js +2 -1
  53. package/dist/policy-service.d.ts +4 -86
  54. package/dist/policy-service.js +8 -16
  55. package/dist/policy-service.js.map +1 -1
  56. package/dist/result/index.d.ts +2 -0
  57. package/dist/result/index.js +3 -0
  58. package/dist/result.js +118 -0
  59. package/dist/result.js.map +1 -0
  60. package/dist/ruleset-registry.js +42 -0
  61. package/dist/ruleset-registry.js.map +1 -0
  62. package/dist/rulesets/index.d.ts +2 -0
  63. package/dist/rulesets/index.js +2 -0
  64. package/dist/temporal-guards.js +15 -0
  65. package/dist/temporal-guards.js.map +1 -0
  66. package/dist/temporal-use-case.d.ts +304 -0
  67. package/dist/temporal-use-case.js +150 -0
  68. package/dist/temporal-use-case.js.map +1 -0
  69. package/dist/unexpected-error.js +179 -0
  70. package/dist/unexpected-error.js.map +1 -0
  71. package/dist/use-case.js +91 -0
  72. package/dist/use-case.js.map +1 -0
  73. package/dist/uuid-identifier.d.ts +230 -0
  74. package/dist/uuid-identifier.js +60 -0
  75. package/dist/uuid-identifier.js.map +1 -0
  76. package/dist/validation-code.d.ts +23 -0
  77. package/dist/validation-code.js +1 -223
  78. package/dist/validation-code.js.map +1 -1
  79. package/dist/validation-error.d.ts +3 -128
  80. package/dist/validation-error.js +2 -52
  81. package/dist/validation-error.js.map +1 -1
  82. package/dist/validation-exception.d.ts +50 -0
  83. package/dist/validation-exception.js +24 -0
  84. package/dist/validation-exception.js.map +1 -0
  85. package/dist/value-object-ruleset.contracts.d.ts +36 -0
  86. package/dist/value-object.js +191 -0
  87. package/dist/value-object.js.map +1 -0
  88. package/dist/version.d.ts +10 -0
  89. package/dist/versioning/index.d.ts +3 -0
  90. package/dist/versioning/index.js +3 -0
  91. package/meta.json +18 -3
  92. package/package.json +37 -4
  93. package/src/application/index.ts +1 -0
  94. package/src/core/application/commands/index.ts +1 -0
  95. package/src/core/application/index.ts +12 -3
  96. package/src/core/application/ports/index.ts +2 -2
  97. package/src/core/application/ports/policy-port.ts +13 -3
  98. package/src/core/application/ports/repository.port.ts +37 -1
  99. package/src/core/application/temporal/temporal-use-case.ts +14 -2
  100. package/src/core/application/use-case.ts +133 -2
  101. package/src/core/domain/rulesets/index.ts +7 -0
  102. package/src/core/domain/uuid-identifier.ts +72 -0
  103. package/src/core/domain/value-object.ts +37 -5
  104. package/src/core/exceptions/index.ts +13 -0
  105. package/src/core/index.ts +15 -12
  106. package/src/core/plugins/index.ts +7 -0
  107. package/src/core/plugins/plugin.ts +99 -0
  108. package/src/core/plugins/types.ts +53 -0
  109. package/src/core/versioning/index.ts +14 -0
  110. package/src/domain/index.ts +7 -0
  111. package/src/examples/application/cancel-order.example.ts +124 -0
  112. package/src/examples/application/in-memory-account-repository.example.ts +73 -0
  113. package/src/exceptions/index.ts +1 -0
  114. package/src/plugins/index.ts +1 -0
  115. package/src/result/index.ts +2 -0
  116. package/src/rulesets/index.ts +1 -0
  117. package/src/version.ts +1 -1
  118. package/src/versioning/index.ts +1 -0
@@ -1,8 +1,31 @@
1
1
  import { type ContractVersion, version } from "../versioning/version.js";
2
2
  import type { Result } from "../result/result.js";
3
3
 
4
+ import type { LoggerPort } from "./ports/logger.port.js";
5
+ import type { MetricsPort } from "./ports/metrics.port.js";
6
+ import type { TracerPort } from "./ports/tracer.port.js";
7
+
4
8
  type MaybePromise<T> = T | Promise<T>;
5
9
 
10
+ /**
11
+ * Observability adapters a use case may opt into.
12
+ *
13
+ * All fields are optional: a use case that provides none keeps the plain
14
+ * `input → Result` behavior with zero overhead. When provided, {@link UseCase.run}
15
+ * instruments every execution around `execute()` — opening a span, recording
16
+ * duration, counting outcomes, and logging business/unexpected failures.
17
+ */
18
+ interface UseCaseObservability {
19
+ readonly logger?: LoggerPort;
20
+ readonly metrics?: MetricsPort;
21
+ readonly tracer?: TracerPort;
22
+ }
23
+
24
+ const EXECUTION_COUNTER = "use_case.executions";
25
+ const DURATION_HISTOGRAM = "use_case.duration_ms";
26
+
27
+ type ExecutionOutcome = "ok" | "error" | "exception";
28
+
6
29
  @version("1.0")
7
30
  abstract class UseCase<
8
31
  Input = void,
@@ -14,11 +37,119 @@ abstract class UseCase<
14
37
  return UseCase.CONTRACT_VERSION;
15
38
  }
16
39
 
40
+ /**
41
+ * Runs the use case.
42
+ *
43
+ * `run()` is the single seam every use case crosses, so it is where
44
+ * cross-cutting instrumentation lives. When {@link observability} exposes
45
+ * any adapter, the call to `execute()` is wrapped with tracing, metrics and
46
+ * failure logging; otherwise it delegates directly with no overhead.
47
+ *
48
+ * Observability is strictly side-effecting: a misbehaving adapter never
49
+ * changes the business result nor masks a thrown error — its own failures
50
+ * are swallowed.
51
+ */
17
52
  public async run(input: Input): Promise<Output> {
18
- return await this.execute(input);
53
+ const observability = this.observability();
54
+ if (
55
+ !observability.logger &&
56
+ !observability.metrics &&
57
+ !observability.tracer
58
+ ) {
59
+ return await this.execute(input);
60
+ }
61
+
62
+ return await this.runInstrumented(input, observability);
63
+ }
64
+
65
+ /**
66
+ * Adapters used to instrument {@link run}. Override to opt a use case into
67
+ * tracing/metrics/logging. Returns an empty object by default, which keeps
68
+ * the plain delegating behavior.
69
+ */
70
+ protected observability(): UseCaseObservability {
71
+ return {};
72
+ }
73
+
74
+ /**
75
+ * Stable identity used for span names and metric labels. Defaults to the
76
+ * runtime class name; override when bundling/minification would otherwise
77
+ * erase a meaningful name.
78
+ */
79
+ protected get useCaseName(): string {
80
+ return this.constructor.name;
19
81
  }
20
82
 
21
83
  protected abstract execute(input: Input): MaybePromise<Output>;
84
+
85
+ private async runInstrumented(
86
+ input: Input,
87
+ observability: UseCaseObservability,
88
+ ): Promise<Output> {
89
+ const { logger, metrics, tracer } = observability;
90
+ const name = this.useCaseName;
91
+ const span = safely(() =>
92
+ tracer?.startSpan(name, { "use_case.name": name }),
93
+ );
94
+ const startedAt = Date.now();
95
+
96
+ try {
97
+ const output = await this.execute(input);
98
+ const outcome: ExecutionOutcome = output.isOk() ? "ok" : "error";
99
+
100
+ if (outcome === "error") {
101
+ safely(() =>
102
+ logger?.warn(`${name} returned a business error`, {
103
+ useCase: name,
104
+ }),
105
+ );
106
+ }
107
+ safely(() => span?.setAttribute("use_case.outcome", outcome));
108
+ safely(() =>
109
+ metrics?.counter(EXECUTION_COUNTER, 1, {
110
+ useCase: name,
111
+ outcome,
112
+ }),
113
+ );
114
+
115
+ return output;
116
+ } catch (error) {
117
+ safely(() =>
118
+ logger?.error(`${name} threw an unexpected error`, {
119
+ useCase: name,
120
+ }),
121
+ );
122
+ safely(() => span?.setAttribute("use_case.outcome", "exception"));
123
+ safely(() => span?.recordException(error));
124
+ safely(() =>
125
+ metrics?.counter(EXECUTION_COUNTER, 1, {
126
+ useCase: name,
127
+ outcome: "exception",
128
+ }),
129
+ );
130
+
131
+ throw error;
132
+ } finally {
133
+ safely(() =>
134
+ metrics?.histogram(DURATION_HISTOGRAM, Date.now() - startedAt, {
135
+ useCase: name,
136
+ }),
137
+ );
138
+ safely(() => span?.end());
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Invokes an observability side effect, isolating any adapter failure so it
145
+ * cannot alter the use case outcome.
146
+ */
147
+ function safely<R>(effect: () => R): R | undefined {
148
+ try {
149
+ return effect();
150
+ } catch {
151
+ return undefined;
152
+ }
22
153
  }
23
154
 
24
- export { type MaybePromise, UseCase };
155
+ export { type MaybePromise, UseCase, type UseCaseObservability };
@@ -0,0 +1,7 @@
1
+ export {
2
+ type CreationRuleset,
3
+ type InvariantRuleset,
4
+ } from "./entity-ruleset.contracts.js";
5
+ export { RulesetRegistry } from "./ruleset-registry.js";
6
+ export { type Ruleset, type RulesetId } from "./ruleset.contracts.js";
7
+ export { type ValueObjectRuleset } from "./value-object-ruleset.contracts.js";
@@ -0,0 +1,72 @@
1
+ import { ValueObject } from "./value-object.js";
2
+
3
+ /**
4
+ * Canonical RFC-4122 UUID (versions 1–5), matched case-insensitively. Declared
5
+ * once here so the dozens of typed identifiers a domain accumulates never have
6
+ * to re-state the pattern.
7
+ */
8
+ const UUID_PATTERN =
9
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
10
+
11
+ /**
12
+ * Base class for UUID-backed identity value objects.
13
+ *
14
+ * A domain typically has many of these — `OrderId`, `CustomerId`, `InvoiceId` —
15
+ * all wrapping the same `string` shape. Two problems follow: the UUID format
16
+ * check gets copy-pasted into every one, and because the wrapped shape is
17
+ * identical, TypeScript's structural typing would happily accept an `OrderId`
18
+ * where a `CustomerId` is expected. `UuidIdentifier` solves both: the format
19
+ * lives here once ({@link isValid}), and the `TBrand` phantom tag makes each
20
+ * subtype nominally distinct so the ids cannot be swapped for one another.
21
+ *
22
+ * It deliberately does not impose a factory. Identifiers vary in how they
23
+ * report an invalid value (their own exception type, their own message), so a
24
+ * concrete id adds a validating `create` that calls {@link isValid} plus a
25
+ * `reconstitute` that trusts an already-persisted value:
26
+ *
27
+ * ```ts
28
+ * class OrderId extends UuidIdentifier<"OrderId"> {
29
+ * private constructor(value: string) { super(value); }
30
+ * static create(raw: string): OrderId {
31
+ * if (!UuidIdentifier.isValid(raw)) throw new InvalidOrderIdError(raw);
32
+ * return new OrderId(raw);
33
+ * }
34
+ * static reconstitute(value: string): OrderId { return new OrderId(value); }
35
+ * }
36
+ * ```
37
+ *
38
+ * @typeParam TBrand - A unique string literal that nominally tags the subtype.
39
+ */
40
+ abstract class UuidIdentifier<
41
+ TBrand extends string = string,
42
+ > extends ValueObject<string, string> {
43
+ /**
44
+ * Phantom brand. Never assigned at runtime (`declare`), it exists only so
45
+ * two identifiers with different brands are not interchangeable at the type
46
+ * level despite sharing the same `string` value.
47
+ */
48
+ protected declare readonly __brand: TBrand;
49
+
50
+ protected constructor(value: string) {
51
+ super(value);
52
+ }
53
+
54
+ public toPrimitive(): string {
55
+ return this.value;
56
+ }
57
+
58
+ /** The wrapped UUID string — convenient for logging and interpolation. */
59
+ public toString(): string {
60
+ return this.value;
61
+ }
62
+
63
+ /**
64
+ * Whether `candidate` is a canonical UUID (versions 1–5). The single source
65
+ * of truth for the format; concrete identifiers call this from `create`.
66
+ */
67
+ public static isValid(candidate: string): boolean {
68
+ return UUID_PATTERN.test(candidate);
69
+ }
70
+ }
71
+
72
+ export { UuidIdentifier };
@@ -1,6 +1,21 @@
1
+ import { PluginManager } from "../plugins/index.js";
1
2
  import { type DeepReadonly, makeImmutable } from "../shared/immutable.js";
2
3
  import { type ContractVersion, version } from "../versioning/version.js";
3
4
 
5
+ /**
6
+ * Extension point for value-object equality. A plugin implementing this
7
+ * contract overrides how any two value objects are compared, letting the host
8
+ * application swap in a structural comparator (e.g. `lodash.isEqual` over the
9
+ * wrapped `value`) without every value object having to implement `equals` by
10
+ * hand.
11
+ */
12
+ type ValueObjectPluginContract = {
13
+ equals: (
14
+ a: ValueObject<unknown, unknown>,
15
+ b: ValueObject<unknown, unknown>,
16
+ ) => boolean;
17
+ };
18
+
4
19
  /**
5
20
  * Base class for value objects — domain concepts defined entirely by their
6
21
  * contents, with no identity of their own. Two value objects are
@@ -20,6 +35,15 @@ import { type ContractVersion, version } from "../versioning/version.js";
20
35
  abstract class ValueObject<T, P> {
21
36
  declare public static readonly CONTRACT_VERSION: ContractVersion;
22
37
 
38
+ /**
39
+ * Registry of equality plugins shared by every value object. Empty by
40
+ * default — when nothing is registered, {@link equals} falls back to a
41
+ * structural comparison of the wrapped `value`. Hosts register a plugin
42
+ * (e.g. `lodash.isEqual`) once at startup to customise equality globally.
43
+ */
44
+ public static readonly plugins =
45
+ new PluginManager<ValueObjectPluginContract>();
46
+
23
47
  /** The wrapped data, deep-frozen so it can never be mutated after construction. */
24
48
  public readonly value: DeepReadonly<T>;
25
49
 
@@ -65,11 +89,19 @@ abstract class ValueObject<T, P> {
65
89
 
66
90
  /**
67
91
  * Compares this value object with another by content. Because value objects
68
- * carry no identity, subclasses implement equality over the wrapped data
69
- * (typically the primitive form), so two independently constructed instances
70
- * holding the same data are considered equal.
92
+ * carry no identity, two independently constructed instances holding the
93
+ * same data are considered equal.
94
+ *
95
+ * Delegates to the registered equality {@link plugins}; with no plugin
96
+ * registered it falls back to comparing the serialized wrapped `value`.
97
+ * Subclasses may still override for a faster or domain-specific comparison.
71
98
  */
72
- public abstract equals(other: this): boolean;
99
+ public equals(other: this): boolean {
100
+ return ValueObject.plugins.invoke("equals", [this, other], {
101
+ fallback: (a, b) =>
102
+ JSON.stringify(a.value) === JSON.stringify(b.value),
103
+ });
104
+ }
73
105
 
74
106
  /**
75
107
  * Projects the value object down to a plain, serializable primitive form —
@@ -78,4 +110,4 @@ abstract class ValueObject<T, P> {
78
110
  public abstract toPrimitive(): P;
79
111
  }
80
112
 
81
- export { type DeepReadonly, ValueObject };
113
+ export { type DeepReadonly, ValueObject, type ValueObjectPluginContract };
@@ -0,0 +1,13 @@
1
+ export { BusinessRuleViolationException } from "./business-rule-violation-exception.js";
2
+ export { DomainException } from "./domain-exception.js";
3
+ export { EntityNotFoundException } from "./entity-not-found-exception.js";
4
+ export { InvalidStateTransitionException } from "./invalid-state-transition-exception.js";
5
+ export { InvariantViolationException } from "./invariant-violation-exception.js";
6
+ export { ValidationCode } from "./validation-code.js";
7
+ export {
8
+ InvalidValueException,
9
+ MultipleValidationException,
10
+ ValidationException,
11
+ type ValidationViolation,
12
+ } from "./validation-exception.js";
13
+ export { ValidationField } from "./validation-field.js";
package/src/core/index.ts CHANGED
@@ -1,15 +1,18 @@
1
1
  export * from "./errors/index.js";
2
- export * from "./exceptions/validation-field.js";
3
- export type {
4
- LogPayload,
5
- LoggerPort,
6
- MetricLabels,
7
- MetricsPort,
8
- TraceAttributeValue,
9
- TraceSpan,
10
- TracerPort,
11
- } from "./application/index.js";
12
- export { mapPolicyEvaluationError } from "./application/policy-error-mapper.js";
2
+ export * from "./exceptions/index.js";
3
+ export * from "./application/index.js";
13
4
  export { Entity, type EntityState } from "./domain/entity.js";
14
- export { ValueObject, type DeepReadonly } from "./domain/value-object.js";
5
+ export { UuidIdentifier } from "./domain/uuid-identifier.js";
6
+ export {
7
+ ValueObject,
8
+ type DeepReadonly,
9
+ type ValueObjectPluginContract,
10
+ } from "./domain/value-object.js";
11
+ export * from "./domain/rulesets/index.js";
12
+ export * from "./plugins/index.js";
13
+ export * from "./versioning/index.js";
14
+ // NOTE: `./policies` re-exports Result/Ok/Err/Outcome/CommonOutcomeStatus and
15
+ // transitively pulls in `zod` (the gate/compute engines). Consumers that only
16
+ // need the zod-free primitives should import the dedicated subpaths
17
+ // (`@cullet/erp-core/{domain,result,exceptions,rulesets,versioning,plugins}`).
15
18
  export * from "./policies/index.js";
@@ -0,0 +1,7 @@
1
+ export { PluginManager } from "./plugin.js";
2
+ export type {
3
+ BasePlugin,
4
+ InvokeOptions,
5
+ PipelineReducer,
6
+ PluginContract,
7
+ } from "./types.js";
@@ -0,0 +1,99 @@
1
+ import {
2
+ type BasePlugin,
3
+ type InvokeOptions,
4
+ type PipelineReducer,
5
+ type PluginContract,
6
+ } from "./types.js";
7
+
8
+ /**
9
+ * Registry that holds a set of plugins implementing a shared {@link PluginContract}
10
+ * and resolves which of them answer a given extension point.
11
+ *
12
+ * Plugins are kept sorted by priority (descending). For a given method,
13
+ * {@link invoke} either delegates to the highest-priority enabled plugin
14
+ * (`'first'`) or folds every enabled plugin into a single value (`'pipeline'`).
15
+ * When no plugin is enabled, the supplied `fallback` runs instead, so the host
16
+ * always has defined behaviour even with an empty registry.
17
+ *
18
+ * @typeParam P - The plugin contract this manager coordinates.
19
+ */
20
+ class PluginManager<P extends PluginContract> {
21
+ /** Internal list, kept sorted by priority (desc). */
22
+ private plugins: (P & BasePlugin)[] = [];
23
+
24
+ /** Registers one or more plugins and re-sorts by priority. */
25
+ public register(...plugins: (P & BasePlugin)[]): void {
26
+ this.plugins.push(...plugins);
27
+ this.reorder();
28
+ }
29
+
30
+ /** Removes a previously registered plugin by name. */
31
+ public unregister(name: string): void {
32
+ this.plugins = this.plugins.filter((p) => p.name !== name);
33
+ }
34
+
35
+ /** Enables a registered plugin by name. */
36
+ public enable(name: string): void {
37
+ const plugin = this.plugins.find((p) => p.name === name);
38
+ if (plugin) plugin.enabled = true;
39
+ }
40
+
41
+ /** Disables a registered plugin by name (without removing it). */
42
+ public disable(name: string): void {
43
+ const plugin = this.plugins.find((p) => p.name === name);
44
+ if (plugin) plugin.enabled = false;
45
+ }
46
+
47
+ /** Lists enabled plugins only, already in priority order. */
48
+ public list(): readonly (P & BasePlugin)[] {
49
+ return this.plugins.filter((p) => p.enabled !== false);
50
+ }
51
+
52
+ /**
53
+ * Resolves `method` against the registered plugins.
54
+ *
55
+ * In `'first'` mode the highest-priority enabled plugin that implements
56
+ * `method` answers. In `'pipeline'` mode every enabled plugin runs and the
57
+ * results are folded with `reducer` (defaults to "keep the last result").
58
+ * When no enabled plugin implements `method`, `fallback` is called.
59
+ */
60
+ public invoke<K extends keyof P>(
61
+ method: K,
62
+ args: Parameters<P[K]>,
63
+ opts: InvokeOptions<P, K>,
64
+ ): ReturnType<P[K]> {
65
+ const { mode = "first", fallback, reducer } = opts;
66
+ const enabled = this.list().filter((p) => method in p) as Array<
67
+ P & BasePlugin
68
+ >;
69
+
70
+ if (enabled.length === 0) {
71
+ return (fallback as P[K])(...args);
72
+ }
73
+
74
+ if (mode === "first") {
75
+ const fn = enabled[0][method] as P[K];
76
+ return fn(...args);
77
+ }
78
+
79
+ const defaultReducer: PipelineReducer<ReturnType<P[K]>, P> = (
80
+ _,
81
+ curr,
82
+ ) => curr;
83
+ const red = reducer ?? defaultReducer;
84
+
85
+ let acc: ReturnType<P[K]> = enabled[0][method]!(...args);
86
+ enabled.slice(1).forEach((plugin, idx) => {
87
+ const res = plugin[method]!(...args);
88
+ acc = red(acc, res, plugin, idx + 1);
89
+ });
90
+ return acc;
91
+ }
92
+
93
+ /** Re-sorts the internal list by priority (desc; default priority = 0). */
94
+ private reorder(): void {
95
+ this.plugins.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
96
+ }
97
+ }
98
+
99
+ export { PluginManager };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Common shape every plugin shares, regardless of the contract it implements.
3
+ * Carries the metadata the {@link PluginManager} uses to identify, order and
4
+ * toggle plugins.
5
+ */
6
+ interface BasePlugin {
7
+ /** Unique identifier, used for logs / debugging and for enable/disable. */
8
+ readonly name: string;
9
+ /** Weight; the highest priority wins (default = 0). */
10
+ readonly priority?: number;
11
+ /** Shortcut to disable a plugin without removing it. */
12
+ enabled?: boolean;
13
+ }
14
+
15
+ /**
16
+ * A plugin contract is a record whose values are the methods plugins may
17
+ * implement for a given extension point.
18
+ *
19
+ * `any` here is intentional: it is the only way to express the constraint
20
+ * "a record whose values are arbitrary functions". Using `unknown` for the
21
+ * parameters breaks assignment of concrete contracts by contravariance —
22
+ * TypeScript rejects functions with specific parameters as a subtype of
23
+ * `(...args: unknown[]) => unknown`.
24
+ */
25
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
+ type PluginContract = Record<string, (...args: any[]) => any>;
27
+
28
+ /**
29
+ * Reducer used in `pipeline` mode to combine the result of each plugin into a
30
+ * single accumulated value.
31
+ */
32
+ type PipelineReducer<R, P extends PluginContract> = (
33
+ accumulated: R,
34
+ current: R,
35
+ plugin: P & BasePlugin,
36
+ index: number,
37
+ ) => R;
38
+
39
+ /** Options accepted by {@link PluginManager.invoke}. */
40
+ interface InvokeOptions<P extends PluginContract, K extends keyof P> {
41
+ /**
42
+ * `'first'` → returns the first enabled plugin with the highest priority.
43
+ * `'pipeline'` → runs every enabled plugin, combining results via `reducer`.
44
+ * default = `'first'`.
45
+ */
46
+ mode?: "first" | "pipeline";
47
+ /** Executed when no plugin is enabled for the method. */
48
+ fallback: P[K];
49
+ /** Reducer used only when `mode = "pipeline"`. */
50
+ reducer?: PipelineReducer<ReturnType<P[K]>, P>;
51
+ }
52
+
53
+ export type { BasePlugin, InvokeOptions, PipelineReducer, PluginContract };
@@ -0,0 +1,14 @@
1
+ export {
2
+ buildDomainEventContractVersions,
3
+ createDomainEventEnvelope,
4
+ type CreateDomainEventEnvelopeInput,
5
+ type DomainEventContractSelection,
6
+ type DomainEventContractVersions,
7
+ type DomainEventEnvelope,
8
+ } from "./domain-event-contracts.js";
9
+ export {
10
+ CONTRACT_VERSION_PROPERTY,
11
+ type ContractVersion,
12
+ version,
13
+ type VersionedTarget,
14
+ } from "./version.js";
@@ -0,0 +1,7 @@
1
+ export { Entity, type EntityState } from "../core/domain/entity.js";
2
+ export { UuidIdentifier } from "../core/domain/uuid-identifier.js";
3
+ export {
4
+ type DeepReadonly,
5
+ ValueObject,
6
+ type ValueObjectPluginContract,
7
+ } from "../core/domain/value-object.js";
@@ -0,0 +1,124 @@
1
+ /**
2
+ * End-to-end reference: a concrete {@link Command} wiring a
3
+ * {@link ResultRepository} and a {@link PolicyPort} together.
4
+ *
5
+ * Flow: load the aggregate → ask a declarative policy whether the action is
6
+ * allowed → mutate and persist. Every recoverable failure (infra error from
7
+ * the repository, a policy `DENY`, a missing aggregate, an optimistic-locking
8
+ * conflict on save) is returned as a typed `Result.err(...)` rather than
9
+ * thrown — the "errors as values" contract that `Command`/`UseCase` enforce.
10
+ *
11
+ * Consumers import these symbols from the package root (`@cullet/erp-core`);
12
+ * inside the kit we use relative paths so the example stays compilable and
13
+ * test-covered, guarding the snippet in the README/KIT_CONTEXT against rot.
14
+ */
15
+ import { Command, type CommandInput } from "../../core/application/index.js";
16
+ import type {
17
+ PolicyPort,
18
+ ResultRepository,
19
+ } from "../../core/application/index.js";
20
+ import { AuthorizationError } from "../../core/errors/authorization-error.js";
21
+ import { NotFoundError } from "../../core/errors/not-found-error.js";
22
+ import {
23
+ asPolicyDecisionId,
24
+ asSchoolId,
25
+ asTenantId,
26
+ } from "../../core/policies/index.js";
27
+ import { Result } from "../../core/result/result.js";
28
+
29
+ // ─── Minimal domain ─────────────────────────────────────────────────────────
30
+
31
+ type OrderStatus = "OPEN" | "CANCELLED";
32
+
33
+ interface Order {
34
+ readonly id: string;
35
+ readonly status: OrderStatus;
36
+ }
37
+
38
+ // ─── Command contract ────────────────────────────────────────────────────────
39
+
40
+ // `CommandInput` forces every mutation to record *who* triggered it.
41
+ interface CancelOrderInput extends CommandInput {
42
+ readonly orderId: string;
43
+ readonly tenantId: string;
44
+ readonly schoolId: string;
45
+ }
46
+
47
+ type CancelOrderError = NotFoundError | AuthorizationError;
48
+
49
+ // ─── The use case ─────────────────────────────────────────────────────────────
50
+
51
+ class CancelOrder extends Command<
52
+ CancelOrderInput,
53
+ Result<Order, CancelOrderError>
54
+ > {
55
+ constructor(
56
+ private readonly orders: ResultRepository<
57
+ Order,
58
+ string,
59
+ CancelOrderError
60
+ >,
61
+ private readonly policies: PolicyPort,
62
+ ) {
63
+ super();
64
+ }
65
+
66
+ protected async execute(
67
+ input: CancelOrderInput,
68
+ ): Promise<Result<Order, CancelOrderError>> {
69
+ // 1. Load the aggregate. Infra failures stay in band as Result.err.
70
+ const found = await this.orders.findById(input.orderId);
71
+ if (found.isErr()) {
72
+ return found;
73
+ }
74
+
75
+ const order = found.getOrThrow();
76
+ if (!order) {
77
+ return Result.err(
78
+ new NotFoundError("Order", { id: input.orderId }),
79
+ );
80
+ }
81
+
82
+ // 2. Decide via a declarative policy (a GATE returns ALLOW/DENY).
83
+ const evaluated = await this.policies.evaluate({
84
+ decisionId: asPolicyDecisionId(input.orderId),
85
+ policyKey: "order.cancel",
86
+ scopeChain: [],
87
+ contextVersion: 1,
88
+ seed: {
89
+ tenantId: asTenantId(input.tenantId),
90
+ schoolId: asSchoolId(input.schoolId),
91
+ fields: { orderStatus: order.status },
92
+ },
93
+ });
94
+ if (evaluated.isErr()) {
95
+ return evaluated;
96
+ }
97
+
98
+ const result = evaluated.getOrThrow();
99
+ if (result.kind === "GATE" && result.decision.status === "DENY") {
100
+ return Result.err(
101
+ AuthorizationError.policyDenied({
102
+ action: "order.cancel",
103
+ resource: { type: "Order", id: order.id },
104
+ policyId: result.ref.definitionId,
105
+ policyVersion: Number(result.ref.policyVersion),
106
+ evaluatedAtIso: result.evaluatedAt.toISOString(),
107
+ }),
108
+ );
109
+ }
110
+
111
+ // 3. Mutate and persist. A version conflict on save also surfaces as
112
+ // Result.err — never an exception crossing the boundary.
113
+ const cancelled: Order = { ...order, status: "CANCELLED" };
114
+ const saved = await this.orders.save(cancelled);
115
+ if (saved.isErr()) {
116
+ return saved;
117
+ }
118
+
119
+ return Result.ok(cancelled);
120
+ }
121
+ }
122
+
123
+ export { CancelOrder };
124
+ export type { CancelOrderError, CancelOrderInput, Order, OrderStatus };