@classytic/primitives 0.13.0 → 0.15.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 +35 -0
- package/README.md +2 -0
- package/dist/canonical.d.mts +41 -0
- package/dist/canonical.mjs +80 -0
- package/dist/event-infra.d.mts +112 -0
- package/dist/event-infra.mjs +119 -0
- package/dist/events.d.mts +60 -2
- package/dist/events.mjs +42 -1
- package/dist/money.d.mts +32 -1
- package/dist/money.mjs +44 -1
- package/dist/retention.d.mts +209 -0
- package/dist/retention.mjs +126 -0
- package/package.json +13 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,41 @@
|
|
|
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.15.0] - 2026-07-25
|
|
7
|
+
|
|
8
|
+
### Added — `/retention`: `PurgeEvidence` value object
|
|
9
|
+
|
|
10
|
+
- **New `@classytic/primitives/retention` subpath** exporting **`PurgeEvidence`**
|
|
11
|
+
— the persisted evidence record for a GDPR / data-retention purge operation
|
|
12
|
+
(proof of what was purged, over what scope, by whom, why, and whether
|
|
13
|
+
analytical measures were retained). Shape: `{ id, subject: { ref, model? },
|
|
14
|
+
scope, strategy: 'hard'|'soft'|'anonymize', measuresRetained, processed,
|
|
15
|
+
occurredAt, actor: { ref, kind: 'user'|'system'|'service' }, reason,
|
|
16
|
+
legalBasis?, status: 'completed'|'partial'|'failed', operationId?,
|
|
17
|
+
startedAt?, completedAt?, results?: PurgeResourceResult[],
|
|
18
|
+
verification?: PurgeVerificationSummary }`.
|
|
19
|
+
- **`createPurgeEvidence(input)`** — pure builder (auto-fills `id` + `occurredAt`
|
|
20
|
+
when absent, defaults `status` to `'completed'`). **`isPurgeEvidence(value)`**
|
|
21
|
+
type guard + **`assertPurgeEvidence(value)`** throwing assertion.
|
|
22
|
+
|
|
23
|
+
### Added — `/canonical`: strict deterministic JSON + integrity digest
|
|
24
|
+
|
|
25
|
+
- **New `@classytic/primitives/canonical` subpath** — `canonicalJson(value)`
|
|
26
|
+
produces a deterministic string (keys sorted recursively, `Date` → `{"$date":"<iso>"}`,
|
|
27
|
+
strict rejection of `undefined`/`NaN`/`BigInt`/`Map`/`Set`/cycles with
|
|
28
|
+
`CanonicalizeError`). **`sha256Hex(input)`** and **`canonicalDigest(value)`**
|
|
29
|
+
(= `sha256Hex(canonicalJson(value))`) — integrity checksums, not tamper-proof
|
|
30
|
+
seals. Uses `node:crypto` directly (same precedent as `/otp`).
|
|
31
|
+
|
|
32
|
+
### Added — `/event-infra`: `propagateHandlerErrors` option
|
|
33
|
+
|
|
34
|
+
- **`InProcessEventBusOptions.propagateHandlerErrors?: boolean`** (default
|
|
35
|
+
`false` — existing error-isolating behavior unchanged). When `true`, `publish`
|
|
36
|
+
runs handlers sequentially and rethrows the first handler error to the
|
|
37
|
+
publisher — for single-consumer projection/relay lanes where a failed handler
|
|
38
|
+
must not be silently acked. `publishMany` maps each event's failure into its
|
|
39
|
+
`PublishManyResult` entry instead of rejecting the batch.
|
|
40
|
+
|
|
6
41
|
## [0.13.0] - 2026-07-22
|
|
7
42
|
|
|
8
43
|
### Added — `/unit-cost-rate`: exact per-unit monetary rates (bigint-safe)
|
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
|
|
|
@@ -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 };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { DomainEvent, EventHandler, EventTransport, PublishManyResult } from "./events.mjs";
|
|
2
|
+
import { OutboxAcknowledgeOptions, OutboxStore, OutboxWriteOptions } from "./outbox.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/events/event-infra.d.ts
|
|
5
|
+
/** Minimal error-only logger — assignable from `console`, pino, fastify.log. */
|
|
6
|
+
interface ErrorLogger {
|
|
7
|
+
error(message: string, ...args: unknown[]): void;
|
|
8
|
+
}
|
|
9
|
+
interface InProcessEventBusOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Transport name (`EventTransport.name`). Kernels use
|
|
12
|
+
* `'in-process-<kernel>'`. Default: `'in-process'`.
|
|
13
|
+
*/
|
|
14
|
+
name?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Logger for per-handler error isolation (default: `console`).
|
|
17
|
+
* Pass `null` to swallow handler errors silently (cart's historical
|
|
18
|
+
* behavior — event emission never propagates OR logs).
|
|
19
|
+
*/
|
|
20
|
+
logger?: ErrorLogger | null;
|
|
21
|
+
/**
|
|
22
|
+
* Label used in the handler-error log line:
|
|
23
|
+
* `[<logLabel>] handler error for <event.type>:`. Default: `name`.
|
|
24
|
+
*/
|
|
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;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* In-process fan-out `EventTransport` — structurally identical to arc's
|
|
59
|
+
* `MemoryEventTransport`, so the two are interchangeable.
|
|
60
|
+
*
|
|
61
|
+
* Semantics (the union of every kernel copy this replaces):
|
|
62
|
+
* - Glob matching via {@link matchEventPattern} (exact / `*` / `prefix.*`
|
|
63
|
+
* / `prefix:*`).
|
|
64
|
+
* - A handler subscribed under multiple matching patterns fires ONCE per
|
|
65
|
+
* event (Set-dedup — the majority variant across kernels).
|
|
66
|
+
* - Per-handler error isolation: one failing subscriber never crashes
|
|
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}).
|
|
70
|
+
* - `publishMany` batches per-event outcomes keyed by `meta.id`.
|
|
71
|
+
* - `close()` clears all subscriptions; publish after close is a no-op;
|
|
72
|
+
* close is idempotent. NOT durable — engines own it under the fleet
|
|
73
|
+
* `ownsBus` convention (PACKAGE_RULES §8.1).
|
|
74
|
+
*/
|
|
75
|
+
declare class InProcessEventBus implements EventTransport {
|
|
76
|
+
readonly name: string;
|
|
77
|
+
private handlers;
|
|
78
|
+
private readonly logger;
|
|
79
|
+
private readonly logLabel;
|
|
80
|
+
private readonly propagateHandlerErrors;
|
|
81
|
+
constructor(options?: InProcessEventBusOptions);
|
|
82
|
+
publish(event: DomainEvent): Promise<void>;
|
|
83
|
+
publishMany(events: readonly DomainEvent[]): Promise<PublishManyResult>;
|
|
84
|
+
subscribe(pattern: string, handler: EventHandler): Promise<() => void>;
|
|
85
|
+
close(): Promise<void>;
|
|
86
|
+
}
|
|
87
|
+
/** Factory form of {@link InProcessEventBus} for hosts that prefer functions. */
|
|
88
|
+
declare function createInProcessBus(options?: InProcessEventBusOptions): InProcessEventBus;
|
|
89
|
+
/**
|
|
90
|
+
* In-memory {@link OutboxStore} for tests and local dev. Not for production —
|
|
91
|
+
* hosts wire arc's `MongoOutboxStore`, a mongokit repo via
|
|
92
|
+
* `repositoryAsOutboxStore`, or any durable implementation.
|
|
93
|
+
*
|
|
94
|
+
* Implements the required trio (`save` / `getPending` / `acknowledge`) plus
|
|
95
|
+
* optional `purge`. Lease-based methods (`claimPending` / `fail` /
|
|
96
|
+
* `getDeadLettered`) are intentionally omitted — single-process tests don't
|
|
97
|
+
* need them, and kernels only call `save()` themselves. `all()` / `clear()`
|
|
98
|
+
* are test-inspection helpers (not part of the contract).
|
|
99
|
+
*/
|
|
100
|
+
declare class MemoryOutboxStore implements OutboxStore {
|
|
101
|
+
private events;
|
|
102
|
+
save(event: DomainEvent, _options?: OutboxWriteOptions): Promise<void>;
|
|
103
|
+
getPending(limit: number): Promise<DomainEvent[]>;
|
|
104
|
+
acknowledge(eventId: string, _options?: OutboxAcknowledgeOptions): Promise<void>;
|
|
105
|
+
purge(olderThanMs: number): Promise<number>;
|
|
106
|
+
/** Every saved event (acknowledged or not) — test inspection helper. */
|
|
107
|
+
all(): ReadonlyArray<DomainEvent>;
|
|
108
|
+
/** Reset the store — test helper. */
|
|
109
|
+
clear(): void;
|
|
110
|
+
}
|
|
111
|
+
//#endregion
|
|
112
|
+
export { ErrorLogger, InProcessEventBus, InProcessEventBusOptions, MemoryOutboxStore, createInProcessBus };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { matchEventPattern } from "./events.mjs";
|
|
2
|
+
//#region src/events/event-infra.ts
|
|
3
|
+
/**
|
|
4
|
+
* In-process fan-out `EventTransport` — structurally identical to arc's
|
|
5
|
+
* `MemoryEventTransport`, so the two are interchangeable.
|
|
6
|
+
*
|
|
7
|
+
* Semantics (the union of every kernel copy this replaces):
|
|
8
|
+
* - Glob matching via {@link matchEventPattern} (exact / `*` / `prefix.*`
|
|
9
|
+
* / `prefix:*`).
|
|
10
|
+
* - A handler subscribed under multiple matching patterns fires ONCE per
|
|
11
|
+
* event (Set-dedup — the majority variant across kernels).
|
|
12
|
+
* - Per-handler error isolation: one failing subscriber never crashes
|
|
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}).
|
|
16
|
+
* - `publishMany` batches per-event outcomes keyed by `meta.id`.
|
|
17
|
+
* - `close()` clears all subscriptions; publish after close is a no-op;
|
|
18
|
+
* close is idempotent. NOT durable — engines own it under the fleet
|
|
19
|
+
* `ownsBus` convention (PACKAGE_RULES §8.1).
|
|
20
|
+
*/
|
|
21
|
+
var InProcessEventBus = class {
|
|
22
|
+
name;
|
|
23
|
+
handlers = /* @__PURE__ */ new Map();
|
|
24
|
+
logger;
|
|
25
|
+
logLabel;
|
|
26
|
+
propagateHandlerErrors;
|
|
27
|
+
constructor(options = {}) {
|
|
28
|
+
this.name = options.name ?? "in-process";
|
|
29
|
+
this.logger = options.logger === null ? null : options.logger ?? console;
|
|
30
|
+
this.logLabel = options.logLabel ?? this.name;
|
|
31
|
+
this.propagateHandlerErrors = options.propagateHandlerErrors ?? false;
|
|
32
|
+
}
|
|
33
|
+
async publish(event) {
|
|
34
|
+
const matched = /* @__PURE__ */ new Set();
|
|
35
|
+
for (const [pattern, set] of this.handlers.entries()) if (matchEventPattern(pattern, event.type)) for (const h of set) matched.add(h);
|
|
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
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async publishMany(events) {
|
|
49
|
+
const results = /* @__PURE__ */ new Map();
|
|
50
|
+
for (const event of events) try {
|
|
51
|
+
await this.publish(event);
|
|
52
|
+
results.set(event.meta.id, null);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
results.set(event.meta.id, err instanceof Error ? err : new Error(String(err)));
|
|
55
|
+
}
|
|
56
|
+
return results;
|
|
57
|
+
}
|
|
58
|
+
async subscribe(pattern, handler) {
|
|
59
|
+
let set = this.handlers.get(pattern);
|
|
60
|
+
if (!set) {
|
|
61
|
+
set = /* @__PURE__ */ new Set();
|
|
62
|
+
this.handlers.set(pattern, set);
|
|
63
|
+
}
|
|
64
|
+
set.add(handler);
|
|
65
|
+
return () => {
|
|
66
|
+
const s = this.handlers.get(pattern);
|
|
67
|
+
if (!s) return;
|
|
68
|
+
s.delete(handler);
|
|
69
|
+
if (s.size === 0) this.handlers.delete(pattern);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
async close() {
|
|
73
|
+
this.handlers.clear();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
/** Factory form of {@link InProcessEventBus} for hosts that prefer functions. */
|
|
77
|
+
function createInProcessBus(options) {
|
|
78
|
+
return new InProcessEventBus(options);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* In-memory {@link OutboxStore} for tests and local dev. Not for production —
|
|
82
|
+
* hosts wire arc's `MongoOutboxStore`, a mongokit repo via
|
|
83
|
+
* `repositoryAsOutboxStore`, or any durable implementation.
|
|
84
|
+
*
|
|
85
|
+
* Implements the required trio (`save` / `getPending` / `acknowledge`) plus
|
|
86
|
+
* optional `purge`. Lease-based methods (`claimPending` / `fail` /
|
|
87
|
+
* `getDeadLettered`) are intentionally omitted — single-process tests don't
|
|
88
|
+
* need them, and kernels only call `save()` themselves. `all()` / `clear()`
|
|
89
|
+
* are test-inspection helpers (not part of the contract).
|
|
90
|
+
*/
|
|
91
|
+
var MemoryOutboxStore = class {
|
|
92
|
+
events = [];
|
|
93
|
+
async save(event, _options) {
|
|
94
|
+
this.events.push({ event });
|
|
95
|
+
}
|
|
96
|
+
async getPending(limit) {
|
|
97
|
+
return this.events.filter((e) => !e.acknowledgedAt).slice(0, limit).map((e) => e.event);
|
|
98
|
+
}
|
|
99
|
+
async acknowledge(eventId, _options) {
|
|
100
|
+
const entry = this.events.find((e) => e.event.meta.id === eventId);
|
|
101
|
+
if (entry) entry.acknowledgedAt = /* @__PURE__ */ new Date();
|
|
102
|
+
}
|
|
103
|
+
async purge(olderThanMs) {
|
|
104
|
+
const cutoff = Date.now() - olderThanMs;
|
|
105
|
+
const before = this.events.length;
|
|
106
|
+
this.events = this.events.filter((e) => !e.acknowledgedAt || e.acknowledgedAt.getTime() >= cutoff);
|
|
107
|
+
return before - this.events.length;
|
|
108
|
+
}
|
|
109
|
+
/** Every saved event (acknowledged or not) — test inspection helper. */
|
|
110
|
+
all() {
|
|
111
|
+
return this.events.map((e) => e.event);
|
|
112
|
+
}
|
|
113
|
+
/** Reset the store — test helper. */
|
|
114
|
+
clear() {
|
|
115
|
+
this.events = [];
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
//#endregion
|
|
119
|
+
export { InProcessEventBus, MemoryOutboxStore, createInProcessBus };
|
package/dist/events.d.mts
CHANGED
|
@@ -179,8 +179,18 @@ interface EventTransport {
|
|
|
179
179
|
/**
|
|
180
180
|
* Subscribe to a glob pattern — exact match, `*`, or `resource.*`. Returns
|
|
181
181
|
* an unsubscribe function.
|
|
182
|
+
*
|
|
183
|
+
* Optional (0.13.0): publish-only transports — outbox bridge adapters,
|
|
184
|
+
* host wrappers around a `publish(type, payload, meta)` helper, fire-and-
|
|
185
|
+
* forget pipes into Kafka/webhooks — are legitimate `EventTransport`s.
|
|
186
|
+
* Consumers that need in-process delivery MUST guard (`transport.subscribe
|
|
187
|
+
* ? … : throw/fallback`) the same way they already do for the optional
|
|
188
|
+
* `publishMany` / `deadLetter` members (see flow's
|
|
189
|
+
* `ConsignmentService.createSettlementListener` for the reference guard).
|
|
190
|
+
* Kernels subscribing on their OWN in-process bus (which always implements
|
|
191
|
+
* `subscribe`) are unaffected.
|
|
182
192
|
*/
|
|
183
|
-
subscribe(pattern: string, handler: EventHandler): Promise<() => void>;
|
|
193
|
+
subscribe?(pattern: string, handler: EventHandler): Promise<() => void>;
|
|
184
194
|
/**
|
|
185
195
|
* Route a permanently-failed event to the transport's dead-letter sink
|
|
186
196
|
* (Kafka DLQ topic, SQS DLQ, Redis Stream `PEL` timeout handler, etc.).
|
|
@@ -227,6 +237,54 @@ declare function createEvent<T>(type: string, payload: T, meta?: Partial<EventMe
|
|
|
227
237
|
* // reserved.meta.userId === user.id (inherited)
|
|
228
238
|
*/
|
|
229
239
|
declare function createChildEvent<T>(parent: DomainEvent, type: string, payload: T, meta?: Partial<EventMeta>): DomainEvent<T>;
|
|
240
|
+
/**
|
|
241
|
+
* The context slice {@link createScopedEvent} maps into {@link EventMeta}.
|
|
242
|
+
*
|
|
243
|
+
* Structurally satisfied by primitives' `OperationContext` (actorId /
|
|
244
|
+
* organizationId as IdLike) AND by the commerce `actorRef` dialect used by
|
|
245
|
+
* cart/promo/crm — so every kernel context threads straight through.
|
|
246
|
+
*/
|
|
247
|
+
interface EventScopeContext {
|
|
248
|
+
/** Authenticated principal id (`OperationContext.actorId` shape). */
|
|
249
|
+
actorId?: string | number | {
|
|
250
|
+
toString(): string;
|
|
251
|
+
} | undefined;
|
|
252
|
+
/** Commerce actor-reference dialect (`user:123`, `guest:abc`). Used when `actorId` is absent. */
|
|
253
|
+
actorRef?: string | undefined;
|
|
254
|
+
/** Tenant / organization / branch scope — rides `meta.organizationId` (META, never payload). */
|
|
255
|
+
organizationId?: string | {
|
|
256
|
+
toString(): string;
|
|
257
|
+
} | undefined;
|
|
258
|
+
/** Correlation id threaded across the causal chain. */
|
|
259
|
+
correlationId?: string | undefined;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Map an operation context onto event META — THE standard context→meta
|
|
263
|
+
* mapping (PACKAGE_RULES §8.3: `organizationId` rides META). Extracted from
|
|
264
|
+
* 25 drifted per-kernel `events/helpers.ts` copies.
|
|
265
|
+
*
|
|
266
|
+
* - `userId` ← `String(ctx.actorId)`, falling back to `ctx.actorRef`
|
|
267
|
+
* - `organizationId` ← `String(ctx.organizationId)`
|
|
268
|
+
* - `correlationId` ← `ctx.correlationId`
|
|
269
|
+
*
|
|
270
|
+
* Absent fields are OMITTED (not set to `undefined`) so the result spreads
|
|
271
|
+
* cleanly under `exactOptionalPropertyTypes`.
|
|
272
|
+
*/
|
|
273
|
+
declare function scopedEventMeta(ctx?: EventScopeContext): Partial<EventMeta>;
|
|
274
|
+
/**
|
|
275
|
+
* Build a {@link DomainEvent} scoped to an emitting package and an operation
|
|
276
|
+
* context — the shared core behind every kernel's `createXEvent` helper.
|
|
277
|
+
*
|
|
278
|
+
* ```ts
|
|
279
|
+
* // purchase/src/events/helpers.ts becomes:
|
|
280
|
+
* export const createPurchaseEvent = <T>(type, payload, ctx?, meta?) =>
|
|
281
|
+
* createScopedEvent('@classytic/purchase', type, payload, ctx, meta);
|
|
282
|
+
* ```
|
|
283
|
+
*
|
|
284
|
+
* Precedence: defaults (id/timestamp) < `source` < context mapping < caller
|
|
285
|
+
* `meta` — identical to the hand-rolled helpers this replaces.
|
|
286
|
+
*/
|
|
287
|
+
declare function createScopedEvent<T>(source: string, type: string, payload: T, ctx?: EventScopeContext, meta?: Partial<EventMeta>): DomainEvent<T>;
|
|
230
288
|
/**
|
|
231
289
|
* Match an event name against a glob pattern. Implements the same rules as
|
|
232
290
|
* arc's `MemoryEventTransport`:
|
|
@@ -237,4 +295,4 @@ declare function createChildEvent<T>(parent: DomainEvent, type: string, payload:
|
|
|
237
295
|
*/
|
|
238
296
|
declare function matchEventPattern(pattern: string, type: string): boolean;
|
|
239
297
|
//#endregion
|
|
240
|
-
export { DeadLetteredEvent, DomainEvent, EventHandler, EventLogger, EventMeta, EventTransport, PublishManyResult, createChildEvent, createEvent, matchEventPattern };
|
|
298
|
+
export { DeadLetteredEvent, DomainEvent, EventHandler, EventLogger, EventMeta, EventScopeContext, EventTransport, PublishManyResult, createChildEvent, createEvent, createScopedEvent, matchEventPattern, scopedEventMeta };
|
package/dist/events.mjs
CHANGED
|
@@ -64,6 +64,47 @@ function createChildEvent(parent, type, payload, meta) {
|
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
/**
|
|
67
|
+
* Map an operation context onto event META — THE standard context→meta
|
|
68
|
+
* mapping (PACKAGE_RULES §8.3: `organizationId` rides META). Extracted from
|
|
69
|
+
* 25 drifted per-kernel `events/helpers.ts` copies.
|
|
70
|
+
*
|
|
71
|
+
* - `userId` ← `String(ctx.actorId)`, falling back to `ctx.actorRef`
|
|
72
|
+
* - `organizationId` ← `String(ctx.organizationId)`
|
|
73
|
+
* - `correlationId` ← `ctx.correlationId`
|
|
74
|
+
*
|
|
75
|
+
* Absent fields are OMITTED (not set to `undefined`) so the result spreads
|
|
76
|
+
* cleanly under `exactOptionalPropertyTypes`.
|
|
77
|
+
*/
|
|
78
|
+
function scopedEventMeta(ctx) {
|
|
79
|
+
if (!ctx) return {};
|
|
80
|
+
const meta = {};
|
|
81
|
+
const actor = ctx.actorId ?? ctx.actorRef;
|
|
82
|
+
if (actor !== void 0) meta.userId = String(actor);
|
|
83
|
+
if (ctx.organizationId !== void 0) meta.organizationId = String(ctx.organizationId);
|
|
84
|
+
if (ctx.correlationId !== void 0) meta.correlationId = ctx.correlationId;
|
|
85
|
+
return meta;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Build a {@link DomainEvent} scoped to an emitting package and an operation
|
|
89
|
+
* context — the shared core behind every kernel's `createXEvent` helper.
|
|
90
|
+
*
|
|
91
|
+
* ```ts
|
|
92
|
+
* // purchase/src/events/helpers.ts becomes:
|
|
93
|
+
* export const createPurchaseEvent = <T>(type, payload, ctx?, meta?) =>
|
|
94
|
+
* createScopedEvent('@classytic/purchase', type, payload, ctx, meta);
|
|
95
|
+
* ```
|
|
96
|
+
*
|
|
97
|
+
* Precedence: defaults (id/timestamp) < `source` < context mapping < caller
|
|
98
|
+
* `meta` — identical to the hand-rolled helpers this replaces.
|
|
99
|
+
*/
|
|
100
|
+
function createScopedEvent(source, type, payload, ctx, meta) {
|
|
101
|
+
return createEvent(type, payload, {
|
|
102
|
+
source,
|
|
103
|
+
...scopedEventMeta(ctx),
|
|
104
|
+
...meta
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
67
108
|
* Match an event name against a glob pattern. Implements the same rules as
|
|
68
109
|
* arc's `MemoryEventTransport`:
|
|
69
110
|
* - `*` matches anything
|
|
@@ -87,4 +128,4 @@ function randomEventId() {
|
|
|
87
128
|
});
|
|
88
129
|
}
|
|
89
130
|
//#endregion
|
|
90
|
-
export { createChildEvent, createEvent, matchEventPattern };
|
|
131
|
+
export { createChildEvent, createEvent, createScopedEvent, matchEventPattern, scopedEventMeta };
|
package/dist/money.d.mts
CHANGED
|
@@ -104,7 +104,38 @@ declare function isPositiveMoney(m: Money): boolean;
|
|
|
104
104
|
declare function isNegativeMoney(m: Money): boolean;
|
|
105
105
|
declare function negateMoney(m: Money): Money;
|
|
106
106
|
declare function absMoney(m: Money): Money;
|
|
107
|
+
/**
|
|
108
|
+
* Decimal major-unit number → integer minor units by decimal exponent.
|
|
109
|
+
* Rounding: raw IEEE `Math.round` — half-up toward +∞ (so `-10.5 → -10`),
|
|
110
|
+
* documented above. Throws when the result leaves the safe-integer range
|
|
111
|
+
* (also catches `NaN`/`Infinity` inputs).
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* majorToMinorUnits(10.5) // 1050
|
|
115
|
+
* majorToMinorUnits(1000, 0) // 1000 (JPY-style)
|
|
116
|
+
* majorToMinorUnits(1.005) // 100 — IEEE, see note above
|
|
117
|
+
*/
|
|
118
|
+
declare function majorToMinorUnits(major: number, minorUnitDecimals?: number): number;
|
|
119
|
+
/** Integer minor units → decimal major-unit number by decimal exponent: `1050 → 10.5`. */
|
|
120
|
+
declare function minorUnitsToMajor(minor: number, minorUnitDecimals?: number): number;
|
|
121
|
+
/**
|
|
122
|
+
* Percentage of an integer minor-unit amount → integer minor units.
|
|
123
|
+
* EXACT multiply-then-round: `Math.round((minor × ratePercent) / 100)` —
|
|
124
|
+
* one rounding point, half-up toward +∞. Handles genuinely fractional rates
|
|
125
|
+
* (QST 9.975 %) without precision loss: `percentOfMinor(20000, 9.975) === 1995`.
|
|
126
|
+
*
|
|
127
|
+
* Hosts whose domain guarantees at-most-2-decimal rates MAY snap the rate to
|
|
128
|
+
* integer basis points BEFORE calling (be-prod's `#shared/money` seam does) —
|
|
129
|
+
* that is host policy layered on top, not part of this primitive.
|
|
130
|
+
*/
|
|
131
|
+
declare function percentOfMinor(minor: number, ratePercent: number): number;
|
|
132
|
+
/**
|
|
133
|
+
* Integer minor units → plain decimal string (no currency symbol, no locale):
|
|
134
|
+
* `formatMinorUnits(10550) → "105.50"`, `formatMinorUnits(1000, 0) → "1000"`.
|
|
135
|
+
* For localized currency strings use `Intl.NumberFormat` at the display layer.
|
|
136
|
+
*/
|
|
137
|
+
declare function formatMinorUnits(minor: number, minorUnitDecimals?: number): string;
|
|
107
138
|
/** Structural type guard. */
|
|
108
139
|
declare function isMoney(value: unknown): value is Money;
|
|
109
140
|
//#endregion
|
|
110
|
-
export { CurrencyMismatchError, MinorUnits, Money, absMoney, addMoney, compareMoney, equalsMoney, fromMajor, isMoney, isNegativeMoney, isPositiveMoney, isZeroMoney, money, multiplyMoney, negateMoney, subtractMoney, sumMoney, toMajor };
|
|
141
|
+
export { CurrencyMismatchError, MinorUnits, Money, absMoney, addMoney, compareMoney, equalsMoney, formatMinorUnits, fromMajor, isMoney, isNegativeMoney, isPositiveMoney, isZeroMoney, majorToMinorUnits, minorUnitsToMajor, money, multiplyMoney, negateMoney, percentOfMinor, subtractMoney, sumMoney, toMajor };
|
package/dist/money.mjs
CHANGED
|
@@ -135,9 +135,52 @@ function negateMoney(m) {
|
|
|
135
135
|
function absMoney(m) {
|
|
136
136
|
return m.amount < 0 ? negateMoney(m) : m;
|
|
137
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Decimal major-unit number → integer minor units by decimal exponent.
|
|
140
|
+
* Rounding: raw IEEE `Math.round` — half-up toward +∞ (so `-10.5 → -10`),
|
|
141
|
+
* documented above. Throws when the result leaves the safe-integer range
|
|
142
|
+
* (also catches `NaN`/`Infinity` inputs).
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* majorToMinorUnits(10.5) // 1050
|
|
146
|
+
* majorToMinorUnits(1000, 0) // 1000 (JPY-style)
|
|
147
|
+
* majorToMinorUnits(1.005) // 100 — IEEE, see note above
|
|
148
|
+
*/
|
|
149
|
+
function majorToMinorUnits(major, minorUnitDecimals = 2) {
|
|
150
|
+
const factor = 10 ** minorUnitDecimals;
|
|
151
|
+
const minor = Math.round(major * factor);
|
|
152
|
+
if (!Number.isSafeInteger(minor)) throw new RangeError(`Amount ${major} exceeds safe integer limit when converted to minor units. Max safe amount: ${Number.MAX_SAFE_INTEGER / factor}`);
|
|
153
|
+
return minor;
|
|
154
|
+
}
|
|
155
|
+
/** Integer minor units → decimal major-unit number by decimal exponent: `1050 → 10.5`. */
|
|
156
|
+
function minorUnitsToMajor(minor, minorUnitDecimals = 2) {
|
|
157
|
+
return minor / 10 ** minorUnitDecimals;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Percentage of an integer minor-unit amount → integer minor units.
|
|
161
|
+
* EXACT multiply-then-round: `Math.round((minor × ratePercent) / 100)` —
|
|
162
|
+
* one rounding point, half-up toward +∞. Handles genuinely fractional rates
|
|
163
|
+
* (QST 9.975 %) without precision loss: `percentOfMinor(20000, 9.975) === 1995`.
|
|
164
|
+
*
|
|
165
|
+
* Hosts whose domain guarantees at-most-2-decimal rates MAY snap the rate to
|
|
166
|
+
* integer basis points BEFORE calling (be-prod's `#shared/money` seam does) —
|
|
167
|
+
* that is host policy layered on top, not part of this primitive.
|
|
168
|
+
*/
|
|
169
|
+
function percentOfMinor(minor, ratePercent) {
|
|
170
|
+
if (!Number.isFinite(minor) || !Number.isFinite(ratePercent)) throw new TypeError(`percentOfMinor received non-finite input: minor=${minor}, ratePercent=${ratePercent}`);
|
|
171
|
+
return Math.round(minor * ratePercent / 100);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Integer minor units → plain decimal string (no currency symbol, no locale):
|
|
175
|
+
* `formatMinorUnits(10550) → "105.50"`, `formatMinorUnits(1000, 0) → "1000"`.
|
|
176
|
+
* For localized currency strings use `Intl.NumberFormat` at the display layer.
|
|
177
|
+
*/
|
|
178
|
+
function formatMinorUnits(minor, minorUnitDecimals = 2) {
|
|
179
|
+
return minorUnitsToMajor(minor, minorUnitDecimals).toFixed(minorUnitDecimals);
|
|
180
|
+
}
|
|
138
181
|
/** Structural type guard. */
|
|
139
182
|
function isMoney(value) {
|
|
140
183
|
return typeof value === "object" && value !== null && typeof value.amount === "number" && Number.isInteger(value.amount) && typeof value.currency === "string";
|
|
141
184
|
}
|
|
142
185
|
//#endregion
|
|
143
|
-
export { CurrencyMismatchError, MinorUnits, absMoney, addMoney, compareMoney, equalsMoney, fromMajor, isMoney, isNegativeMoney, isPositiveMoney, isZeroMoney, money, multiplyMoney, negateMoney, subtractMoney, sumMoney, toMajor };
|
|
186
|
+
export { CurrencyMismatchError, MinorUnits, absMoney, addMoney, compareMoney, equalsMoney, formatMinorUnits, fromMajor, isMoney, isNegativeMoney, isPositiveMoney, isZeroMoney, majorToMinorUnits, minorUnitsToMajor, money, multiplyMoney, negateMoney, percentOfMinor, subtractMoney, sumMoney, toMajor };
|
|
@@ -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.15.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",
|
|
@@ -61,6 +61,10 @@
|
|
|
61
61
|
"types": "./dist/events.d.mts",
|
|
62
62
|
"default": "./dist/events.mjs"
|
|
63
63
|
},
|
|
64
|
+
"./event-infra": {
|
|
65
|
+
"types": "./dist/event-infra.d.mts",
|
|
66
|
+
"default": "./dist/event-infra.mjs"
|
|
67
|
+
},
|
|
64
68
|
"./outbox": {
|
|
65
69
|
"types": "./dist/outbox.d.mts",
|
|
66
70
|
"default": "./dist/outbox.mjs"
|
|
@@ -153,6 +157,14 @@
|
|
|
153
157
|
"types": "./dist/sla-policy.d.mts",
|
|
154
158
|
"default": "./dist/sla-policy.mjs"
|
|
155
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
|
+
},
|
|
156
168
|
"./package.json": "./package.json"
|
|
157
169
|
},
|
|
158
170
|
"files": [
|