@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/address.d.mts +1 -1
  3. package/dist/address.mjs +1 -1
  4. package/dist/approval.d.mts +1 -1
  5. package/dist/approval.mjs +1 -1
  6. package/dist/bank-transaction.d.mts +1 -1
  7. package/dist/{brand-DApaegoR.d.mts → brand-LLXqoh5J.d.mts} +1 -1
  8. package/dist/brand.d.mts +1 -1
  9. package/dist/cadence.d.mts +1 -1
  10. package/dist/cadence.mjs +1 -1
  11. package/dist/condition.d.mts +76 -0
  12. package/dist/condition.mjs +131 -0
  13. package/dist/context.d.mts +1 -1
  14. package/dist/{currency-rgUAn5KM.d.mts → currency-B4rSdN7D.d.mts} +2 -2
  15. package/dist/currency.d.mts +1 -1
  16. package/dist/currency.mjs +1 -1
  17. package/dist/events.d.mts +1 -1
  18. package/dist/events.mjs +1 -1
  19. package/dist/hold.d.mts +1 -1
  20. package/dist/hold.mjs +1 -1
  21. package/dist/mixin.d.mts +73 -0
  22. package/dist/mixin.mjs +47 -0
  23. package/dist/money.d.mts +2 -2
  24. package/dist/money.mjs +1 -1
  25. package/dist/outbox.d.mts +1 -1
  26. package/dist/outbox.mjs +1 -1
  27. package/dist/payment-gateway.d.mts +1 -1
  28. package/dist/period.d.mts +1 -1
  29. package/dist/period.mjs +1 -1
  30. package/dist/person.d.mts +1 -1
  31. package/dist/person.mjs +1 -1
  32. package/dist/phone.d.mts +47 -0
  33. package/dist/phone.mjs +310 -0
  34. package/dist/reference.d.mts +1 -1
  35. package/dist/reference.mjs +1 -1
  36. package/dist/result.d.mts +1 -1
  37. package/dist/result.mjs +1 -1
  38. package/dist/sla-policy.d.mts +116 -0
  39. package/dist/sla-policy.mjs +120 -0
  40. package/dist/sla.d.mts +1 -1
  41. package/dist/sla.mjs +1 -1
  42. package/dist/split-allocation.d.mts +1 -1
  43. package/dist/split-allocation.mjs +1 -1
  44. package/dist/state-machine.d.mts +1 -1
  45. package/dist/state-machine.mjs +1 -1
  46. package/dist/status-history.d.mts +86 -0
  47. package/dist/status-history.mjs +48 -0
  48. 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
