@cullet/erp-core 1.0.10 → 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 (62) 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 +122 -45
  14. package/dist/index.js +104 -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 +102 -86
  24. package/dist/policy-service.js +78 -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 +34 -47
  32. package/dist/validation-code.js.map +1 -1
  33. package/dist/validation-error.d.ts +171 -74
  34. package/dist/validation-error.js +152 -38
  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 +4 -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/domain/entity.ts +71 -0
  49. package/src/core/domain/value-object.ts +41 -0
  50. package/src/core/errors/app-error.ts +33 -0
  51. package/src/core/errors/authorization-error.ts +43 -0
  52. package/src/core/errors/conflict-error.ts +69 -0
  53. package/src/core/errors/integration-error.ts +25 -0
  54. package/src/core/errors/not-found-error.ts +14 -0
  55. package/src/core/errors/validation-error.ts +18 -0
  56. package/src/core/index.ts +1 -10
  57. package/src/core/policies/catalog/policy-catalog.ts +36 -0
  58. package/src/core/policies/resolver/policy-resolver.ts +10 -0
  59. package/src/core/policies/service/policy-service.ts +53 -0
  60. package/src/examples/application/cancel-order.example.ts +124 -0
  61. package/src/examples/application/in-memory-account-repository.example.ts +73 -0
  62. 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 };
@@ -3,6 +3,14 @@ import { assertValidAggregateVersion } from "../shared/aggregate-version.js";
3
3
  import { assertValidDate, cloneDate } from "../shared/temporal-guards.js";
4
4
  import { type ContractVersion, version } from "../versioning/version.js";
5
5
 
