@classytic/primitives 0.18.0 → 0.20.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 (46) hide show
  1. package/CHANGELOG.md +906 -715
  2. package/README.md +160 -159
  3. package/dist/approval.d.mts +49 -10
  4. package/dist/approval.mjs +44 -10
  5. package/dist/bank-transaction.d.mts +1 -1
  6. package/dist/cadence-BFQVC1Qp.d.mts +107 -0
  7. package/dist/cadence.d.mts +1 -92
  8. package/dist/cadence.mjs +133 -4
  9. package/dist/calendar-B6fm-FbR.d.mts +46 -0
  10. package/dist/calendar.d.mts +1 -45
  11. package/dist/canonical.mjs +17 -0
  12. package/dist/context.d.mts +28 -1
  13. package/dist/currency.d.mts +2 -2
  14. package/dist/currency.mjs +134 -19
  15. package/dist/event-infra.d.mts +1 -34
  16. package/dist/event-infra.mjs +16 -46
  17. package/dist/idempotency.d.mts +253 -0
  18. package/dist/idempotency.mjs +226 -0
  19. package/dist/memory-outbox.d.mts +64 -0
  20. package/dist/memory-outbox.mjs +172 -0
  21. package/dist/monetization.d.mts +195 -0
  22. package/dist/monetization.mjs +79 -0
  23. package/dist/money-DPo9fLBi.d.mts +347 -0
  24. package/dist/money.d.mts +1 -140
  25. package/dist/money.mjs +6 -15
  26. package/dist/outbox.d.mts +49 -1
  27. package/dist/outbox.mjs +64 -1
  28. package/dist/payment-events.d.mts +132 -4
  29. package/dist/payment-events.mjs +22 -2
  30. package/dist/payment-gateway.d.mts +210 -7
  31. package/dist/payment-gateway.mjs +18 -1
  32. package/dist/period.d.mts +138 -7
  33. package/dist/period.mjs +316 -5
  34. package/dist/person-parse.d.mts +82 -0
  35. package/dist/person-parse.mjs +109 -0
  36. package/dist/proration.d.mts +76 -0
  37. package/dist/proration.mjs +132 -0
  38. package/dist/sla-policy.d.mts +1 -1
  39. package/dist/state-machine.mjs +13 -0
  40. package/dist/suspension.d.mts +1 -1
  41. package/dist/timezone-tlG7ygwx.d.mts +114 -0
  42. package/dist/timezone.d.mts +2 -100
  43. package/dist/timezone.mjs +38 -3
  44. package/dist/unit-cost-rate.d.mts +1 -2
  45. package/package.json +244 -224
  46. package/dist/currency-BCJnJBlb.d.mts +0 -119
