@cullet/erp-core 1.0.11 → 1.1.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 +67 -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 +2 -0
- package/dist/errors/index.d.ts +2 -1
- package/dist/errors/index.js +4 -2
- package/dist/gate-engine-registry.d.ts +80 -0
- package/dist/gate-v1-payload.schema.js +2 -28
- 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/index.d.ts +11 -45
- package/dist/index.js +12 -85
- package/dist/index.js.map +1 -1
- package/dist/not-found-error.js +54 -0
- package/dist/not-found-error.js.map +1 -0
- package/dist/outcome.js +1 -1
- package/dist/parse-gate-payload.d.ts +3 -78
- package/dist/path.d.ts +89 -0
- package/dist/policies/engines/index.d.ts +2 -1
- package/dist/policies/index.d.ts +4 -2
- package/dist/policy-service.d.ts +3 -86
- package/dist/policy-service.js +5 -16
- package/dist/policy-service.js.map +1 -1
- package/dist/temporal-guards.js +30 -0
- package/dist/temporal-guards.js.map +1 -0
- package/dist/temporal-use-case.d.ts +309 -0
- package/dist/temporal-use-case.js +284 -0
- package/dist/temporal-use-case.js.map +1 -0
- package/dist/validation-code.js +1 -47
- package/dist/validation-code.js.map +1 -1
- package/dist/validation-error.d.ts +2 -107
- package/dist/validation-error.js +2 -52
- package/dist/validation-error.js.map +1 -1
- package/dist/validation-exception.js +18 -0
- package/dist/validation-exception.js.map +1 -0
- package/meta.json +3 -2
- package/package.json +5 -1
- 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/index.ts +1 -10
- package/src/examples/application/cancel-order.example.ts +124 -0
- package/src/examples/application/in-memory-account-repository.example.ts +73 -0
- package/src/version.ts +1 -1
|
@@ -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 };
|
package/src/core/index.ts
CHANGED
|
@@ -1,15 +1,6 @@
|
|
|
1
1
|
export * from "./errors/index.js";
|
|
2
2
|
export * from "./exceptions/validation-field.js";
|
|
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";
|
|
3
|
+
export * from "./application/index.js";
|
|
13
4
|
export { Entity, type EntityState } from "./domain/entity.js";
|
|
14
5
|
export { ValueObject, type DeepReadonly } from "./domain/value-object.js";
|
|
15
6
|
export * from "./policies/index.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 };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reference implementation of a {@link ResultRepository}: shows how a
|
|
3
|
+
* repository signals recoverable persistence outcomes as `Result` values
|
|
4
|
+
* instead of throwing.
|
|
5
|
+
*
|
|
6
|
+
* - `save` returns a `ConflictError` on an optimistic-concurrency version
|
|
7
|
+
* mismatch, otherwise persists and bumps the version.
|
|
8
|
+
* - `delete` returns a `NotFoundError` when the row is absent.
|
|
9
|
+
* - `findById` keeps `null` as the "not present" answer (a valid lookup
|
|
10
|
+
* result, not an error), still wrapped in a `Result` so genuine infra
|
|
11
|
+
* failures could stay in band.
|
|
12
|
+
*
|
|
13
|
+
* Consumers import `ResultRepository` from the package root
|
|
14
|
+
* (`@cullet/erp-core`); the relative paths here keep the example compilable
|
|
15
|
+
* and test-covered.
|
|
16
|
+
*/
|
|
17
|
+
import type { ResultRepository } from "../../core/application/index.js";
|
|
18
|
+
import {
|
|
19
|
+
AlreadyExistsError,
|
|
20
|
+
type ConflictError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
} from "../../core/errors/index.js";
|
|
23
|
+
import { Result } from "../../core/result/result.js";
|
|
24
|
+
|
|
25
|
+
interface Account {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly version: number;
|
|
28
|
+
readonly balance: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type AccountError = NotFoundError | ConflictError;
|
|
32
|
+
|
|
33
|
+
class InMemoryAccountRepository implements ResultRepository<
|
|
34
|
+
Account,
|
|
35
|
+
string,
|
|
36
|
+
AccountError
|
|
37
|
+
> {
|
|
38
|
+
private readonly store = new Map<string, Account>();
|
|
39
|
+
|
|
40
|
+
seed(account: Account): void {
|
|
41
|
+
this.store.set(account.id, account);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async findById(id: string): Promise<Result<Account | null, AccountError>> {
|
|
45
|
+
return Result.ok(this.store.get(id) ?? null);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async save(entity: Account): Promise<Result<void, AccountError>> {
|
|
49
|
+
const current = this.store.get(entity.id);
|
|
50
|
+
if (current && current.version !== entity.version) {
|
|
51
|
+
return Result.err(
|
|
52
|
+
AlreadyExistsError.detected({
|
|
53
|
+
entity: "Account",
|
|
54
|
+
operation: "update",
|
|
55
|
+
existingId: entity.id,
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
this.store.set(entity.id, { ...entity, version: entity.version + 1 });
|
|
60
|
+
return Result.ok(undefined);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async delete(id: string): Promise<Result<void, AccountError>> {
|
|
64
|
+
if (!this.store.has(id)) {
|
|
65
|
+
return Result.err(new NotFoundError("Account", { id }));
|
|
66
|
+
}
|
|
67
|
+
this.store.delete(id);
|
|
68
|
+
return Result.ok(undefined);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { InMemoryAccountRepository };
|
|
73
|
+
export type { Account, AccountError };
|
package/src/version.ts
CHANGED
|
@@ -4,4 +4,4 @@
|
|
|
4
4
|
// Mantemos a versao aqui, dentro de src/, para que a copia full-control seja
|
|
5
5
|
// auto-contida: ao copiar so o conteudo de src/, o entry nao depende de um
|
|
6
6
|
// `../package.json` que deixaria de existir no projeto consumidor.
|
|
7
|
-
export const version = "1.0
|
|
7
|
+
export const version = "1.1.0";
|