6
+ /**
7
+ * The minimal persisted shape needed to reconstitute an {@link Entity}.
8
+ *
9
+ * This is the contract between storage and the domain: a repository maps a row
10
+ * (or document) onto these four fields and hands them to a subclass constructor.
11
+ * `aggregateVersion` travels with the state so optimistic-concurrency checks
12
+ * survive a round-trip through the database.
13
+ */
6
14
  interface EntityState<TIdentifier> {
7
15
  readonly id: TIdentifier;
8
16
  readonly createdAt: Date;
@@ -10,6 +18,20 @@ interface EntityState<TIdentifier> {
10
18
  readonly aggregateVersion: number;
11
19
  }
12
20
 
21
+ /**
22
+ * Base class for domain entities — objects defined by a stable identity rather
23
+ * than by their attributes. Two entities are "the same" when their `id`
24
+ * matches, even if every other field differs; this is the opposite of a
25
+ * {@link ValueObject}, which is defined entirely by its contents.
26
+ *
27
+ * The class owns the bookkeeping common to every aggregate root: a creation
28
+ * timestamp that never changes, a last-modified timestamp, and an
29
+ * `aggregateVersion` counter used for optimistic concurrency control. All three
30
+ * are validated on construction and the dates are defensively cloned, so an
31
+ * entity can never be built into — or leak — an inconsistent temporal state.
32
+ *
33
+ * @typeParam TIdentifier - The identity type (e.g. a branded string id or a VO).
34
+ */
13
35
  @version("1.0")
14
36
  abstract class Entity<TIdentifier> {
15
37
  declare public static readonly CONTRACT_VERSION: ContractVersion;
@@ -19,6 +41,20 @@ abstract class Entity<TIdentifier> {
19
41
  private _updatedAt: Date;
20
42
  private _aggregateVersion: number;
21
43
 
44
+ /**
45
+ * Reconstitutes an entity from its persisted {@link EntityState}.
46
+ *
47
+ * Validates the temporal invariants up front so an invalid entity is
48
+ * impossible to construct: both dates must be valid, the aggregate version
49
+ * must be a non-negative integer, and `updatedAt` may not predate
50
+ * `createdAt`. Both dates are cloned on the way in so a later mutation of
51
+ * the caller's `Date` objects cannot reach into the entity's internal state.
52
+ *
53
+ * Declared `protected` because entities are reconstituted through a
54
+ * subclass factory, never instantiated directly by application code.
55
+ *
56
+ * @throws {InvariantViolationException} When `updatedAt` is earlier than `createdAt`.
57
+ */
22
58
  protected constructor(state: EntityState<TIdentifier>) {
23
59
  assertValidDate("createdAt", state.createdAt);
24
60
  assertValidDate("updatedAt", state.updatedAt);
@@ -36,26 +72,61 @@ abstract class Entity<TIdentifier> {
36
72
  this._aggregateVersion = state.aggregateVersion;
37
73
  }
38
74
 
75
+ /** The entity's stable identity — the basis for equality between entities. */
39
76
  public get id(): TIdentifier {
40
77
  return this._id;
41
78
  }
42
79
 
80
+ /**
81
+ * When the entity was first created.
82
+ *
83
+ * Returns a clone so callers cannot mutate the entity's internal `Date`;
84
+ * the value is fixed at construction and never changes thereafter.
85
+ */
43
86
  public get createdAt(): Date {
44
87
  return cloneDate(this._createdAt);
45
88
  }
46
89
 
90
+ /**
91
+ * When the entity was last modified.
92
+ *
93
+ * Returns a clone for the same encapsulation reason as {@link createdAt};
94
+ * the underlying value advances only through {@link markAsModified}.
95
+ */
47
96
  public get updatedAt(): Date {
48
97
  return cloneDate(this._updatedAt);
49
98
  }
50
99
 
100
+ /**
101
+ * Monotonic counter incremented on every mutation, used for optimistic
102
+ * concurrency control: a writer reads this value, and the persistence layer
103
+ * rejects the write if the stored version has moved on in the meantime.
104
+ */
51
105
  public get aggregateVersion(): number {
52
106
  return this._aggregateVersion;
53
107
  }
54
108
 
109
+ /**
110
+ * The schema/contract version stamped on this entity type by the
111
+ * `@version` decorator — used to detect and migrate state persisted under
112
+ * an older shape.
113
+ */
55
114
  public get contractVersion(): ContractVersion {
56
115
  return Entity.CONTRACT_VERSION;
57
116
  }
58
117
 
118
+ /**
119
+ * The single seam through which an entity records a mutation: it advances
120
+ * `updatedAt` and bumps {@link aggregateVersion} by one. Subclasses must
121
+ * call this from every state-changing method so the version counter stays
122
+ * an accurate optimistic-lock token.
123
+ *
124
+ * @param updatedAt - The modification instant; defaults to now. Validated
125
+ * and required not to predate `createdAt`, preserving the same invariant
126
+ * the constructor enforces.
127
+ * @returns The new aggregate version after the increment.
128
+ * @throws {InvariantViolationException} When `updatedAt` is earlier than `createdAt`.
129
+ */
59
130
  protected markAsModified(updatedAt: Date = new Date()): number {
60
131
  assertValidDate("updatedAt", updatedAt);
61
132
 
@@ -1,15 +1,41 @@
1
1
  import { type DeepReadonly, makeImmutable } from "../shared/immutable.js";
2
2
  import { type ContractVersion, version } from "../versioning/version.js";
3
3
 
4
+ /**
5
+ * Base class for value objects — domain concepts defined entirely by their
6
+ * contents, with no identity of their own. Two value objects are
7
+ * interchangeable when they hold equal data, which is the opposite of an
8
+ * {@link Entity} (compared by id). Money, a CPF, a date range: replacing one
9
+ * instance with an equal one changes nothing about the model.
10
+ *
11
+ * Immutability is the defining guarantee here. The wrapped `value` is
12
+ * deep-frozen on construction, so a value object can be shared freely without
13
+ * any risk of a consumer mutating shared state. Subclasses seal the instance
14
+ * itself with {@link finalize} once their own fields are set.
15
+ *
16
+ * @typeParam T - The shape of the wrapped data.
17
+ * @typeParam P - The primitive form produced by {@link toPrimitive} / {@link toJSON}.
18
+ */
4
19
  @version("1.0")
5
20
  abstract class ValueObject<T, P> {
6
21
  declare public static readonly CONTRACT_VERSION: ContractVersion;
22
+
23
+ /** The wrapped data, deep-frozen so it can never be mutated after construction. */
7
24
  public readonly value: DeepReadonly<T>;
8
25
 
26
+ /**
27
+ * Wraps `value`, deep-freezing it so the value object is immutable from the
28
+ * moment it exists. Declared `protected` because value objects are built
29
+ * through a validating subclass factory, never instantiated directly.
30
+ */
9
31
  protected constructor(value: T) {
10
32
  this.value = makeImmutable(value);
11
33
  }
12
34
 
35
+ /**
36
+ * The schema/contract version stamped on this value-object type by the
37
+ * `@version` decorator — used to detect state persisted under an older shape.
38
+ */
13
39
  public get contractVersion(): ContractVersion {
14
40
  return ValueObject.CONTRACT_VERSION;
15
41
  }
@@ -28,12 +54,27 @@ abstract class ValueObject<T, P> {
28
54
  Object.freeze(this);
29
55
  }
30
56
 
57
+ /**
58
+ * Hook for `JSON.stringify`. Delegates to {@link toPrimitive} so a value
59
+ * object serializes to its primitive form rather than exposing the internal
60
+ * `value` wrapper.
61
+ */
31
62
  public toJSON(): P {
32
63
  return this.toPrimitive();
33
64
  }
34
65
 
66
+ /**
67
+ * 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.
71
+ */
35
72
  public abstract equals(other: this): boolean;
36
73
 
74
+ /**
75
+ * Projects the value object down to a plain, serializable primitive form —
76
+ * the representation suitable for persistence, transport, or comparison.
77
+ */
37
78
  public abstract toPrimitive(): P;
38
79
  }
39
80
 
@@ -12,6 +12,22 @@ import { assertJsonSafeMetadata } from "./utils/index.js";
12
12
  // AppError (abstract base class)
13
13
  // ─────────────────────────────────────────────────────────────────────────────
14
14
 
15
+ /**
16
+ * Root of the application/domain error hierarchy. Every error the system raises
17
+ * on purpose extends `AppError`, which gives callers a single `instanceof` to
18
+ * catch and a uniform, serializable shape to log and transport.
19
+ *
20
+ * Beyond the native `Error` message it carries a stable `code` (the contract the
21
+ * outside world matches on), an optional non-leaking `publicMessage`, a
22
+ * severity, JSON-safe `metadata`, and the correlation/request/command ids that
23
+ * stitch an error back to the request that produced it. The metadata is
24
+ * validated as JSON-safe on construction, so a logger can serialize any
25
+ * `AppError` without hitting a circular reference or a non-serializable value.
26
+ *
27
+ * Abstract on purpose: callers should throw a specific subclass (e.g.
28
+ * {@link ValidationError}, {@link NotFoundError}) so the `code` and shape are
29
+ * meaningful, never a bare `AppError`.
30
+ */
15
31
  abstract class AppError extends Error {
16
32
  public readonly code: string;
17
33
  public readonly cause?: unknown;
@@ -36,6 +52,23 @@ abstract class AppError extends Error {
36
52
  */
37
53
  public readonly commandId?: string;
38
54
 
55
+ /**
56
+ * Builds the common error envelope shared by every subclass.
57
+ *
58
+ * `name` is taken from `new.target` so the thrown instance reports its
59
+ * concrete subclass name (not `"AppError"`), and the prototype is re-pinned
60
+ * via `setPrototypeOf` so `instanceof` keeps working after transpilation to
61
+ * older targets where extending built-ins breaks the chain. `createdAtIso`
62
+ * defaults to now, and `metadata` is validated as JSON-safe so the error is
63
+ * always serializable.
64
+ *
65
+ * Declared `protected`: instantiate a concrete subclass, never `AppError`.
66
+ *
67
+ * @param message - The internal, developer-facing message.
68
+ * @param code - The stable machine-readable code callers match on.
69
+ * @param options - Optional cause, metadata, severity, and correlation ids.
70
+ * @throws When `options.metadata` contains a value that is not JSON-safe.
71
+ */
39
72
  protected constructor(
40
73
  message: string,
41
74
  code: string,
@@ -61,6 +61,19 @@ type AuthorizationErrorFactoryOptions = Omit<
61
61
  // AuthorizationError
62
62
  // ─────────────────────────────────────────────────────────────────────────────
63
63
 
64
+ /**
65
+ * Raised when an authenticated actor is not allowed to perform a business
66
+ * action — the "you may not" error, distinct from authentication ("who are
67
+ * you"). Maps to an HTTP 403 at the edge.
68
+ *
69
+ * The discriminating {@link reason} records *why* access was denied (a flat
70
+ * forbid, a missing role/capability, an out-of-scope target, or a policy
71
+ * decision) so the boundary can shape the response without re-deriving it. The
72
+ * metadata is deliberately built around a stable business `action` and a
73
+ * type/id resource reference rather than an HTTP route or full payload, keeping
74
+ * sensitive data out of logs. Instances are frozen. Construct through the
75
+ * static factories, never directly.
76
+ */
64
77
  class AuthorizationError extends AppError {
65
78
  public readonly reason: AuthorizationErrorReason;
66
79
 
@@ -98,6 +111,12 @@ class AuthorizationError extends AppError {
98
111
  // Factories
99
112
  // ─────────────────────────────────────────────────────────────────────────
100
113
 
114
+ /**
115
+ * A flat denial with no more specific reason — the actor simply may not
116
+ * perform this action. Reach for a more precise factory
117
+ * ({@link AuthorizationError.missingCapability}, {@link AuthorizationError.outOfScope},
118
+ * {@link AuthorizationError.policyDenied}) when the cause is known.
119
+ */
101
120
  static forbidden(
102
121
  input: AuthorizationErrorFactoryOptions,
103
122
  ): AuthorizationError {
@@ -110,6 +129,15 @@ class AuthorizationError extends AppError {
110
129
  });
111
130
  }
112
131
 
132
+ /**
133
+ * The action was denied by an evaluated policy. Captures the deciding
134
+ * policy's id, version, and evaluation instant into the metadata (with
135
+ * `decision: "deny"`) so the denial is auditable back to the exact policy
136
+ * that produced it.
137
+ *
138
+ * @param input - Factory options plus the required `policyId`,
139
+ * `policyVersion`, and `evaluatedAtIso` of the deciding policy.
140
+ */
113
141
  static policyDenied(
114
142
  input: AuthorizationErrorFactoryOptions & {
115
143
  policyId: string;
@@ -130,6 +158,13 @@ class AuthorizationError extends AppError {
130
158
  });
131
159
  }
132
160
 
161
+ /**
162
+ * The actor lacks a required role or capability. The expected
163
+ * {@link AuthorizationRequirement} is recorded so the boundary can tell the
164
+ * caller precisely what grant is missing.
165
+ *
166
+ * @param input - Factory options plus the `required` role/capability/scope.
167
+ */
133
168
  static missingCapability(
134
169
  input: AuthorizationErrorFactoryOptions & {
135
170
  required: AuthorizationRequirement;
@@ -147,6 +182,14 @@ class AuthorizationError extends AppError {
147
182
  });
148
183
  }
149
184
 
185
+ /**
186
+ * The actor may perform the action in general, but not on *this* target —
187
+ * the resource falls outside the actor's permitted scope (e.g. a different
188
+ * school or tenant than the one they are bound to).
189
+ *
190
+ * @param input - Factory options plus the optional `required` scope that the
191
+ * target failed to satisfy.
192
+ */
150
193
  static outOfScope(
151
194
  input: AuthorizationErrorFactoryOptions & {
152
195
  required?: AuthorizationRequirement;
@@ -26,6 +26,11 @@ type ConflictErrorMetadata = {
26
26
  hint?: string;
27
27
  };
28
28
 
29
+ /**
30
+ * Storage-level description of a unique-constraint breach, as reported by a
31
+ * database driver. Used to translate a raw DB error into a domain
32
+ * {@link DuplicateError} via {@link translateUniqueViolationToDuplicate}.
33
+ */
29
34
  type UniqueConstraintViolation = {
30
35
  kind: "unique_violation";
31
36
  constraintName?: string;
@@ -33,6 +38,17 @@ type UniqueConstraintViolation = {
33
38
  columns?: string[];
34
39
  };
35
40
 
41
+ /**
42
+ * Base for errors that signal a state conflict — the request collides with data
43
+ * that already exists. Common at an HTTP 409 boundary.
44
+ *
45
+ * The discriminating {@link kind} lets a handler branch on *why* it conflicted
46
+ * (an already-existing record, a domain duplicate, or a raw storage uniqueness
47
+ * breach) without `instanceof` chains. Abstract: construct one of the concrete
48
+ * subclasses through its `detected(...)` factory, which fills in the right code,
49
+ * message, and metadata. Instances are frozen, so a conflict error can be shared
50
+ * and rethrown without risk of tampering.
51
+ */
36
52
  abstract class ConflictError extends AppError {
37
53
  public readonly kind: ConflictKind;
38
54
 
@@ -58,6 +74,11 @@ abstract class ConflictError extends AppError {
58
74
  }
59
75
  }
60
76
 
77
+ /**
78
+ * The operation cannot proceed because a matching record already exists —
79
+ * e.g. creating something whose natural key is already taken. Construct via
80
+ * {@link AlreadyExistsError.detected}.
81
+ */
61
82
  class AlreadyExistsError extends ConflictError {
62
83
  private constructor(
63
84
  input: {
@@ -74,6 +95,14 @@ class AlreadyExistsError extends ConflictError {
74
95
  });
75
96
  }
76
97
 
98
+ /**
99
+ * Builds an {@link AlreadyExistsError} for a detected collision. `operation`
100
+ * defaults to `"create"` and a generic retry `hint` is supplied when none is
101
+ * given, so the error is actionable even from a minimal call site.
102
+ *
103
+ * @param input - The conflicting entity plus optional non-sensitive context
104
+ * (operation, field, existing id, value hash/preview, correlation ids).
105
+ */
77
106
  static detected(input: {
78
107
  entity: string;
79
108
  operation?: string;
@@ -107,6 +136,13 @@ class AlreadyExistsError extends ConflictError {
107
136
  }
108
137
  }
109
138
 
139
+ /**
140
+ * A domain-level duplicate: the data being written would duplicate an existing
141
+ * record under the model's own uniqueness rules. Use this when the duplication
142
+ * is recognized in the domain, as opposed to a raw DB constraint
143
+ * ({@link UniqueConstraintViolationError}). Construct via
144
+ * {@link DuplicateError.detected}.
145
+ */
110
146
  class DuplicateError extends ConflictError {
111
147
  private constructor(
112
148
  input: {
@@ -122,6 +158,13 @@ class DuplicateError extends ConflictError {
122
158
  });
123
159
  }
124
160
 
161
+ /**
162
+ * Builds a {@link DuplicateError} for a detected duplicate, defaulting to a
163
+ * "adjust the data so it does not duplicate" hint when none is provided.
164
+ *
165
+ * @param input - The conflicting entity plus optional non-sensitive context
166
+ * (field, constraint name, existing id, value hash/preview, correlation ids).
167
+ */
125
168
  static detected(input: {
126
169
  entity: string;
127
170
  operation?: string;
@@ -159,6 +202,14 @@ class DuplicateError extends ConflictError {
159
202
  }
160
203
  }
161
204
 
205
+ /**
206
+ * A uniqueness breach surfaced by the storage layer itself (a database unique
207
+ * index), carrying the raw {@link UniqueConstraintViolation} (constraint, table,
208
+ * columns). Keep this distinct from {@link DuplicateError}: this one is the
209
+ * infrastructure signal; translate it into a domain duplicate at the boundary
210
+ * with {@link translateUniqueViolationToDuplicate} when the model should own the
211
+ * message. Construct via {@link UniqueConstraintViolationError.detected}.
212
+ */
162
213
  class UniqueConstraintViolationError extends ConflictError {
163
214
  private constructor(
164
215
  input: {
@@ -176,6 +227,13 @@ class UniqueConstraintViolationError extends ConflictError {
176
227
  });
177
228
  }
178
229
 
230
+ /**
231
+ * Builds a {@link UniqueConstraintViolationError} from the constraint details
232
+ * a driver reports, packaging them into a nested `violation` payload.
233
+ *
234
+ * @param input - The entity plus the offending constraint name, table, and
235
+ * columns, and optional correlation ids.
236
+ */
179
237
  static detected(input: {
180
238
  entity: string;
181
239
  operation?: string;
@@ -210,6 +268,17 @@ class UniqueConstraintViolationError extends ConflictError {
210
268
  }
211
269
  }
212
270
 
271
+ /**
272
+ * Translates a raw storage {@link UniqueConstraintViolation} into a domain-level
273
+ * {@link DuplicateError}. This is the seam where an infrastructure concern (a DB
274
+ * unique index firing) is re-expressed in the language of the model, so callers
275
+ * upstream catch a `DuplicateError` and never have to know a database was
276
+ * involved.
277
+ *
278
+ * @param input - The entity, the driver-reported `violation`, and optional
279
+ * request context (`ctx`) carrying correlation ids and a clock instant.
280
+ * @returns A {@link DuplicateError} carrying the constraint name as its field hint.
281
+ */
213
282
  function translateUniqueViolationToDuplicate(input: {
214
283
  entity: string;
215
284
  operation?: string;
@@ -42,6 +42,18 @@ type IntegrationErrorConstructorParams = {
42
42
  metadata: IntegrationErrorMetadata;
43
43
  } & IntegrationErrorAppOptions;
44
44
 
45
+ /**
46
+ * Raised when a call to an external provider fails — a timeout, an unreachable
47
+ * endpoint, or a malformed response. This is the boundary error that separates
48
+ * "our code is fine, the outside world misbehaved" from internal faults, which
49
+ * matters for retry and alerting decisions.
50
+ *
51
+ * Every instance records the `provider`, the `operation`, and timing
52
+ * (`startedAtIso` / `durationMs`) so failures are correlatable across services
53
+ * and a slow dependency is visible in the metadata. The discriminating
54
+ * {@link reason} lets callers decide whether a failure is worth retrying.
55
+ * Instances are frozen; construct through the static factories.
56
+ */
45
57
  class IntegrationError extends AppError {
46
58
  public readonly reason: IntegrationErrorReason;
47
59
 
@@ -56,6 +68,10 @@ class IntegrationError extends AppError {
56
68
  Object.freeze(this);
57
69
  }
58
70
 
71
+ /**
72
+ * The provider did not respond within the allotted time. Usually retryable,
73
+ * since a timeout leaves the outcome unknown rather than known-failed.
74
+ */
59
75
  static timeout(options: IntegrationErrorTimeoutOptions): IntegrationError {
60
76
  return new IntegrationError({
61
77
  message: "Timeout while integrating with external provider",
@@ -66,6 +82,10 @@ class IntegrationError extends AppError {
66
82
  });
67
83
  }
68
84
 
85
+ /**
86
+ * The provider could not be reached at all (connection refused, DNS
87
+ * failure, network partition) — the request never landed.
88
+ */
69
89
  static unreachable(
70
90
  options: IntegrationErrorEndpointOptions,
71
91
  ): IntegrationError {
@@ -78,6 +98,11 @@ class IntegrationError extends AppError {
78
98
  });
79
99
  }
80
100
 
101
+ /**
102
+ * The provider answered, but with something the system cannot use — an
103
+ * unexpected status, an unparsable body, or a contract mismatch. Unlike a
104
+ * timeout this is a definite failure, so blind retries rarely help.
105
+ */
81
106
  static badResponse(
82
107
  options: IntegrationErrorEndpointOptions,
83
108
  ): IntegrationError {
@@ -6,7 +6,21 @@ import type { AppErrorOptions } from "./types.js";
6
6
  // NotFoundError
7
7
  // ─────────────────────────────────────────────────────────────────────────────
8
8
 
9
+ /**
10
+ * Raised when a requested resource does not exist. Maps naturally onto an HTTP
11
+ * 404 at the edge, but stays transport-agnostic here.
12
+ *
13
+ * The `resource` name builds the message (`"<resource> not found"`) and, with
14
+ * the optional lookup `criteria`, lands in the metadata so logs show *what* was
15
+ * searched for without the caller having to restate it in the message.
16
+ */
9
17
  class NotFoundError extends AppError {
18
+ /**
19
+ * @param resource - Human-readable name of the missing resource (e.g. `"Order"`).
20
+ * @param criteria - Optional lookup keys used in the search; recorded in
21
+ * metadata to aid debugging. Avoid putting sensitive values here.
22
+ * @param options - Optional cause, correlation ids, and extra metadata.
23
+ */
10
24
  constructor(
11
25
  resource: string,
12
26
  criteria?: Record<string, unknown>,