@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.
- package/CHANGELOG.md +906 -715
- package/README.md +160 -159
- package/dist/approval.d.mts +49 -10
- package/dist/approval.mjs +44 -10
- package/dist/bank-transaction.d.mts +1 -1
- package/dist/cadence-BFQVC1Qp.d.mts +107 -0
- package/dist/cadence.d.mts +1 -92
- package/dist/cadence.mjs +133 -4
- package/dist/calendar-B6fm-FbR.d.mts +46 -0
- package/dist/calendar.d.mts +1 -45
- package/dist/canonical.mjs +17 -0
- package/dist/context.d.mts +28 -1
- package/dist/currency.d.mts +2 -2
- package/dist/currency.mjs +134 -19
- package/dist/event-infra.d.mts +1 -34
- package/dist/event-infra.mjs +16 -46
- package/dist/idempotency.d.mts +253 -0
- package/dist/idempotency.mjs +226 -0
- package/dist/memory-outbox.d.mts +64 -0
- package/dist/memory-outbox.mjs +172 -0
- package/dist/monetization.d.mts +195 -0
- package/dist/monetization.mjs +79 -0
- package/dist/money-DPo9fLBi.d.mts +347 -0
- package/dist/money.d.mts +1 -140
- package/dist/money.mjs +6 -15
- package/dist/outbox.d.mts +49 -1
- package/dist/outbox.mjs +64 -1
- package/dist/payment-events.d.mts +132 -4
- package/dist/payment-events.mjs +22 -2
- package/dist/payment-gateway.d.mts +210 -7
- package/dist/payment-gateway.mjs +18 -1
- package/dist/period.d.mts +138 -7
- package/dist/period.mjs +316 -5
- package/dist/person-parse.d.mts +82 -0
- package/dist/person-parse.mjs +109 -0
- package/dist/proration.d.mts +76 -0
- package/dist/proration.mjs +132 -0
- package/dist/sla-policy.d.mts +1 -1
- package/dist/state-machine.mjs +13 -0
- package/dist/suspension.d.mts +1 -1
- package/dist/timezone-tlG7ygwx.d.mts +114 -0
- package/dist/timezone.d.mts +2 -100
- package/dist/timezone.mjs +38 -3
- package/dist/unit-cost-rate.d.mts +1 -2
- package/package.json +244 -224
- package/dist/currency-BCJnJBlb.d.mts +0 -119
package/README.md
CHANGED
|
@@ -1,159 +1,160 @@
|
|
|
1
|
-
# @classytic/primitives
|
|
2
|
-
|
|
3
|
-
[](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
|
-
| `/
|
|
78
|
-
| `/
|
|
79
|
-
| `/
|
|
80
|
-
| `/
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
`
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
1
|
+
# @classytic/primitives
|
|
2
|
+
|
|
3
|
+
[](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
|
package/dist/approval.d.mts
CHANGED
|
@@ -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
|
|
133
|
-
* else throw
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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
|
|
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
|
|
120
|
-
* else throw
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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
|
|
128
|
-
if (chain
|
|
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 };
|
|
@@ -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 };
|