@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/dist/money.mjs ADDED
@@ -0,0 +1,131 @@
1
+ import { minorUnitFactor } from "./currency.mjs";
2
+ //#region src/money.ts
3
+ var CurrencyMismatchError = class extends Error {
4
+ name = "CurrencyMismatchError";
5
+ left;
6
+ right;
7
+ constructor(left, right) {
8
+ super(`Currency mismatch: ${left} vs ${right}`);
9
+ this.left = left;
10
+ this.right = right;
11
+ }
12
+ };
13
+ /**
14
+ * Construct {@link Money} from an integer minor-unit amount.
15
+ *
16
+ * @throws TypeError if `amount` is not a finite integer.
17
+ */
18
+ function money(amount, currency) {
19
+ if (!Number.isFinite(amount) || !Number.isInteger(amount)) throw new TypeError(`Money.amount must be a finite integer in minor units, got ${amount}`);
20
+ return {
21
+ amount,
22
+ currency
23
+ };
24
+ }
25
+ /**
26
+ * Construct {@link Money} from a major-unit number (e.g. dollars, taka) by
27
+ * scaling to the currency's minor unit and rounding half-away-from-zero.
28
+ *
29
+ * Uses `toPrecision(15)` to strip the trailing-bit noise that appears in
30
+ * multiply-then-round (e.g. `0.1 + 0.2 = 0.30000000000000004`). This is
31
+ * enough for the vast majority of decimals encountered in real-world pricing.
32
+ *
33
+ * **Precision caveat.** A handful of decimals cannot be represented exactly
34
+ * in IEEE 754 and will round to the IEEE-closest integer rather than the
35
+ * mathematically-expected one — e.g. `1.005` is stored as `1.00499999…` and
36
+ * rounds to `100` minor units, not `101`. If you need decimal-exact rounding,
37
+ * construct money from a string or use integer minor units directly via
38
+ * {@link money}.
39
+ *
40
+ * @example
41
+ * fromMajor(19.99, 'USD') // { amount: 1999, currency: 'USD' }
42
+ * fromMajor(0.1 + 0.2, 'USD') // { amount: 30, currency: 'USD' }
43
+ * fromMajor(1500, 'JPY') // { amount: 1500, currency: 'JPY' }
44
+ */
45
+ function fromMajor(major, currency) {
46
+ if (!Number.isFinite(major)) throw new TypeError(`fromMajor received non-finite value: ${major}`);
47
+ const raw = major * minorUnitFactor(currency);
48
+ const cleaned = Number(raw.toPrecision(15));
49
+ return {
50
+ amount: Math.round(Math.abs(cleaned)) * (cleaned < 0 ? -1 : 1),
51
+ currency
52
+ };
53
+ }
54
+ /** Convert {@link Money} back to its major-unit floating-point representation. */
55
+ function toMajor(m) {
56
+ return m.amount / minorUnitFactor(m.currency);
57
+ }
58
+ function assertSameCurrency(a, b) {
59
+ if (a.currency !== b.currency) throw new CurrencyMismatchError(a.currency, b.currency);
60
+ }
61
+ function addMoney(a, b) {
62
+ assertSameCurrency(a, b);
63
+ return {
64
+ amount: a.amount + b.amount,
65
+ currency: a.currency
66
+ };
67
+ }
68
+ function subtractMoney(a, b) {
69
+ assertSameCurrency(a, b);
70
+ return {
71
+ amount: a.amount - b.amount,
72
+ currency: a.currency
73
+ };
74
+ }
75
+ /**
76
+ * Multiply money by a scalar. The result is rounded to an integer number of
77
+ * minor units using half-away-from-zero rounding.
78
+ */
79
+ function multiplyMoney(m, scalar) {
80
+ if (!Number.isFinite(scalar)) throw new TypeError(`multiplyMoney received non-finite scalar: ${scalar}`);
81
+ const raw = m.amount * scalar;
82
+ return {
83
+ amount: Math.round(Math.abs(raw)) * (raw < 0 ? -1 : 1),
84
+ currency: m.currency
85
+ };
86
+ }
87
+ /** Sum a list of {@link Money}. Returns zero in the given currency for empty input. */
88
+ function sumMoney(values, currency) {
89
+ return values.reduce((acc, v) => addMoney(acc, v), {
90
+ amount: 0,
91
+ currency
92
+ });
93
+ }
94
+ /** Strict equality (amount and currency both match). */
95
+ function equalsMoney(a, b) {
96
+ return a.amount === b.amount && a.currency === b.currency;
97
+ }
98
+ /**
99
+ * Ordering predicate. Returns -1, 0, or 1. Throws on currency mismatch — use
100
+ * {@link equalsMoney} for a total equality check across currencies.
101
+ */
102
+ function compareMoney(a, b) {
103
+ assertSameCurrency(a, b);
104
+ if (a.amount < b.amount) return -1;
105
+ if (a.amount > b.amount) return 1;
106
+ return 0;
107
+ }
108
+ function isZeroMoney(m) {
109
+ return m.amount === 0;
110
+ }
111
+ function isPositiveMoney(m) {
112
+ return m.amount > 0;
113
+ }
114
+ function isNegativeMoney(m) {
115
+ return m.amount < 0;
116
+ }
117
+ function negateMoney(m) {
118
+ return {
119
+ amount: m.amount === 0 ? 0 : -m.amount,
120
+ currency: m.currency
121
+ };
122
+ }
123
+ function absMoney(m) {
124
+ return m.amount < 0 ? negateMoney(m) : m;
125
+ }
126
+ /** Structural type guard. */
127
+ function isMoney(value) {
128
+ return typeof value === "object" && value !== null && typeof value.amount === "number" && Number.isInteger(value.amount) && typeof value.currency === "string";
129
+ }
130
+ //#endregion
131
+ export { CurrencyMismatchError, absMoney, addMoney, compareMoney, equalsMoney, fromMajor, isMoney, isNegativeMoney, isPositiveMoney, isZeroMoney, money, multiplyMoney, negateMoney, subtractMoney, sumMoney, toMajor };
@@ -0,0 +1,222 @@
1
+ import { DeadLetteredEvent, DomainEvent } from "./events.mjs";
2
+
3
+ //#region src/outbox.d.ts
4
+ /**
5
+ * Options passed to {@link OutboxStore.save} for richer write semantics.
6
+ * Stores may ignore fields they don't support — contract is best-effort.
7
+ */
8
+ interface OutboxWriteOptions {
9
+ /**
10
+ * Host-provided DB session/transaction handle for atomic writes. Typed as
11
+ * `unknown` so stores can accept any backend (mongoose session, pg client,
12
+ * Prisma tx, etc.) without primitives taking a peer dep.
13
+ */
14
+ readonly session?: unknown;
15
+ /** Earliest time the event should be visible to relay workers (for delayed publishing). */
16
+ readonly visibleAt?: Date;
17
+ /** Idempotency key — stores that support it should dedupe on this. */
18
+ readonly dedupeKey?: string;
19
+ /** Partition / routing key for sharded transports (Kafka, Redis Streams). */
20
+ readonly partitionKey?: string;
21
+ /** Arbitrary headers propagated to the transport layer. */
22
+ readonly headers?: Readonly<Record<string, string>>;
23
+ }
24
+ /**
25
+ * Options for {@link OutboxStore.claimPending} — lease-based work claim.
26
+ * Supports safe multi-worker relay: each worker atomically claims a batch
27
+ * with a lease TTL, preventing duplicate publishes.
28
+ */
29
+ interface OutboxClaimOptions {
30
+ /** Max events to claim. */
31
+ readonly limit?: number;
32
+ /** Unique identifier for the claiming worker. */
33
+ readonly consumerId?: string;
34
+ /** Lease duration in ms — claim is released automatically after this. */
35
+ readonly leaseMs?: number;
36
+ /** Only claim events of these types (optional filter). */
37
+ readonly types?: readonly string[];
38
+ }
39
+ /** Options for {@link OutboxStore.acknowledge}. */
40
+ interface OutboxAcknowledgeOptions {
41
+ /** Worker identifier — stores may enforce "only owner can ack". */
42
+ readonly consumerId?: string;
43
+ }
44
+ /** Options for {@link OutboxStore.fail}. */
45
+ interface OutboxFailOptions {
46
+ /** Worker identifier — stores may enforce "only owner can fail". */
47
+ readonly consumerId?: string;
48
+ /** Schedule retry for a later time (implements backoff). */
49
+ readonly retryAt?: Date;
50
+ /** Move the event to dead-letter state (no further retries). */
51
+ readonly deadLetter?: boolean;
52
+ }
53
+ /** Normalized error info passed to {@link OutboxStore.fail}. */
54
+ interface OutboxErrorInfo {
55
+ readonly message: string;
56
+ readonly code?: string;
57
+ }
58
+ /** Context passed to {@link OutboxFailurePolicy}. */
59
+ interface OutboxFailureContext {
60
+ /** The event whose delivery just failed. */
61
+ readonly event: DomainEvent;
62
+ /** The error returned by the transport / handler. */
63
+ readonly error: Error;
64
+ /**
65
+ * Attempt count including this failure (1 on the first fail). Tracked
66
+ * in-process by the relay; accurate within a single relay process, resets
67
+ * on restart. For durable attempt counts, the policy can query the store.
68
+ */
69
+ readonly attempts: number;
70
+ }
71
+ /**
72
+ * Decision returned by {@link OutboxFailurePolicy} — controls how the relay
73
+ * calls `store.fail()` for a failed event.
74
+ */
75
+ interface OutboxFailureDecision {
76
+ /** Schedule retry for this time. Omit for immediate (next-poll) retry. */
77
+ readonly retryAt?: Date;
78
+ /** Move the event to dead-letter state (no further retries). */
79
+ readonly deadLetter?: boolean;
80
+ }
81
+ /**
82
+ * Centralised retry/DLQ policy evaluated by the arc relay on every failure.
83
+ * Returns the options passed to `store.fail()`.
84
+ *
85
+ * ```ts
86
+ * failurePolicy: ({ attempts }) => {
87
+ * if (attempts >= 5) return { deadLetter: true };
88
+ * return { retryAt: exponentialBackoff({ attempt: attempts }) };
89
+ * }
90
+ * ```
91
+ */
92
+ type OutboxFailurePolicy = (ctx: OutboxFailureContext) => OutboxFailureDecision | Promise<OutboxFailureDecision>;
93
+ /**
94
+ * Thrown by a store when `acknowledge` / `fail` is called by a consumer that
95
+ * does not own the event's current lease.
96
+ *
97
+ * Stores that enforce lease ownership MUST throw this (not silently return)
98
+ * so the relay can detect the mismatch and avoid over-counting successful
99
+ * deliveries.
100
+ */
101
+ declare class OutboxOwnershipError extends Error {
102
+ readonly name = "OutboxOwnershipError";
103
+ readonly eventId: string;
104
+ readonly attemptedBy: string;
105
+ readonly currentOwner: string | null;
106
+ constructor(eventId: string, attemptedBy: string, currentOwner: string | null);
107
+ }
108
+ /** Thrown when an event missing `type` or `meta.id` is passed to the outbox. */
109
+ declare class InvalidOutboxEventError extends Error {
110
+ readonly name = "InvalidOutboxEventError";
111
+ constructor(reason: string);
112
+ }
113
+ /**
114
+ * Durable storage contract for the transactional outbox pattern.
115
+ *
116
+ * **Required methods**: `save`, `getPending`, `acknowledge`.
117
+ *
118
+ * **Optional capabilities** — stores opt-in to richer semantics:
119
+ *
120
+ * - `claimPending` — lease-based work claim for multi-worker relay
121
+ * - `fail` — failure tracking with retry scheduling and dead-letter
122
+ * - `getDeadLettered` — typed DLQ replay surface
123
+ * - `purge` — acknowledged event cleanup
124
+ *
125
+ * Arc's runtime detects capabilities at runtime and degrades gracefully for
126
+ * legacy stores that only implement the required methods.
127
+ *
128
+ * ## Required semantics
129
+ *
130
+ * Implementations MUST honor these contracts — arc's relay depends on them
131
+ * for correctness of at-least-once delivery:
132
+ *
133
+ * 1. **`save` must reject invalid events.** If `event.type` or `event.meta.id`
134
+ * is missing/empty, throw — do not persist. Callers pre-validate but
135
+ * stores should defend against direct-save code paths.
136
+ *
137
+ * 2. **`claimPending` must be atomic.** Two workers calling `claimPending`
138
+ * concurrently must never receive the same event. SQL: `SELECT ... FOR
139
+ * UPDATE SKIP LOCKED`; Mongo: `findOneAndUpdate` with `{ leaseOwner:
140
+ * null }` in the match condition.
141
+ *
142
+ * 3. **`acknowledge` / `fail` must throw {@link OutboxOwnershipError} on
143
+ * ownership mismatch.** When `options.consumerId` is provided and does
144
+ * not match the current lease owner, throw — never silently no-op. The
145
+ * relay relies on this signal to avoid counting hijacked events as
146
+ * successfully relayed.
147
+ *
148
+ * 4. **`acknowledge` on an unknown `eventId` is a no-op.** Keeps the relay
149
+ * idempotent when the store has been purged or the event was manually
150
+ * removed. Do NOT throw `OutboxOwnershipError` in this case.
151
+ *
152
+ * 5. **`fail` must deterministically update lease/visibility.** On success,
153
+ * the event MUST become re-claimable (immediately or at `retryAt`) or
154
+ * transition to dead-letter. Lease owner should be cleared.
155
+ *
156
+ * 6. **Malformed events must never be returned by `getPending` /
157
+ * `claimPending`.** If the underlying storage has corrupt rows, the
158
+ * store is responsible for quarantining them.
159
+ */
160
+ interface OutboxStore {
161
+ /**
162
+ * Save an event to the outbox (typically called inside a business
163
+ * transaction). MUST reject events missing `type` or `meta.id` — throw
164
+ * rather than persist.
165
+ */
166
+ save(event: DomainEvent, options?: OutboxWriteOptions): Promise<void>;
167
+ /**
168
+ * Get pending (unrelayed) events, ordered FIFO. Multi-worker deployments
169
+ * should prefer {@link OutboxStore.claimPending}. Events returned MUST be
170
+ * well-formed (valid `type` and `meta.id`).
171
+ */
172
+ getPending(limit: number): Promise<DomainEvent[]>;
173
+ /**
174
+ * Atomically claim pending events with a lease. Two concurrent callers
175
+ * must never receive overlapping events. When implemented, the relay
176
+ * prefers this over `getPending`.
177
+ */
178
+ claimPending?(options?: OutboxClaimOptions): Promise<DomainEvent[]>;
179
+ /**
180
+ * Mark event as successfully relayed.
181
+ *
182
+ * **Ownership contract**: If `options.consumerId` is provided and does
183
+ * not match the current lease owner, throw {@link OutboxOwnershipError}.
184
+ * Unknown `eventId` is a no-op.
185
+ *
186
+ * @throws {@link OutboxOwnershipError} on ownership mismatch
187
+ */
188
+ acknowledge(eventId: string, options?: OutboxAcknowledgeOptions): Promise<void>;
189
+ /**
190
+ * Record a relay failure. When implemented, the relay calls this instead
191
+ * of stopping the batch — enables retry scheduling and DLQ.
192
+ *
193
+ * **Ownership contract**: Same as `acknowledge` — throw
194
+ * {@link OutboxOwnershipError} on mismatch. After a successful call the
195
+ * lease owner MUST be cleared and the event MUST be re-claimable (at
196
+ * `retryAt` if provided) or transitioned to dead-letter.
197
+ *
198
+ * @throws {@link OutboxOwnershipError} on ownership mismatch
199
+ */
200
+ fail?(eventId: string, error: OutboxErrorInfo, options?: OutboxFailOptions): Promise<void>;
201
+ /**
202
+ * Return events currently in dead-letter state as typed
203
+ * {@link DeadLetteredEvent} envelopes. Closes the loop between
204
+ * `fail({ deadLetter: true })` and the DLQ replay surface. MUST populate
205
+ * `error`, `attempts`, `firstFailedAt`, `lastFailedAt` from whatever the
206
+ * store tracked during `fail()` calls. `handlerName` is optional.
207
+ */
208
+ getDeadLettered?(limit: number): Promise<DeadLetteredEvent[]>;
209
+ /**
210
+ * Purge old **delivered** events. Scoped to `delivered` state only —
211
+ * pending, failed, or dead-lettered events MUST NOT be removed.
212
+ *
213
+ * - MongoDB: typically done via TTL index on `deliveredAt` (zero-code).
214
+ * - SQL: scheduled `DELETE WHERE status = 'delivered' AND delivered_at < :cutoff`.
215
+ * - Redis: `EXPIRE` on delivered keys.
216
+ *
217
+ * @returns Number of purged events.
218
+ */
219
+ purge?(olderThanMs: number): Promise<number>;
220
+ }
221
+ //#endregion
222
+ export { InvalidOutboxEventError, OutboxAcknowledgeOptions, OutboxClaimOptions, OutboxErrorInfo, OutboxFailOptions, OutboxFailureContext, OutboxFailureDecision, OutboxFailurePolicy, OutboxOwnershipError, OutboxStore, OutboxWriteOptions };
@@ -0,0 +1,30 @@
1
+ //#region src/outbox.ts
2
+ /**
3
+ * Thrown by a store when `acknowledge` / `fail` is called by a consumer that
4
+ * does not own the event's current lease.
5
+ *
6
+ * Stores that enforce lease ownership MUST throw this (not silently return)
7
+ * so the relay can detect the mismatch and avoid over-counting successful
8
+ * deliveries.
9
+ */
10
+ var OutboxOwnershipError = class extends Error {
11
+ name = "OutboxOwnershipError";
12
+ eventId;
13
+ attemptedBy;
14
+ currentOwner;
15
+ constructor(eventId, attemptedBy, currentOwner) {
16
+ super(`Outbox ownership mismatch for event "${eventId}": attempted by "${attemptedBy}", current owner is "${currentOwner ?? "none"}". The lease may have expired and been reclaimed by another worker.`);
17
+ this.eventId = eventId;
18
+ this.attemptedBy = attemptedBy;
19
+ this.currentOwner = currentOwner;
20
+ }
21
+ };
22
+ /** Thrown when an event missing `type` or `meta.id` is passed to the outbox. */
23
+ var InvalidOutboxEventError = class extends Error {
24
+ name = "InvalidOutboxEventError";
25
+ constructor(reason) {
26
+ super(`Invalid outbox event: ${reason}`);
27
+ }
28
+ };
29
+ //#endregion
30
+ export { InvalidOutboxEventError, OutboxOwnershipError };
@@ -0,0 +1,57 @@
1
+ //#region src/pagination.d.ts
2
+ /**
3
+ * Pagination and sorting primitives shared between repositories, HTTP
4
+ * controllers, and SDKs. Shapes mirror `@classytic/mongokit`'s result types so
5
+ * adapters can flow through without translation.
6
+ */
7
+ type SortDirection = 'asc' | 'desc' | 1 | -1;
8
+ /**
9
+ * Sort specification — accepts any of:
10
+ * - `'field'` / `'-field'` (Mongoose-style prefix)
11
+ * - `['field1', '-field2']`
12
+ * - `{ field1: 1, field2: -1 }`
13
+ */
14
+ type SortSpec = string | ReadonlyArray<string> | Readonly<Record<string, SortDirection>>;
15
+ interface PageParams {
16
+ page?: number;
17
+ limit?: number;
18
+ sort?: SortSpec;
19
+ }
20
+ interface KeysetParams {
21
+ cursor?: string;
22
+ limit?: number;
23
+ sort?: SortSpec;
24
+ }
25
+ /**
26
+ * Offset-paginated response. Mirrors `mongokit.OffsetPaginationResult<T>`.
27
+ */
28
+ interface OffsetPage<T> {
29
+ docs: ReadonlyArray<T>;
30
+ total: number;
31
+ page: number;
32
+ limit: number;
33
+ pages: number;
34
+ hasNext: boolean;
35
+ hasPrev: boolean;
36
+ }
37
+ /**
38
+ * Cursor-paginated response. Mirrors `mongokit.KeysetPaginationResult<T>`.
39
+ */
40
+ interface KeysetPage<T> {
41
+ docs: ReadonlyArray<T>;
42
+ nextCursor: string | null;
43
+ prevCursor: string | null;
44
+ limit: number;
45
+ }
46
+ /** Aggregation-based pagination response. */
47
+ interface AggregatePage<T> {
48
+ docs: ReadonlyArray<T>;
49
+ total: number;
50
+ page: number;
51
+ limit: number;
52
+ pages: number;
53
+ }
54
+ declare function emptyOffsetPage<T>(limit?: number): OffsetPage<T>;
55
+ declare function emptyKeysetPage<T>(limit?: number): KeysetPage<T>;
56
+ //#endregion
57
+ export { AggregatePage, KeysetPage, KeysetParams, OffsetPage, PageParams, SortDirection, SortSpec, emptyKeysetPage, emptyOffsetPage };
@@ -0,0 +1,22 @@
1
+ //#region src/pagination.ts
2
+ function emptyOffsetPage(limit = 20) {
3
+ return {
4
+ docs: [],
5
+ total: 0,
6
+ page: 1,
7
+ limit,
8
+ pages: 0,
9
+ hasNext: false,
10
+ hasPrev: false
11
+ };
12
+ }
13
+ function emptyKeysetPage(limit = 20) {
14
+ return {
15
+ docs: [],
16
+ nextCursor: null,
17
+ prevCursor: null,
18
+ limit
19
+ };
20
+ }
21
+ //#endregion
22
+ export { emptyKeysetPage, emptyOffsetPage };
@@ -0,0 +1,46 @@
1
+ //#region src/period.d.ts
2
+ /**
3
+ * A closed date range `[start, end]`. Both endpoints inclusive — consumers
4
+ * decide whether to treat them as `[start, end)` at the query layer.
5
+ */
6
+ interface DateRange {
7
+ start: Date;
8
+ end: Date;
9
+ }
10
+ /**
11
+ * A named accounting / reporting period. At least one of the resolution fields
12
+ * (`month`, `quarter`) should be set, or `start`/`end` for ad-hoc ranges.
13
+ */
14
+ interface Period {
15
+ /** Four-digit calendar year, e.g. 2026. */
16
+ year: number;
17
+ /** 1..12. */
18
+ month?: number;
19
+ /** 1..4. */
20
+ quarter?: 1 | 2 | 3 | 4;
21
+ /** Ad-hoc override — useful when the period spans a non-calendar boundary. */
22
+ start?: Date;
23
+ end?: Date;
24
+ }
25
+ declare function isDateRange(value: unknown): value is DateRange;
26
+ /** True if `d` is inclusively within `range`. */
27
+ declare function isWithin(d: Date, range: DateRange): boolean;
28
+ /** Range duration in milliseconds. */
29
+ declare function rangeDurationMs(range: DateRange): number;
30
+ /**
31
+ * True if two half-open ranges `[start, end)` overlap.
32
+ *
33
+ * Note: the `DateRange` shape documents its endpoints as closed, but
34
+ * overlap-detection queries almost always want half-open semantics so two
35
+ * adjacent slots (10-11, 11-12) don't register as a conflict. Consumers
36
+ * needing fully-closed semantics can still use `isWithin` against each
37
+ * endpoint.
38
+ *
39
+ * Used by:
40
+ * - `@classytic/order` booking overlap detection
41
+ * - `@classytic/flow` reservation window collision
42
+ * - promo / offer campaign window overlap checks
43
+ */
44
+ declare function rangesOverlap(a: DateRange, b: DateRange): boolean;
45
+ //#endregion
46
+ export { DateRange, Period, isDateRange, isWithin, rangeDurationMs, rangesOverlap };
@@ -0,0 +1,31 @@
1
+ //#region src/period.ts
2
+ function isDateRange(value) {
3
+ return typeof value === "object" && value !== null && value.start instanceof Date && value.end instanceof Date;
4
+ }
5
+ /** True if `d` is inclusively within `range`. */
6
+ function isWithin(d, range) {
7
+ return d.getTime() >= range.start.getTime() && d.getTime() <= range.end.getTime();
8
+ }
9
+ /** Range duration in milliseconds. */
10
+ function rangeDurationMs(range) {
11
+ return range.end.getTime() - range.start.getTime();
12
+ }
13
+ /**
14
+ * True if two half-open ranges `[start, end)` overlap.
15
+ *
16
+ * Note: the `DateRange` shape documents its endpoints as closed, but
17
+ * overlap-detection queries almost always want half-open semantics so two
18
+ * adjacent slots (10-11, 11-12) don't register as a conflict. Consumers
19
+ * needing fully-closed semantics can still use `isWithin` against each
20
+ * endpoint.
21
+ *
22
+ * Used by:
23
+ * - `@classytic/order` booking overlap detection
24
+ * - `@classytic/flow` reservation window collision
25
+ * - promo / offer campaign window overlap checks
26
+ */
27
+ function rangesOverlap(a, b) {
28
+ return a.start.getTime() < b.end.getTime() && b.start.getTime() < a.end.getTime();
29
+ }
30
+ //#endregion
31
+ export { isDateRange, isWithin, rangeDurationMs, rangesOverlap };
@@ -0,0 +1,44 @@
1
+ //#region src/person.d.ts
2
+ /**
3
+ * Structured person name. Handles the parts most locales need; domains that
4
+ * require more (per-locale honorifics, patronymics) extend from here.
5
+ */
6
+ interface PersonName {
7
+ given: string;
8
+ family: string;
9
+ /** Middle name(s), as a single string preserving author's formatting. */
10
+ middle?: string;
11
+ /** Title prefix — Mr./Dr./Eng./etc. */
12
+ prefix?: string;
13
+ /** Suffix — Jr./Sr./PhD/etc. */
14
+ suffix?: string;
15
+ /** Preferred display name when it differs from formal given/family. */
16
+ preferred?: string;
17
+ }
18
+ declare function formatFullName(name: PersonName): string;
19
+ declare function formatDisplayName(name: PersonName): string;
20
+ /**
21
+ * Contact channels for a person. Prefer E.164 for phone when possible.
22
+ *
23
+ * The work-vs-personal split exists because compliance / off-boarding flows
24
+ * (HR) distinguish them, and because marketing opt-outs are per-channel.
25
+ */
26
+ interface ContactInfo {
27
+ email?: string;
28
+ personalEmail?: string;
29
+ phone?: string;
30
+ alternatePhone?: string;
31
+ }
32
+ interface EmergencyContact {
33
+ name: string;
34
+ relationship: string;
35
+ phone: string;
36
+ email?: string;
37
+ }
38
+ /**
39
+ * Gender as an open string — common values enumerated for tooling, but the
40
+ * shape accepts any string to stay inclusive and locale-flexible.
41
+ */
42
+ type Gender = 'male' | 'female' | 'other' | 'prefer-not-to-say' | (string & {});
43
+ //#endregion
44
+ export { ContactInfo, EmergencyContact, Gender, PersonName, formatDisplayName, formatFullName };
@@ -0,0 +1,16 @@
1
+ //#region src/person.ts
2
+ function formatFullName(name) {
3
+ return [
4
+ name.prefix,
5
+ name.given,
6
+ name.middle,
7
+ name.family,
8
+ name.suffix
9
+ ].filter((p) => Boolean(p?.trim())).join(" ");
10
+ }
11
+ function formatDisplayName(name) {
12
+ if (name.preferred?.trim()) return name.preferred;
13
+ return `${name.given} ${name.family}`.trim();
14
+ }
15
+ //#endregion
16
+ export { formatDisplayName, formatFullName };
@@ -0,0 +1,39 @@
1
+ //#region src/reference.d.ts
2
+ /**
3
+ * Minimal structural type for any ID object with a `.toString()` method. Does
4
+ * NOT depend on `mongoose` — `Types.ObjectId` satisfies this shape
5
+ * automatically, as do UUIDs from `uuid`, KSUIDs, ULIDs, etc.
6
+ *
7
+ * Use this wherever you'd otherwise import `Types.ObjectId` but don't want to
8
+ * force a runtime dependency on Mongoose.
9
+ */
10
+ interface ObjectIdLike {
11
+ toString(): string;
12
+ toHexString?(): string;
13
+ }
14
+ /** A value that can be used as an identifier — raw string or object-id-like. */
15
+ type IdLike = string | ObjectIdLike;
16
+ /** Coerce any {@link IdLike} to its canonical string form. */
17
+ declare function idToString(id: IdLike): string;
18
+ /**
19
+ * Polymorphic external reference — points at a resource that may live in
20
+ * another package, another service, or a different data store altogether.
21
+ *
22
+ * Paired with a host-implemented `SourceBridge` (see PACKAGE_RULES.md §7),
23
+ * this pattern works across single-Mongo, microservice, and mixed-datastore
24
+ * deployments.
25
+ */
26
+ interface ExternalRef {
27
+ /** Opaque identifier — hex ObjectId, UUID, Stripe ID, or any other format. */
28
+ sourceId: string;
29
+ /** Logical model / type name — e.g. 'Order', 'StripeCharge', 'PostgresInvoice'. */
30
+ sourceModel: string;
31
+ }
32
+ /** Typed reference to a document in a specific Mongo collection by ObjectId. */
33
+ interface DocumentRef {
34
+ collection: string;
35
+ id: string;
36
+ }
37
+ declare function isExternalRef(value: unknown): value is ExternalRef;
38
+ //#endregion
39
+ export { DocumentRef, ExternalRef, IdLike, ObjectIdLike, idToString, isExternalRef };
@@ -0,0 +1,10 @@
1
+ //#region src/reference.ts
2
+ /** Coerce any {@link IdLike} to its canonical string form. */
3
+ function idToString(id) {
4
+ return typeof id === "string" ? id : id.toString();
5
+ }
6
+ function isExternalRef(value) {
7
+ return typeof value === "object" && value !== null && typeof value.sourceId === "string" && typeof value.sourceModel === "string";
8
+ }
9
+ //#endregion
10
+ export { idToString, isExternalRef };