@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.
- package/KIT_CONTEXT.md +1 -0
- package/README.md +128 -1
- package/dist/app-error.d.ts +108 -0
- package/dist/application/index.d.ts +2 -0
- package/dist/application/index.js +3 -0
- package/dist/domain/index.d.ts +3 -0
- package/dist/domain/index.js +3 -0
- package/dist/domain-event-contracts.d.ts +27 -0
- package/dist/domain-event-contracts.js +22 -0
- package/dist/domain-event-contracts.js.map +1 -0
- package/dist/domain-exception.d.ts +7 -0
- package/dist/domain-exception.js +12 -0
- package/dist/domain-exception.js.map +1 -0
- package/dist/errors/index.d.ts +2 -1
- package/dist/errors/index.js +4 -2
- package/dist/exceptions/index.d.ts +5 -0
- package/dist/exceptions/index.js +7 -0
- package/dist/gate-engine-registry.d.ts +81 -0
- package/dist/gate-engine-registry.js +2 -1
- package/dist/gate-engine-registry.js.map +1 -1
- package/dist/gate-types.d.ts +3 -139
- package/dist/gate-v1-payload.schema.js +3 -143
- package/dist/gate-v1-payload.schema.js.map +1 -1
- package/dist/hashing.js +49 -0
- package/dist/hashing.js.map +1 -0
- package/dist/immutable.d.ts +6 -0
- package/dist/immutable.js +54 -0
- package/dist/immutable.js.map +1 -0
- package/dist/index.d.ts +21 -200
- package/dist/index.js +21 -251
- package/dist/index.js.map +1 -1
- package/dist/invalid-state-transition-exception.js +30 -0
- package/dist/invalid-state-transition-exception.js.map +1 -0
- package/dist/invariant-violation-exception.js +11 -0
- package/dist/invariant-violation-exception.js.map +1 -0
- package/dist/not-found-error.js +54 -0
- package/dist/not-found-error.js.map +1 -0
- package/dist/outcome.d.ts +140 -0
- package/dist/outcome.js +1 -1
- package/dist/parse-gate-payload.d.ts +4 -78
- package/dist/path.d.ts +90 -0
- package/dist/plugin.d.ts +85 -0
- package/dist/plugin.js +74 -0
- package/dist/plugin.js.map +1 -0
- package/dist/plugins/index.d.ts +2 -0
- package/dist/plugins/index.js +2 -0
- package/dist/policies/engines/index.d.ts +3 -2
- package/dist/policies/engines/v1/gate/index.d.ts +2 -1
- package/dist/policies/engines/v1/gate/index.js +2 -1
- package/dist/policies/engines/v1/gate/index.js.map +1 -1
- package/dist/policies/index.d.ts +6 -3
- package/dist/policies/index.js +2 -1
- package/dist/policy-service.d.ts +4 -86
- package/dist/policy-service.js +8 -16
- package/dist/policy-service.js.map +1 -1
- package/dist/result/index.d.ts +2 -0
- package/dist/result/index.js +3 -0
- package/dist/result.js +118 -0
- package/dist/result.js.map +1 -0
- package/dist/ruleset-registry.js +42 -0
- package/dist/ruleset-registry.js.map +1 -0
- package/dist/rulesets/index.d.ts +2 -0
- package/dist/rulesets/index.js +2 -0
- package/dist/temporal-guards.js +15 -0
- package/dist/temporal-guards.js.map +1 -0
- package/dist/temporal-use-case.d.ts +304 -0
- package/dist/temporal-use-case.js +150 -0
- package/dist/temporal-use-case.js.map +1 -0
- package/dist/unexpected-error.js +179 -0
- package/dist/unexpected-error.js.map +1 -0
- package/dist/use-case.js +91 -0
- package/dist/use-case.js.map +1 -0
- package/dist/uuid-identifier.d.ts +230 -0
- package/dist/uuid-identifier.js +60 -0
- package/dist/uuid-identifier.js.map +1 -0
- package/dist/validation-code.d.ts +23 -0
- package/dist/validation-code.js +1 -223
- package/dist/validation-code.js.map +1 -1
- package/dist/validation-error.d.ts +3 -128
- package/dist/validation-error.js +2 -52
- package/dist/validation-error.js.map +1 -1
- package/dist/validation-exception.d.ts +50 -0
- package/dist/validation-exception.js +24 -0
- package/dist/validation-exception.js.map +1 -0
- package/dist/value-object-ruleset.contracts.d.ts +36 -0
- package/dist/value-object.js +191 -0
- package/dist/value-object.js.map +1 -0
- package/dist/version.d.ts +10 -0
- package/dist/versioning/index.d.ts +3 -0
- package/dist/versioning/index.js +3 -0
- package/meta.json +18 -3
- package/package.json +37 -4
- package/src/application/index.ts +1 -0
- package/src/core/application/commands/index.ts +1 -0
- package/src/core/application/index.ts +12 -3
- package/src/core/application/ports/index.ts +2 -2
- package/src/core/application/ports/policy-port.ts +13 -3
- package/src/core/application/ports/repository.port.ts +37 -1
- package/src/core/application/temporal/temporal-use-case.ts +14 -2
- package/src/core/application/use-case.ts +133 -2
- package/src/core/domain/rulesets/index.ts +7 -0
- package/src/core/domain/uuid-identifier.ts +72 -0
- package/src/core/domain/value-object.ts +37 -5
- package/src/core/exceptions/index.ts +13 -0
- package/src/core/index.ts +15 -12
- package/src/core/plugins/index.ts +7 -0
- package/src/core/plugins/plugin.ts +99 -0
- package/src/core/plugins/types.ts +53 -0
- package/src/core/versioning/index.ts +14 -0
- package/src/domain/index.ts +7 -0
- package/src/examples/application/cancel-order.example.ts +124 -0
- package/src/examples/application/in-memory-account-repository.example.ts +73 -0
- package/src/exceptions/index.ts +1 -0
- package/src/plugins/index.ts +1 -0
- package/src/result/index.ts +2 -0
- package/src/rulesets/index.ts +1 -0
- package/src/version.ts +1 -1
- 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
|
-
|
|
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,
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
|
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/
|
|
3
|
-
export
|
|
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 {
|
|
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,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,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 };
|