@classytic/primitives 0.14.0 → 0.16.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/CHANGELOG.md +50 -77
- package/README.md +2 -0
- package/dist/approval.d.mts +17 -2
- package/dist/approval.mjs +13 -1
- package/dist/canonical.d.mts +41 -0
- package/dist/canonical.mjs +80 -0
- package/dist/event-infra.d.mts +33 -0
- package/dist/event-infra.mjs +14 -4
- package/dist/retention.d.mts +209 -0
- package/dist/retention.mjs +126 -0
- package/package.json +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,83 +3,56 @@
|
|
|
3
3
|
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
4
4
|
adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
5
|
|
|
6
|
-
## [0.
|
|
7
|
-
|
|
8
|
-
### Added —
|
|
9
|
-
|
|
10
|
-
- **`
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
`
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
`
|
|
55
|
-
|
|
56
|
-
`createPurchaseEvent = (t, p, ctx?, m?) =>
|
|
57
|
-
createScopedEvent('@classytic/purchase', t, p, ctx, m)`.
|
|
58
|
-
|
|
59
|
-
### Changed — `EventTransport.subscribe` is now optional
|
|
60
|
-
|
|
61
|
-
- **`/events` `EventTransport.subscribe?`** — publish-only transports (outbox
|
|
62
|
-
bridge adapters, host wrappers around a `publish(type, payload, meta)`
|
|
63
|
-
helper, fire-and-forget pipes) now conform without casting. Hosts were
|
|
64
|
-
writing `subscribe: undefined as unknown as EventTransport['subscribe']`
|
|
65
|
-
literals or `as unknown as EventTransport` widenings to inject them into
|
|
66
|
-
kernels that never call `subscribe` on the injected transport. Consumers
|
|
67
|
-
that need in-process delivery must guard for an absent `subscribe` — the
|
|
68
|
-
same "detect and fall back" contract `publishMany` and `deadLetter`
|
|
69
|
-
already carry (flow's `createSettlementListener` is the reference guard;
|
|
70
|
-
it has guarded `?.subscribe` since flow 0.5). Kernels subscribing on
|
|
71
|
-
their OWN in-process bus are unaffected — concrete buses still implement
|
|
72
|
-
`subscribe`, and a concrete method satisfies an optional member.
|
|
73
|
-
- Audited call sites before the change (`commerce/packages/*/src`,
|
|
74
|
-
`commerce/spine/packages/*/src`): the `.subscribe` uses on injected
|
|
75
|
-
transports are pass-through dispatcher methods hosts opt into (crm/party
|
|
76
|
-
`dispatch.ts`), an already-guarded listener (flow's
|
|
77
|
-
`createSettlementListener`), and invoice's audit bridge — which only
|
|
78
|
-
subscribes when `config.audit` is wired. Each of these sources picks up a
|
|
79
|
-
compile-time "possibly undefined" prompt on its NEXT rebuild against this
|
|
80
|
-
version, forcing the guard (or a documented throw) exactly where a
|
|
81
|
-
publish-only transport would have failed at runtime before. No published
|
|
82
|
-
dist behavior changes.
|
|
6
|
+
## [0.16.0] - 2026-07-27
|
|
7
|
+
|
|
8
|
+
### Added — `assertApproved` + `CHAIN_INCOMPLETE` (`/approval`)
|
|
9
|
+
|
|
10
|
+
- **`ApprovalErrorCode` gains `'CHAIN_INCOMPLETE'`** — the single canonical
|
|
11
|
+
error code for "finalize/commit attempted before the chain reached `approved`".
|
|
12
|
+
Hosts route it through `rethrowApprovalError` to the `approval.chain_incomplete`
|
|
13
|
+
wire code so all commit/post/finalize boundaries raise the same structured error.
|
|
14
|
+
- **`assertApproved(chain, message?)`** — canonical finalize-time gate: asserts
|
|
15
|
+
the chain has reached `approved`, else throws `ApprovalError('CHAIN_INCOMPLETE')`.
|
|
16
|
+
A `null`/absent chain passes (no chain required). Use at every journal-entry
|
|
17
|
+
post, PO approve, transfer dispatch, etc. to replace per-domain hand-rolled
|
|
18
|
+
divergent codes with one definition.
|
|
19
|
+
|
|
20
|
+
Purely additive — no existing exports changed.
|
|
21
|
+
|
|
22
|
+
## [0.15.0] - 2026-07-25
|
|
23
|
+
|
|
24
|
+
### Added — `/retention`: `PurgeEvidence` value object
|
|
25
|
+
|
|
26
|
+
- **New `@classytic/primitives/retention` subpath** exporting **`PurgeEvidence`**
|
|
27
|
+
— the persisted evidence record for a GDPR / data-retention purge operation
|
|
28
|
+
(proof of what was purged, over what scope, by whom, why, and whether
|
|
29
|
+
analytical measures were retained). Shape: `{ id, subject: { ref, model? },
|
|
30
|
+
scope, strategy: 'hard'|'soft'|'anonymize', measuresRetained, processed,
|
|
31
|
+
occurredAt, actor: { ref, kind: 'user'|'system'|'service' }, reason,
|
|
32
|
+
legalBasis?, status: 'completed'|'partial'|'failed', operationId?,
|
|
33
|
+
startedAt?, completedAt?, results?: PurgeResourceResult[],
|
|
34
|
+
verification?: PurgeVerificationSummary }`.
|
|
35
|
+
- **`createPurgeEvidence(input)`** — pure builder (auto-fills `id` + `occurredAt`
|
|
36
|
+
when absent, defaults `status` to `'completed'`). **`isPurgeEvidence(value)`**
|
|
37
|
+
type guard + **`assertPurgeEvidence(value)`** throwing assertion.
|
|
38
|
+
|
|
39
|
+
### Added — `/canonical`: strict deterministic JSON + integrity digest
|
|
40
|
+
|
|
41
|
+
- **New `@classytic/primitives/canonical` subpath** — `canonicalJson(value)`
|
|
42
|
+
produces a deterministic string (keys sorted recursively, `Date` → `{"$date":"<iso>"}`,
|
|
43
|
+
strict rejection of `undefined`/`NaN`/`BigInt`/`Map`/`Set`/cycles with
|
|
44
|
+
`CanonicalizeError`). **`sha256Hex(input)`** and **`canonicalDigest(value)`**
|
|
45
|
+
(= `sha256Hex(canonicalJson(value))`) — integrity checksums, not tamper-proof
|
|
46
|
+
seals. Uses `node:crypto` directly (same precedent as `/otp`).
|
|
47
|
+
|
|
48
|
+
### Added — `/event-infra`: `propagateHandlerErrors` option
|
|
49
|
+
|
|
50
|
+
- **`InProcessEventBusOptions.propagateHandlerErrors?: boolean`** (default
|
|
51
|
+
`false` — existing error-isolating behavior unchanged). When `true`, `publish`
|
|
52
|
+
runs handlers sequentially and rethrows the first handler error to the
|
|
53
|
+
publisher — for single-consumer projection/relay lanes where a failed handler
|
|
54
|
+
must not be silently acked. `publishMany` maps each event's failure into its
|
|
55
|
+
`PublishManyResult` entry instead of rejecting the batch.
|
|
83
56
|
|
|
84
57
|
## [0.13.0] - 2026-07-22
|
|
85
58
|
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @classytic/primitives
|
|
2
2
|
|
|
3
|
+
[](https://github.com/sponsors/classytic)
|
|
4
|
+
|
|
3
5
|
Framework-agnostic TypeScript primitives shared across Classytic packages.
|
|
4
6
|
Zero runtime dependencies. ESM only. Node 22+.
|
|
5
7
|
|
package/dist/approval.d.mts
CHANGED
|
@@ -83,7 +83,12 @@ interface CreateStepInput {
|
|
|
83
83
|
readonly requiredApprovals?: number;
|
|
84
84
|
readonly threshold?: ApprovalThreshold;
|
|
85
85
|
}
|
|
86
|
-
type ApprovalErrorCode = 'EMPTY_STEPS' | 'DUPLICATE_STEP_ID' | 'EMPTY_APPROVERS' | 'DUPLICATE_APPROVER_ID' | 'INVALID_QUORUM' | 'UNKNOWN_STEP' | 'UNAUTHORIZED_APPROVER' | 'STEP_NOT_ACTIVE' | 'STEP_ALREADY_DECIDED_BY_APPROVER'
|
|
86
|
+
type ApprovalErrorCode = 'EMPTY_STEPS' | 'DUPLICATE_STEP_ID' | 'EMPTY_APPROVERS' | 'DUPLICATE_APPROVER_ID' | 'INVALID_QUORUM' | 'UNKNOWN_STEP' | 'UNAUTHORIZED_APPROVER' | 'STEP_NOT_ACTIVE' | 'STEP_ALREADY_DECIDED_BY_APPROVER'
|
|
87
|
+
/** A finalize/commit was attempted while the chain has not reached
|
|
88
|
+
* `approved` (still `pending`, or `rejected`). The single canonical
|
|
89
|
+
* "not yet approved" gate — hosts route it through arc-approval's
|
|
90
|
+
* `rethrowApprovalError` to the `approval.chain_incomplete` wire code. */
|
|
91
|
+
| 'CHAIN_INCOMPLETE';
|
|
87
92
|
declare class ApprovalError extends Error {
|
|
88
93
|
readonly name = "ApprovalError";
|
|
89
94
|
readonly code: ApprovalErrorCode;
|
|
@@ -123,6 +128,16 @@ declare function applyDecision(chain: ApprovalChain, input: DecisionInput): Appr
|
|
|
123
128
|
*/
|
|
124
129
|
declare function skipStep(chain: ApprovalChain, stepId: string, reason?: string): ApprovalChain;
|
|
125
130
|
declare function isApproved(chain: ApprovalChain): boolean;
|
|
131
|
+
/**
|
|
132
|
+
* The canonical finalize-time gate: assert `chain` has reached `approved`,
|
|
133
|
+
* else throw `ApprovalError('CHAIN_INCOMPLETE')`. Use this at every
|
|
134
|
+
* commit/post/finalize boundary that must not proceed on an unapproved chain
|
|
135
|
+
* (journal-entry post, PO approve, transfer dispatch, …) so they all raise the
|
|
136
|
+
* SAME error instead of hand-rolling divergent codes. A `null`/absent chain is
|
|
137
|
+
* treated as "no chain required" and passes — callers that require a chain
|
|
138
|
+
* check presence separately.
|
|
139
|
+
*/
|
|
140
|
+
declare function assertApproved(chain: ApprovalChain | null | undefined, message?: string): void;
|
|
126
141
|
declare function isRejected(chain: ApprovalChain): boolean;
|
|
127
142
|
declare function isPending(chain: ApprovalChain): boolean;
|
|
128
143
|
/**
|
|
@@ -130,4 +145,4 @@ declare function isPending(chain: ApprovalChain): boolean;
|
|
|
130
145
|
*/
|
|
131
146
|
declare function decisionCount(chain: ApprovalChain): number;
|
|
132
147
|
//#endregion
|
|
133
|
-
export { ApprovalChain, ApprovalDecision, ApprovalError, ApprovalErrorCode, ApprovalStatus, ApprovalStep, ApprovalThreshold, Approver, ChainOrder, CreateChainInput, CreateStepInput, DecisionInput, applyDecision, createChain, decisionCount, isApproved, isPending, isRejected, nextPendingStep, pendingSteps, skipStep };
|
|
148
|
+
export { ApprovalChain, ApprovalDecision, ApprovalError, ApprovalErrorCode, ApprovalStatus, ApprovalStep, ApprovalThreshold, Approver, ChainOrder, CreateChainInput, CreateStepInput, DecisionInput, applyDecision, assertApproved, createChain, decisionCount, isApproved, isPending, isRejected, nextPendingStep, pendingSteps, skipStep };
|
package/dist/approval.mjs
CHANGED
|
@@ -115,6 +115,18 @@ function skipStep(chain, stepId, reason) {
|
|
|
115
115
|
function isApproved(chain) {
|
|
116
116
|
return chain.status === "approved";
|
|
117
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* The canonical finalize-time gate: assert `chain` has reached `approved`,
|
|
120
|
+
* else throw `ApprovalError('CHAIN_INCOMPLETE')`. Use this at every
|
|
121
|
+
* commit/post/finalize boundary that must not proceed on an unapproved chain
|
|
122
|
+
* (journal-entry post, PO approve, transfer dispatch, …) so they all raise the
|
|
123
|
+
* SAME error instead of hand-rolling divergent codes. A `null`/absent chain is
|
|
124
|
+
* treated as "no chain required" and passes — callers that require a chain
|
|
125
|
+
* check presence separately.
|
|
126
|
+
*/
|
|
127
|
+
function assertApproved(chain, message) {
|
|
128
|
+
if (chain != null && !isApproved(chain)) throw new ApprovalError("CHAIN_INCOMPLETE", message ?? `approval chain has not reached 'approved' (status='${chain.status}')`);
|
|
129
|
+
}
|
|
118
130
|
function isRejected(chain) {
|
|
119
131
|
return chain.status === "rejected";
|
|
120
132
|
}
|
|
@@ -143,4 +155,4 @@ function computeChainStatus(steps) {
|
|
|
143
155
|
return steps.every((s) => s.status === "approved" || s.status === "skipped") ? "approved" : "pending";
|
|
144
156
|
}
|
|
145
157
|
//#endregion
|
|
146
|
-
export { ApprovalError, applyDecision, createChain, decisionCount, isApproved, isPending, isRejected, nextPendingStep, pendingSteps, skipStep };
|
|
158
|
+
export { ApprovalError, applyDecision, assertApproved, createChain, decisionCount, isApproved, isPending, isRejected, nextPendingStep, pendingSteps, skipStep };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
//#region src/serialization/canonical.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* `@classytic/primitives/canonical` — strict, deterministic JSON canonicalization
|
|
4
|
+
* + a SHA-256 hex helper.
|
|
5
|
+
*
|
|
6
|
+
* Cross-domain vocabulary: any subsystem that needs a STABLE checksum of
|
|
7
|
+
* structured data (a cleanup plan/manifest, a financial-close evidence
|
|
8
|
+
* manifest, an audit seal) canonicalizes here so the digest is:
|
|
9
|
+
*
|
|
10
|
+
* - invariant to object key insertion order (keys sorted recursively);
|
|
11
|
+
* - explicit about `Date` (serialized as `{"$date":"<iso>"}`), so timestamps
|
|
12
|
+
* actually participate in the digest instead of collapsing to `{}` the way
|
|
13
|
+
* a naive `JSON.stringify` of a `Date`-bearing object would; and
|
|
14
|
+
* - STRICT — ambiguous / non-portable values (`undefined`, `NaN`/`Infinity`,
|
|
15
|
+
* `BigInt`, `function`, `symbol`, `Map`, `Set`, cyclic refs) are REJECTED
|
|
16
|
+
* with a `CanonicalizeError` rather than silently mis-hashed.
|
|
17
|
+
*
|
|
18
|
+
* These are integrity CHECKSUMS (accidental-corruption + drift detection), NOT
|
|
19
|
+
* tamper-proof seals: plain SHA-256 does not stop an actor with write access
|
|
20
|
+
* from replacing a record and recomputing its digest. Pair with access control
|
|
21
|
+
* / keyed signatures where that matters.
|
|
22
|
+
*
|
|
23
|
+
* `node:crypto` is used directly (precedent: `@classytic/primitives/identity`
|
|
24
|
+
* OTP already imports `node:crypto`); the zero-runtime-dependency policy covers
|
|
25
|
+
* third-party deps, not Node built-ins.
|
|
26
|
+
*/
|
|
27
|
+
declare class CanonicalizeError extends Error {
|
|
28
|
+
constructor(message: string);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Strict canonical JSON string of any value. Deterministic (sorted keys),
|
|
32
|
+
* `Date`-explicit; throws {@link CanonicalizeError} on unsupported/ambiguous
|
|
33
|
+
* input. Arrays keep their order (semantically meaningful).
|
|
34
|
+
*/
|
|
35
|
+
declare function canonicalJson(value: unknown): string;
|
|
36
|
+
/** `sha256(input)` as a lowercase hex digest. */
|
|
37
|
+
declare function sha256Hex(input: string): string;
|
|
38
|
+
/** Convenience: `sha256Hex(canonicalJson(value))` — the integrity digest of a value. */
|
|
39
|
+
declare function canonicalDigest(value: unknown): string;
|
|
40
|
+
//#endregion
|
|
41
|
+
export { CanonicalizeError, canonicalDigest, canonicalJson, sha256Hex };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
//#region src/serialization/canonical.ts
|
|
3
|
+
/**
|
|
4
|
+
* `@classytic/primitives/canonical` — strict, deterministic JSON canonicalization
|
|
5
|
+
* + a SHA-256 hex helper.
|
|
6
|
+
*
|
|
7
|
+
* Cross-domain vocabulary: any subsystem that needs a STABLE checksum of
|
|
8
|
+
* structured data (a cleanup plan/manifest, a financial-close evidence
|
|
9
|
+
* manifest, an audit seal) canonicalizes here so the digest is:
|
|
10
|
+
*
|
|
11
|
+
* - invariant to object key insertion order (keys sorted recursively);
|
|
12
|
+
* - explicit about `Date` (serialized as `{"$date":"<iso>"}`), so timestamps
|
|
13
|
+
* actually participate in the digest instead of collapsing to `{}` the way
|
|
14
|
+
* a naive `JSON.stringify` of a `Date`-bearing object would; and
|
|
15
|
+
* - STRICT — ambiguous / non-portable values (`undefined`, `NaN`/`Infinity`,
|
|
16
|
+
* `BigInt`, `function`, `symbol`, `Map`, `Set`, cyclic refs) are REJECTED
|
|
17
|
+
* with a `CanonicalizeError` rather than silently mis-hashed.
|
|
18
|
+
*
|
|
19
|
+
* These are integrity CHECKSUMS (accidental-corruption + drift detection), NOT
|
|
20
|
+
* tamper-proof seals: plain SHA-256 does not stop an actor with write access
|
|
21
|
+
* from replacing a record and recomputing its digest. Pair with access control
|
|
22
|
+
* / keyed signatures where that matters.
|
|
23
|
+
*
|
|
24
|
+
* `node:crypto` is used directly (precedent: `@classytic/primitives/identity`
|
|
25
|
+
* OTP already imports `node:crypto`); the zero-runtime-dependency policy covers
|
|
26
|
+
* third-party deps, not Node built-ins.
|
|
27
|
+
*/
|
|
28
|
+
var CanonicalizeError = class extends Error {
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super(`canonicalize: ${message}`);
|
|
31
|
+
this.name = "CanonicalizeError";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
function canonicalize(value, seen) {
|
|
35
|
+
if (value === null) return "null";
|
|
36
|
+
const t = typeof value;
|
|
37
|
+
if (t === "string") return JSON.stringify(value);
|
|
38
|
+
if (t === "boolean") return value ? "true" : "false";
|
|
39
|
+
if (t === "number") {
|
|
40
|
+
if (!Number.isFinite(value)) throw new CanonicalizeError(`non-finite number ${String(value)}`);
|
|
41
|
+
return JSON.stringify(value);
|
|
42
|
+
}
|
|
43
|
+
if (t === "undefined") throw new CanonicalizeError("undefined is not serializable");
|
|
44
|
+
if (t === "bigint") throw new CanonicalizeError("bigint is not serializable");
|
|
45
|
+
if (t === "function" || t === "symbol") throw new CanonicalizeError(`${t} is not serializable`);
|
|
46
|
+
if (value instanceof Date) {
|
|
47
|
+
const time = value.getTime();
|
|
48
|
+
if (Number.isNaN(time)) throw new CanonicalizeError("invalid Date");
|
|
49
|
+
return `{"$date":${JSON.stringify(value.toISOString())}}`;
|
|
50
|
+
}
|
|
51
|
+
if (value instanceof Map || value instanceof Set) throw new CanonicalizeError(`${value.constructor.name} is not supported — use a plain object/array`);
|
|
52
|
+
const obj = value;
|
|
53
|
+
if (seen.has(obj)) throw new CanonicalizeError("cyclic reference");
|
|
54
|
+
seen.add(obj);
|
|
55
|
+
try {
|
|
56
|
+
if (Array.isArray(value)) return `[${value.map((v) => canonicalize(v, seen)).join(",")}]`;
|
|
57
|
+
const rec = value;
|
|
58
|
+
return `{${Object.keys(rec).sort().map((k) => `${JSON.stringify(k)}:${canonicalize(rec[k], seen)}`).join(",")}}`;
|
|
59
|
+
} finally {
|
|
60
|
+
seen.delete(obj);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Strict canonical JSON string of any value. Deterministic (sorted keys),
|
|
65
|
+
* `Date`-explicit; throws {@link CanonicalizeError} on unsupported/ambiguous
|
|
66
|
+
* input. Arrays keep their order (semantically meaningful).
|
|
67
|
+
*/
|
|
68
|
+
function canonicalJson(value) {
|
|
69
|
+
return canonicalize(value, /* @__PURE__ */ new WeakSet());
|
|
70
|
+
}
|
|
71
|
+
/** `sha256(input)` as a lowercase hex digest. */
|
|
72
|
+
function sha256Hex(input) {
|
|
73
|
+
return createHash("sha256").update(input).digest("hex");
|
|
74
|
+
}
|
|
75
|
+
/** Convenience: `sha256Hex(canonicalJson(value))` — the integrity digest of a value. */
|
|
76
|
+
function canonicalDigest(value) {
|
|
77
|
+
return sha256Hex(canonicalJson(value));
|
|
78
|
+
}
|
|
79
|
+
//#endregion
|
|
80
|
+
export { CanonicalizeError, canonicalDigest, canonicalJson, sha256Hex };
|
package/dist/event-infra.d.mts
CHANGED
|
@@ -23,6 +23,36 @@ interface InProcessEventBusOptions {
|
|
|
23
23
|
* `[<logLabel>] handler error for <event.type>:`. Default: `name`.
|
|
24
24
|
*/
|
|
25
25
|
logLabel?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Rethrow handler errors from `publish` instead of isolating them
|
|
28
|
+
* (default: `false` — the historical catch-log-continue behavior).
|
|
29
|
+
*
|
|
30
|
+
* **When to use (`true`)** — single-consumer projection/relay lanes: the
|
|
31
|
+
* bus sits between an outbox relay (arc's `EventOutbox` or equivalent) and
|
|
32
|
+
* exactly ONE subscriber (a projector, read-model builder, or forwarder).
|
|
33
|
+
* There, a thrown handler error MUST propagate to the publisher so the
|
|
34
|
+
* delivery FAILS — the relay then retries with backoff / dead-letters the
|
|
35
|
+
* event instead of silently acknowledging work that was never processed.
|
|
36
|
+
* With the default isolation, the relay sees `publish` resolve, acks the
|
|
37
|
+
* outbox row, and the projection update is lost forever.
|
|
38
|
+
*
|
|
39
|
+
* **When NOT to use (leave `false`)** — shared operational buses with
|
|
40
|
+
* multiple independent subscribers (the normal kernel default bus). One
|
|
41
|
+
* misbehaving subscriber must never crash its siblings or the publishing
|
|
42
|
+
* code path; failures are logged via {@link logger} and the event is
|
|
43
|
+
* considered delivered.
|
|
44
|
+
*
|
|
45
|
+
* Strict-mode semantics: handlers run sequentially in subscription order
|
|
46
|
+
* and `publish` rethrows IMMEDIATELY on the first handler failure — later
|
|
47
|
+
* matching subscribers are SKIPPED for that event. That is the honest
|
|
48
|
+
* contract for a single-consumer lane; if you attach multiple subscribers
|
|
49
|
+
* to a strict bus, understand that a failure in an earlier one starves the
|
|
50
|
+
* later ones. Nothing is logged on the strict path (the propagated error
|
|
51
|
+
* is the publisher's to handle); `publishMany` maps each event's failure
|
|
52
|
+
* into its `PublishManyResult` entry (keyed by `meta.id`) instead of
|
|
53
|
+
* rejecting the batch.
|
|
54
|
+
*/
|
|
55
|
+
propagateHandlerErrors?: boolean | undefined;
|
|
26
56
|
}
|
|
27
57
|
/**
|
|
28
58
|
* In-process fan-out `EventTransport` — structurally identical to arc's
|
|
@@ -35,6 +65,8 @@ interface InProcessEventBusOptions {
|
|
|
35
65
|
* event (Set-dedup — the majority variant across kernels).
|
|
36
66
|
* - Per-handler error isolation: one failing subscriber never crashes
|
|
37
67
|
* siblings; the failure is logged (or swallowed with `logger: null`).
|
|
68
|
+
* Opt out per-bus with `propagateHandlerErrors: true` (single-consumer
|
|
69
|
+
* projection/relay lanes — see {@link InProcessEventBusOptions.propagateHandlerErrors}).
|
|
38
70
|
* - `publishMany` batches per-event outcomes keyed by `meta.id`.
|
|
39
71
|
* - `close()` clears all subscriptions; publish after close is a no-op;
|
|
40
72
|
* close is idempotent. NOT durable — engines own it under the fleet
|
|
@@ -45,6 +77,7 @@ declare class InProcessEventBus implements EventTransport {
|
|
|
45
77
|
private handlers;
|
|
46
78
|
private readonly logger;
|
|
47
79
|
private readonly logLabel;
|
|
80
|
+
private readonly propagateHandlerErrors;
|
|
48
81
|
constructor(options?: InProcessEventBusOptions);
|
|
49
82
|
publish(event: DomainEvent): Promise<void>;
|
|
50
83
|
publishMany(events: readonly DomainEvent[]): Promise<PublishManyResult>;
|
package/dist/event-infra.mjs
CHANGED
|
@@ -11,6 +11,8 @@ import { matchEventPattern } from "./events.mjs";
|
|
|
11
11
|
* event (Set-dedup — the majority variant across kernels).
|
|
12
12
|
* - Per-handler error isolation: one failing subscriber never crashes
|
|
13
13
|
* siblings; the failure is logged (or swallowed with `logger: null`).
|
|
14
|
+
* Opt out per-bus with `propagateHandlerErrors: true` (single-consumer
|
|
15
|
+
* projection/relay lanes — see {@link InProcessEventBusOptions.propagateHandlerErrors}).
|
|
14
16
|
* - `publishMany` batches per-event outcomes keyed by `meta.id`.
|
|
15
17
|
* - `close()` clears all subscriptions; publish after close is a no-op;
|
|
16
18
|
* close is idempotent. NOT durable — engines own it under the fleet
|
|
@@ -21,18 +23,26 @@ var InProcessEventBus = class {
|
|
|
21
23
|
handlers = /* @__PURE__ */ new Map();
|
|
22
24
|
logger;
|
|
23
25
|
logLabel;
|
|
26
|
+
propagateHandlerErrors;
|
|
24
27
|
constructor(options = {}) {
|
|
25
28
|
this.name = options.name ?? "in-process";
|
|
26
29
|
this.logger = options.logger === null ? null : options.logger ?? console;
|
|
27
30
|
this.logLabel = options.logLabel ?? this.name;
|
|
31
|
+
this.propagateHandlerErrors = options.propagateHandlerErrors ?? false;
|
|
28
32
|
}
|
|
29
33
|
async publish(event) {
|
|
30
34
|
const matched = /* @__PURE__ */ new Set();
|
|
31
35
|
for (const [pattern, set] of this.handlers.entries()) if (matchEventPattern(pattern, event.type)) for (const h of set) matched.add(h);
|
|
32
|
-
for (const handler of matched)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
for (const handler of matched) {
|
|
37
|
+
if (this.propagateHandlerErrors) {
|
|
38
|
+
await handler(event);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
await handler(event);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
this.logger?.error(`[${this.logLabel}] handler error for ${event.type}:`, err);
|
|
45
|
+
}
|
|
36
46
|
}
|
|
37
47
|
}
|
|
38
48
|
async publishMany(events) {
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
//#region src/retention/purge-evidence.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* `PurgeEvidence` — the persisted evidence record for a GDPR / data-retention
|
|
4
|
+
* purge operation.
|
|
5
|
+
*
|
|
6
|
+
* When a host redacts, soft-deletes, or hard-deletes a slice of data to
|
|
7
|
+
* satisfy a right-to-be-forgotten request or a retention window, it must be
|
|
8
|
+
* able to PROVE — to an auditor, a DPA, or a court — what was purged, over
|
|
9
|
+
* what scope, by whom, why, and whether analytical measures were retained.
|
|
10
|
+
* This value object is that proof. Hosts persist one record per purge op
|
|
11
|
+
* alongside the mutation.
|
|
12
|
+
*
|
|
13
|
+
* A **legal hold is the opposite of a purge trigger** — a hold PREVENTS the
|
|
14
|
+
* purge from running. It is never a reason recorded here; if a hold applies,
|
|
15
|
+
* no `PurgeEvidence` is produced for the held subject in the first place.
|
|
16
|
+
*
|
|
17
|
+
* Pure data + pure builder — no persistence, no I/O, no events. The
|
|
18
|
+
* `strategy` enum is a SUBSET of repo-core's `TenantPurgeStrategy['type']`
|
|
19
|
+
* (`hard` | `soft` | `anonymize`) — a purge that ran a `skip` strategy did
|
|
20
|
+
* nothing, so it produces no evidence, and there is no `custom` variant.
|
|
21
|
+
*
|
|
22
|
+
* **Validation convention.** `@classytic/primitives` is deliberately
|
|
23
|
+
* dependency-free (no Zod, no runtime schema library) so it stays a
|
|
24
|
+
* zero-footprint building block for ~73 downstream consumers. Structural
|
|
25
|
+
* validation is therefore a pure type-guard ({@link isPurgeEvidence}) plus a
|
|
26
|
+
* throwing assertion ({@link assertPurgeEvidence}), matching every other
|
|
27
|
+
* primitive (`isExternalRef`, `isBankTransaction`, ...). Hosts that need a
|
|
28
|
+
* Zod schema derive one at their edge from {@link PurgeEvidence}.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* import { createPurgeEvidence } from '@classytic/primitives/retention';
|
|
32
|
+
*
|
|
33
|
+
* const evidence = createPurgeEvidence({
|
|
34
|
+
* subject: { ref: 'customer:c_123', model: 'Customer' },
|
|
35
|
+
* scope: 'org:org_bd_dhaka',
|
|
36
|
+
* strategy: 'anonymize',
|
|
37
|
+
* measuresRetained: true, // sales measures kept; PII dimension redacted
|
|
38
|
+
* processed: 4210,
|
|
39
|
+
* actor: { ref: 'user:admin_7', kind: 'user' },
|
|
40
|
+
* reason: 'GDPR erasure request #4821',
|
|
41
|
+
* legalBasis: 'GDPR Art. 17',
|
|
42
|
+
* });
|
|
43
|
+
* // evidence.id and evidence.occurredAt are auto-filled.
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* Strategy that produced the evidence. SUBSET of repo-core's
|
|
47
|
+
* `TenantPurgeStrategy['type']` — `skip` is excluded (a skipped purge
|
|
48
|
+
* mutates nothing, so it has no evidence to record).
|
|
49
|
+
*/
|
|
50
|
+
type PurgeStrategyKind = 'hard' | 'soft' | 'anonymize';
|
|
51
|
+
/** What the purge targeted — a reference plus an optional logical model. */
|
|
52
|
+
interface PurgeSubject {
|
|
53
|
+
/**
|
|
54
|
+
* Opaque reference to the purged subject. Any stable string form —
|
|
55
|
+
* `'customer:c_123'`, a bare id, an ExternalRef-style `model:id`. The
|
|
56
|
+
* primitive does not parse it; it is round-tripped verbatim for audit.
|
|
57
|
+
*/
|
|
58
|
+
readonly ref: string;
|
|
59
|
+
/** Optional logical model / type name — e.g. `'Customer'`, `'Lead'`. */
|
|
60
|
+
readonly model?: string;
|
|
61
|
+
}
|
|
62
|
+
/** Who ran the purge. */
|
|
63
|
+
interface PurgeActor {
|
|
64
|
+
/** Opaque reference to the actor — `'user:admin_7'`, `'service:retention-cron'`. */
|
|
65
|
+
readonly ref: string;
|
|
66
|
+
/** Actor category, for filtering audit reports by human vs automated action. */
|
|
67
|
+
readonly kind: 'user' | 'system' | 'service';
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Terminal outcome of the purge operation the evidence records.
|
|
71
|
+
* - `completed` — every targeted resource succeeded.
|
|
72
|
+
* - `partial` — some resources succeeded, others failed (see {@link
|
|
73
|
+
* PurgeEvidence.results}); the operation is resumable.
|
|
74
|
+
* - `failed` — the operation did not achieve its intended effect.
|
|
75
|
+
* A `partial`/`failed` record is STILL evidence — "we attempted X, this is
|
|
76
|
+
* exactly how far it got" — never suppressed.
|
|
77
|
+
*/
|
|
78
|
+
type PurgeStatus = 'completed' | 'partial' | 'failed';
|
|
79
|
+
/**
|
|
80
|
+
* Per-resource / per-provider breakdown of a multi-step purge — one entry
|
|
81
|
+
* per logical resource the operation touched. Optional: a single-resource
|
|
82
|
+
* purge needs only the top-level {@link PurgeEvidence.processed}.
|
|
83
|
+
*/
|
|
84
|
+
interface PurgeResourceResult {
|
|
85
|
+
/** Logical resource / module / provider name — NOT a collection name. */
|
|
86
|
+
readonly resource: string;
|
|
87
|
+
/** Rows this resource processed (non-negative integer). */
|
|
88
|
+
readonly processed: number;
|
|
89
|
+
/** Whether this resource's step succeeded. */
|
|
90
|
+
readonly ok: boolean;
|
|
91
|
+
/** Failure message when `ok` is false. */
|
|
92
|
+
readonly error?: string;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Compact verification summary — the recipe's own post-checks, not a raw
|
|
96
|
+
* delete count. `A successful Mongo delete count alone is not success.`
|
|
97
|
+
*/
|
|
98
|
+
interface PurgeVerificationSummary {
|
|
99
|
+
/** Whether every verification invariant held. */
|
|
100
|
+
readonly ok: boolean;
|
|
101
|
+
/** How many invariants were checked. */
|
|
102
|
+
readonly checks?: number;
|
|
103
|
+
/** Short human-readable note (e.g. `'trial balance clean; stock reseeded'`). */
|
|
104
|
+
readonly note?: string;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Persisted evidence for one purge operation.
|
|
108
|
+
*/
|
|
109
|
+
interface PurgeEvidence {
|
|
110
|
+
/** Unique evidence-record identifier (UUID v4 recommended). */
|
|
111
|
+
readonly id: string;
|
|
112
|
+
/**
|
|
113
|
+
* Optional stable operation id correlating this evidence with the run's
|
|
114
|
+
* logs, audit events, and (for a Cleanup Center) its durable run record.
|
|
115
|
+
* Distinct from {@link id} (the evidence-record id): many evidence rows
|
|
116
|
+
* can share one `operationId`.
|
|
117
|
+
*/
|
|
118
|
+
readonly operationId?: string;
|
|
119
|
+
/** What was purged. */
|
|
120
|
+
readonly subject: PurgeSubject;
|
|
121
|
+
/**
|
|
122
|
+
* The scope the purge ran over — free-form but conventionally a
|
|
123
|
+
* `kind:value` tag so reports group cleanly. Examples:
|
|
124
|
+
* `'generation:5'` (retention generation), `'occurrence:<dedupeKey>'`
|
|
125
|
+
* (a specific event occurrence), `'org:<id>'` (a tenant/branch window).
|
|
126
|
+
*/
|
|
127
|
+
readonly scope: string;
|
|
128
|
+
/** Strategy that ran — `hard` | `soft` | `anonymize`. */
|
|
129
|
+
readonly strategy: PurgeStrategyKind;
|
|
130
|
+
/** Terminal outcome. Defaults to `completed` from the builder. */
|
|
131
|
+
readonly status: PurgeStatus;
|
|
132
|
+
/**
|
|
133
|
+
* True when analytical MEASURES were retained (the anonymize/soft case
|
|
134
|
+
* where PII dimensions are redacted but aggregates survive); false for a
|
|
135
|
+
* hard deletion that removed the rows entirely.
|
|
136
|
+
*/
|
|
137
|
+
readonly measuresRetained: boolean;
|
|
138
|
+
/** Rows actually processed by the purge (non-negative integer). */
|
|
139
|
+
readonly processed: number;
|
|
140
|
+
/**
|
|
141
|
+
* When the purge STARTED, if known. Distinct from {@link completedAt} /
|
|
142
|
+
* {@link occurredAt} so a report can show duration + ordering.
|
|
143
|
+
*/
|
|
144
|
+
readonly startedAt?: Date;
|
|
145
|
+
/** When the purge finished, if distinguished from {@link occurredAt}. */
|
|
146
|
+
readonly completedAt?: Date;
|
|
147
|
+
/** When the evidence was recorded (canonical audit timestamp). */
|
|
148
|
+
readonly occurredAt: Date;
|
|
149
|
+
/** Who ran it. */
|
|
150
|
+
readonly actor: PurgeActor;
|
|
151
|
+
/** Human-readable justification — surfaces in audit reports. */
|
|
152
|
+
readonly reason: string;
|
|
153
|
+
/** Optional legal basis citation — e.g. `'GDPR Art. 17'`, `'CCPA §1798.105'`. */
|
|
154
|
+
readonly legalBasis?: string;
|
|
155
|
+
/** Optional per-resource breakdown of a multi-step purge. */
|
|
156
|
+
readonly results?: readonly PurgeResourceResult[];
|
|
157
|
+
/** Optional verification summary — the recipe's own post-checks. */
|
|
158
|
+
readonly verification?: PurgeVerificationSummary;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Input to {@link createPurgeEvidence}. `id` and `occurredAt` are optional —
|
|
162
|
+
* the builder fills them when absent. Everything else is caller-supplied.
|
|
163
|
+
*/
|
|
164
|
+
interface CreatePurgeEvidenceInput {
|
|
165
|
+
readonly id?: string;
|
|
166
|
+
readonly operationId?: string;
|
|
167
|
+
readonly subject: PurgeSubject;
|
|
168
|
+
readonly scope: string;
|
|
169
|
+
readonly strategy: PurgeStrategyKind;
|
|
170
|
+
/** Defaults to `'completed'` when omitted. */
|
|
171
|
+
readonly status?: PurgeStatus;
|
|
172
|
+
readonly measuresRetained: boolean;
|
|
173
|
+
readonly processed: number;
|
|
174
|
+
readonly startedAt?: Date;
|
|
175
|
+
readonly completedAt?: Date;
|
|
176
|
+
readonly occurredAt?: Date;
|
|
177
|
+
readonly actor: PurgeActor;
|
|
178
|
+
readonly reason: string;
|
|
179
|
+
readonly legalBasis?: string;
|
|
180
|
+
readonly results?: readonly PurgeResourceResult[];
|
|
181
|
+
readonly verification?: PurgeVerificationSummary;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Build a {@link PurgeEvidence} record with auto-filled `id` + `occurredAt`.
|
|
185
|
+
*
|
|
186
|
+
* Pure and side-effect-free at module scope — the id generator and the
|
|
187
|
+
* timestamp are only invoked when the function runs (mirrors
|
|
188
|
+
* `@classytic/primitives/events` `createEvent`), never at import time, so the
|
|
189
|
+
* module stays tree-shakeable and deterministic to load.
|
|
190
|
+
*
|
|
191
|
+
* Caller-supplied `id` / `occurredAt` win over the generated defaults.
|
|
192
|
+
*/
|
|
193
|
+
declare function createPurgeEvidence(input: CreatePurgeEvidenceInput): PurgeEvidence;
|
|
194
|
+
/**
|
|
195
|
+
* Structural type guard — validates the full {@link PurgeEvidence} shape,
|
|
196
|
+
* including the nested `subject` / `actor` objects and the two closed enums.
|
|
197
|
+
* Zero-dependency stand-in for a runtime schema.
|
|
198
|
+
*/
|
|
199
|
+
declare function isPurgeEvidence(value: unknown): value is PurgeEvidence;
|
|
200
|
+
/**
|
|
201
|
+
* Throwing assertion form of {@link isPurgeEvidence}. Use at trust
|
|
202
|
+
* boundaries (deserialized audit rows, cross-service payloads) where an
|
|
203
|
+
* invalid record must fail loudly rather than propagate.
|
|
204
|
+
*
|
|
205
|
+
* @throws {TypeError} when `value` is not a well-formed {@link PurgeEvidence}.
|
|
206
|
+
*/
|
|
207
|
+
declare function assertPurgeEvidence(value: unknown): asserts value is PurgeEvidence;
|
|
208
|
+
//#endregion
|
|
209
|
+
export { CreatePurgeEvidenceInput, PurgeActor, PurgeEvidence, PurgeResourceResult, PurgeStatus, PurgeStrategyKind, PurgeSubject, PurgeVerificationSummary, assertPurgeEvidence, createPurgeEvidence, isPurgeEvidence };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
//#region src/retention/purge-evidence.ts
|
|
2
|
+
const STRATEGY_KINDS = new Set([
|
|
3
|
+
"hard",
|
|
4
|
+
"soft",
|
|
5
|
+
"anonymize"
|
|
6
|
+
]);
|
|
7
|
+
const ACTOR_KINDS = new Set([
|
|
8
|
+
"user",
|
|
9
|
+
"system",
|
|
10
|
+
"service"
|
|
11
|
+
]);
|
|
12
|
+
const STATUS_KINDS = new Set([
|
|
13
|
+
"completed",
|
|
14
|
+
"partial",
|
|
15
|
+
"failed"
|
|
16
|
+
]);
|
|
17
|
+
/** Non-empty string check — the guard's baseline for every required label. */
|
|
18
|
+
function isNonEmptyString(v) {
|
|
19
|
+
return typeof v === "string" && v.length > 0;
|
|
20
|
+
}
|
|
21
|
+
function isValidDate(v) {
|
|
22
|
+
return v instanceof Date && !Number.isNaN(v.getTime());
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Build a {@link PurgeEvidence} record with auto-filled `id` + `occurredAt`.
|
|
26
|
+
*
|
|
27
|
+
* Pure and side-effect-free at module scope — the id generator and the
|
|
28
|
+
* timestamp are only invoked when the function runs (mirrors
|
|
29
|
+
* `@classytic/primitives/events` `createEvent`), never at import time, so the
|
|
30
|
+
* module stays tree-shakeable and deterministic to load.
|
|
31
|
+
*
|
|
32
|
+
* Caller-supplied `id` / `occurredAt` win over the generated defaults.
|
|
33
|
+
*/
|
|
34
|
+
function createPurgeEvidence(input) {
|
|
35
|
+
return {
|
|
36
|
+
id: input.id ?? randomEvidenceId(),
|
|
37
|
+
subject: input.subject,
|
|
38
|
+
scope: input.scope,
|
|
39
|
+
strategy: input.strategy,
|
|
40
|
+
status: input.status ?? "completed",
|
|
41
|
+
measuresRetained: input.measuresRetained,
|
|
42
|
+
processed: input.processed,
|
|
43
|
+
occurredAt: input.occurredAt ?? /* @__PURE__ */ new Date(),
|
|
44
|
+
actor: input.actor,
|
|
45
|
+
reason: input.reason,
|
|
46
|
+
...input.operationId !== void 0 ? { operationId: input.operationId } : {},
|
|
47
|
+
...input.startedAt !== void 0 ? { startedAt: input.startedAt } : {},
|
|
48
|
+
...input.completedAt !== void 0 ? { completedAt: input.completedAt } : {},
|
|
49
|
+
...input.legalBasis !== void 0 ? { legalBasis: input.legalBasis } : {},
|
|
50
|
+
...input.results !== void 0 ? { results: input.results } : {},
|
|
51
|
+
...input.verification !== void 0 ? { verification: input.verification } : {}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Structural type guard — validates the full {@link PurgeEvidence} shape,
|
|
56
|
+
* including the nested `subject` / `actor` objects and the two closed enums.
|
|
57
|
+
* Zero-dependency stand-in for a runtime schema.
|
|
58
|
+
*/
|
|
59
|
+
function isPurgeEvidence(value) {
|
|
60
|
+
if (typeof value !== "object" || value === null) return false;
|
|
61
|
+
const v = value;
|
|
62
|
+
if (!isNonEmptyString(v.id)) return false;
|
|
63
|
+
if (v.operationId !== void 0 && !isNonEmptyString(v.operationId)) return false;
|
|
64
|
+
if (!isNonEmptyString(v.scope)) return false;
|
|
65
|
+
if (typeof v.strategy !== "string" || !STRATEGY_KINDS.has(v.strategy)) return false;
|
|
66
|
+
if (typeof v.status !== "string" || !STATUS_KINDS.has(v.status)) return false;
|
|
67
|
+
if (typeof v.measuresRetained !== "boolean") return false;
|
|
68
|
+
if (typeof v.processed !== "number" || !Number.isInteger(v.processed) || v.processed < 0) return false;
|
|
69
|
+
if (v.startedAt !== void 0 && !isValidDate(v.startedAt)) return false;
|
|
70
|
+
if (v.completedAt !== void 0 && !isValidDate(v.completedAt)) return false;
|
|
71
|
+
if (!isValidDate(v.occurredAt)) return false;
|
|
72
|
+
if (!isNonEmptyString(v.reason)) return false;
|
|
73
|
+
if (v.legalBasis !== void 0 && !isNonEmptyString(v.legalBasis)) return false;
|
|
74
|
+
const subject = v.subject;
|
|
75
|
+
if (typeof subject !== "object" || subject === null) return false;
|
|
76
|
+
if (!isNonEmptyString(subject.ref)) return false;
|
|
77
|
+
if (subject.model !== void 0 && !isNonEmptyString(subject.model)) return false;
|
|
78
|
+
const actor = v.actor;
|
|
79
|
+
if (typeof actor !== "object" || actor === null) return false;
|
|
80
|
+
if (!isNonEmptyString(actor.ref)) return false;
|
|
81
|
+
if (typeof actor.kind !== "string" || !ACTOR_KINDS.has(actor.kind)) return false;
|
|
82
|
+
if (v.results !== void 0) {
|
|
83
|
+
if (!Array.isArray(v.results)) return false;
|
|
84
|
+
for (const r of v.results) {
|
|
85
|
+
if (typeof r !== "object" || r === null) return false;
|
|
86
|
+
const rr = r;
|
|
87
|
+
if (!isNonEmptyString(rr.resource)) return false;
|
|
88
|
+
if (typeof rr.processed !== "number" || !Number.isInteger(rr.processed) || rr.processed < 0) return false;
|
|
89
|
+
if (typeof rr.ok !== "boolean") return false;
|
|
90
|
+
if (rr.error !== void 0 && typeof rr.error !== "string") return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (v.verification !== void 0) {
|
|
94
|
+
if (typeof v.verification !== "object" || v.verification === null) return false;
|
|
95
|
+
const ver = v.verification;
|
|
96
|
+
if (typeof ver.ok !== "boolean") return false;
|
|
97
|
+
if (ver.checks !== void 0 && (!Number.isInteger(ver.checks) || ver.checks < 0)) return false;
|
|
98
|
+
if (ver.note !== void 0 && typeof ver.note !== "string") return false;
|
|
99
|
+
}
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Throwing assertion form of {@link isPurgeEvidence}. Use at trust
|
|
104
|
+
* boundaries (deserialized audit rows, cross-service payloads) where an
|
|
105
|
+
* invalid record must fail loudly rather than propagate.
|
|
106
|
+
*
|
|
107
|
+
* @throws {TypeError} when `value` is not a well-formed {@link PurgeEvidence}.
|
|
108
|
+
*/
|
|
109
|
+
function assertPurgeEvidence(value) {
|
|
110
|
+
if (!isPurgeEvidence(value)) throw new TypeError("Invalid PurgeEvidence: value does not satisfy the PurgeEvidence shape");
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* UUID-v4-style identifier. Uses `crypto.randomUUID` when available; falls
|
|
114
|
+
* back to an RFC-4122 v4 string only on very old runtimes without Web Crypto.
|
|
115
|
+
* Mirrors the generator in `@classytic/primitives/events`.
|
|
116
|
+
*/
|
|
117
|
+
function randomEvidenceId() {
|
|
118
|
+
const g = globalThis;
|
|
119
|
+
if (g.crypto?.randomUUID) return g.crypto.randomUUID();
|
|
120
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
121
|
+
const r = Math.random() * 16 | 0;
|
|
122
|
+
return (c === "x" ? r : r & 3 | 8).toString(16);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
//#endregion
|
|
126
|
+
export { assertPurgeEvidence, createPurgeEvidence, isPurgeEvidence };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/primitives",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Framework-agnostic TypeScript primitives shared across Classytic packages — Money, events, pagination, tenant config, and other building blocks.",
|
|
5
5
|
"author": "Classytic",
|
|
6
6
|
"license": "MIT",
|
|
@@ -157,6 +157,14 @@
|
|
|
157
157
|
"types": "./dist/sla-policy.d.mts",
|
|
158
158
|
"default": "./dist/sla-policy.mjs"
|
|
159
159
|
},
|
|
160
|
+
"./retention": {
|
|
161
|
+
"types": "./dist/retention.d.mts",
|
|
162
|
+
"default": "./dist/retention.mjs"
|
|
163
|
+
},
|
|
164
|
+
"./canonical": {
|
|
165
|
+
"types": "./dist/canonical.d.mts",
|
|
166
|
+
"default": "./dist/canonical.mjs"
|
|
167
|
+
},
|
|
160
168
|
"./package.json": "./package.json"
|
|
161
169
|
},
|
|
162
170
|
"files": [
|