@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
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 };
|
package/dist/outbox.mjs
ADDED
|
@@ -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 };
|
package/dist/period.mjs
ADDED
|
@@ -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 };
|
package/dist/person.mjs
ADDED
|
@@ -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 };
|