@@ -1,4 +1,4 @@
1
- //#region src/address.d.ts
1
+ //#region src/identity/address.d.ts
2
2
  /**
3
3
  * Postal address.
4
4
  *
package/dist/address.mjs CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/address.ts
1
+ //#region src/identity/address.ts
2
2
  function toGeoJsonPoint(p) {
3
3
  return {
4
4
  type: "Point",
@@ -1,4 +1,4 @@
1
- //#region src/approval.d.ts
1
+ //#region src/workflow/approval.d.ts
2
2
  /**
3
3
  * Approval chain value object and pure state-transition helpers.
4
4
  *
package/dist/approval.mjs CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/approval.ts
1
+ //#region src/workflow/approval.ts
2
2
  var ApprovalError = class extends Error {
3
3
  name = "ApprovalError";
4
4
  code;
@@ -1,6 +1,6 @@
1
1
  import { Money } from "./money.mjs";
2
2
 
3
- //#region src/bank-transaction.d.ts
3
+ //#region src/money/bank-transaction.d.ts
4
4
  /**
5
5
  * The other side of a bank transaction. All fields optional — different
6
6
  * formats populate different subsets:
@@ -1,4 +1,4 @@
1
- //#region src/brand.d.ts
1
+ //#region src/composition/brand.d.ts
2
2
  /**
3
3
  * Type utilities for building nominal types and transforming shapes.
4
4
  *
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-DApaegoR.mjs";
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 };
@@ -1,4 +1,4 @@
1
- //#region src/cadence.d.ts
1
+ //#region src/scheduling/cadence.d.ts
2
2
  /**
3
3
  * Recurrence spec + pure date arithmetic.
4
4
  *
package/dist/cadence.mjs CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/cadence.ts
1
+ //#region src/scheduling/cadence.ts
2
2
  var CadenceError = class extends Error {
3
3
  name = "CadenceError";
4
4
  code;
@@ -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 };
@@ -1,6 +1,6 @@
1
1
  import { IdLike } from "./reference.mjs";
2
2
 
3
- //#region src/context.d.ts
3
+ //#region src/composition/context.d.ts
4
4
  /**
5
5
  * Operation context threaded through package APIs.
6
6
  *
@@ -1,6 +1,6 @@
1
- import { t as Brand } from "./brand-DApaegoR.mjs";
1
+ import { t as Brand } from "./brand-LLXqoh5J.mjs";
2
2
 
3
- //#region src/currency.d.ts
3
+ //#region src/money/currency.d.ts
4
4
  /**
5
5
  * ISO 4217 currency code (3 uppercase letters).
6
6
  *
@@ -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-rgUAn5KM.mjs";
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
@@ -1,4 +1,4 @@
1
- //#region src/currency.ts
1
+ //#region src/money/currency.ts
2
2
  /**
3
3
  * Common ISO 4217 codes. Non-exhaustive — add more at the consumer level if
4
4
  * needed. Listed here because shared-types ships with these by default.
package/dist/events.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/events.d.ts
1
+ //#region src/events/events.d.ts
2
2
  /**
3
3
  * Domain event transport primitives.
4
4
  *
package/dist/events.mjs CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/events.ts
1
+ //#region src/events/events.ts
2
2
  /**
3
3
  * Build a {@link DomainEvent} with auto-filled `id` + `timestamp`. All other
4
4
  * `EventMeta` fields are caller-controlled and override the defaults if
package/dist/hold.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/hold.d.ts
1
+ //#region src/workflow/hold.d.ts
2
2
  /**
3
3
  * Hold reason value object + collection helpers.
4
4
  *
package/dist/hold.mjs CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/hold.ts
1
+ //#region src/workflow/hold.ts
2
2
  var HoldError = class extends Error {
3
3
  name = "HoldError";
4
4
  code;
@@ -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-rgUAn5KM.mjs";
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
@@ -1,5 +1,5 @@
1
1
  import { minorUnitFactor } from "./currency.mjs";
2
- //#region src/money.ts
2
+ //#region src/money/money.ts
3
3
  /**
4
4
  * Cast a raw number to {@link MinorUnits}. Pass the currency as the second
5
5
  * argument to fix the phantom type. With no second arg, `MinorUnits<string>`
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
@@ -1,4 +1,4 @@
1
- //#region src/outbox.ts
1
+ //#region src/events/outbox.ts
2
2
  /**
3
3
  * Thrown by a store when `acknowledge` / `fail` is called by a consumer that
4
4
  * does not own the event's current lease.
@@ -1,6 +1,6 @@
1
1
  import { Money } from "./money.mjs";
2
2
 
3
- //#region src/payment-gateway.d.ts
3
+ //#region src/money/payment-gateway.d.ts
4
4
  /**
5
5
  * Static capability declaration — providers expose this so the engine
6
6
  * can branch on what's supported (e.g. skip the refund button for
package/dist/period.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/period.d.ts
1
+ //#region src/scheduling/period.d.ts
2
2
  /**
3
3
  * A closed date range `[start, end]`. Both endpoints inclusive — consumers
4
4
  * decide whether to treat them as `[start, end)` at the query layer.
package/dist/period.mjs CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/period.ts
1
+ //#region src/scheduling/period.ts
2
2
  function isDateRange(value) {
3
3
  return typeof value === "object" && value !== null && value.start instanceof Date && value.end instanceof Date;
4
4
  }
package/dist/person.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/person.d.ts
1
+ //#region src/identity/person.d.ts
2
2
  /**
3
3
  * Structured person name. Handles the parts most locales need; domains that
4
4
  * require more (per-locale honorifics, patronymics) extend from here.
package/dist/person.mjs CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/person.ts
1
+ //#region src/identity/person.ts
2
2
  function formatFullName(name) {
3
3
  return [
4
4
  name.prefix,
@@ -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 };