@cullet/erp-core 1.4.0 → 2.0.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 +7 -1
- package/dist/abac/index.d.cts +2 -2
- package/dist/abac/index.d.ts +2 -2
- package/dist/aggregate-version.cjs +14 -0
- package/dist/aggregate-version.cjs.map +1 -0
- package/dist/aggregate-version.js +9 -0
- package/dist/aggregate-version.js.map +1 -0
- package/dist/app-error.cjs +21 -6
- package/dist/app-error.cjs.map +1 -1
- package/dist/app-error.js +21 -6
- package/dist/app-error.js.map +1 -1
- package/dist/application/index.cjs +8 -4
- package/dist/application/index.d.cts +2 -2
- package/dist/application/index.d.ts +2 -2
- package/dist/application/index.js +1 -2
- package/dist/authorization-error.cjs +67 -17
- package/dist/authorization-error.cjs.map +1 -1
- package/dist/authorization-error.d.cts +6 -2
- package/dist/authorization-error.d.ts +6 -2
- package/dist/authorization-error.js +50 -18
- package/dist/authorization-error.js.map +1 -1
- package/dist/authorizer.port.d.cts +18 -3
- package/dist/authorizer.port.d.ts +18 -3
- package/dist/composite-authorizer.d.cts +63 -10
- package/dist/composite-authorizer.d.ts +63 -10
- package/dist/condition-evaluator.cjs +117 -172
- package/dist/condition-evaluator.cjs.map +1 -1
- package/dist/condition-evaluator.js +118 -167
- package/dist/condition-evaluator.js.map +1 -1
- package/dist/core-config.d.cts +53 -6
- package/dist/core-config.d.ts +53 -6
- package/dist/decorate.cjs +14 -0
- package/dist/decorate.js +9 -0
- package/dist/domain/index.cjs +107 -2
- package/dist/domain/index.cjs.map +1 -0
- package/dist/domain/index.d.cts +25 -1
- package/dist/domain/index.d.ts +25 -1
- package/dist/domain/index.js +99 -3
- package/dist/domain/index.js.map +1 -0
- package/dist/domain-event-contracts.cjs +12 -8
- package/dist/domain-event-contracts.cjs.map +1 -1
- package/dist/domain-event-contracts.d.cts +29 -4
- package/dist/domain-event-contracts.d.ts +29 -4
- package/dist/domain-event-contracts.js +11 -7
- package/dist/domain-event-contracts.js.map +1 -1
- package/dist/domain-exception.cjs +2 -1
- package/dist/domain-exception.cjs.map +1 -1
- package/dist/domain-exception.js +2 -1
- package/dist/domain-exception.js.map +1 -1
- package/dist/errors/index.cjs +3 -2
- package/dist/errors/index.d.cts +1 -1
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +3 -2
- package/dist/exceptions/index.cjs +2 -2
- package/dist/exceptions/index.d.cts +1 -2
- package/dist/exceptions/index.d.ts +1 -2
- package/dist/exceptions/index.js +2 -2
- package/dist/gate-engine-registry.cjs.map +1 -1
- package/dist/gate-engine-registry.d.cts +1 -1
- package/dist/gate-engine-registry.d.ts +1 -1
- package/dist/gate-engine-registry.js.map +1 -1
- package/dist/gate-v1-payload.schema.cjs +11 -6
- package/dist/gate-v1-payload.schema.cjs.map +1 -1
- package/dist/gate-v1-payload.schema.js +11 -6
- package/dist/gate-v1-payload.schema.js.map +1 -1
- package/dist/hashing.cjs +2 -44
- package/dist/hashing.cjs.map +1 -1
- package/dist/hashing.js +3 -39
- package/dist/hashing.js.map +1 -1
- package/dist/immutable.cjs +25 -12
- package/dist/immutable.cjs.map +1 -1
- package/dist/immutable.js +24 -11
- package/dist/immutable.js.map +1 -1
- package/dist/index.cjs +15 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -10
- package/dist/index.d.ts +9 -10
- package/dist/index.js +8 -9
- package/dist/index.js.map +1 -1
- package/dist/invalid-state-transition-exception.cjs +6 -6
- package/dist/invalid-state-transition-exception.cjs.map +1 -1
- package/dist/invalid-state-transition-exception.js +6 -6
- package/dist/invalid-state-transition-exception.js.map +1 -1
- package/dist/invariant-violation-exception.cjs +2 -2
- package/dist/invariant-violation-exception.cjs.map +1 -1
- package/dist/invariant-violation-exception.js +2 -2
- package/dist/invariant-violation-exception.js.map +1 -1
- package/dist/not-found-error.cjs +6 -4
- package/dist/not-found-error.cjs.map +1 -1
- package/dist/not-found-error.js +6 -4
- package/dist/not-found-error.js.map +1 -1
- package/dist/outcome.cjs +5 -5
- package/dist/outcome.cjs.map +1 -1
- package/dist/outcome.js +5 -5
- package/dist/outcome.js.map +1 -1
- package/dist/parse-gate-payload.d.cts +1 -1
- package/dist/parse-gate-payload.d.ts +1 -1
- package/dist/path.d.cts +2 -2
- package/dist/path.d.ts +2 -2
- package/dist/plugin.cjs +23 -13
- package/dist/plugin.cjs.map +1 -1
- package/dist/plugin.d.cts +22 -8
- package/dist/plugin.d.ts +22 -8
- package/dist/plugin.js +23 -13
- package/dist/plugin.js.map +1 -1
- package/dist/policies/engines/index.d.cts +1 -1
- package/dist/policies/engines/index.d.ts +1 -1
- package/dist/policies/engines/v1/gate/index.d.cts +3 -13
- package/dist/policies/engines/v1/gate/index.d.ts +3 -13
- package/dist/policies/index.d.cts +1 -1
- package/dist/policies/index.d.ts +1 -1
- package/dist/policy-bridge.cjs +30 -2
- package/dist/policy-bridge.cjs.map +1 -1
- package/dist/policy-bridge.d.cts +18 -0
- package/dist/policy-bridge.d.ts +18 -0
- package/dist/policy-bridge.js +30 -2
- package/dist/policy-bridge.js.map +1 -1
- package/dist/policy-service.cjs +41 -46
- package/dist/policy-service.cjs.map +1 -1
- package/dist/policy-service.d.cts +72 -9
- package/dist/policy-service.d.ts +72 -9
- package/dist/policy-service.js +43 -48
- package/dist/policy-service.js.map +1 -1
- package/dist/requested-by.cjs +4 -4
- package/dist/requested-by.cjs.map +1 -1
- package/dist/requested-by.js +2 -2
- package/dist/requested-by.js.map +1 -1
- package/dist/result.cjs +29 -1
- package/dist/result.cjs.map +1 -1
- package/dist/result.d.cts +12 -0
- package/dist/result.d.ts +12 -0
- package/dist/result.js +29 -1
- package/dist/result.js.map +1 -1
- package/dist/rule.cjs +94 -24
- package/dist/rule.cjs.map +1 -1
- package/dist/rule.js +94 -24
- package/dist/rule.js.map +1 -1
- package/dist/ruleset-registry.cjs +19 -0
- package/dist/ruleset-registry.cjs.map +1 -1
- package/dist/ruleset-registry.js +19 -0
- package/dist/ruleset-registry.js.map +1 -1
- package/dist/stable-stringify.cjs +87 -0
- package/dist/stable-stringify.cjs.map +1 -0
- package/dist/stable-stringify.js +76 -0
- package/dist/stable-stringify.js.map +1 -0
- package/dist/temporal-snapshot.d.cts +87 -0
- package/dist/temporal-snapshot.d.ts +87 -0
- package/dist/temporal-use-case.cjs +174 -16
- package/dist/temporal-use-case.cjs.map +1 -1
- package/dist/temporal-use-case.d.cts +82 -44
- package/dist/temporal-use-case.d.ts +82 -44
- package/dist/temporal-use-case.js +167 -15
- package/dist/temporal-use-case.js.map +1 -1
- package/dist/unexpected-error.cjs +4 -2
- package/dist/unexpected-error.cjs.map +1 -1
- package/dist/unexpected-error.js +4 -2
- package/dist/unexpected-error.js.map +1 -1
- package/dist/uuid-identifier.cjs +141 -8
- package/dist/uuid-identifier.cjs.map +1 -1
- package/dist/uuid-identifier.d.cts +26 -7
- package/dist/uuid-identifier.d.ts +26 -7
- package/dist/uuid-identifier.js +135 -8
- package/dist/uuid-identifier.js.map +1 -1
- package/dist/uuid.cjs +23 -0
- package/dist/uuid.cjs.map +1 -0
- package/dist/uuid.js +18 -0
- package/dist/uuid.js.map +1 -0
- package/dist/validation-code.cjs +9 -0
- package/dist/validation-code.cjs.map +1 -1
- package/dist/validation-code.d.cts +3 -0
- package/dist/validation-code.d.ts +3 -0
- package/dist/validation-code.js +9 -0
- package/dist/validation-code.js.map +1 -1
- package/dist/validation-error.cjs +42 -67
- package/dist/validation-error.cjs.map +1 -1
- package/dist/validation-error.d.cts +29 -8
- package/dist/validation-error.d.ts +29 -8
- package/dist/validation-error.js +41 -66
- package/dist/validation-error.js.map +1 -1
- package/dist/validation-exception.cjs +17 -6
- package/dist/validation-exception.cjs.map +1 -1
- package/dist/validation-exception.d.cts +33 -9
- package/dist/validation-exception.d.ts +33 -9
- package/dist/validation-exception.js +17 -6
- package/dist/validation-exception.js.map +1 -1
- package/dist/validation-field.cjs +3 -0
- package/dist/validation-field.cjs.map +1 -1
- package/dist/validation-field.d.cts +1 -0
- package/dist/validation-field.d.ts +1 -0
- package/dist/validation-field.js +3 -0
- package/dist/validation-field.js.map +1 -1
- package/dist/value-object-ruleset.contracts.d.cts +10 -0
- package/dist/value-object-ruleset.contracts.d.ts +10 -0
- package/dist/value-object.cjs +23 -4
- package/dist/value-object.cjs.map +1 -1
- package/dist/value-object.d.cts +25 -1
- package/dist/value-object.d.ts +25 -1
- package/dist/value-object.js +23 -4
- package/dist/value-object.js.map +1 -1
- package/dist/version.d.cts +27 -0
- package/dist/version.d.ts +27 -0
- package/dist/versioning/index.d.cts +2 -2
- package/dist/versioning/index.d.ts +2 -2
- package/meta.json +5 -2
- package/package.json +1 -1
- package/src/core/abac/authorizer.ts +60 -10
- package/src/core/abac/domain/policy-set.ts +52 -5
- package/src/core/abac/domain/rule.ts +28 -16
- package/src/core/abac/index.ts +7 -1
- package/src/core/application/commands/command.ts +14 -1
- package/src/core/application/commands/requested-by.ts +13 -5
- package/src/core/application/index.ts +2 -1
- package/src/core/application/policy-error-mapper.ts +6 -0
- package/src/core/application/ports/index.ts +7 -0
- package/src/core/application/ports/temporal-repository.port.ts +35 -2
- package/src/core/application/queries/index.ts +1 -1
- package/src/core/application/queries/query.ts +25 -3
- package/src/core/application/temporal/temporal-use-case.ts +31 -4
- package/src/core/application/use-case.ts +45 -8
- package/src/core/config/core-config.ts +46 -25
- package/src/core/config/index.ts +1 -0
- package/src/core/config/policy-reporter.ts +32 -1
- package/src/core/domain/entity.ts +51 -8
- package/src/core/domain/rulesets/entity-ruleset.contracts.ts +0 -2
- package/src/core/domain/rulesets/ruleset-registry.ts +24 -0
- package/src/core/domain/rulesets/value-object-ruleset.contracts.ts +1 -7
- package/src/core/domain/temporal/half-open-interval.ts +34 -0
- package/src/core/domain/temporal/temporal-snapshot.ts +13 -2
- package/src/core/domain/temporal/transaction-time.ts +19 -15
- package/src/core/domain/temporal/valid-time.ts +20 -15
- package/src/core/domain/uuid-identifier.ts +6 -11
- package/src/core/domain/value-object.ts +29 -3
- package/src/core/errors/authentication-error.ts +5 -31
- package/src/core/errors/authorization-error.ts +12 -20
- package/src/core/errors/business-rule-violation-error.ts +4 -1
- package/src/core/errors/idempotency-error.ts +5 -2
- package/src/core/errors/index.ts +4 -0
- package/src/core/errors/integration-error.ts +4 -24
- package/src/core/errors/legacy-incompatible-error.ts +1 -0
- package/src/core/errors/not-found-error.ts +4 -1
- package/src/core/errors/temporal-error.ts +22 -20
- package/src/core/errors/unexpected-error.ts +5 -1
- package/src/core/errors/utils/factory-helpers.ts +70 -0
- package/src/core/errors/utils/index.ts +5 -0
- package/src/core/errors/utils/json-safe.ts +35 -12
- package/src/core/errors/validation-error.ts +4 -1
- package/src/core/exceptions/business-rule-violation-exception.ts +2 -1
- package/src/core/exceptions/domain-exception.ts +9 -1
- package/src/core/exceptions/entity-not-found-exception.ts +5 -1
- package/src/core/exceptions/invalid-state-transition-exception.ts +5 -2
- package/src/core/exceptions/invariant-violation-exception.ts +2 -2
- package/src/core/exceptions/validation-code.ts +14 -0
- package/src/core/exceptions/validation-exception.ts +44 -5
- package/src/core/exceptions/validation-field.ts +11 -1
- package/src/core/plugins/plugin.ts +25 -15
- package/src/core/plugins/types.ts +4 -2
- package/src/core/policies/asof/asof.ts +12 -0
- package/src/core/policies/catalog/policy-catalog-entry.ts +3 -1
- package/src/core/policies/catalog/policy-catalog.ts +5 -1
- package/src/core/policies/context/context-builder.ts +20 -0
- package/src/core/policies/context/context-resolver.ts +5 -0
- package/src/core/policies/context/context-seed.ts +11 -0
- package/src/core/policies/defs/in-memory-policy-definition-repo.ts +0 -2
- package/src/core/policies/engines/parse-gate-payload.ts +9 -0
- package/src/core/policies/engines/v1/condition-date-operands.ts +112 -0
- package/src/core/policies/engines/v1/condition-evaluator.ts +120 -291
- package/src/core/policies/engines/v1/condition-schema.ts +23 -19
- package/src/core/policies/engines/v1/condition-types.ts +18 -11
- package/src/core/policies/index.ts +4 -6
- package/src/core/policies/service/policy-service.ts +46 -35
- package/src/core/policies/utils/hash.ts +5 -69
- package/src/core/policies/utils/result.ts +1 -1
- package/src/core/rbac/access-request.ts +12 -2
- package/src/core/rbac/authorizer.ts +8 -1
- package/src/core/rbac/domain/role.ts +20 -0
- package/src/core/rbac/domain/scope.ts +6 -1
- package/src/core/result/index.ts +5 -0
- package/src/core/result/outcome.ts +5 -7
- package/src/core/result/result.ts +34 -1
- package/src/core/shared/hashing.ts +6 -57
- package/src/core/shared/immutable.ts +22 -4
- package/src/core/shared/stable-stringify.ts +144 -0
- package/src/core/shared/uuid.ts +16 -0
- package/src/core/versioning/domain-event-contracts.ts +43 -15
- package/src/core/versioning/index.ts +1 -0
- package/src/core/versioning/version.ts +30 -1
- package/src/domain/index.ts +5 -0
- package/src/examples/rulesets/entity/order-creation-rules-v1.ts +1 -1
- package/src/examples/rulesets/entity/order-invariants-v1.ts +2 -4
- package/src/examples/rulesets/entity/order-invariants-v2.ts +2 -4
- package/src/examples/rulesets/value-object/cpf-rules-v1.ts +2 -4
- package/src/examples/rulesets/value-object/cpf-rules-v2.ts +2 -4
- package/src/examples/rulesets/value-object/person-name-rules-v1.ts +2 -4
- package/src/examples/rulesets/value-object/person-name-rules-v2.ts +2 -4
- package/src/result/index.ts +7 -2
- package/src/version.ts +1 -1
- package/dist/domain-exception.d.cts +0 -7
- package/dist/domain-exception.d.ts +0 -7
- package/dist/entity.cjs +0 -126
- package/dist/entity.cjs.map +0 -1
- package/dist/entity.js +0 -115
- package/dist/entity.js.map +0 -1
- package/dist/use-case.cjs +0 -96
- package/dist/use-case.cjs.map +0 -1
- package/dist/use-case.js +0 -91
- package/dist/use-case.js.map +0 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { UseCase } from "../use-case.js";
|
|
2
|
+
import { version } from "../../versioning/version.js";
|
|
2
3
|
import type { ContextSeed } from "../../policies/index.js";
|
|
3
4
|
import type { Result } from "../../result/result.js";
|
|
4
5
|
|
|
@@ -7,6 +8,14 @@ import {
|
|
|
7
8
|
type TemporalContext,
|
|
8
9
|
} from "./temporal-context.js";
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Marker mixed into a temporal use case's `Input`.
|
|
13
|
+
*
|
|
14
|
+
* The consumer's `Input` must not re-declare `temporalContext` with an
|
|
15
|
+
* incompatible type: `TemporalUseCase` intersects the two, and a clashing
|
|
16
|
+
* declaration collapses the field to `never` (surfacing only as a confusing
|
|
17
|
+
* error at the call site, not here).
|
|
18
|
+
*/
|
|
10
19
|
interface TemporalUseCaseInput {
|
|
11
20
|
readonly temporalContext?: TemporalContext;
|
|
12
21
|
}
|
|
@@ -25,6 +34,7 @@ type TemporalizedContextSeed<TSeed extends ContextSeed> = Omit<
|
|
|
25
34
|
readonly fields: TSeed["fields"] & { readonly now: Date };
|
|
26
35
|
};
|
|
27
36
|
|
|
37
|
+
@version("1.0")
|
|
28
38
|
abstract class TemporalUseCase<
|
|
29
39
|
Input extends object,
|
|
30
40
|
Output extends Result<unknown, unknown>,
|
|
@@ -35,16 +45,33 @@ abstract class TemporalUseCase<
|
|
|
35
45
|
return createTemporalContext(input.temporalContext);
|
|
36
46
|
}
|
|
37
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Enriches a policy `ContextSeed` with `fields.now`, taken from the
|
|
50
|
+
* context's `requestedAt` (wall-clock time of the request).
|
|
51
|
+
*
|
|
52
|
+
* NOTE: this injects **only** `now` (from `requestedAt`) — it does *not*
|
|
53
|
+
* propagate `temporalContext.asOf`. Policy evaluation derives its own
|
|
54
|
+
* `asOf` from whichever seed field the policy's `asOfSource` points at
|
|
55
|
+
* (via `fields.now` by default). So a retroactive use case (an `asOf` in
|
|
56
|
+
* the past) will still evaluate policies at the *current* time unless the
|
|
57
|
+
* author maps `temporalContext.asOf` onto that seed field explicitly. Do
|
|
58
|
+
* that mapping in the use case when back-dated evaluation is intended.
|
|
59
|
+
*/
|
|
38
60
|
protected buildPolicySeed<TSeed extends ContextSeed>(
|
|
39
61
|
seed: TSeed,
|
|
40
62
|
temporalContext: TemporalContext,
|
|
41
63
|
): TemporalizedContextSeed<TSeed> {
|
|
64
|
+
// ponytail: shallow-freeze the two objects we create (outer + fields);
|
|
65
|
+
// nested `fields` values belong to the caller's seed — not ours to
|
|
66
|
+
// deep-freeze (would freeze shared refs) or structuredClone (would
|
|
67
|
+
// flatten any class instances the seed carries).
|
|
68
|
+
const fields = Object.freeze({
|
|
69
|
+
...seed.fields,
|
|
70
|
+
now: new Date(temporalContext.requestedAt.getTime()),
|
|
71
|
+
});
|
|
42
72
|
return Object.freeze({
|
|
43
73
|
...seed,
|
|
44
|
-
fields
|
|
45
|
-
...seed.fields,
|
|
46
|
-
now: new Date(temporalContext.requestedAt.getTime()),
|
|
47
|
-
},
|
|
74
|
+
fields,
|
|
48
75
|
}) as TemporalizedContextSeed<TSeed>;
|
|
49
76
|
}
|
|
50
77
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { type ContractVersion, version } from "../versioning/version.js";
|
|
2
|
+
import { AppError } from "../errors/index.js";
|
|
2
3
|
import type { Result } from "../result/result.js";
|
|
3
4
|
|
|
4
5
|
import type { LoggerPort } from "./ports/logger.port.js";
|
|
5
6
|
import type { MetricsPort } from "./ports/metrics.port.js";
|
|
6
|
-
import type { TracerPort } from "./ports/tracer.port.js";
|
|
7
|
+
import type { TraceAttributeValue, TracerPort } from "./ports/tracer.port.js";
|
|
7
8
|
|
|
8
9
|
type MaybePromise<T> = T | Promise<T>;
|
|
9
10
|
|
|
@@ -34,7 +35,7 @@ abstract class UseCase<
|
|
|
34
35
|
declare public static readonly CONTRACT_VERSION: ContractVersion;
|
|
35
36
|
|
|
36
37
|
public get contractVersion(): ContractVersion {
|
|
37
|
-
return UseCase.CONTRACT_VERSION;
|
|
38
|
+
return (this.constructor as typeof UseCase).CONTRACT_VERSION;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/**
|
|
@@ -82,25 +83,45 @@ abstract class UseCase<
|
|
|
82
83
|
|
|
83
84
|
protected abstract execute(input: Input): MaybePromise<Output>;
|
|
84
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Extra attributes stamped onto the span and every metric emitted for this
|
|
88
|
+
* execution. Override to attach stable, **low-cardinality** dimensions
|
|
89
|
+
* (e.g. `Command` adds `requested_by.kind`). Keep values bounded — they
|
|
90
|
+
* become metric label values, so a user id or free-form string here would
|
|
91
|
+
* explode the metric's cardinality. Defaults to none.
|
|
92
|
+
*/
|
|
93
|
+
protected spanAttributes(
|
|
94
|
+
_input: Input,
|
|
95
|
+
): Readonly<Record<string, TraceAttributeValue>> {
|
|
96
|
+
return {};
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
private async runInstrumented(
|
|
86
100
|
input: Input,
|
|
87
101
|
observability: UseCaseObservability,
|
|
88
102
|
): Promise<Output> {
|
|
89
103
|
const { logger, metrics, tracer } = observability;
|
|
90
104
|
const name = this.useCaseName;
|
|
105
|
+
const attributes = safely(() => this.spanAttributes(input)) ?? {};
|
|
91
106
|
const span = safely(() =>
|
|
92
|
-
tracer?.startSpan(name, { "use_case.name": name }),
|
|
107
|
+
tracer?.startSpan(name, { "use_case.name": name, ...attributes }),
|
|
93
108
|
);
|
|
94
|
-
const startedAt =
|
|
109
|
+
const startedAt = performance.now();
|
|
110
|
+
// Declared here (not inside `try`) so the `finally` histogram can label
|
|
111
|
+
// duration by outcome; defaults to "exception" and is overwritten once
|
|
112
|
+
// execute() returns without throwing.
|
|
113
|
+
let outcome: ExecutionOutcome = "exception";
|
|
95
114
|
|
|
96
115
|
try {
|
|
97
116
|
const output = await this.execute(input);
|
|
98
|
-
|
|
117
|
+
outcome = output.isOk() ? "ok" : "error";
|
|
99
118
|
|
|
100
119
|
if (outcome === "error") {
|
|
120
|
+
const code = businessErrorCode(output);
|
|
101
121
|
safely(() =>
|
|
102
122
|
logger?.warn(`${name} returned a business error`, {
|
|
103
123
|
useCase: name,
|
|
124
|
+
...(code ? { code } : {}),
|
|
104
125
|
}),
|
|
105
126
|
);
|
|
106
127
|
}
|
|
@@ -109,6 +130,7 @@ abstract class UseCase<
|
|
|
109
130
|
metrics?.counter(EXECUTION_COUNTER, 1, {
|
|
110
131
|
useCase: name,
|
|
111
132
|
outcome,
|
|
133
|
+
...attributes,
|
|
112
134
|
}),
|
|
113
135
|
);
|
|
114
136
|
|
|
@@ -125,21 +147,36 @@ abstract class UseCase<
|
|
|
125
147
|
metrics?.counter(EXECUTION_COUNTER, 1, {
|
|
126
148
|
useCase: name,
|
|
127
149
|
outcome: "exception",
|
|
150
|
+
...attributes,
|
|
128
151
|
}),
|
|
129
152
|
);
|
|
130
153
|
|
|
131
154
|
throw error;
|
|
132
155
|
} finally {
|
|
133
156
|
safely(() =>
|
|
134
|
-
metrics?.histogram(
|
|
135
|
-
|
|
136
|
-
|
|
157
|
+
metrics?.histogram(
|
|
158
|
+
DURATION_HISTOGRAM,
|
|
159
|
+
performance.now() - startedAt,
|
|
160
|
+
{ useCase: name, outcome, ...attributes },
|
|
161
|
+
),
|
|
137
162
|
);
|
|
138
163
|
safely(() => span?.end());
|
|
139
164
|
}
|
|
140
165
|
}
|
|
141
166
|
}
|
|
142
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Pulls the stable `code` off a business-error `Result` for logging. Returns
|
|
170
|
+
* `undefined` when the error is not an {@link AppError} — the code is safe to
|
|
171
|
+
* log (it's the public contract), unlike the message which may carry PII.
|
|
172
|
+
*/
|
|
173
|
+
function businessErrorCode(
|
|
174
|
+
output: Result<unknown, unknown>,
|
|
175
|
+
): string | undefined {
|
|
176
|
+
const error = output.errorOrNull();
|
|
177
|
+
return error instanceof AppError ? error.code : undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
143
180
|
/**
|
|
144
181
|
* Invokes an observability side effect, isolating any adapter failure so it
|
|
145
182
|
* cannot alter the use case outcome.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ConditionEvaluationOptions,
|
|
3
|
+
ConditionEvaluationReport,
|
|
4
|
+
ConditionEvaluationReportLevel,
|
|
3
5
|
ConditionEvaluatorReporter,
|
|
4
6
|
} from "../policies/engines/condition-evaluator-reporter.js";
|
|
5
7
|
import type { PolicyEvent, PolicyReporter } from "./policy-reporter.js";
|
|
@@ -10,23 +12,32 @@ export interface CoreObservabilityConfig {
|
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export interface CoreConfigOptions {
|
|
15
|
+
// `observability` is today's only section. Future core config (settings,
|
|
16
|
+
// feature flags) joins here as sibling keys — that umbrella is why the
|
|
17
|
+
// reporter is nested under `observability` instead of living at the root.
|
|
13
18
|
readonly observability?: CoreObservabilityConfig;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
event: PolicyEvent,
|
|
19
|
-
): void {
|
|
21
|
+
/** Reports an event without letting a throwing reporter abort the caller. */
|
|
22
|
+
function safeReport(policyReporter: PolicyReporter, event: PolicyEvent): void {
|
|
20
23
|
try {
|
|
21
24
|
policyReporter.report(event);
|
|
22
25
|
} catch {
|
|
23
|
-
//
|
|
26
|
+
// Telemetry failures must never interrupt the domain flow.
|
|
24
27
|
}
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
31
|
+
* The core's config namespace. Today it holds a single section —
|
|
32
|
+
* `observability` — whose whole job is to decide *where policy telemetry goes*
|
|
33
|
+
* while guaranteeing telemetry is best-effort (a throwing reporter can never
|
|
34
|
+
* abort a domain flow). App config (env, settings, feature flags) is
|
|
35
|
+
* deliberately absent for now: when the core needs it, it joins here as a
|
|
36
|
+
* sibling section rather than a new module — which is why this is `config`,
|
|
37
|
+
* not `observability`. It holds the active {@link PolicyReporter} (silent by
|
|
38
|
+
* default, so the core carries no logging dependency), bridges it to the
|
|
39
|
+
* engines' {@link ConditionEvaluatorReporter}, and offers `reportSafely` as
|
|
40
|
+
* the single guarded emit path shared with {@link PolicyService}.
|
|
30
41
|
*/
|
|
31
42
|
export class CoreConfig {
|
|
32
43
|
readonly #initialReporter: PolicyReporter;
|
|
@@ -39,22 +50,30 @@ export class CoreConfig {
|
|
|
39
50
|
this.#currentReporter = reporter;
|
|
40
51
|
}
|
|
41
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Swaps the active reporter. Pass `observability.reporter` to enable
|
|
55
|
+
* telemetry, or set it explicitly to `undefined` to go silent again.
|
|
56
|
+
* Omitting `observability` (or its `reporter` key) is a no-op.
|
|
57
|
+
*/
|
|
42
58
|
configure(options: CoreConfigOptions): this {
|
|
43
|
-
const
|
|
44
|
-
if (reporter) {
|
|
45
|
-
this.#currentReporter =
|
|
59
|
+
const observability = options.observability;
|
|
60
|
+
if (observability && "reporter" in observability) {
|
|
61
|
+
this.#currentReporter =
|
|
62
|
+
observability.reporter ?? new SilentPolicyReporter();
|
|
46
63
|
}
|
|
47
64
|
return this;
|
|
48
65
|
}
|
|
49
66
|
|
|
50
|
-
// Restores the reporter captured at construction time
|
|
67
|
+
// Restores the reporter captured at construction time — which is silent
|
|
68
|
+
// only if this instance was built without one.
|
|
51
69
|
reset(): this {
|
|
52
70
|
this.#currentReporter = this.#initialReporter;
|
|
53
71
|
return this;
|
|
54
72
|
}
|
|
55
73
|
|
|
56
|
-
|
|
57
|
-
|
|
74
|
+
/** Emits a policy event through the active reporter, best-effort. */
|
|
75
|
+
reportSafely(event: PolicyEvent): void {
|
|
76
|
+
safeReport(this.#currentReporter, event);
|
|
58
77
|
}
|
|
59
78
|
|
|
60
79
|
getConditionEvaluationOptions(
|
|
@@ -69,19 +88,21 @@ export class CoreConfig {
|
|
|
69
88
|
#bridgeToConditionReporter(
|
|
70
89
|
policyReporter: PolicyReporter,
|
|
71
90
|
): ConditionEvaluatorReporter {
|
|
91
|
+
// Capture the reporter now so a mid-evaluation reconfigure never splits
|
|
92
|
+
// a single evaluation's reports across two reporters (intentional).
|
|
93
|
+
const emit = (
|
|
94
|
+
level: ConditionEvaluationReportLevel,
|
|
95
|
+
report: ConditionEvaluationReport,
|
|
96
|
+
): void => {
|
|
97
|
+
safeReport(policyReporter, {
|
|
98
|
+
kind: "condition-eval",
|
|
99
|
+
...report,
|
|
100
|
+
level,
|
|
101
|
+
});
|
|
102
|
+
};
|
|
72
103
|
return {
|
|
73
|
-
warn(report)
|
|
74
|
-
|
|
75
|
-
kind: "condition-eval",
|
|
76
|
-
...report,
|
|
77
|
-
} satisfies PolicyEvent);
|
|
78
|
-
},
|
|
79
|
-
error(report) {
|
|
80
|
-
reportSafely(policyReporter, {
|
|
81
|
-
kind: "condition-eval",
|
|
82
|
-
...report,
|
|
83
|
-
} satisfies PolicyEvent);
|
|
84
|
-
},
|
|
104
|
+
warn: (report) => emit("warn", report),
|
|
105
|
+
error: (report) => emit("error", report),
|
|
85
106
|
};
|
|
86
107
|
}
|
|
87
108
|
}
|
package/src/core/config/index.ts
CHANGED
|
@@ -40,14 +40,45 @@ export interface PolicyEvaluationCompletedEvent {
|
|
|
40
40
|
readonly occurredAt: Date;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* A resolved ABAC decision (both PERMIT and DENY). Emitted best-effort by the
|
|
45
|
+
* `AbacAuthorizer` so a host can build an authorization audit trail without an
|
|
46
|
+
* adapter of its own; silent by default like every other event. Types are kept
|
|
47
|
+
* self-contained (no import from `core/abac`) to avoid a config→abac cycle.
|
|
48
|
+
*/
|
|
49
|
+
export interface AbacDecisionEvent {
|
|
50
|
+
readonly kind: "abac-decision";
|
|
51
|
+
readonly level: "info";
|
|
52
|
+
readonly action: string;
|
|
53
|
+
readonly resource?: { readonly type: string; readonly id?: string };
|
|
54
|
+
readonly actorId: string;
|
|
55
|
+
readonly effect: "PERMIT" | "DENY";
|
|
56
|
+
/** Deciding rule id, or `"<default>"` / `"<default-deny>"` when the set's default decided. */
|
|
57
|
+
readonly decidingRuleId: string;
|
|
58
|
+
readonly decidingRuleVersion?: number;
|
|
59
|
+
readonly algorithm: string;
|
|
60
|
+
readonly occurredAt: Date;
|
|
61
|
+
}
|
|
62
|
+
|
|
43
63
|
export type PolicyEvent =
|
|
44
64
|
| ConditionEvalEvent
|
|
45
65
|
| PolicyResolutionEvent
|
|
46
66
|
| PolicyEvaluationFailedEvent
|
|
47
|
-
| PolicyEvaluationCompletedEvent
|
|
67
|
+
| PolicyEvaluationCompletedEvent
|
|
68
|
+
| AbacDecisionEvent;
|
|
48
69
|
|
|
49
70
|
// ─── Reporter interface ───────────────────────────────────────────────────────
|
|
50
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Sink for policy telemetry. The core never logs on its own — it hands typed
|
|
74
|
+
* events here and a host implementation decides what to do with them.
|
|
75
|
+
*
|
|
76
|
+
* Redaction is the host's responsibility: a `condition-eval` event carries the
|
|
77
|
+
* evaluated field path and value in `details` (e.g. `student.flags.*`), so in
|
|
78
|
+
* an ERP those payloads can contain PII. Sanitize before persisting or shipping
|
|
79
|
+
* events off-box; the core deliberately keeps no redaction seam so it stays
|
|
80
|
+
* pure and unopinionated about your logging stack.
|
|
81
|
+
*/
|
|
51
82
|
export interface PolicyReporter {
|
|
52
83
|
report(event: PolicyEvent): void;
|
|
53
84
|
}
|
|
@@ -3,6 +3,8 @@ 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
|
+
import { ValueObject } from "./value-object.js";
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* The minimal persisted shape needed to reconstitute an {@link Entity}.
|
|
8
10
|
*
|
|
@@ -53,9 +55,16 @@ abstract class Entity<TIdentifier> {
|
|
|
53
55
|
* Declared `protected` because entities are reconstituted through a
|
|
54
56
|
* subclass factory, never instantiated directly by application code.
|
|
55
57
|
*
|
|
56
|
-
* @throws {InvariantViolationException} When `
|
|
58
|
+
* @throws {InvariantViolationException} When `id` is absent, or when
|
|
59
|
+
* `updatedAt` is earlier than `createdAt`.
|
|
57
60
|
*/
|
|
58
61
|
protected constructor(state: EntityState<TIdentifier>) {
|
|
62
|
+
if (state.id == null) {
|
|
63
|
+
throw new InvariantViolationException(
|
|
64
|
+
"id is required: an entity cannot exist without an identity",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
59
68
|
assertValidDate("createdAt", state.createdAt);
|
|
60
69
|
assertValidDate("updatedAt", state.updatedAt);
|
|
61
70
|
assertValidAggregateVersion(state.aggregateVersion);
|
|
@@ -112,7 +121,7 @@ abstract class Entity<TIdentifier> {
|
|
|
112
121
|
* an older shape.
|
|
113
122
|
*/
|
|
114
123
|
public get contractVersion(): ContractVersion {
|
|
115
|
-
return Entity.CONTRACT_VERSION;
|
|
124
|
+
return (this.constructor as typeof Entity).CONTRACT_VERSION;
|
|
116
125
|
}
|
|
117
126
|
|
|
118
127
|
/**
|
|
@@ -121,18 +130,23 @@ abstract class Entity<TIdentifier> {
|
|
|
121
130
|
* call this from every state-changing method so the version counter stays
|
|
122
131
|
* an accurate optimistic-lock token.
|
|
123
132
|
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
133
|
+
* `updatedAt` is monotonic: each call must be at or after the current
|
|
134
|
+
* `updatedAt` (equal is fine — two mutations can share a millisecond).
|
|
135
|
+
* Without this, the version counter would advance while the timestamp
|
|
136
|
+
* walked backwards, corrupting any audit trail or ordering that reads
|
|
137
|
+
* `updatedAt`. A caller replaying out-of-order events must sort them first.
|
|
138
|
+
*
|
|
139
|
+
* @param updatedAt - The modification instant; defaults to now.
|
|
127
140
|
* @returns The new aggregate version after the increment.
|
|
128
|
-
* @throws {InvariantViolationException} When `updatedAt` is earlier than
|
|
141
|
+
* @throws {InvariantViolationException} When `updatedAt` is earlier than
|
|
142
|
+
* the current `updatedAt`.
|
|
129
143
|
*/
|
|
130
144
|
protected markAsModified(updatedAt: Date = new Date()): number {
|
|
131
145
|
assertValidDate("updatedAt", updatedAt);
|
|
132
146
|
|
|
133
|
-
if (updatedAt.getTime() < this.
|
|
147
|
+
if (updatedAt.getTime() < this._updatedAt.getTime()) {
|
|
134
148
|
throw new InvariantViolationException(
|
|
135
|
-
"updatedAt cannot be
|
|
149
|
+
"updatedAt cannot move backwards: it must be at or after the current updatedAt",
|
|
136
150
|
);
|
|
137
151
|
}
|
|
138
152
|
|
|
@@ -141,6 +155,35 @@ abstract class Entity<TIdentifier> {
|
|
|
141
155
|
|
|
142
156
|
return this._aggregateVersion;
|
|
143
157
|
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Identity-based equality — the semantics that define an entity. Two
|
|
161
|
+
* entities are equal when they are the same concrete class and their ids
|
|
162
|
+
* match, regardless of any other field.
|
|
163
|
+
*
|
|
164
|
+
* The class check keeps a `CustomerId`-bearing entity from equalling an
|
|
165
|
+
* unrelated entity that happens to reuse the same raw id. Value-object ids
|
|
166
|
+
* are compared through their own `equals`, so two independently
|
|
167
|
+
* reconstituted id instances still match; primitive ids use `===`.
|
|
168
|
+
*/
|
|
169
|
+
public equals(other: Entity<TIdentifier> | null | undefined): boolean {
|
|
170
|
+
if (other == null) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
if (other === this) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
if (other.constructor !== this.constructor) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const id: unknown = this._id;
|
|
181
|
+
if (id instanceof ValueObject) {
|
|
182
|
+
return id.equals(other._id as typeof id);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return this._id === other._id;
|
|
186
|
+
}
|
|
144
187
|
}
|
|
145
188
|
|
|
146
189
|
export { Entity, type EntityState };
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { type Ruleset, type RulesetId } from "./ruleset.contracts.js";
|
|
2
|
-
import { DomainException } from "../../exceptions/domain-exception.js";
|
|
3
2
|
|
|
4
3
|
interface CreationRuleset<TData> extends Ruleset {
|
|
5
4
|
validate(data: TData): void;
|
|
@@ -9,7 +8,6 @@ type InvariantRuleset = Ruleset;
|
|
|
9
8
|
|
|
10
9
|
export {
|
|
11
10
|
type CreationRuleset,
|
|
12
|
-
DomainException,
|
|
13
11
|
type InvariantRuleset,
|
|
14
12
|
type Ruleset,
|
|
15
13
|
type RulesetId,
|
|
@@ -6,6 +6,15 @@ import {
|
|
|
6
6
|
|
|
7
7
|
class RulesetRegistryError extends DomainException {}
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Runtime guard for the `RulesetId` shape (`name@major.minor`). The template
|
|
11
|
+
* type only protects TypeScript callers — a JS consumer (or a cast) could
|
|
12
|
+
* register `"foo"` or `"foo@1.5.2"`, and either would silently corrupt
|
|
13
|
+
* {@link RulesetRegistry.getCurrent}'s version ordering via `NaN`/ignored
|
|
14
|
+
* segments. Same idea as `CONTRACT_VERSION_PATTERN` in `versioning/version.ts`.
|
|
15
|
+
*/
|
|
16
|
+
const RULESET_ID_PATTERN = /^.+@\d+\.\d+$/;
|
|
17
|
+
|
|
9
18
|
function parseVersion(id: string): [number, number] {
|
|
10
19
|
const atIdx = id.lastIndexOf("@");
|
|
11
20
|
const versionStr = id.slice(atIdx + 1);
|
|
@@ -23,6 +32,11 @@ class RulesetRegistry implements RulesetRegistryContract {
|
|
|
23
32
|
"Registry is sealed. No new rulesets can be registered.",
|
|
24
33
|
);
|
|
25
34
|
}
|
|
35
|
+
if (!RULESET_ID_PATTERN.test(ruleset.id)) {
|
|
36
|
+
throw new RulesetRegistryError(
|
|
37
|
+
`Invalid ruleset id "${ruleset.id}". Expected "<name>@<major>.<minor>", e.g. "order-creation@1.0".`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
26
40
|
if (this._store.has(ruleset.id)) {
|
|
27
41
|
throw new RulesetRegistryError(
|
|
28
42
|
`Ruleset with id "${ruleset.id}" is already registered.`,
|
|
@@ -35,6 +49,12 @@ class RulesetRegistry implements RulesetRegistryContract {
|
|
|
35
49
|
this._sealed = true;
|
|
36
50
|
}
|
|
37
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Looks up a ruleset by exact id. The `T` parameter is a caller-asserted
|
|
54
|
+
* cast, not a runtime check: asking for the wrong `T` compiles and returns
|
|
55
|
+
* the object mistyped — the standard registry trade-off. Keep the id and
|
|
56
|
+
* the expected type paired at the call site.
|
|
57
|
+
*/
|
|
38
58
|
get<T extends Ruleset>(id: string): T {
|
|
39
59
|
const ruleset = this._store.get(id);
|
|
40
60
|
if (!ruleset) {
|
|
@@ -46,6 +66,10 @@ class RulesetRegistry implements RulesetRegistryContract {
|
|
|
46
66
|
return ruleset as T;
|
|
47
67
|
}
|
|
48
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Returns the highest `major.minor` version registered under `prefix`.
|
|
71
|
+
* The same caller-asserted `T` cast as {@link get} applies.
|
|
72
|
+
*/
|
|
49
73
|
getCurrent<T extends Ruleset>(prefix: string): T {
|
|
50
74
|
const matchingEntries = Array.from(this._store.entries()).filter(
|
|
51
75
|
([key]) => key.startsWith(prefix + "@"),
|
|
@@ -1,13 +1,7 @@
|
|
|
1
|
-
import { DomainException } from "../../exceptions/domain-exception.js";
|
|
2
1
|
import { type Ruleset, type RulesetId } from "./ruleset.contracts.js";
|
|
3
2
|
|
|
4
3
|
interface ValueObjectRuleset<T> extends Ruleset {
|
|
5
4
|
validate(value: T): void;
|
|
6
5
|
}
|
|
7
6
|
|
|
8
|
-
export {
|
|
9
|
-
DomainException,
|
|
10
|
-
type Ruleset,
|
|
11
|
-
type RulesetId,
|
|
12
|
-
type ValueObjectRuleset,
|
|
13
|
-
};
|
|
7
|
+
export { type Ruleset, type RulesetId, type ValueObjectRuleset };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { InvariantViolationException } from "../../exceptions/invariant-violation-exception.js";
|
|
2
|
+
import { assertValidDate } from "../../shared/temporal-guards.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared core of the bitemporal primitives: a half-open interval
|
|
6
|
+
* `[start, end)` whose end, when present, must be strictly later than its
|
|
7
|
+
* start. `ValidTime` and `TransactionTime` are the same shape under different
|
|
8
|
+
* (deliberate) vocabularies — the invariant lives here once so the two files
|
|
9
|
+
* cannot drift apart.
|
|
10
|
+
*
|
|
11
|
+
* Internal to `domain/temporal`; not exported from the barrel.
|
|
12
|
+
*/
|
|
13
|
+
function assertHalfOpenInterval(
|
|
14
|
+
startLabel: string,
|
|
15
|
+
start: Date,
|
|
16
|
+
endLabel: string,
|
|
17
|
+
end: Date | undefined,
|
|
18
|
+
): void {
|
|
19
|
+
assertValidDate(startLabel, start);
|
|
20
|
+
|
|
21
|
+
if (end === undefined) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
assertValidDate(endLabel, end);
|
|
26
|
+
|
|
27
|
+
if (end.getTime() <= start.getTime()) {
|
|
28
|
+
throw new InvariantViolationException(
|
|
29
|
+
`${endLabel} must be later than ${startLabel}`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export { assertHalfOpenInterval };
|
|
@@ -14,6 +14,14 @@ import {
|
|
|
14
14
|
/**
|
|
15
15
|
* Pairs a domain datum with its time dimensions (Business and Transaction).
|
|
16
16
|
* It is the representation of an entry in the append-only (historical) table.
|
|
17
|
+
*
|
|
18
|
+
* `data` must be plain, structured-cloneable state — the primitive shape of an
|
|
19
|
+
* aggregate, not the aggregate itself. `createTemporalSnapshot` deep-clones it
|
|
20
|
+
* via `structuredClone`: functions and symbols inside `data` throw an
|
|
21
|
+
* `InvariantViolationException`, and class instances (a `ValueObject`, an
|
|
22
|
+
* `Entity`) are NOT rejected — they are silently flattened to plain objects,
|
|
23
|
+
* losing their prototype (and with it `equals`/`toPrimitive`). Project rich
|
|
24
|
+
* objects down first, e.g. with `toPrimitive()`.
|
|
17
25
|
*/
|
|
18
26
|
interface TemporalSnapshot<T> {
|
|
19
27
|
/** The data or state captured by the snapshot. */
|
|
@@ -33,8 +41,11 @@ interface CreateTemporalSnapshotInput<T> {
|
|
|
33
41
|
function createTemporalSnapshot<T>(
|
|
34
42
|
input: CreateTemporalSnapshotInput<T>,
|
|
35
43
|
): TemporalSnapshot<T> {
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
// The time dimensions come out of their factories already cloned and
|
|
45
|
+
// frozen; only `data` still needs the clone+freeze. Freezing the shell by
|
|
46
|
+
// hand avoids a second structuredClone pass over the whole snapshot.
|
|
47
|
+
return Object.freeze({
|
|
48
|
+
data: makeImmutable(input.data),
|
|
38
49
|
validTime: createValidTime(input.validTime),
|
|
39
50
|
txTime: createTransactionTime(input.txTime),
|
|
40
51
|
});
|
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
import { InvariantViolationException } from "../../exceptions/invariant-violation-exception.js";
|
|
2
1
|
import { makeImmutable } from "../../shared/immutable.js";
|
|
3
|
-
|
|
2
|
+
|
|
3
|
+
import { assertHalfOpenInterval } from "./half-open-interval.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Represents transaction time (Transaction Time).
|
|
7
7
|
* Defines when the system learned about or recorded a piece of information.
|
|
8
8
|
* Useful for auditing and reconstructing system state at a specific past moment.
|
|
9
|
+
*
|
|
10
|
+
* The window is half-open — `recordedAt` inclusive, `supersededAt` exclusive —
|
|
11
|
+
* and strictly non-empty: `supersededAt` must be later than `recordedAt`, so a
|
|
12
|
+
* record superseded in the same instant it was written is not representable.
|
|
13
|
+
*
|
|
14
|
+
* One caveat mirrors {@link ValueObject}: `createTransactionTime` clones the
|
|
15
|
+
* input dates (no aliasing) but the inner `Date`s are NOT frozen —
|
|
16
|
+
* `Object.freeze` cannot block `setTime`/`setFullYear`, so a caller can still
|
|
17
|
+
* mutate `recordedAt` in place and corrupt the audit record after creation.
|
|
18
|
+
* `readonly` only prevents reassignment. Treat the dates as read-only, or keep
|
|
19
|
+
* instants as ISO/epoch in your own state when that guarantee matters.
|
|
9
20
|
*/
|
|
10
21
|
interface TransactionTime {
|
|
11
22
|
/** When the information was first recorded in the system. */
|
|
@@ -20,19 +31,12 @@ function assertTransactionTime(
|
|
|
20
31
|
txTime: TransactionTime,
|
|
21
32
|
fieldName: string = "txTime",
|
|
22
33
|
): void {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
assertValidDate(`${fieldName}.supersededAt`, txTime.supersededAt);
|
|
30
|
-
|
|
31
|
-
if (txTime.supersededAt.getTime() <= txTime.recordedAt.getTime()) {
|
|
32
|
-
throw new InvariantViolationException(
|
|
33
|
-
`${fieldName}.supersededAt must be later than ${fieldName}.recordedAt`,
|
|
34
|
-
);
|
|
35
|
-
}
|
|
34
|
+
assertHalfOpenInterval(
|
|
35
|
+
`${fieldName}.recordedAt`,
|
|
36
|
+
txTime.recordedAt,
|
|
37
|
+
`${fieldName}.supersededAt`,
|
|
38
|
+
txTime.supersededAt,
|
|
39
|
+
);
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
function createTransactionTime(
|