@classytic/primitives 0.5.0 → 0.6.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 +72 -0
- package/dist/address.d.mts +1 -1
- package/dist/address.mjs +1 -1
- package/dist/approval.d.mts +1 -1
- package/dist/approval.mjs +1 -1
- package/dist/bank-transaction.d.mts +1 -1
- package/dist/{brand-DApaegoR.d.mts → brand-LLXqoh5J.d.mts} +1 -1
- package/dist/brand.d.mts +1 -1
- package/dist/cadence.d.mts +1 -1
- package/dist/cadence.mjs +1 -1
- package/dist/condition.d.mts +76 -0
- package/dist/condition.mjs +131 -0
- package/dist/context.d.mts +1 -1
- package/dist/{currency-rgUAn5KM.d.mts → currency-B4rSdN7D.d.mts} +2 -2
- package/dist/currency.d.mts +1 -1
- package/dist/currency.mjs +1 -1
- package/dist/events.d.mts +1 -1
- package/dist/events.mjs +1 -1
- package/dist/hold.d.mts +1 -1
- package/dist/hold.mjs +1 -1
- package/dist/mixin.d.mts +73 -0
- package/dist/mixin.mjs +47 -0
- package/dist/money.d.mts +2 -2
- package/dist/money.mjs +1 -1
- package/dist/outbox.d.mts +1 -1
- package/dist/outbox.mjs +1 -1
- package/dist/payment-gateway.d.mts +1 -1
- package/dist/period.d.mts +1 -1
- package/dist/period.mjs +1 -1
- package/dist/person.d.mts +1 -1
- package/dist/person.mjs +1 -1
- package/dist/phone.d.mts +47 -0
- package/dist/phone.mjs +310 -0
- package/dist/reference.d.mts +1 -1
- package/dist/reference.mjs +1 -1
- package/dist/result.d.mts +1 -1
- package/dist/result.mjs +1 -1
- package/dist/sla-policy.d.mts +116 -0
- package/dist/sla-policy.mjs +120 -0
- package/dist/sla.d.mts +1 -1
- package/dist/sla.mjs +1 -1
- package/dist/split-allocation.d.mts +1 -1
- package/dist/split-allocation.mjs +1 -1
- package/dist/state-machine.d.mts +1 -1
- package/dist/state-machine.mjs +1 -1
- package/dist/status-history.d.mts +86 -0
- package/dist/status-history.mjs +48 -0
- package/package.json +21 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,78 @@
|
|
|
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.6.0] - 2026-05-17
|
|
7
|
+
|
|
8
|
+
### Added — five new primitives for CRM / workflow domains
|
|
9
|
+
|
|
10
|
+
These were extracted after deep-diving Twenty, Huly Platform, Frappe CRM,
|
|
11
|
+
and NocoBase. Each fills a gap the existing primitives didn't cover, and
|
|
12
|
+
each ships under its own flat subpath import (no breaking changes).
|
|
13
|
+
|
|
14
|
+
- **`/phone`** — `PhoneNumber` value object + `parsePhone(input)` that
|
|
15
|
+
normalizes free-form input to E.164, captures the country calling code
|
|
16
|
+
as a discrete field, and reports failures via `Result<PhoneNumber,
|
|
17
|
+
PhoneError>`. Zero external deps; ITU-T E.164 prefixes inlined. Hosts
|
|
18
|
+
that want region-of-issue / line-type detection layer on top with
|
|
19
|
+
libphonenumber-js.
|
|
20
|
+
|
|
21
|
+
- **`/status-history`** — Generic, immutable status-change log:
|
|
22
|
+
`StatusChangeEntry<TStatus>`, `appendStatus()`, `timeInStatus()`,
|
|
23
|
+
`lastTransitionTo()`. Captures `durationInPriorMs` at write time
|
|
24
|
+
(Frappe's "status change log" pattern), so funnel-velocity dashboards
|
|
25
|
+
("avg time in Qualification") work without re-walking timestamps.
|
|
26
|
+
Pairs naturally with `/state-machine`.
|
|
27
|
+
|
|
28
|
+
- **`/condition`** — Tiny JSON-serializable predicate DSL: `FieldCondition`
|
|
29
|
+
(`{ field, op, value }`) composed via `AllCondition` / `AnyCondition`
|
|
30
|
+
/ `NotCondition`, plus a total `evaluate(condition, target)` and a
|
|
31
|
+
`validateCondition()` for boot-time schema checks. Powers SLA scope
|
|
32
|
+
filters, drip "if opened/clicked" branches, automation triggers, and
|
|
33
|
+
permission row-scopes — anywhere a predicate needs to round-trip
|
|
34
|
+
through a database.
|
|
35
|
+
|
|
36
|
+
- **`/mixin`** — Additive type composition without subclassing:
|
|
37
|
+
`withMixin<T, K, M>()` / `getMixin()` / `hasMixin()` / `withoutMixin()`.
|
|
38
|
+
Lets a `Contact` carry per-domain payloads (`mixins.customer`,
|
|
39
|
+
`mixins.lead-fit`) on a typed `mixins` namespace without bloating the
|
|
40
|
+
base type. Distilled from Huly's mixin idea minus the decorators,
|
|
41
|
+
global hierarchy, and proxies.
|
|
42
|
+
|
|
43
|
+
- **`/sla-policy`** — Higher-order SLA layered on the existing `/sla`:
|
|
44
|
+
priority matrix (`urgent` / `high` / `normal` → durations), first-
|
|
45
|
+
response vs rolling-response semantics, and working-hours window
|
|
46
|
+
(weekdays + start/end minute + holidays). `defineSLAPolicy()` validates
|
|
47
|
+
at boot; `evaluateSLAStatus()` produces `{ kind, responseBy,
|
|
48
|
+
remainingMs, breached }` from a policy + per-entity inputs. The
|
|
49
|
+
existing simple `/sla` interface is unchanged — `sla-policy` derives
|
|
50
|
+
concrete `SLA` instances from a policy + priority and delegates to it.
|
|
51
|
+
|
|
52
|
+
### Changed — source-tree reorganization (no public-surface impact)
|
|
53
|
+
|
|
54
|
+
The 24 primitive source files moved from a flat `src/*.ts` layout into
|
|
55
|
+
six category folders so the package is self-documenting at the file
|
|
56
|
+
system level:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
src/
|
|
60
|
+
money/ money, currency, split-allocation, bank-transaction, payment-gateway
|
|
61
|
+
identity/ person, address, phone
|
|
62
|
+
scheduling/ period, cadence, sla, sla-policy
|
|
63
|
+
workflow/ state-machine, status-history, approval, hold, condition
|
|
64
|
+
events/ events, outbox
|
|
65
|
+
composition/ mixin, reference, context, brand, result
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Public subpath imports are unchanged** — `@classytic/primitives/money`,
|
|
69
|
+
`@classytic/primitives/sla`, etc. still resolve. tsdown emits each entry
|
|
70
|
+
to `dist/<name>.mjs` regardless of source nesting. Existing consumers
|
|
71
|
+
upgrade with `npm install @classytic/primitives@^0.6.0` and a no-op
|
|
72
|
+
diff.
|
|
73
|
+
|
|
74
|
+
### Dependencies
|
|
75
|
+
|
|
76
|
+
Still zero runtime deps. Node 22+.
|
|
77
|
+
|
|
6
78
|
## [0.5.0] - 2026-05-05
|
|
7
79
|
|
|
8
80
|
### Added — `/bank-transaction` subpath
|
package/dist/address.d.mts
CHANGED
package/dist/address.mjs
CHANGED
package/dist/approval.d.mts
CHANGED
package/dist/approval.mjs
CHANGED
package/dist/brand.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as NonEmptyArray, c as Prettify, i as KeysMatching, l as RequireKeys, n as DeepPartial, o as Nullable, r as DeepReadonly, s as OptionalKeys, t as Brand } from "./brand-
|
|
1
|
+
import { a as NonEmptyArray, c as Prettify, i as KeysMatching, l as RequireKeys, n as DeepPartial, o as Nullable, r as DeepReadonly, s as OptionalKeys, t as Brand } from "./brand-LLXqoh5J.mjs";
|
|
2
2
|
export { Brand, DeepPartial, DeepReadonly, KeysMatching, NonEmptyArray, Nullable, OptionalKeys, Prettify, RequireKeys };
|
package/dist/cadence.d.mts
CHANGED
package/dist/cadence.mjs
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
//#region src/workflow/condition.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Tiny JSON-serializable predicate DSL + evaluator.
|
|
4
|
+
*
|
|
5
|
+
* Hosts need to express "when this is true, do that" rules in places that
|
|
6
|
+
* can't carry code — SLA scope filters, drip-sequence branches ("if opened
|
|
7
|
+
* within 24h"), automation triggers, permission row-scopes. A 60-line
|
|
8
|
+
* predicate spec covers every realistic case better than a hand-rolled
|
|
9
|
+
* SQL-like grammar.
|
|
10
|
+
*
|
|
11
|
+
* Two layers:
|
|
12
|
+
* 1. `Condition` — pure data. JSON-safe. Round-trips through DB.
|
|
13
|
+
* 2. `evaluate(condition, value)` — pure function. Walks a dotted-path
|
|
14
|
+
* expression on `value`, applies the comparator, returns boolean.
|
|
15
|
+
*
|
|
16
|
+
* No regex parsing, no AST tooling, no string interpolation — the grammar
|
|
17
|
+
* is structurally typed and the evaluator is a 30-line switch.
|
|
18
|
+
*
|
|
19
|
+
* @example Single comparison
|
|
20
|
+
* evaluate({ field: 'status', op: 'eq', value: 'won' }, opportunity);
|
|
21
|
+
*
|
|
22
|
+
* @example Composite — drip "if opened in last 7d AND not clicked"
|
|
23
|
+
* evaluate(
|
|
24
|
+
* {
|
|
25
|
+
* all: [
|
|
26
|
+
* { field: 'lastOpenedAt', op: 'gte', value: sevenDaysAgo },
|
|
27
|
+
* { field: 'lastClickedAt', op: 'isNull' },
|
|
28
|
+
* ],
|
|
29
|
+
* },
|
|
30
|
+
* contactEngagement,
|
|
31
|
+
* );
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* Comparators. Kept small — every operator here has a single, unambiguous
|
|
35
|
+
* semantic across types. `in` accepts an array; `contains` accepts a single
|
|
36
|
+
* scalar against an array field; `startsWith`/`endsWith` are string-only;
|
|
37
|
+
* `isNull` / `isNotNull` have no `value`.
|
|
38
|
+
*/
|
|
39
|
+
type Comparator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'contains' | 'startsWith' | 'endsWith' | 'isNull' | 'isNotNull';
|
|
40
|
+
/** Atomic predicate: extract `field` from the target, compare to `value`. */
|
|
41
|
+
interface FieldCondition {
|
|
42
|
+
/** Dotted path: `'contact.email'`, `'metadata.utm.source'`, etc. */
|
|
43
|
+
readonly field: string;
|
|
44
|
+
readonly op: Comparator;
|
|
45
|
+
/** Comparand. Omitted for `isNull` / `isNotNull`. */
|
|
46
|
+
readonly value?: unknown;
|
|
47
|
+
}
|
|
48
|
+
/** All branches must be true. */
|
|
49
|
+
interface AllCondition {
|
|
50
|
+
readonly all: readonly Condition[];
|
|
51
|
+
}
|
|
52
|
+
/** Any branch true short-circuits true. */
|
|
53
|
+
interface AnyCondition {
|
|
54
|
+
readonly any: readonly Condition[];
|
|
55
|
+
}
|
|
56
|
+
/** Inverts the wrapped condition. */
|
|
57
|
+
interface NotCondition {
|
|
58
|
+
readonly not: Condition;
|
|
59
|
+
}
|
|
60
|
+
type Condition = FieldCondition | AllCondition | AnyCondition | NotCondition;
|
|
61
|
+
/** Evaluate a `Condition` against `target`. Total — never throws on shape. */
|
|
62
|
+
declare function evaluate(condition: Condition, target: unknown): boolean;
|
|
63
|
+
type ConditionErrorCode = 'EMPTY' | 'UNKNOWN_SHAPE' | 'INVALID_OP' | 'MISSING_VALUE' | 'EMPTY_GROUP';
|
|
64
|
+
declare class ConditionError extends Error {
|
|
65
|
+
readonly name = "ConditionError";
|
|
66
|
+
readonly code: ConditionErrorCode;
|
|
67
|
+
constructor(code: ConditionErrorCode, message: string);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Validate a condition shape before persisting. Recurses through composites.
|
|
71
|
+
* Throws `ConditionError` so hosts get a stable code + message in API
|
|
72
|
+
* responses (`{ code: 'INVALID_OP', message: '...' }`).
|
|
73
|
+
*/
|
|
74
|
+
declare function validateCondition(condition: Condition): void;
|
|
75
|
+
//#endregion
|
|
76
|
+
export { AllCondition, AnyCondition, Comparator, Condition, ConditionError, ConditionErrorCode, FieldCondition, NotCondition, evaluate, validateCondition };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
//#region src/workflow/condition.ts
|
|
2
|
+
function isFieldCondition(c) {
|
|
3
|
+
return typeof c.field === "string" && typeof c.op === "string";
|
|
4
|
+
}
|
|
5
|
+
function isAllCondition(c) {
|
|
6
|
+
return Array.isArray(c.all);
|
|
7
|
+
}
|
|
8
|
+
function isAnyCondition(c) {
|
|
9
|
+
return Array.isArray(c.any);
|
|
10
|
+
}
|
|
11
|
+
function isNotCondition(c) {
|
|
12
|
+
return typeof c.not === "object" && c.not !== null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Walk a dotted path on `target`. Returns `undefined` when any segment
|
|
16
|
+
* doesn't exist. Treats `null` as a terminal (doesn't try to recurse on it).
|
|
17
|
+
*/
|
|
18
|
+
function getPath(target, path) {
|
|
19
|
+
if (path === "" || target === null || target === void 0) return void 0;
|
|
20
|
+
let cursor = target;
|
|
21
|
+
for (const segment of path.split(".")) {
|
|
22
|
+
if (cursor === null || cursor === void 0) return void 0;
|
|
23
|
+
if (typeof cursor !== "object") return void 0;
|
|
24
|
+
cursor = cursor[segment];
|
|
25
|
+
}
|
|
26
|
+
return cursor;
|
|
27
|
+
}
|
|
28
|
+
/** Evaluate a `Condition` against `target`. Total — never throws on shape. */
|
|
29
|
+
function evaluate(condition, target) {
|
|
30
|
+
if (isAllCondition(condition)) return condition.all.every((c) => evaluate(c, target));
|
|
31
|
+
if (isAnyCondition(condition)) return condition.any.some((c) => evaluate(c, target));
|
|
32
|
+
if (isNotCondition(condition)) return !evaluate(condition.not, target);
|
|
33
|
+
if (isFieldCondition(condition)) return evaluateField(condition, target);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
function evaluateField(c, target) {
|
|
37
|
+
const lhs = getPath(target, c.field);
|
|
38
|
+
const rhs = c.value;
|
|
39
|
+
switch (c.op) {
|
|
40
|
+
case "eq": return strictEqual(lhs, rhs);
|
|
41
|
+
case "neq": return !strictEqual(lhs, rhs);
|
|
42
|
+
case "gt": return compare(lhs, rhs) > 0;
|
|
43
|
+
case "gte": return compare(lhs, rhs) >= 0;
|
|
44
|
+
case "lt": return compare(lhs, rhs) < 0;
|
|
45
|
+
case "lte": return compare(lhs, rhs) <= 0;
|
|
46
|
+
case "in": return Array.isArray(rhs) && rhs.some((v) => strictEqual(lhs, v));
|
|
47
|
+
case "nin": return Array.isArray(rhs) && !rhs.some((v) => strictEqual(lhs, v));
|
|
48
|
+
case "contains": return Array.isArray(lhs) && lhs.some((v) => strictEqual(v, rhs));
|
|
49
|
+
case "startsWith": return typeof lhs === "string" && typeof rhs === "string" && lhs.startsWith(rhs);
|
|
50
|
+
case "endsWith": return typeof lhs === "string" && typeof rhs === "string" && lhs.endsWith(rhs);
|
|
51
|
+
case "isNull": return lhs === null || lhs === void 0;
|
|
52
|
+
case "isNotNull": return lhs !== null && lhs !== void 0;
|
|
53
|
+
default: return ((_) => false)(c.op);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Equality with `Date` awareness. `===` on two `Date` objects with the same
|
|
58
|
+
* millis returns `false`; this normalizes them. Plain primitives use `===`.
|
|
59
|
+
*/
|
|
60
|
+
function strictEqual(a, b) {
|
|
61
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
|
62
|
+
if (a instanceof Date && typeof b === "string") return a.toISOString() === b;
|
|
63
|
+
if (typeof a === "string" && b instanceof Date) return a === b.toISOString();
|
|
64
|
+
return a === b;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Numeric / chronological comparison. Returns `NaN` (which `compare()`
|
|
68
|
+
* callers handle by returning false for gt/lt/gte/lte) when operands aren't
|
|
69
|
+
* comparable. Dates are compared by their unix timestamp.
|
|
70
|
+
*/
|
|
71
|
+
function compare(a, b) {
|
|
72
|
+
const av = a instanceof Date ? a.getTime() : a;
|
|
73
|
+
const bv = b instanceof Date ? b.getTime() : b;
|
|
74
|
+
if (typeof av === "number" && typeof bv === "number") return av - bv;
|
|
75
|
+
if (typeof av === "string" && typeof bv === "string") return av < bv ? -1 : av > bv ? 1 : 0;
|
|
76
|
+
return NaN;
|
|
77
|
+
}
|
|
78
|
+
var ConditionError = class extends Error {
|
|
79
|
+
name = "ConditionError";
|
|
80
|
+
code;
|
|
81
|
+
constructor(code, message) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.code = code;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const COMPARATORS = new Set([
|
|
87
|
+
"eq",
|
|
88
|
+
"neq",
|
|
89
|
+
"gt",
|
|
90
|
+
"gte",
|
|
91
|
+
"lt",
|
|
92
|
+
"lte",
|
|
93
|
+
"in",
|
|
94
|
+
"nin",
|
|
95
|
+
"contains",
|
|
96
|
+
"startsWith",
|
|
97
|
+
"endsWith",
|
|
98
|
+
"isNull",
|
|
99
|
+
"isNotNull"
|
|
100
|
+
]);
|
|
101
|
+
const UNARY_OPS = new Set(["isNull", "isNotNull"]);
|
|
102
|
+
/**
|
|
103
|
+
* Validate a condition shape before persisting. Recurses through composites.
|
|
104
|
+
* Throws `ConditionError` so hosts get a stable code + message in API
|
|
105
|
+
* responses (`{ code: 'INVALID_OP', message: '...' }`).
|
|
106
|
+
*/
|
|
107
|
+
function validateCondition(condition) {
|
|
108
|
+
if (condition === null || typeof condition !== "object") throw new ConditionError("EMPTY", "condition is null or not an object");
|
|
109
|
+
if (isAllCondition(condition)) {
|
|
110
|
+
if (condition.all.length === 0) throw new ConditionError("EMPTY_GROUP", "all-group must have at least one branch");
|
|
111
|
+
condition.all.forEach(validateCondition);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (isAnyCondition(condition)) {
|
|
115
|
+
if (condition.any.length === 0) throw new ConditionError("EMPTY_GROUP", "any-group must have at least one branch");
|
|
116
|
+
condition.any.forEach(validateCondition);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (isNotCondition(condition)) {
|
|
120
|
+
validateCondition(condition.not);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (isFieldCondition(condition)) {
|
|
124
|
+
if (!COMPARATORS.has(condition.op)) throw new ConditionError("INVALID_OP", `unknown comparator: ${JSON.stringify(condition.op)}`);
|
|
125
|
+
if (!UNARY_OPS.has(condition.op) && condition.value === void 0) throw new ConditionError("MISSING_VALUE", `comparator '${condition.op}' requires a value`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
throw new ConditionError("UNKNOWN_SHAPE", `condition must be a field/all/any/not — got: ${JSON.stringify(condition)}`);
|
|
129
|
+
}
|
|
130
|
+
//#endregion
|
|
131
|
+
export { ConditionError, evaluate, validateCondition };
|
package/dist/context.d.mts
CHANGED
package/dist/currency.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as convertWithSnapshot, c as minorUnitFactor, i as MINOR_UNIT_FACTOR, l as reverseWithSnapshot, n as CurrencyCode, o as isCurrencyCode, r as FxSnapshot, s as isFxSnapshot, t as CURRENCIES, u as toCurrencyCode } from "./currency-
|
|
1
|
+
import { a as convertWithSnapshot, c as minorUnitFactor, i as MINOR_UNIT_FACTOR, l as reverseWithSnapshot, n as CurrencyCode, o as isCurrencyCode, r as FxSnapshot, s as isFxSnapshot, t as CURRENCIES, u as toCurrencyCode } from "./currency-B4rSdN7D.mjs";
|
|
2
2
|
export { CURRENCIES, CurrencyCode, FxSnapshot, MINOR_UNIT_FACTOR, convertWithSnapshot, isCurrencyCode, isFxSnapshot, minorUnitFactor, reverseWithSnapshot, toCurrencyCode };
|
package/dist/currency.mjs
CHANGED
package/dist/events.d.mts
CHANGED
package/dist/events.mjs
CHANGED
package/dist/hold.d.mts
CHANGED
package/dist/hold.mjs
CHANGED
package/dist/mixin.d.mts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
//#region src/composition/mixin.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Additive composition for domain entities — without subclassing.
|
|
4
|
+
*
|
|
5
|
+
* Most mixin system (good idea, heavy implementation) lets a `Contact`
|
|
6
|
+
* become a `Contact & Customer` without rewriting the type or copying
|
|
7
|
+
* fields. We replicate the GOOD bit (additive type composition stored as
|
|
8
|
+
* nested data) with none of the bad bits (no decorators, no global class
|
|
9
|
+
* registry, no proxies).
|
|
10
|
+
*
|
|
11
|
+
* Pattern: store mixin payloads on a known well-typed key (`mixins.customer`,
|
|
12
|
+
* `mixins.lead-fit`, …) instead of spreading them onto the entity root. This
|
|
13
|
+
* keeps the base type stable and lets each mixin own its own keyspace —
|
|
14
|
+
* additive schema evolution without breaking exhaustiveness.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* import { withMixin, getMixin, hasMixin } from '@classytic/primitives/mixin';
|
|
18
|
+
*
|
|
19
|
+
* interface Contact { id: string; name: string; mixins?: Record<string, unknown>; }
|
|
20
|
+
* interface CustomerFacts { lifetimeValue: number; firstOrderAt: Date; }
|
|
21
|
+
*
|
|
22
|
+
* const c: Contact = { id: 'c1', name: 'Acme' };
|
|
23
|
+
* const enriched = withMixin<Contact, 'customer', CustomerFacts>(c, 'customer', {
|
|
24
|
+
* lifetimeValue: 9200,
|
|
25
|
+
* firstOrderAt: new Date('2024-09-12'),
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* getMixin<CustomerFacts>(enriched, 'customer')?.lifetimeValue; // 9200
|
|
29
|
+
* hasMixin(enriched, 'customer'); // true
|
|
30
|
+
*
|
|
31
|
+
* // Domain stays exhaustive — the base type didn't change, only the
|
|
32
|
+
* // optional `mixins` namespace did.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Marker interface — entities that participate in the mixin protocol declare
|
|
36
|
+
* `mixins?: Mixinable['mixins']`. Hosts can opt in retroactively by widening
|
|
37
|
+
* their existing types via intersection.
|
|
38
|
+
*/
|
|
39
|
+
interface Mixinable {
|
|
40
|
+
readonly mixins?: Readonly<Record<string, unknown>>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Result type: the base `T` with `mixins[K]` typed as `M`. The intersection
|
|
44
|
+
* keeps callers exhaustive over the original entity while exposing the
|
|
45
|
+
* mixin payload through a well-typed accessor.
|
|
46
|
+
*/
|
|
47
|
+
type WithMixin<T, K extends string, M> = T & {
|
|
48
|
+
readonly mixins: Readonly<Record<string, unknown>> & { readonly [P in K]: M };
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Attach (or overwrite) a mixin on a base entity. Returns a NEW object —
|
|
52
|
+
* never mutates. The other mixins on the entity are preserved by spread.
|
|
53
|
+
*
|
|
54
|
+
* Type-level: caller picks the mixin key and the mixin payload shape; we
|
|
55
|
+
* intersect them into the return type so chained reads stay precise.
|
|
56
|
+
*/
|
|
57
|
+
declare function withMixin<T extends Mixinable, K extends string, M>(base: T, key: K, data: M): WithMixin<T, K, M>;
|
|
58
|
+
/**
|
|
59
|
+
* Pull a mixin payload off an entity by key. Returns `null` (not `undefined`)
|
|
60
|
+
* so call sites can use `??` to fall through to a default without ambiguity
|
|
61
|
+
* with a real `undefined` field on the payload itself.
|
|
62
|
+
*/
|
|
63
|
+
declare function getMixin<M>(entity: Mixinable, key: string): M | null;
|
|
64
|
+
/** Cheap presence check — useful for `if (hasMixin(c, 'customer')) …` branches. */
|
|
65
|
+
declare function hasMixin(entity: Mixinable, key: string): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Remove a mixin. Returns a new entity. No-op if the mixin isn't present.
|
|
68
|
+
* Useful for migrations / GDPR scrubs ("remove `customer` payload but keep
|
|
69
|
+
* the base `Contact` row").
|
|
70
|
+
*/
|
|
71
|
+
declare function withoutMixin<T extends Mixinable>(entity: T, key: string): T;
|
|
72
|
+
//#endregion
|
|
73
|
+
export { Mixinable, WithMixin, getMixin, hasMixin, withMixin, withoutMixin };
|
package/dist/mixin.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//#region src/composition/mixin.ts
|
|
2
|
+
/**
|
|
3
|
+
* Attach (or overwrite) a mixin on a base entity. Returns a NEW object —
|
|
4
|
+
* never mutates. The other mixins on the entity are preserved by spread.
|
|
5
|
+
*
|
|
6
|
+
* Type-level: caller picks the mixin key and the mixin payload shape; we
|
|
7
|
+
* intersect them into the return type so chained reads stay precise.
|
|
8
|
+
*/
|
|
9
|
+
function withMixin(base, key, data) {
|
|
10
|
+
return {
|
|
11
|
+
...base,
|
|
12
|
+
mixins: {
|
|
13
|
+
...base.mixins ?? {},
|
|
14
|
+
[key]: data
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Pull a mixin payload off an entity by key. Returns `null` (not `undefined`)
|
|
20
|
+
* so call sites can use `??` to fall through to a default without ambiguity
|
|
21
|
+
* with a real `undefined` field on the payload itself.
|
|
22
|
+
*/
|
|
23
|
+
function getMixin(entity, key) {
|
|
24
|
+
if (entity.mixins === void 0 || entity.mixins === null) return null;
|
|
25
|
+
const found = entity.mixins[key];
|
|
26
|
+
return found === void 0 ? null : found;
|
|
27
|
+
}
|
|
28
|
+
/** Cheap presence check — useful for `if (hasMixin(c, 'customer')) …` branches. */
|
|
29
|
+
function hasMixin(entity, key) {
|
|
30
|
+
return entity.mixins !== void 0 && entity.mixins !== null && entity.mixins[key] !== void 0;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Remove a mixin. Returns a new entity. No-op if the mixin isn't present.
|
|
34
|
+
* Useful for migrations / GDPR scrubs ("remove `customer` payload but keep
|
|
35
|
+
* the base `Contact` row").
|
|
36
|
+
*/
|
|
37
|
+
function withoutMixin(entity, key) {
|
|
38
|
+
if (!hasMixin(entity, key)) return entity;
|
|
39
|
+
const next = { ...entity.mixins };
|
|
40
|
+
delete next[key];
|
|
41
|
+
return {
|
|
42
|
+
...entity,
|
|
43
|
+
mixins: next
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
export { getMixin, hasMixin, withMixin, withoutMixin };
|
package/dist/money.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { n as CurrencyCode } from "./currency-
|
|
1
|
+
import { n as CurrencyCode } from "./currency-B4rSdN7D.mjs";
|
|
2
2
|
|
|
3
|
-
//#region src/money.d.ts
|
|
3
|
+
//#region src/money/money.d.ts
|
|
4
4
|
/**
|
|
5
5
|
* Wire-format monetary amount: integer minor units, branded with a phantom
|
|
6
6
|
* currency type so calls like `formatBdt(usdValue)` fail at compile time.
|
package/dist/money.mjs
CHANGED
package/dist/outbox.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DeadLetteredEvent, DomainEvent } from "./events.mjs";
|
|
2
2
|
|
|
3
|
-
//#region src/outbox.d.ts
|
|
3
|
+
//#region src/events/outbox.d.ts
|
|
4
4
|
/**
|
|
5
5
|
* Options passed to {@link OutboxStore.save} for richer write semantics.
|
|
6
6
|
* Stores may ignore fields they don't support — contract is best-effort.
|
package/dist/outbox.mjs
CHANGED
package/dist/period.d.mts
CHANGED
package/dist/period.mjs
CHANGED
package/dist/person.d.mts
CHANGED
package/dist/person.mjs
CHANGED
package/dist/phone.d.mts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Result } from "./result.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/identity/phone.d.ts
|
|
4
|
+
type PhoneErrorCode = 'EMPTY' | 'MISSING_PLUS' | 'NOT_E164' | 'TOO_SHORT' | 'TOO_LONG';
|
|
5
|
+
declare class PhoneError extends Error {
|
|
6
|
+
readonly name = "PhoneError";
|
|
7
|
+
readonly code: PhoneErrorCode;
|
|
8
|
+
constructor(code: PhoneErrorCode, message: string);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Parsed phone number. Stored E.164 (always begins with `+`, digits only after).
|
|
12
|
+
* `callingCode` is the 1–3 digit country dial code (`'1'` US, `'44'` UK,
|
|
13
|
+
* `'880'` BD, etc.) — useful for routing without re-parsing.
|
|
14
|
+
*
|
|
15
|
+
* Construction goes through `parsePhone()` (never assemble by hand) so the
|
|
16
|
+
* invariants are guaranteed by the parser.
|
|
17
|
+
*/
|
|
18
|
+
interface PhoneNumber {
|
|
19
|
+
readonly e164: string;
|
|
20
|
+
readonly callingCode: string;
|
|
21
|
+
/** The national-number portion (digits after the calling code). */
|
|
22
|
+
readonly nationalNumber: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Parse free-form phone input into a normalized `PhoneNumber`. Accepts:
|
|
26
|
+
* - `'+14155550182'` → e164 unchanged
|
|
27
|
+
* - `'+1 (415) 555-0182'` → strips spaces / punctuation
|
|
28
|
+
* - `'+1.415.555.0182'` → strips dots
|
|
29
|
+
*
|
|
30
|
+
* Rejects anything without a leading `+` — we don't guess the country.
|
|
31
|
+
* If the host knows the user's region, prepend the code before calling.
|
|
32
|
+
*/
|
|
33
|
+
declare function parsePhone(input: string): Result<PhoneNumber, PhoneError>;
|
|
34
|
+
/** Convenience: returns the E.164 string. Useful when piping to a transport. */
|
|
35
|
+
declare function formatPhone(phone: PhoneNumber): string;
|
|
36
|
+
/**
|
|
37
|
+
* Best-effort national-format render: groups the national number into 3-digit
|
|
38
|
+
* blocks. Not locale-aware — for display polish, hosts should format per
|
|
39
|
+
* region. Useful as a default in admin UIs.
|
|
40
|
+
*
|
|
41
|
+
* @example formatNational(parsePhone('+14155550182').value!) → '+1 415 555 0182'
|
|
42
|
+
*/
|
|
43
|
+
declare function formatNational(phone: PhoneNumber): string;
|
|
44
|
+
/** Type guard. */
|
|
45
|
+
declare function isPhoneNumber(value: unknown): value is PhoneNumber;
|
|
46
|
+
//#endregion
|
|
47
|
+
export { PhoneError, PhoneErrorCode, PhoneNumber, formatNational, formatPhone, isPhoneNumber, parsePhone };
|