package/README.md CHANGED
@@ -1,159 +1,160 @@
1
- # @classytic/primitives
2
-
3
- [![Sponsor](https://img.shields.io/github/sponsors/classytic?style=flat-square&label=Sponsor&logo=GitHub&color=EA4AAA)](https://github.com/sponsors/classytic)
4
-
5
- Framework-agnostic TypeScript primitives shared across Classytic packages.
6
- Zero runtime dependencies. ESM only. Node 22+.
7
-
8
- These are the building blocks every Classytic package speaks in common:
9
- `Money`, `DomainEvent`, `OperationContext`, polymorphic references. When a
10
- package duplicates one of these shapes, it drifts — this package exists so it
11
- doesn't.
12
-
13
- > **Canonical-contract relocations in 0.3.0.** Three modules moved out to
14
- > `@classytic/repo-core` so kits and arc see one source of truth instead of
15
- > two:
16
- > - **Pagination** (`OffsetPaginationResult`, `KeysetPaginationResult`,
17
- > `AggregatePaginationResult`, `PaginationResult`, `toCanonicalList()`) →
18
- > `@classytic/repo-core/pagination`.
19
- > - **Tenant config** (`TenantConfig`, `TenantStrategy`, `TenantFieldType`,
20
- > `resolveTenantConfig`, `DEFAULT_TENANT_CONFIG`, `ResolvedTenantConfig`) →
21
- > `@classytic/repo-core/tenant`. Mongokit and sqlitekit's
22
- > `MultiTenantOptions extends Pick<TenantConfig, ...>`.
23
- > - **Error contracts** (`HttpError` throwable, `ErrorContract` wire shape,
24
- > `ErrorDetail`, `ErrorCode`, `ERROR_CODES`, `toErrorContract()`,
25
- > `statusToErrorCode()`) → `@classytic/repo-core/errors`.
26
- > `ArcError implements HttpError`.
27
- >
28
- > Events stay here. `@classytic/primitives/events` remains the canonical
29
- > source for `EventMeta`, `DomainEvent`, `EventHandler`, `EventLogger`,
30
- > `EventTransport`, `DeadLetteredEvent`, `PublishManyResult`, `createEvent`,
31
- > `createChildEvent`, `matchEventPattern`. Arc 2.12 re-exports only the
32
- > runtime `MemoryEventTransport`.
33
-
34
- ## Install
35
-
36
- ```bash
37
- npm install @classytic/primitives
38
- ```
39
-
40
- ## Use
41
-
42
- Prefer **subpath imports** — each concern is its own entry point, types erase
43
- at compile time, runtime helpers are tiny.
44
-
45
- ```ts
46
- import { addMoney, fromMajor, type Money } from '@classytic/primitives/money';
47
- import { createEvent, type DomainEvent, type EventTransport } from '@classytic/primitives/events';
48
- import type { OperationContext } from '@classytic/primitives/context';
49
- import { allocate, type SplitResult } from '@classytic/primitives/split-allocation';
50
- // Tenant config, error contracts, and pagination now live in repo-core:
51
- import { resolveTenantConfig, type TenantConfig } from '@classytic/repo-core/tenant';
52
- import type { HttpError, ErrorContract } from '@classytic/repo-core/errors';
53
- import type {
54
- OffsetPaginationResult, KeysetPaginationResult, AggregatePaginationResult,
55
- } from '@classytic/repo-core/pagination';
56
- ```
57
-
58
- There is intentionally **no root barrel** — a barrel forces the Node ESM
59
- loader and TypeScript compiler to walk every module on a single convenience
60
- import, which slows CI and pulls unused code into the consumer's graph. Use
61
- subpath imports only.
62
-
63
- ## What's inside
64
-
65
- | Subpath | Ships |
66
- |---|---|
67
- | `/money` | `Money`, `fromMajor`, `toMajor`, `addMoney`, `subtractMoney`, `multiplyMoney`, `sumMoney`, `equalsMoney`, `compareMoney`, `negateMoney`, `absMoney`, `isMoney`, `CurrencyMismatchError` |
68
- | `/currency` | `CurrencyCode` brand, `CURRENCIES`, `MINOR_UNIT_FACTOR`, `minorUnitFactor`, `toCurrencyCode`, `isCurrencyCode` |
69
- | `/address` | `Address`, `ContactAddress`, `GeoPoint`, `GeoJsonPoint`, `toGeoJsonPoint`, `fromGeoJsonPoint` |
70
- | `/period` | `DateRange`, `Period`, `isDateRange`, `isWithin`, `rangeDurationMs` |
71
- | `/reference` | `ExternalRef`, `ObjectIdLike`, `IdLike`, `DocumentRef`, `idToString`, `isExternalRef` |
72
- | `/context` | `OperationContext`, `ActorRef` |
73
- | `/events` | `DomainEvent`, `EventMeta`, `EventHandler`, `EventLogger`, `EventTransport`, `DeadLetteredEvent`, `PublishManyResult`, `createEvent`, `createChildEvent`, `matchEventPattern` |
74
- | `/result` | `Result<T, E>`, `ok`, `err`, `isOk`, `isErr`, `mapResult`, `mapError`, `unwrap` |
75
- | `/brand` | `Brand<T, B>`, `Prettify`, `DeepPartial`, `DeepReadonly`, `RequireKeys`, `OptionalKeys`, `Nullable`, `KeysMatching`, `NonEmptyArray` |
76
- | `/split-allocation` | `allocate`, `isBalanced`, `SplitAllocationError`, `SplitMethod`, `SplitSubject`, `SplitPart`, `SplitResult` |
77
- | `/approval` | `createChain`, `applyDecision`, `skipStep`, `nextPendingStep`, `pendingSteps`, `isApproved`, `isRejected`, `isPending`, `decisionCount`, `ApprovalError`, `ApprovalChain`, `ApprovalStep`, `ApprovalDecision`, `Approver`, `ApprovalThreshold` |
78
- | `/cadence` | `nextOccurrence`, `occurrencesBetween`, `validateCadence`, `CadenceError`, `Cadence`, `DailyCadence`, `WeeklyCadence`, `MonthlyCadence`, `YearlyCadence`, `CronCadence`, `IsoWeekday` |
79
- | `/hold` | `addHold`, `resolveHold`, `activeHolds`, `resolvedHolds`, `isOnHold`, `hasActiveHoldOfCode`, `HoldError`, `HoldReason`, `HoldActor` |
80
- | `/sla` | `breachedAt`, `remainingMs`, `elapsedMs`, `isBreached`, `consumedFraction`, `validateSLA`, `SLAError`, `SLA`, `BreachPolicy` |
81
-
82
- ## Design
83
-
84
- ### Money — integer minor units, always
85
-
86
- ```ts
87
- import { fromMajor, addMoney, toMajor } from '@classytic/primitives/money';
88
-
89
- const subtotal = fromMajor(19.99, 'USD'); // { amount: 1999, currency: 'USD' }
90
- const shipping = fromMajor(5.00, 'USD'); // { amount: 500, currency: 'USD' }
91
- const total = addMoney(subtotal, shipping); // { amount: 2499, currency: 'USD' }
92
- toMajor(total); // 24.99
93
- ```
94
-
95
- `Money.amount` is always an integer in the currency's minor unit — `1` = one
96
- cent for USD, one yen for JPY, one fils for KWD. Mixing currencies throws
97
- `CurrencyMismatchError`. This eliminates the float-rounding drift that happens
98
- when every package rolls its own `{ amount: number, currency: string }`.
99
-
100
- ### DomainEvent — structurally identical to Arc
101
-
102
- ```ts
103
- import { createEvent, type EventTransport } from '@classytic/primitives/events';
104
-
105
- const event = createEvent('order:placed', { orderId: 'ord_123' }, {
106
- organizationId: ctx.organizationId,
107
- userId: ctx.actorId,
108
- correlationId: ctx.correlationId,
109
- });
110
-
111
- await transport.publish(event);
112
- ```
113
-
114
- Shape matches `@classytic/arc`'s `DomainEvent` exactly — any Arc transport
115
- (memory, Redis pub/sub, Redis Streams, Kafka) plugs into a package expecting
116
- this interface without adapters. See PACKAGE_RULES.md §11.
117
-
118
- ### TenantConfig — moved to `@classytic/repo-core/tenant`
119
-
120
- ```ts
121
- import { resolveTenantConfig } from '@classytic/repo-core/tenant';
122
-
123
- const tenant = resolveTenantConfig(config.tenant);
124
- // { enabled: true, tenantField: 'organizationId', fieldType: 'objectId',
125
- // ref: 'organization', contextKey: 'organizationId', required: true }
126
- ```
127
-
128
- Mongokit's `MultiTenantOptions` and sqlitekit's equivalent both
129
- `extends Pick<TenantConfig, ...>` from repo-core — pass the resolved config
130
- straight into `multiTenantPlugin(resolved)`.
131
-
132
- ### ExternalRef — polymorphic, framework-free
133
-
134
- ```ts
135
- interface ExternalRef {
136
- sourceId: string;
137
- sourceModel: string;
138
- }
139
- ```
140
-
141
- For references that cross trust boundaries (another package, another service,
142
- Stripe, Postgres) — paired with a host-implemented `SourceBridge`. Works for
143
- ObjectId, UUID, Stripe ID, anything. See PACKAGE_RULES.md §7.
144
-
145
- ## What's not here (on purpose)
146
-
147
- - **Mongoose types** — use `ObjectIdLike` from `/reference`. Any Mongoose
148
- `Types.ObjectId` satisfies it structurally.
149
- - **Transaction / Order / Cart shapes** — those are domain types, not
150
- primitives. They live in their respective packages.
151
- - **Error subclasses** — throw plain `Error`s; hosts serialize them into
152
- `ErrorContract` (from `@classytic/repo-core/errors`) at the HTTP boundary
153
- via `toErrorContract(err)`.
154
- - **Runtime currency database** — ship a whitelist at the app layer if you
155
- need one. `MINOR_UNIT_FACTOR` covers common cases.
156
-
157
- ## License
158
-
159
- MIT © Classytic
1
+ # @classytic/primitives
2
+
3
+ [![Sponsor](https://img.shields.io/github/sponsors/classytic?style=flat-square&label=Sponsor&logo=GitHub&color=EA4AAA)](https://github.com/sponsors/classytic)
4
+
5
+ Framework-agnostic TypeScript primitives shared across Classytic packages.
6
+ Zero runtime dependencies. ESM only. Node 22+.
7
+
8
+ These are the building blocks every Classytic package speaks in common:
9
+ `Money`, `DomainEvent`, `OperationContext`, polymorphic references. When a
10
+ package duplicates one of these shapes, it drifts — this package exists so it
11
+ doesn't.
12
+
13
+ > **Canonical-contract relocations in 0.3.0.** Three modules moved out to
14
+ > `@classytic/repo-core` so kits and arc see one source of truth instead of
15
+ > two:
16
+ > - **Pagination** (`OffsetPaginationResult`, `KeysetPaginationResult`,
17
+ > `AggregatePaginationResult`, `PaginationResult`, `toCanonicalList()`) →
18
+ > `@classytic/repo-core/pagination`.
19
+ > - **Tenant config** (`TenantConfig`, `TenantStrategy`, `TenantFieldType`,
20
+ > `resolveTenantConfig`, `DEFAULT_TENANT_CONFIG`, `ResolvedTenantConfig`) →
21
+ > `@classytic/repo-core/tenant`. Mongokit and sqlitekit's
22
+ > `MultiTenantOptions extends Pick<TenantConfig, ...>`.
23
+ > - **Error contracts** (`HttpError` throwable, `ErrorContract` wire shape,
24
+ > `ErrorDetail`, `ErrorCode`, `ERROR_CODES`, `toErrorContract()`,
25
+ > `statusToErrorCode()`) → `@classytic/repo-core/errors`.
26
+ > `ArcError implements HttpError`.
27
+ >
28
+ > Events stay here. `@classytic/primitives/events` remains the canonical
29
+ > source for `EventMeta`, `DomainEvent`, `EventHandler`, `EventLogger`,
30
+ > `EventTransport`, `DeadLetteredEvent`, `PublishManyResult`, `createEvent`,
31
+ > `createChildEvent`, `matchEventPattern`. Arc 2.12 re-exports only the
32
+ > runtime `MemoryEventTransport`.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ npm install @classytic/primitives
38
+ ```
39
+
40
+ ## Use
41
+
42
+ Prefer **subpath imports** — each concern is its own entry point, types erase
43
+ at compile time, runtime helpers are tiny.
44
+
45
+ ```ts
46
+ import { addMoney, fromMajor, type Money } from '@classytic/primitives/money';
47
+ import { createEvent, type DomainEvent, type EventTransport } from '@classytic/primitives/events';
48
+ import type { OperationContext } from '@classytic/primitives/context';
49
+ import { allocate, type SplitResult } from '@classytic/primitives/split-allocation';
50
+ // Tenant config, error contracts, and pagination now live in repo-core:
51
+ import { resolveTenantConfig, type TenantConfig } from '@classytic/repo-core/tenant';
52
+ import type { HttpError, ErrorContract } from '@classytic/repo-core/errors';
53
+ import type {
54
+ OffsetPaginationResult, KeysetPaginationResult, AggregatePaginationResult,
55
+ } from '@classytic/repo-core/pagination';
56
+ ```
57
+
58
+ There is intentionally **no root barrel** — a barrel forces the Node ESM
59
+ loader and TypeScript compiler to walk every module on a single convenience
60
+ import, which slows CI and pulls unused code into the consumer's graph. Use
61
+ subpath imports only.
62
+
63
+ ## What's inside
64
+
65
+ | Subpath | Ships |
66
+ |---|---|
67
+ | `/money` | `Money`, `fromMajor`, `toMajor`, `addMoney`, `subtractMoney`, `multiplyMoney`, `sumMoney`, `equalsMoney`, `compareMoney`, `negateMoney`, `absMoney`, `isMoney`, `CurrencyMismatchError` |
68
+ | `/currency` | `CurrencyCode` brand, `CURRENCIES`, `MINOR_UNIT_FACTOR`, `minorUnitFactor`, `isKnownCurrency`, `toCurrencyCode`, `currencyCode`, `isCurrencyCode`, `InvalidCurrencyCodeError`, `CurrencyMismatchError`, `FxSnapshot`, `convertWithSnapshot`, `reverseWithSnapshot`, `FxRounding` |
69
+ | `/address` | `Address`, `ContactAddress`, `GeoPoint`, `GeoJsonPoint`, `toGeoJsonPoint`, `fromGeoJsonPoint` |
70
+ | `/period` | `DateRange`, `Period`, `isDateRange`, `isWithin`, `rangeDurationMs`, `rangesOverlap`, `DateRangeError` — **plus timezone-aware resolution**: `resolvePeriod`, `resolveDay`, `resolveMonth`, `resolveQuarter`, `resolveYear`, `resolveDateSpan`, `dayStart`, `periodOf`, `parsePeriod`, `formatPeriod`, `granularityOf`, `periodTimeZone`, `inclusiveEnd`, `PeriodError` |
71
+ | `/reference` | `ExternalRef`, `ObjectIdLike`, `IdLike`, `DocumentRef`, `idToString`, `isExternalRef` |
72
+ | `/context` | `OperationContext`, `ActorRef` |
73
+ | `/events` | `DomainEvent`, `EventMeta`, `EventHandler`, `EventLogger`, `EventTransport`, `DeadLetteredEvent`, `PublishManyResult`, `createEvent`, `createChildEvent`, `matchEventPattern` |
74
+ | `/result` | `Result<T, E>`, `ok`, `err`, `isOk`, `isErr`, `mapResult`, `mapError`, `unwrap` |
75
+ | `/brand` | `Brand<T, B>`, `Prettify`, `DeepPartial`, `DeepReadonly`, `RequireKeys`, `OptionalKeys`, `Nullable`, `KeysMatching`, `NonEmptyArray` |
76
+ | `/split-allocation` | `allocate`, `isBalanced`, `SplitAllocationError`, `SplitMethod`, `SplitSubject`, `SplitPart`, `SplitResult` |
77
+ | `/idempotency` | `decideClaim`, `completeClaim`, `renewLease`, `identityKey`, `sameIdentity`, `fingerprintRequest`, `newLeaseToken`, `isLeaseExpired`, `holdsLease`, `isInFlight`, `DEFAULT_LEASE_MS`, `IdempotencyClaim`, `ClaimOutcome`, `ClaimResult`, `ClaimState`, `IdempotencyIdentity`, `IdempotencyError` |
78
+ | `/approval` | `createChain`, `applyDecision`, `skipStep`, `nextPendingStep`, `pendingSteps`, `isApproved`, `assertApproved`, `assertApprovedIfPresent`, `isRejected`, `isPending`, `decisionCount`, `ApprovalError`, `ApprovalChain`, `ApprovalStep`, `ApprovalDecision`, `Approver`, `ApprovalThreshold` |
79
+ | `/cadence` | `nextOccurrence`, `occurrencesBetween`, `validateCadence`, `CadenceError`, `Cadence`, `DailyCadence`, `WeeklyCadence`, `MonthlyCadence`, `YearlyCadence`, `CronCadence`, `IsoWeekday` |
80
+ | `/hold` | `addHold`, `resolveHold`, `activeHolds`, `resolvedHolds`, `isOnHold`, `hasActiveHoldOfCode`, `HoldError`, `HoldReason`, `HoldActor` |
81
+ | `/sla` | `breachedAt`, `remainingMs`, `elapsedMs`, `isBreached`, `consumedFraction`, `validateSLA`, `SLAError`, `SLA`, `BreachPolicy` |
82
+
83
+ ## Design
84
+
85
+ ### Money — integer minor units, always
86
+
87
+ ```ts
88
+ import { fromMajor, addMoney, toMajor } from '@classytic/primitives/money';
89
+
90
+ const subtotal = fromMajor(19.99, 'USD'); // { amount: 1999, currency: 'USD' }
91
+ const shipping = fromMajor(5.00, 'USD'); // { amount: 500, currency: 'USD' }
92
+ const total = addMoney(subtotal, shipping); // { amount: 2499, currency: 'USD' }
93
+ toMajor(total); // 24.99
94
+ ```
95
+
96
+ `Money.amount` is always an integer in the currency's minor unit — `1` = one
97
+ cent for USD, one yen for JPY, one fils for KWD. Mixing currencies throws
98
+ `CurrencyMismatchError`. This eliminates the float-rounding drift that happens
99
+ when every package rolls its own `{ amount: number, currency: string }`.
100
+
101
+ ### DomainEvent — structurally identical to Arc
102
+
103
+ ```ts
104
+ import { createEvent, type EventTransport } from '@classytic/primitives/events';
105
+
106
+ const event = createEvent('order:placed', { orderId: 'ord_123' }, {
107
+ organizationId: ctx.organizationId,
108
+ userId: ctx.actorId,
109
+ correlationId: ctx.correlationId,
110
+ });
111
+
112
+ await transport.publish(event);
113
+ ```
114
+
115
+ Shape matches `@classytic/arc`'s `DomainEvent` exactly — any Arc transport
116
+ (memory, Redis pub/sub, Redis Streams, Kafka) plugs into a package expecting
117
+ this interface without adapters. See PACKAGE_RULES.md §11.
118
+
119
+ ### TenantConfig — moved to `@classytic/repo-core/tenant`
120
+
121
+ ```ts
122
+ import { resolveTenantConfig } from '@classytic/repo-core/tenant';
123
+
124
+ const tenant = resolveTenantConfig(config.tenant);
125
+ // { enabled: true, tenantField: 'organizationId', fieldType: 'objectId',
126
+ // ref: 'organization', contextKey: 'organizationId', required: true }
127
+ ```
128
+
129
+ Mongokit's `MultiTenantOptions` and sqlitekit's equivalent both
130
+ `extends Pick<TenantConfig, ...>` from repo-core — pass the resolved config
131
+ straight into `multiTenantPlugin(resolved)`.
132
+
133
+ ### ExternalRef — polymorphic, framework-free
134
+
135
+ ```ts
136
+ interface ExternalRef {
137
+ sourceId: string;
138
+ sourceModel: string;
139
+ }
140
+ ```
141
+
142
+ For references that cross trust boundaries (another package, another service,
143
+ Stripe, Postgres) — paired with a host-implemented `SourceBridge`. Works for
144
+ ObjectId, UUID, Stripe ID, anything. See PACKAGE_RULES.md §7.
145
+
146
+ ## What's not here (on purpose)
147
+
148
+ - **Mongoose types** — use `ObjectIdLike` from `/reference`. Any Mongoose
149
+ `Types.ObjectId` satisfies it structurally.
150
+ - **Transaction / Order / Cart shapes** — those are domain types, not
151
+ primitives. They live in their respective packages.
152
+ - **Error subclasses** — throw plain `Error`s; hosts serialize them into
153
+ `ErrorContract` (from `@classytic/repo-core/errors`) at the HTTP boundary
154
+ via `toErrorContract(err)`.
155
+ - **Runtime currency database** — ship a whitelist at the app layer if you
156
+ need one. `MINOR_UNIT_FACTOR` covers common cases.
157
+
158
+ ## License
159
+
160
+ MIT © Classytic
@@ -88,7 +88,12 @@ type ApprovalErrorCode = 'EMPTY_STEPS' | 'DUPLICATE_STEP_ID' | 'EMPTY_APPROVERS'
88
88
  * `approved` (still `pending`, or `rejected`). The single canonical
89
89
  * "not yet approved" gate — hosts route it through arc-approval's
90
90
  * `rethrowApprovalError` to the `approval.chain_incomplete` wire code. */
91
- | 'CHAIN_INCOMPLETE';
91
+ | 'CHAIN_INCOMPLETE'
92
+ /** A finalize/commit was attempted with NO chain at all. Distinct from
93
+ * `CHAIN_INCOMPLETE`: the chain is not unfinished, it is absent — the
94
+ * document never carried one, or a schema that never declared the field
95
+ * stripped it on write. See {@link assertApproved}. */
96
+ | 'CHAIN_MISSING';
92
97
  declare class ApprovalError extends Error {
93
98
  readonly name = "ApprovalError";
94
99
  readonly code: ApprovalErrorCode;
@@ -128,16 +133,50 @@ declare function applyDecision(chain: ApprovalChain, input: DecisionInput): Appr
128
133
  */
129
134
  declare function skipStep(chain: ApprovalChain, stepId: string, reason?: string): ApprovalChain;
130
135
  declare function isApproved(chain: ApprovalChain): boolean;
136
+ interface AssertApprovedOptions {
137
+ /** Override the thrown message. */
138
+ readonly message?: string;
139
+ }
131
140
  /**
132
- * The canonical finalize-time gate: assert `chain` has reached `approved`,
133
- * else throw `ApprovalError('CHAIN_INCOMPLETE')`. Use this at every
134
- * commit/post/finalize boundary that must not proceed on an unapproved chain
135
- * (journal-entry post, PO approve, transfer dispatch, …) so they all raise the
136
- * SAME error instead of hand-rolling divergent codes. A `null`/absent chain is
137
- * treated as "no chain required" and passes — callers that require a chain
138
- * check presence separately.
141
+ * The canonical finalize-time gate: assert a chain EXISTS and has reached
142
+ * `approved`, else throw. Use it at every commit/post/finalize boundary that
143
+ * must not proceed unapproved (journal-entry post, PO approve, transfer
144
+ * dispatch, payment release) so they all raise the same two codes instead of
145
+ * hand-rolling divergent ones.
146
+ *
147
+ * ## Absence is not approval
148
+ *
149
+ * This function used to PASS on `null`/`undefined`, documented as "no chain
150
+ * required", with only prose telling callers to check presence separately. That
151
+ * is the permissive default with the widest blast radius: the gate reads as
152
+ * enforced at every call site while doing nothing for the one input most likely
153
+ * to occur by accident.
154
+ *
155
+ * And it occurs by accident routinely — a mongoose schema that never declared
156
+ * `approvalChain` **strips it on write**, so a document that carried a chain in
157
+ * memory reads back without one. The gate then passes, the money moves, and
158
+ * nothing anywhere says so. The flag has to survive the write; a gate that
159
+ * cannot tell "no approval configured" from "approval silently lost" must
160
+ * assume the dangerous one.
161
+ *
162
+ * A caller that genuinely permits an absent chain must say so by name —
163
+ * {@link assertApprovedIfPresent} — which is greppable in review, unlike a
164
+ * `null` that never got checked.
165
+ *
166
+ * @throws ApprovalError `CHAIN_MISSING` when `chain` is null/undefined.
167
+ * @throws ApprovalError `CHAIN_INCOMPLETE` when it exists but is not `approved`.
168
+ */
169
+ declare function assertApproved(chain: ApprovalChain | null | undefined, options?: AssertApprovedOptions): void;
170
+ /**
171
+ * The EXPLICIT opt-out: enforce the chain when one is present, allow the
172
+ * operation when none is.
173
+ *
174
+ * Only correct where "no chain" is a configured state the host can distinguish
175
+ * from a lost one — e.g. a policy engine that decided this document needs no
176
+ * approval and recorded that decision elsewhere. If you cannot tell the two
177
+ * apart, use {@link assertApproved}.
139
178
  */
140
- declare function assertApproved(chain: ApprovalChain | null | undefined, message?: string): void;
179
+ declare function assertApprovedIfPresent(chain: ApprovalChain | null | undefined, options?: AssertApprovedOptions): void;
141
180
  declare function isRejected(chain: ApprovalChain): boolean;
142
181
  declare function isPending(chain: ApprovalChain): boolean;
143
182
  /**
@@ -145,4 +184,4 @@ declare function isPending(chain: ApprovalChain): boolean;
145
184
  */
146
185
  declare function decisionCount(chain: ApprovalChain): number;
147
186
  //#endregion
148
- export { ApprovalChain, ApprovalDecision, ApprovalError, ApprovalErrorCode, ApprovalStatus, ApprovalStep, ApprovalThreshold, Approver, ChainOrder, CreateChainInput, CreateStepInput, DecisionInput, applyDecision, assertApproved, createChain, decisionCount, isApproved, isPending, isRejected, nextPendingStep, pendingSteps, skipStep };
187
+ export { ApprovalChain, ApprovalDecision, ApprovalError, ApprovalErrorCode, ApprovalStatus, ApprovalStep, ApprovalThreshold, Approver, AssertApprovedOptions, ChainOrder, CreateChainInput, CreateStepInput, DecisionInput, applyDecision, assertApproved, assertApprovedIfPresent, createChain, decisionCount, isApproved, isPending, isRejected, nextPendingStep, pendingSteps, skipStep };
package/dist/approval.mjs CHANGED
@@ -116,16 +116,50 @@ function isApproved(chain) {
116
116
  return chain.status === "approved";
117
117
  }
118
118
  /**
119
- * The canonical finalize-time gate: assert `chain` has reached `approved`,
120
- * else throw `ApprovalError('CHAIN_INCOMPLETE')`. Use this at every
121
- * commit/post/finalize boundary that must not proceed on an unapproved chain
122
- * (journal-entry post, PO approve, transfer dispatch, …) so they all raise the
123
- * SAME error instead of hand-rolling divergent codes. A `null`/absent chain is
124
- * treated as "no chain required" and passes — callers that require a chain
125
- * check presence separately.
119
+ * The canonical finalize-time gate: assert a chain EXISTS and has reached
120
+ * `approved`, else throw. Use it at every commit/post/finalize boundary that
121
+ * must not proceed unapproved (journal-entry post, PO approve, transfer
122
+ * dispatch, payment release) so they all raise the same two codes instead of
123
+ * hand-rolling divergent ones.
124
+ *
125
+ * ## Absence is not approval
126
+ *
127
+ * This function used to PASS on `null`/`undefined`, documented as "no chain
128
+ * required", with only prose telling callers to check presence separately. That
129
+ * is the permissive default with the widest blast radius: the gate reads as
130
+ * enforced at every call site while doing nothing for the one input most likely
131
+ * to occur by accident.
132
+ *
133
+ * And it occurs by accident routinely — a mongoose schema that never declared
134
+ * `approvalChain` **strips it on write**, so a document that carried a chain in
135
+ * memory reads back without one. The gate then passes, the money moves, and
136
+ * nothing anywhere says so. The flag has to survive the write; a gate that
137
+ * cannot tell "no approval configured" from "approval silently lost" must
138
+ * assume the dangerous one.
139
+ *
140
+ * A caller that genuinely permits an absent chain must say so by name —
141
+ * {@link assertApprovedIfPresent} — which is greppable in review, unlike a
142
+ * `null` that never got checked.
143
+ *
144
+ * @throws ApprovalError `CHAIN_MISSING` when `chain` is null/undefined.
145
+ * @throws ApprovalError `CHAIN_INCOMPLETE` when it exists but is not `approved`.
146
+ */
147
+ function assertApproved(chain, options = {}) {
148
+ if (chain == null) throw new ApprovalError("CHAIN_MISSING", options.message ?? "no approval chain present at a gate that requires one — an absent chain is not an approved one. If this boundary legitimately runs without approval, call assertApprovedIfPresent() so the exemption is explicit.");
149
+ if (!isApproved(chain)) throw new ApprovalError("CHAIN_INCOMPLETE", options.message ?? `approval chain has not reached 'approved' (status='${chain.status}')`);
150
+ }
151
+ /**
152
+ * The EXPLICIT opt-out: enforce the chain when one is present, allow the
153
+ * operation when none is.
154
+ *
155
+ * Only correct where "no chain" is a configured state the host can distinguish
156
+ * from a lost one — e.g. a policy engine that decided this document needs no
157
+ * approval and recorded that decision elsewhere. If you cannot tell the two
158
+ * apart, use {@link assertApproved}.
126
159
  */
127
- function assertApproved(chain, message) {
128
- if (chain != null && !isApproved(chain)) throw new ApprovalError("CHAIN_INCOMPLETE", message ?? `approval chain has not reached 'approved' (status='${chain.status}')`);
160
+ function assertApprovedIfPresent(chain, options = {}) {
161
+ if (chain == null) return;
162
+ assertApproved(chain, options);
129
163
  }
130
164
  function isRejected(chain) {
131
165
  return chain.status === "rejected";
@@ -155,4 +189,4 @@ function computeChainStatus(steps) {
155
189
  return steps.every((s) => s.status === "approved" || s.status === "skipped") ? "approved" : "pending";
156
190
  }
157
191
  //#endregion
158
- export { ApprovalError, applyDecision, assertApproved, createChain, decisionCount, isApproved, isPending, isRejected, nextPendingStep, pendingSteps, skipStep };
192
+ export { ApprovalError, applyDecision, assertApproved, assertApprovedIfPresent, createChain, decisionCount, isApproved, isPending, isRejected, nextPendingStep, pendingSteps, skipStep };
@@ -1,4 +1,4 @@
1
- import { Money } from "./money.mjs";
1
+ import { n as Money } from "./money-DPo9fLBi.mjs";
2
2
 
3
3
  //#region src/money/bank-transaction.d.ts
4
4
  /**
@@ -0,0 +1,107 @@
1
+ //#region src/scheduling/cadence.d.ts
2
+ /**
3
+ * Recurrence spec + pure date arithmetic.
4
+ *
5
+ * Describes "every N days", "every weekday", "on the 15th of each month", etc.
6
+ * Consumed by `@classytic/order` (blanket / recurring orders, subscription
7
+ * renewal), `@classytic/flow` (replenishment schedules), and
8
+ * `@classytic/commission` (vesting periods, statement close).
9
+ *
10
+ * No cron parser dep — the `cron` kind stores an opaque string that the host
11
+ * parses with its own library (node-cron, croner, etc.). This keeps primitives
12
+ * zero-dep and lets hosts pick whatever cron dialect they want.
13
+ *
14
+ * @example
15
+ * const monthly: Cadence = {
16
+ * kind: 'monthly',
17
+ * interval: 1,
18
+ * dayOfMonth: 15,
19
+ * startAt: new Date('2026-01-15T00:00:00Z'),
20
+ * };
21
+ * nextOccurrence(monthly, new Date('2026-02-20T00:00:00Z'));
22
+ * // → 2026-03-15T00:00:00Z
23
+ */
24
+ type CadenceKind = 'daily' | 'weekly' | 'monthly' | 'yearly' | 'cron';
25
+ /** ISO weekday — 1=Mon … 7=Sun. Matches Temporal + ISO 8601. */
26
+ type IsoWeekday = 1 | 2 | 3 | 4 | 5 | 6 | 7;
27
+ interface CadenceBase {
28
+ readonly interval: number;
29
+ readonly startAt: Date;
30
+ readonly endAt?: Date;
31
+ /**
32
+ * IANA zone the recurrence's WALL CLOCK is anchored to (e.g. `Asia/Dhaka`).
33
+ * **Authoritative, not decorative.**
34
+ *
35
+ * When set, occurrence math runs on that zone's calendar: `dayOfMonth: 1`
36
+ * lands on local midnight-relative wall time of the local 1st, weekday
37
+ * filters read the local weekday, and daily steps advance local calendar
38
+ * days (23h or 25h across a DST transition, not a flat 24h).
39
+ *
40
+ * When ABSENT, everything is UTC — the right default for a schedule that
41
+ * genuinely has no locale.
42
+ *
43
+ * History worth keeping: this field used to be documented as display-only
44
+ * while `stepMonthly` / `stepYearly` read `getUTCMonth()` / `getUTCDate()`.
45
+ * With `timezone: 'Asia/Dhaka'` and `dayOfMonth: 1` that placed every
46
+ * occurrence on the UTC 1st — 06:00 on the local 1st, or with a midnight
47
+ * anchor, the local **2nd at 00:00**. The field said one thing and the math
48
+ * did another, and nothing failed. A field that lies is worse than no field.
49
+ */
50
+ readonly timezone?: string;
51
+ }
52
+ interface DailyCadence extends CadenceBase {
53
+ readonly kind: 'daily';
54
+ }
55
+ interface WeeklyCadence extends CadenceBase {
56
+ readonly kind: 'weekly';
57
+ /**
58
+ * If provided, only these ISO weekdays count as occurrences; the earliest
59
+ * matching weekday on or after `startAt` is the first occurrence. If omitted,
60
+ * the weekday of `startAt` is used.
61
+ */
62
+ readonly daysOfWeek?: readonly IsoWeekday[];
63
+ }
64
+ interface MonthlyCadence extends CadenceBase {
65
+ readonly kind: 'monthly';
66
+ /**
67
+ * Day of month, 1–31. If the target month has fewer days (e.g. 31st → Feb),
68
+ * occurrence snaps to the last day of the month.
69
+ */
70
+ readonly dayOfMonth: number;
71
+ }
72
+ interface YearlyCadence extends CadenceBase {
73
+ readonly kind: 'yearly';
74
+ /** Calendar month 1–12. */
75
+ readonly month: number;
76
+ /** Day of month 1–31. Snaps to last day of month if needed (e.g. Feb 29 → Feb 28 in non-leap years). */
77
+ readonly dayOfMonth: number;
78
+ }
79
+ interface CronCadence extends CadenceBase {
80
+ readonly kind: 'cron';
81
+ /** Opaque cron expression — caller parses with its own cron library. */
82
+ readonly expression: string;
83
+ }
84
+ type Cadence = DailyCadence | WeeklyCadence | MonthlyCadence | YearlyCadence | CronCadence;
85
+ type CadenceErrorCode = 'INVALID_INTERVAL' | 'INVALID_DAY_OF_MONTH' | 'INVALID_MONTH' | 'INVALID_DAYS_OF_WEEK' | 'INVALID_START_AT' | 'INVALID_END_AT' | 'INVALID_TIMEZONE' | 'UNSUPPORTED_CRON';
86
+ declare class CadenceError extends Error {
87
+ readonly name = "CadenceError";
88
+ readonly code: CadenceErrorCode;
89
+ constructor(code: CadenceErrorCode, message: string);
90
+ }
91
+ /** Validate a Cadence spec. Call this before storing host-side. */
92
+ declare function validateCadence(c: Cadence): void;
93
+ /**
94
+ * Next occurrence strictly after `from`. Returns `null` if the cadence has
95
+ * ended (endAt passed) or the first occurrence is still in the future.
96
+ *
97
+ * Cron cadences return `null` — hosts must parse the expression themselves.
98
+ */
99
+ declare function nextOccurrence(cadence: Cadence, from: Date): Date | null;
100
+ /**
101
+ * Enumerate occurrences in `[from, to)` inclusive at `from`, exclusive at `to`.
102
+ * Returns up to `limit` results (default 10_000) to guard against runaway
103
+ * misconfigured cadences. Cron returns an empty array.
104
+ */
105
+ declare function occurrencesBetween(cadence: Cadence, from: Date, to: Date, limit?: number): Date[];
106
+ //#endregion
107
+ export { CronCadence as a, MonthlyCadence as c, nextOccurrence as d, occurrencesBetween as f, CadenceKind as i, WeeklyCadence as l, CadenceError as n, DailyCadence as o, validateCadence as p, CadenceErrorCode as r, IsoWeekday as s, Cadence as t, YearlyCadence as u };