@classytic/primitives 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +123 -0
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/dist/address.d.mts +38 -0
- package/dist/address.mjs +16 -0
- package/dist/approval.d.mts +133 -0
- package/dist/approval.mjs +146 -0
- package/dist/brand-DApaegoR.d.mts +37 -0
- package/dist/brand.d.mts +2 -0
- package/dist/brand.mjs +1 -0
- package/dist/cadence.d.mts +93 -0
- package/dist/cadence.mjs +139 -0
- package/dist/context.d.mts +48 -0
- package/dist/context.mjs +1 -0
- package/dist/currency.d.mts +110 -0
- package/dist/currency.mjs +116 -0
- package/dist/errors.d.mts +50 -0
- package/dist/errors.mjs +20 -0
- package/dist/events.d.mts +240 -0
- package/dist/events.mjs +90 -0
- package/dist/hold.d.mts +89 -0
- package/dist/hold.mjs +69 -0
- package/dist/money.d.mts +78 -0
- package/dist/money.mjs +131 -0
- package/dist/outbox.d.mts +222 -0
- package/dist/outbox.mjs +30 -0
- package/dist/pagination.d.mts +57 -0
- package/dist/pagination.mjs +22 -0
- package/dist/period.d.mts +46 -0
- package/dist/period.mjs +31 -0
- package/dist/person.d.mts +44 -0
- package/dist/person.mjs +16 -0
- package/dist/reference.d.mts +39 -0
- package/dist/reference.mjs +10 -0
- package/dist/result.d.mts +32 -0
- package/dist/result.mjs +34 -0
- package/dist/sla.d.mts +71 -0
- package/dist/sla.mjs +57 -0
- package/dist/split-allocation.d.mts +84 -0
- package/dist/split-allocation.mjs +115 -0
- package/dist/tenant.d.mts +122 -0
- package/dist/tenant.mjs +54 -0
- package/package.json +142 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
//#region src/events.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Domain event transport primitives.
|
|
4
|
+
*
|
|
5
|
+
* These shapes MIRROR `@classytic/arc`'s event types verbatim. Arc owns the
|
|
6
|
+
* contract — primitives tracks. Keep this file bit-identical to
|
|
7
|
+
* `packages/reference-only/arc/src/events/EventTransport.ts` for `EventMeta`,
|
|
8
|
+
* `DomainEvent`, `EventTransport`, `DeadLetteredEvent`, and
|
|
9
|
+
* `PublishManyResult`. Helpers (`createEvent`, `createChildEvent`) are
|
|
10
|
+
* allowed to be more defensive (e.g. fall back when `crypto.randomUUID` is
|
|
11
|
+
* unavailable) but their signatures must match arc.
|
|
12
|
+
*
|
|
13
|
+
* Packages declare these from primitives (instead of importing arc) so they
|
|
14
|
+
* remain runtime-independent of arc while still plugging into any arc
|
|
15
|
+
* `EventTransport` without adapters. See PACKAGE_RULES.md §11.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Event metadata.
|
|
19
|
+
*
|
|
20
|
+
* Mirrored from arc v2.9. Any consumer using `@classytic/arc`'s `EventMeta`
|
|
21
|
+
* type gets a structural match with primitives' `EventMeta` and vice-versa.
|
|
22
|
+
*/
|
|
23
|
+
interface EventMeta {
|
|
24
|
+
/** Unique event identifier — UUID v4 recommended. */
|
|
25
|
+
id: string;
|
|
26
|
+
/** Emit timestamp. */
|
|
27
|
+
timestamp: Date;
|
|
28
|
+
/**
|
|
29
|
+
* Schema version for this event type. Default: `1`.
|
|
30
|
+
*
|
|
31
|
+
* Use when the payload shape evolves so handlers can branch on version
|
|
32
|
+
* during migration windows (`if (event.meta.schemaVersion === 2) ...`).
|
|
33
|
+
* Bump ONLY when the payload contract changes in a breaking way.
|
|
34
|
+
*/
|
|
35
|
+
schemaVersion?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Correlation ID — stays stable across an entire causal chain so a single
|
|
38
|
+
* user action can be traced through every downstream event. Spans service
|
|
39
|
+
* boundaries. Generated at the edge (HTTP request, CLI invocation) and
|
|
40
|
+
* inherited by every child event.
|
|
41
|
+
*
|
|
42
|
+
* Distinct from {@link causationId}: **correlation groups, causation chains.**
|
|
43
|
+
*/
|
|
44
|
+
correlationId?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Causation ID — the `meta.id` of the direct parent event that caused
|
|
47
|
+
* this one. Forms a linked-list of cause-and-effect within a correlation.
|
|
48
|
+
*
|
|
49
|
+
* Use {@link createChildEvent} to populate this automatically so handlers
|
|
50
|
+
* can't forget to propagate causality.
|
|
51
|
+
*/
|
|
52
|
+
causationId?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Partition key hint for ordered transports (Kafka, Kinesis, Redis Streams
|
|
55
|
+
* consumer groups). Events with the same partitionKey are guaranteed to be
|
|
56
|
+
* delivered in publish order by transports that honour it.
|
|
57
|
+
*
|
|
58
|
+
* Defaults to `resourceId` if unset. Transports that don't support ordering
|
|
59
|
+
* (in-memory, simple pub/sub) ignore this field.
|
|
60
|
+
*/
|
|
61
|
+
partitionKey?: string;
|
|
62
|
+
/** Source resource name — e.g. 'order', 'transaction'. */
|
|
63
|
+
resource?: string;
|
|
64
|
+
/** Resource identifier — typically the document's public id. */
|
|
65
|
+
resourceId?: string;
|
|
66
|
+
/** User who triggered the event. */
|
|
67
|
+
userId?: string;
|
|
68
|
+
/** Organization / tenant scope. */
|
|
69
|
+
organizationId?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Originating service or package (e.g. `'commerce'`, `'billing'`, `'arc-core'`).
|
|
72
|
+
*
|
|
73
|
+
* In a multi-service deployment, consumers route / log / alert by `source`
|
|
74
|
+
* without parsing `type` prefixes. Arc itself never populates this — hosts
|
|
75
|
+
* set it once per emitter. Inherited by {@link createChildEvent} so
|
|
76
|
+
* downstream events carry the same source unless overridden.
|
|
77
|
+
*/
|
|
78
|
+
source?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Idempotency key — stable hint that this event represents a specific
|
|
81
|
+
* operation exactly once. Consumers dedupe with
|
|
82
|
+
* `if (processed.has(meta.idempotencyKey)) return`.
|
|
83
|
+
*
|
|
84
|
+
* Survives every transport (Memory / Pub-Sub / Streams / Kafka) because
|
|
85
|
+
* it's part of the event, not a transport-side option. Distinct from
|
|
86
|
+
* `meta.id` (which is fresh per emit — a retry would produce a new id).
|
|
87
|
+
*
|
|
88
|
+
* Typical sources: HTTP `Idempotency-Key` header, outbox `dedupeKey`, or
|
|
89
|
+
* `{aggregate.type}:{aggregate.id}:{action}`. Inherited by child events.
|
|
90
|
+
*/
|
|
91
|
+
idempotencyKey?: string;
|
|
92
|
+
/**
|
|
93
|
+
* DDD aggregate marker — the aggregate that owns this event's invariant.
|
|
94
|
+
*
|
|
95
|
+
* Use when routing events by aggregate, doing event-sourcing replay, or
|
|
96
|
+
* enforcing consistency boundaries. Distinct from `resource` / `resourceId`
|
|
97
|
+
* (HTTP-origin entity) because an event emitted *by* one REST resource can
|
|
98
|
+
* *belong to* a different aggregate (e.g. `POST /orders/:id/ship` emits
|
|
99
|
+
* `shipment.dispatched` owned by a shipment aggregate).
|
|
100
|
+
*
|
|
101
|
+
* Downstream packages narrow `aggregate.type` to their own string union via
|
|
102
|
+
* interface extension:
|
|
103
|
+
*
|
|
104
|
+
* ```ts
|
|
105
|
+
* type CartAggregateType = 'cart' | 'cart-item';
|
|
106
|
+
* interface CartEventMeta extends EventMeta {
|
|
107
|
+
* aggregate?: { type: CartAggregateType; id: string };
|
|
108
|
+
* }
|
|
109
|
+
* ```
|
|
110
|
+
*
|
|
111
|
+
* Not inherited by {@link createChildEvent} — child events typically belong
|
|
112
|
+
* to a different aggregate than their parent.
|
|
113
|
+
*/
|
|
114
|
+
aggregate?: {
|
|
115
|
+
type: string;
|
|
116
|
+
id: string;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
interface DomainEvent<T = unknown> {
|
|
120
|
+
/** Dotted / colon-separated event name — e.g. 'order:placed'. */
|
|
121
|
+
type: string;
|
|
122
|
+
payload: T;
|
|
123
|
+
meta: EventMeta;
|
|
124
|
+
}
|
|
125
|
+
type EventHandler<T = unknown> = (event: DomainEvent<T>) => void | Promise<void>;
|
|
126
|
+
/**
|
|
127
|
+
* A permanently-failed event routed to a dead-letter sink after retries
|
|
128
|
+
* have been exhausted. Mirrors the shape a caller would log, alert on, or
|
|
129
|
+
* replay from once the upstream issue is fixed.
|
|
130
|
+
*/
|
|
131
|
+
interface DeadLetteredEvent<T = unknown> {
|
|
132
|
+
/** The original event. */
|
|
133
|
+
event: DomainEvent<T>;
|
|
134
|
+
/** Serialised failure reason (message + optional machine code + stack). */
|
|
135
|
+
error: {
|
|
136
|
+
message: string;
|
|
137
|
+
code?: string;
|
|
138
|
+
stack?: string;
|
|
139
|
+
};
|
|
140
|
+
/** How many delivery attempts were made before giving up. */
|
|
141
|
+
attempts: number;
|
|
142
|
+
/** First failure timestamp. */
|
|
143
|
+
firstFailedAt: Date;
|
|
144
|
+
/** Last failure timestamp (immediately before dead-lettering). */
|
|
145
|
+
lastFailedAt: Date;
|
|
146
|
+
/** Optional handler / subscriber name that last failed (for debug). */
|
|
147
|
+
handlerName?: string;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Minimal logger interface for event transports. Compatible with `console`,
|
|
151
|
+
* `pino`, `fastify.log`, and any custom logger. Mirrors arc's `EventLogger`.
|
|
152
|
+
*/
|
|
153
|
+
interface EventLogger {
|
|
154
|
+
warn(message: string, ...args: unknown[]): void;
|
|
155
|
+
error(message: string, ...args: unknown[]): void;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Per-event publish outcome keyed by `meta.id`. `null` entry = success,
|
|
159
|
+
* `Error` = failure for that specific event. Matches arc's `publishMany`
|
|
160
|
+
* return contract.
|
|
161
|
+
*/
|
|
162
|
+
type PublishManyResult = ReadonlyMap<string, Error | null>;
|
|
163
|
+
/**
|
|
164
|
+
* Transport contract. Any object matching this shape — arc's
|
|
165
|
+
* `MemoryEventTransport`, a Redis-backed transport, a Kafka publisher, or an
|
|
166
|
+
* in-process bus — can be passed into a Classytic package as
|
|
167
|
+
* `config.eventTransport`.
|
|
168
|
+
*/
|
|
169
|
+
interface EventTransport {
|
|
170
|
+
readonly name: string;
|
|
171
|
+
publish(event: DomainEvent): Promise<void>;
|
|
172
|
+
/**
|
|
173
|
+
* Publish a batch of events in one round-trip (optional). Arc v2.8.1+
|
|
174
|
+
* transports implement this; outbox relays auto-detect it for higher
|
|
175
|
+
* throughput. Consumers must fall back to per-event `publish()` when
|
|
176
|
+
* absent.
|
|
177
|
+
*/
|
|
178
|
+
publishMany?(events: readonly DomainEvent[]): Promise<PublishManyResult>;
|
|
179
|
+
/**
|
|
180
|
+
* Subscribe to a glob pattern — exact match, `*`, or `resource.*`. Returns
|
|
181
|
+
* an unsubscribe function.
|
|
182
|
+
*/
|
|
183
|
+
subscribe(pattern: string, handler: EventHandler): Promise<() => void>;
|
|
184
|
+
/**
|
|
185
|
+
* Route a permanently-failed event to the transport's dead-letter sink
|
|
186
|
+
* (Kafka DLQ topic, SQS DLQ, Redis Stream `PEL` timeout handler, etc.).
|
|
187
|
+
*
|
|
188
|
+
* Called by outbox relays after exhausting retries. Transports that don't
|
|
189
|
+
* have a native DLQ can omit this — callers treat an absent `deadLetter`
|
|
190
|
+
* as "log and drop".
|
|
191
|
+
*/
|
|
192
|
+
deadLetter?(dlq: DeadLetteredEvent): Promise<void>;
|
|
193
|
+
close?(): Promise<void>;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Build a {@link DomainEvent} with auto-filled `id` + `timestamp`. All other
|
|
197
|
+
* `EventMeta` fields are caller-controlled and override the defaults if
|
|
198
|
+
* supplied (same semantics as arc's `createEvent`).
|
|
199
|
+
*/
|
|
200
|
+
declare function createEvent<T>(type: string, payload: T, meta?: Partial<EventMeta>): DomainEvent<T>;
|
|
201
|
+
/**
|
|
202
|
+
* Create a child event that chains causation from a parent event.
|
|
203
|
+
*
|
|
204
|
+
* Rules (mirrored from arc v2.9):
|
|
205
|
+
* - `causationId` is set to the parent's `id` (direct cause)
|
|
206
|
+
* - `correlationId` is inherited from the parent if set, else falls back
|
|
207
|
+
* to the parent's `id` (root correlation)
|
|
208
|
+
* - `userId` / `organizationId` / `source` / `idempotencyKey` are inherited
|
|
209
|
+
* when set on the parent so the whole chain stays scoped to the
|
|
210
|
+
* originating principal, tenant, emitter, and logical operation
|
|
211
|
+
* - `aggregate` is NOT inherited — child events typically belong to a
|
|
212
|
+
* different aggregate than their parent
|
|
213
|
+
*
|
|
214
|
+
* Caller-supplied `meta` wins over inherited fields — pass `{ userId: newActor }`
|
|
215
|
+
* to override when a subsystem acts on behalf of a different principal.
|
|
216
|
+
*
|
|
217
|
+
* @example
|
|
218
|
+
* const orderPlaced = createEvent('order.placed', { orderId: 'o1' }, {
|
|
219
|
+
* correlationId: req.id, userId: user.id,
|
|
220
|
+
* });
|
|
221
|
+
*
|
|
222
|
+
* const reserved = createChildEvent(orderPlaced, 'inventory.reserved', {
|
|
223
|
+
* orderId: 'o1', skus: ['sku-1', 'sku-2'],
|
|
224
|
+
* });
|
|
225
|
+
* // reserved.meta.causationId === orderPlaced.meta.id
|
|
226
|
+
* // reserved.meta.correlationId === orderPlaced.meta.correlationId
|
|
227
|
+
* // reserved.meta.userId === user.id (inherited)
|
|
228
|
+
*/
|
|
229
|
+
declare function createChildEvent<T>(parent: DomainEvent, type: string, payload: T, meta?: Partial<EventMeta>): DomainEvent<T>;
|
|
230
|
+
/**
|
|
231
|
+
* Match an event name against a glob pattern. Implements the same rules as
|
|
232
|
+
* arc's `MemoryEventTransport`:
|
|
233
|
+
* - `*` matches anything
|
|
234
|
+
* - `prefix.*` matches anything starting with `prefix.`
|
|
235
|
+
* - `prefix:*` matches anything starting with `prefix:` (Classytic convention)
|
|
236
|
+
* - exact match otherwise
|
|
237
|
+
*/
|
|
238
|
+
declare function matchEventPattern(pattern: string, type: string): boolean;
|
|
239
|
+
//#endregion
|
|
240
|
+
export { DeadLetteredEvent, DomainEvent, EventHandler, EventLogger, EventMeta, EventTransport, PublishManyResult, createChildEvent, createEvent, matchEventPattern };
|
package/dist/events.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
//#region src/events.ts
|
|
2
|
+
/**
|
|
3
|
+
* Build a {@link DomainEvent} with auto-filled `id` + `timestamp`. All other
|
|
4
|
+
* `EventMeta` fields are caller-controlled and override the defaults if
|
|
5
|
+
* supplied (same semantics as arc's `createEvent`).
|
|
6
|
+
*/
|
|
7
|
+
function createEvent(type, payload, meta) {
|
|
8
|
+
return {
|
|
9
|
+
type,
|
|
10
|
+
payload,
|
|
11
|
+
meta: {
|
|
12
|
+
id: randomEventId(),
|
|
13
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
14
|
+
...meta
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Create a child event that chains causation from a parent event.
|
|
20
|
+
*
|
|
21
|
+
* Rules (mirrored from arc v2.9):
|
|
22
|
+
* - `causationId` is set to the parent's `id` (direct cause)
|
|
23
|
+
* - `correlationId` is inherited from the parent if set, else falls back
|
|
24
|
+
* to the parent's `id` (root correlation)
|
|
25
|
+
* - `userId` / `organizationId` / `source` / `idempotencyKey` are inherited
|
|
26
|
+
* when set on the parent so the whole chain stays scoped to the
|
|
27
|
+
* originating principal, tenant, emitter, and logical operation
|
|
28
|
+
* - `aggregate` is NOT inherited — child events typically belong to a
|
|
29
|
+
* different aggregate than their parent
|
|
30
|
+
*
|
|
31
|
+
* Caller-supplied `meta` wins over inherited fields — pass `{ userId: newActor }`
|
|
32
|
+
* to override when a subsystem acts on behalf of a different principal.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* const orderPlaced = createEvent('order.placed', { orderId: 'o1' }, {
|
|
36
|
+
* correlationId: req.id, userId: user.id,
|
|
37
|
+
* });
|
|
38
|
+
*
|
|
39
|
+
* const reserved = createChildEvent(orderPlaced, 'inventory.reserved', {
|
|
40
|
+
* orderId: 'o1', skus: ['sku-1', 'sku-2'],
|
|
41
|
+
* });
|
|
42
|
+
* // reserved.meta.causationId === orderPlaced.meta.id
|
|
43
|
+
* // reserved.meta.correlationId === orderPlaced.meta.correlationId
|
|
44
|
+
* // reserved.meta.userId === user.id (inherited)
|
|
45
|
+
*/
|
|
46
|
+
function createChildEvent(parent, type, payload, meta) {
|
|
47
|
+
const inherited = {
|
|
48
|
+
correlationId: parent.meta.correlationId ?? parent.meta.id,
|
|
49
|
+
causationId: parent.meta.id
|
|
50
|
+
};
|
|
51
|
+
if (parent.meta.userId !== void 0) inherited.userId = parent.meta.userId;
|
|
52
|
+
if (parent.meta.organizationId !== void 0) inherited.organizationId = parent.meta.organizationId;
|
|
53
|
+
if (parent.meta.source !== void 0) inherited.source = parent.meta.source;
|
|
54
|
+
if (parent.meta.idempotencyKey !== void 0) inherited.idempotencyKey = parent.meta.idempotencyKey;
|
|
55
|
+
return {
|
|
56
|
+
type,
|
|
57
|
+
payload,
|
|
58
|
+
meta: {
|
|
59
|
+
id: randomEventId(),
|
|
60
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
61
|
+
...inherited,
|
|
62
|
+
...meta
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Match an event name against a glob pattern. Implements the same rules as
|
|
68
|
+
* arc's `MemoryEventTransport`:
|
|
69
|
+
* - `*` matches anything
|
|
70
|
+
* - `prefix.*` matches anything starting with `prefix.`
|
|
71
|
+
* - `prefix:*` matches anything starting with `prefix:` (Classytic convention)
|
|
72
|
+
* - exact match otherwise
|
|
73
|
+
*/
|
|
74
|
+
function matchEventPattern(pattern, type) {
|
|
75
|
+
if (pattern === "*") return true;
|
|
76
|
+
if (pattern.endsWith(".*")) return type.startsWith(pattern.slice(0, -1));
|
|
77
|
+
if (pattern.endsWith(":*")) return type.startsWith(pattern.slice(0, -1));
|
|
78
|
+
return pattern === type;
|
|
79
|
+
}
|
|
80
|
+
/** Minimal UUID-v4-style identifier. Uses `crypto.randomUUID` when available. */
|
|
81
|
+
function randomEventId() {
|
|
82
|
+
const g = globalThis;
|
|
83
|
+
if (g.crypto?.randomUUID) return g.crypto.randomUUID();
|
|
84
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
85
|
+
const r = Math.random() * 16 | 0;
|
|
86
|
+
return (c === "x" ? r : r & 3 | 8).toString(16);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
//#endregion
|
|
90
|
+
export { createChildEvent, createEvent, matchEventPattern };
|
package/dist/hold.d.mts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
//#region src/hold.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Hold reason value object + collection helpers.
|
|
4
|
+
*
|
|
5
|
+
* A `HoldReason` records that a domain document is blocked from progressing
|
|
6
|
+
* until the hold is resolved. The primitive is *pure data + pure helpers* —
|
|
7
|
+
* no FSM, no persistence, no events. Hosts embed `HoldReason[]` on their
|
|
8
|
+
* documents and use the helpers to add / resolve / query.
|
|
9
|
+
*
|
|
10
|
+
* Consumers: `@classytic/order` (fraud, manual, payment, credit, inventory
|
|
11
|
+
* holds), `@classytic/flow` (quality hold, customs hold), `@classytic/revenue`
|
|
12
|
+
* (chargeback hold).
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* import { addHold, resolveHold, activeHolds, isOnHold } from '@classytic/primitives/hold';
|
|
16
|
+
*
|
|
17
|
+
* let holds = addHold([], {
|
|
18
|
+
* code: 'credit_check_failed',
|
|
19
|
+
* note: 'customer exceeds $50k limit',
|
|
20
|
+
* actor: { id: 'system', kind: 'system' },
|
|
21
|
+
* });
|
|
22
|
+
* isOnHold(holds); // true
|
|
23
|
+
* holds = resolveHold(holds, holds[0].id, { actor: { id: 'user_42', kind: 'user' }, note: 'override approved' });
|
|
24
|
+
* isOnHold(holds); // false
|
|
25
|
+
*/
|
|
26
|
+
type HoldActorKind = 'user' | 'system' | 'service';
|
|
27
|
+
interface HoldActor {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly kind: HoldActorKind;
|
|
30
|
+
readonly name?: string;
|
|
31
|
+
}
|
|
32
|
+
interface HoldReason {
|
|
33
|
+
/**
|
|
34
|
+
* Unique identifier within the collection — used to resolve a specific hold
|
|
35
|
+
* when multiple are active. Generated by {@link addHold} if caller omits it.
|
|
36
|
+
*/
|
|
37
|
+
readonly id: string;
|
|
38
|
+
/** Domain-defined stable code, e.g. `fraud_review`, `customs_detention`. */
|
|
39
|
+
readonly code: string;
|
|
40
|
+
readonly note?: string;
|
|
41
|
+
readonly actor: HoldActor;
|
|
42
|
+
readonly createdAt: Date;
|
|
43
|
+
readonly resolvedAt?: Date;
|
|
44
|
+
readonly resolvedBy?: HoldActor;
|
|
45
|
+
readonly resolutionNote?: string;
|
|
46
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
47
|
+
}
|
|
48
|
+
type HoldErrorCode = 'UNKNOWN_HOLD_ID' | 'ALREADY_RESOLVED' | 'INVALID_CODE' | 'DUPLICATE_HOLD_ID';
|
|
49
|
+
declare class HoldError extends Error {
|
|
50
|
+
readonly name = "HoldError";
|
|
51
|
+
readonly code: HoldErrorCode;
|
|
52
|
+
constructor(code: HoldErrorCode, message: string);
|
|
53
|
+
}
|
|
54
|
+
interface AddHoldInput {
|
|
55
|
+
readonly id?: string;
|
|
56
|
+
readonly code: string;
|
|
57
|
+
readonly note?: string;
|
|
58
|
+
readonly actor: HoldActor;
|
|
59
|
+
readonly createdAt?: Date;
|
|
60
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
61
|
+
}
|
|
62
|
+
interface ResolveHoldInput {
|
|
63
|
+
readonly actor: HoldActor;
|
|
64
|
+
readonly note?: string;
|
|
65
|
+
readonly resolvedAt?: Date;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Add a new hold to the collection. Returns a new array — original is not
|
|
69
|
+
* mutated. `id` is generated if omitted (stable format: `hold_{epochMs}_{rand}`).
|
|
70
|
+
*/
|
|
71
|
+
declare function addHold(holds: readonly HoldReason[], input: AddHoldInput): readonly HoldReason[];
|
|
72
|
+
/**
|
|
73
|
+
* Resolve a specific hold by id. Throws if the id is unknown or the hold is
|
|
74
|
+
* already resolved.
|
|
75
|
+
*/
|
|
76
|
+
declare function resolveHold(holds: readonly HoldReason[], id: string, input: ResolveHoldInput): readonly HoldReason[];
|
|
77
|
+
/** All holds that have not yet been resolved. */
|
|
78
|
+
declare function activeHolds(holds: readonly HoldReason[]): readonly HoldReason[];
|
|
79
|
+
/** True if at least one active (unresolved) hold exists. */
|
|
80
|
+
declare function isOnHold(holds: readonly HoldReason[]): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* True if at least one active hold matches the given code. Useful for checking
|
|
83
|
+
* for a specific class of hold (e.g., "is this order on a fraud hold?").
|
|
84
|
+
*/
|
|
85
|
+
declare function hasActiveHoldOfCode(holds: readonly HoldReason[], code: string): boolean;
|
|
86
|
+
/** All resolved holds — useful for audit timelines. */
|
|
87
|
+
declare function resolvedHolds(holds: readonly HoldReason[]): readonly HoldReason[];
|
|
88
|
+
//#endregion
|
|
89
|
+
export { AddHoldInput, HoldActor, HoldActorKind, HoldError, HoldErrorCode, HoldReason, ResolveHoldInput, activeHolds, addHold, hasActiveHoldOfCode, isOnHold, resolveHold, resolvedHolds };
|
package/dist/hold.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
//#region src/hold.ts
|
|
2
|
+
var HoldError = class extends Error {
|
|
3
|
+
name = "HoldError";
|
|
4
|
+
code;
|
|
5
|
+
constructor(code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Add a new hold to the collection. Returns a new array — original is not
|
|
12
|
+
* mutated. `id` is generated if omitted (stable format: `hold_{epochMs}_{rand}`).
|
|
13
|
+
*/
|
|
14
|
+
function addHold(holds, input) {
|
|
15
|
+
if (!input.code || input.code.trim() === "") throw new HoldError("INVALID_CODE", "hold code must be a non-empty string");
|
|
16
|
+
const id = input.id ?? generateHoldId();
|
|
17
|
+
if (holds.some((h) => h.id === id)) throw new HoldError("DUPLICATE_HOLD_ID", `hold id already present: ${id}`);
|
|
18
|
+
const hold = {
|
|
19
|
+
id,
|
|
20
|
+
code: input.code,
|
|
21
|
+
...input.note !== void 0 ? { note: input.note } : {},
|
|
22
|
+
actor: input.actor,
|
|
23
|
+
createdAt: input.createdAt ?? /* @__PURE__ */ new Date(),
|
|
24
|
+
...input.metadata !== void 0 ? { metadata: input.metadata } : {}
|
|
25
|
+
};
|
|
26
|
+
return [...holds, hold];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a specific hold by id. Throws if the id is unknown or the hold is
|
|
30
|
+
* already resolved.
|
|
31
|
+
*/
|
|
32
|
+
function resolveHold(holds, id, input) {
|
|
33
|
+
const index = holds.findIndex((h) => h.id === id);
|
|
34
|
+
if (index === -1) throw new HoldError("UNKNOWN_HOLD_ID", `no hold with id ${id}`);
|
|
35
|
+
const hold = holds[index];
|
|
36
|
+
if (!hold) throw new HoldError("UNKNOWN_HOLD_ID", `no hold with id ${id}`);
|
|
37
|
+
if (hold.resolvedAt !== void 0) throw new HoldError("ALREADY_RESOLVED", `hold ${id} is already resolved`);
|
|
38
|
+
const resolved = {
|
|
39
|
+
...hold,
|
|
40
|
+
resolvedAt: input.resolvedAt ?? /* @__PURE__ */ new Date(),
|
|
41
|
+
resolvedBy: input.actor,
|
|
42
|
+
...input.note !== void 0 ? { resolutionNote: input.note } : {}
|
|
43
|
+
};
|
|
44
|
+
return holds.map((h, i) => i === index ? resolved : h);
|
|
45
|
+
}
|
|
46
|
+
/** All holds that have not yet been resolved. */
|
|
47
|
+
function activeHolds(holds) {
|
|
48
|
+
return holds.filter((h) => h.resolvedAt === void 0);
|
|
49
|
+
}
|
|
50
|
+
/** True if at least one active (unresolved) hold exists. */
|
|
51
|
+
function isOnHold(holds) {
|
|
52
|
+
return holds.some((h) => h.resolvedAt === void 0);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* True if at least one active hold matches the given code. Useful for checking
|
|
56
|
+
* for a specific class of hold (e.g., "is this order on a fraud hold?").
|
|
57
|
+
*/
|
|
58
|
+
function hasActiveHoldOfCode(holds, code) {
|
|
59
|
+
return holds.some((h) => h.resolvedAt === void 0 && h.code === code);
|
|
60
|
+
}
|
|
61
|
+
/** All resolved holds — useful for audit timelines. */
|
|
62
|
+
function resolvedHolds(holds) {
|
|
63
|
+
return holds.filter((h) => h.resolvedAt !== void 0);
|
|
64
|
+
}
|
|
65
|
+
function generateHoldId() {
|
|
66
|
+
return `hold_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
67
|
+
}
|
|
68
|
+
//#endregion
|
|
69
|
+
export { HoldError, activeHolds, addHold, hasActiveHoldOfCode, isOnHold, resolveHold, resolvedHolds };
|
package/dist/money.d.mts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { CurrencyCode } from "./currency.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/money.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* A monetary amount stored as an integer in the currency's minor unit.
|
|
6
|
+
*
|
|
7
|
+
* Examples:
|
|
8
|
+
* - `{ amount: 19_99, currency: 'USD' }` — 19.99 USD (2 decimals)
|
|
9
|
+
* - `{ amount: 1_500, currency: 'JPY' }` — 1500 JPY (0 decimals)
|
|
10
|
+
* - `{ amount: 500_000, currency: 'KWD' }` — 500.000 KWD (3 decimals)
|
|
11
|
+
*
|
|
12
|
+
* Storing as integers avoids IEEE 754 rounding across arithmetic. Never store
|
|
13
|
+
* `Money.amount` as a float.
|
|
14
|
+
*/
|
|
15
|
+
interface Money {
|
|
16
|
+
readonly amount: number;
|
|
17
|
+
readonly currency: CurrencyCode | string;
|
|
18
|
+
}
|
|
19
|
+
declare class CurrencyMismatchError extends Error {
|
|
20
|
+
readonly name = "CurrencyMismatchError";
|
|
21
|
+
readonly left: string;
|
|
22
|
+
readonly right: string;
|
|
23
|
+
constructor(left: string, right: string);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Construct {@link Money} from an integer minor-unit amount.
|
|
27
|
+
*
|
|
28
|
+
* @throws TypeError if `amount` is not a finite integer.
|
|
29
|
+
*/
|
|
30
|
+
declare function money(amount: number, currency: string): Money;
|
|
31
|
+
/**
|
|
32
|
+
* Construct {@link Money} from a major-unit number (e.g. dollars, taka) by
|
|
33
|
+
* scaling to the currency's minor unit and rounding half-away-from-zero.
|
|
34
|
+
*
|
|
35
|
+
* Uses `toPrecision(15)` to strip the trailing-bit noise that appears in
|
|
36
|
+
* multiply-then-round (e.g. `0.1 + 0.2 = 0.30000000000000004`). This is
|
|
37
|
+
* enough for the vast majority of decimals encountered in real-world pricing.
|
|
38
|
+
*
|
|
39
|
+
* **Precision caveat.** A handful of decimals cannot be represented exactly
|
|
40
|
+
* in IEEE 754 and will round to the IEEE-closest integer rather than the
|
|
41
|
+
* mathematically-expected one — e.g. `1.005` is stored as `1.00499999…` and
|
|
42
|
+
* rounds to `100` minor units, not `101`. If you need decimal-exact rounding,
|
|
43
|
+
* construct money from a string or use integer minor units directly via
|
|
44
|
+
* {@link money}.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* fromMajor(19.99, 'USD') // { amount: 1999, currency: 'USD' }
|
|
48
|
+
* fromMajor(0.1 + 0.2, 'USD') // { amount: 30, currency: 'USD' }
|
|
49
|
+
* fromMajor(1500, 'JPY') // { amount: 1500, currency: 'JPY' }
|
|
50
|
+
*/
|
|
51
|
+
declare function fromMajor(major: number, currency: string): Money;
|
|
52
|
+
/** Convert {@link Money} back to its major-unit floating-point representation. */
|
|
53
|
+
declare function toMajor(m: Money): number;
|
|
54
|
+
declare function addMoney(a: Money, b: Money): Money;
|
|
55
|
+
declare function subtractMoney(a: Money, b: Money): Money;
|
|
56
|
+
/**
|
|
57
|
+
* Multiply money by a scalar. The result is rounded to an integer number of
|
|
58
|
+
* minor units using half-away-from-zero rounding.
|
|
59
|
+
*/
|
|
60
|
+
declare function multiplyMoney(m: Money, scalar: number): Money;
|
|
61
|
+
/** Sum a list of {@link Money}. Returns zero in the given currency for empty input. */
|
|
62
|
+
declare function sumMoney(values: readonly Money[], currency: string): Money;
|
|
63
|
+
/** Strict equality (amount and currency both match). */
|
|
64
|
+
declare function equalsMoney(a: Money, b: Money): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Ordering predicate. Returns -1, 0, or 1. Throws on currency mismatch — use
|
|
67
|
+
* {@link equalsMoney} for a total equality check across currencies.
|
|
68
|
+
*/
|
|
69
|
+
declare function compareMoney(a: Money, b: Money): -1 | 0 | 1;
|
|
70
|
+
declare function isZeroMoney(m: Money): boolean;
|
|
71
|
+
declare function isPositiveMoney(m: Money): boolean;
|
|
72
|
+
declare function isNegativeMoney(m: Money): boolean;
|
|
73
|
+
declare function negateMoney(m: Money): Money;
|
|
74
|
+
declare function absMoney(m: Money): Money;
|
|
75
|
+
/** Structural type guard. */
|
|
76
|
+
declare function isMoney(value: unknown): value is Money;
|
|
77
|
+
//#endregion
|
|
78
|
+
export { CurrencyMismatchError, Money, absMoney, addMoney, compareMoney, equalsMoney, fromMajor, isMoney, isNegativeMoney, isPositiveMoney, isZeroMoney, money, multiplyMoney, negateMoney, subtractMoney, sumMoney, toMajor };
|