@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.
Files changed (51) hide show
  1. package/KIT_CONTEXT.md +67 -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 +2 -0
  6. package/dist/errors/index.d.ts +2 -1
  7. package/dist/errors/index.js +4 -2
  8. package/dist/gate-engine-registry.d.ts +80 -0
  9. package/dist/gate-v1-payload.schema.js +2 -28
  10. package/dist/gate-v1-payload.schema.js.map +1 -1
  11. package/dist/hashing.js +49 -0
  12. package/dist/hashing.js.map +1 -0
  13. package/dist/index.d.ts +11 -45
  14. package/dist/index.js +12 -85
  15. package/dist/index.js.map +1 -1
  16. package/dist/not-found-error.js +54 -0
  17. package/dist/not-found-error.js.map +1 -0
  18. package/dist/outcome.js +1 -1
  19. package/dist/parse-gate-payload.d.ts +3 -78
  20. package/dist/path.d.ts +89 -0
  21. package/dist/policies/engines/index.d.ts +2 -1
  22. package/dist/policies/index.d.ts +4 -2
  23. package/dist/policy-service.d.ts +3 -86
  24. package/dist/policy-service.js +5 -16
  25. package/dist/policy-service.js.map +1 -1
  26. package/dist/temporal-guards.js +30 -0
  27. package/dist/temporal-guards.js.map +1 -0
  28. package/dist/temporal-use-case.d.ts +309 -0
  29. package/dist/temporal-use-case.js +284 -0
  30. package/dist/temporal-use-case.js.map +1 -0
  31. package/dist/validation-code.js +1 -47
  32. package/dist/validation-code.js.map +1 -1
  33. package/dist/validation-error.d.ts +2 -107
  34. package/dist/validation-error.js +2 -52
  35. package/dist/validation-error.js.map +1 -1
  36. package/dist/validation-exception.js +18 -0
  37. package/dist/validation-exception.js.map +1 -0
  38. package/meta.json +3 -2
  39. package/package.json +5 -1
  40. package/src/application/index.ts +1 -0
  41. package/src/core/application/commands/index.ts +1 -0
  42. package/src/core/application/index.ts +12 -3
  43. package/src/core/application/ports/index.ts +2 -2
  44. package/src/core/application/ports/policy-port.ts +13 -3
  45. package/src/core/application/ports/repository.port.ts +37 -1
  46. package/src/core/application/temporal/temporal-use-case.ts +14 -2
  47. package/src/core/application/use-case.ts +133 -2
  48. package/src/core/index.ts +1 -10
  49. package/src/examples/application/cancel-order.example.ts +124 -0
  50. package/src/examples/application/in-memory-account-repository.example.ts +73 -0
  51. 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
- 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 };
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 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";
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.11";
7
+ export const version = "1.1.0";