@classytic/primitives 0.23.0 → 0.25.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 +16 -0
- package/README.md +160 -160
- package/dist/address.d.mts +270 -1
- package/dist/address.mjs +190 -1
- package/dist/item-identity.d.mts +71 -0
- package/dist/item-identity.mjs +50 -0
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
4
4
|
adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
5
|
|
|
6
|
+
## [0.25.0] - 2026-08-24
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **`@classytic/primitives/item-identity` subpath** — `SkuFields`, `stockKeyOf`, `skuDisplay`. `sku` is a label; `skuRef` is the stock identity — the two fall back in opposite directions and getting one backwards type-checks while returning a plausible wrong string. `stockKeyOf` resolves `skuRef ?? sku ?? fallback` (the key to reserve, decrement and post against); `skuDisplay` resolves `skuLabel ?? sku ?? skuRef` (what a human reads). Canonicalises the per-package `variantSku || String(productId)` derivations into one place.
|
|
11
|
+
|
|
12
|
+
## [0.24.0] - 2026-08-24
|
|
13
|
+
|
|
14
|
+
### Added — `@classytic/primitives/address`: capture-format system
|
|
15
|
+
|
|
16
|
+
`AddressFormat`, `AddressFieldDescriptor`, `AddressFieldKey`, `PartialAddress`, `AddressFieldError` — a description of HOW a country collects a `ContactAddress`: field labels, order, requiredness, subdivision taxonomy hooks, and postal code pattern. Country packs (e.g. `@classytic/bd-areas/address-format`) live beside their taxonomies; this subpath owns only the generic shape.
|
|
17
|
+
|
|
18
|
+
`validateAddress(address, format)` — validates a partial address against a format; returns `AddressFieldError[]`. Enforces `required` on both canonical and extension fields identically, and checks `postalCodePattern` only when a value is given and a pattern is declared. `isAddressValid` and `describeAddressError` are convenience wrappers.
|
|
19
|
+
|
|
20
|
+
Every `key` in a descriptor is a field of `ContactAddress`; co-location with the value object is what makes a mismatch obvious instead of invisible.
|
|
21
|
+
|
|
6
22
|
## [0.23.0] - 2026-08-15
|
|
7
23
|
|
|
8
24
|
### Added — `./approval`: `notRequiredChain(reason)` + `isNotRequired(chain)`
|
package/README.md
CHANGED
|
@@ -1,160 +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`, `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
|
|
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/address.d.mts
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* Kept deliberately generic — international postal formats vary too much to
|
|
6
6
|
* enforce per-country rules in a shared type. Consumers can extend or narrow
|
|
7
7
|
* as needed (e.g. a BD-specific schema might add `district`, `upazila`).
|
|
8
|
+
*
|
|
9
|
+
* That still holds, and the CAPTURE FORMAT section below does not weaken it. A
|
|
10
|
+
* format constrains no type and adds no field: it is opt-in DATA describing how
|
|
11
|
+
* a country asks for the fields declared here. The type stays generic precisely
|
|
12
|
+
* so one canonical shape can be captured many ways.
|
|
8
13
|
*/
|
|
9
14
|
interface Address {
|
|
10
15
|
line1: string;
|
|
@@ -34,5 +39,269 @@ interface GeoJsonPoint {
|
|
|
34
39
|
}
|
|
35
40
|
declare function toGeoJsonPoint(p: GeoPoint): GeoJsonPoint;
|
|
36
41
|
declare function fromGeoJsonPoint(g: GeoJsonPoint): GeoPoint;
|
|
42
|
+
/**
|
|
43
|
+
* How a country's postal address is CAPTURED — labels, order, requiredness, and
|
|
44
|
+
* which fields come from a subdivision taxonomy rather than free text.
|
|
45
|
+
*
|
|
46
|
+
* ## Why this lives HERE, beside the value object
|
|
47
|
+
*
|
|
48
|
+
* Storage was already standardised: an address is `ContactAddress` above. What
|
|
49
|
+
* varied — and varied by being hardcoded — is CAPTURE. The checkout form asked
|
|
50
|
+
* for "Division", "District" and "Delivery Area" because the deployment is
|
|
51
|
+
* Bangladeshi, so selling in a second country meant editing the form. That is
|
|
52
|
+
* the defect `@classytic/ledger` already solved for charts of accounts: the
|
|
53
|
+
* ENGINE was generic and the BINDING was not.
|
|
54
|
+
*
|
|
55
|
+
* This first went into a separate `@classytic/address` package. Wrong, for the
|
|
56
|
+
* reason this codebase has just paid for twice: a description of a shape that
|
|
57
|
+
* lives away from the shape DRIFTS from it. `Order`'s Mongoose schema said
|
|
58
|
+
* `addressLine1` while the type it declared said `line1`, and nothing could see
|
|
59
|
+
* the contradiction because they were in different files. Every `key` below is
|
|
60
|
+
* a field of `ContactAddress`; putting them in the same file is what makes a
|
|
61
|
+
* mismatch obvious instead of invisible.
|
|
62
|
+
*
|
|
63
|
+
* The COUNTRY PACKS do not live here — they are localizations, and they follow
|
|
64
|
+
* the `ledger` / `ledger-bd` precedent: Bangladesh's is
|
|
65
|
+
* `@classytic/bd-areas/address-format`, beside the taxonomy it selects from.
|
|
66
|
+
* Nothing in this file knows what a division is.
|
|
67
|
+
*
|
|
68
|
+
* ## What a format does NOT do
|
|
69
|
+
*
|
|
70
|
+
* It does not introduce new stored fields. Every descriptor's `key` is a field
|
|
71
|
+
* of `ContactAddress`, so a format is a VIEW over one canonical shape:
|
|
72
|
+
* Bangladesh labels `state` "Division", Japan labels it "Prefecture", the United
|
|
73
|
+
* States labels it "State". Same column, same value object, different word.
|
|
74
|
+
*
|
|
75
|
+
* A country that genuinely needs an extra datum (a thana, an upazila, a landmark)
|
|
76
|
+
* puts it in `metadata` — primitives' own guidance — never in a new top-level
|
|
77
|
+
* field, because that would make the stored shape country-dependent again.
|
|
78
|
+
*/
|
|
79
|
+
/** The `ContactAddress` fields a format may lay out. */
|
|
80
|
+
type AddressFieldKey = 'name' | 'phone' | 'email' | 'company' | 'line1' | 'line2' | 'city' | 'state' | 'postalCode' | 'country';
|
|
81
|
+
/**
|
|
82
|
+
* How a field is entered.
|
|
83
|
+
*
|
|
84
|
+
* `subdivision` is the interesting one: the value is still a plain string on the
|
|
85
|
+
* stored address, but it is CHOSEN from a taxonomy rather than typed. Bangladesh
|
|
86
|
+
* picks a division then a district; India picks a state; the US picks a state.
|
|
87
|
+
* The taxonomy itself is supplied by the host through `AddressTaxonomyPort` —
|
|
88
|
+
* this package ships no place names, so it stays country-agnostic and small.
|
|
89
|
+
*/
|
|
90
|
+
type AddressFieldInput = 'text' | 'tel' | 'email' | 'subdivision';
|
|
91
|
+
interface AddressFieldSpec {
|
|
92
|
+
/** The canonical `ContactAddress` field this captures. */
|
|
93
|
+
key: AddressFieldKey;
|
|
94
|
+
/** Label in the deployment's language. */
|
|
95
|
+
label: string;
|
|
96
|
+
input: AddressFieldInput;
|
|
97
|
+
required?: boolean;
|
|
98
|
+
/** Spans the full row in a two-column form. */
|
|
99
|
+
wide?: boolean;
|
|
100
|
+
placeholder?: string;
|
|
101
|
+
/** Help text under the field. */
|
|
102
|
+
hint?: string;
|
|
103
|
+
/**
|
|
104
|
+
* For `input: 'subdivision'` — the taxonomy level this field selects, resolved
|
|
105
|
+
* through `AddressTaxonomyPort`. Opaque to this package: `'division'`,
|
|
106
|
+
* `'district'`, `'area'`, `'state'`, `'prefecture'` are all just strings.
|
|
107
|
+
*/
|
|
108
|
+
taxonomy?: string;
|
|
109
|
+
/**
|
|
110
|
+
* The field whose value narrows this one's options — Bangladesh's district
|
|
111
|
+
* list depends on the chosen division. Must appear EARLIER in `fields`.
|
|
112
|
+
*/
|
|
113
|
+
dependsOn?: string;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* A field a country needs that `ContactAddress` does not model.
|
|
117
|
+
*
|
|
118
|
+
* ## Why this exists rather than growing the value object
|
|
119
|
+
*
|
|
120
|
+
* Bangladesh's checkout captures a **Delivery Area** — the locality couriers
|
|
121
|
+
* actually route and price on, finer than a district. It is required there and
|
|
122
|
+
* meaningless in most countries.
|
|
123
|
+
*
|
|
124
|
+
* The two obvious answers are both wrong. Adding `areaName` to `ContactAddress`
|
|
125
|
+
* puts one market's concept in the base type every country pays for; leaving it
|
|
126
|
+
* out entirely means the country that most needs a format cannot express its
|
|
127
|
+
* most important field, which is how the form ends up hardcoded again.
|
|
128
|
+
*
|
|
129
|
+
* So an extension field is DECLARED like any other — same labels, same
|
|
130
|
+
* requiredness, same taxonomy machinery — but its `name` is free-form and the
|
|
131
|
+
* consuming DOMAIN decides where the value lands. `@classytic/order` promotes
|
|
132
|
+
* `areaId` / `areaName` / `zoneId` to real columns because its carrier adapters
|
|
133
|
+
* read them; anything it does not recognise goes to `metadata`. Neither
|
|
134
|
+
* decision belongs in this package, and neither requires a release of it.
|
|
135
|
+
*
|
|
136
|
+
* Values live FLAT alongside the canonical ones, not in a nested bag — which is
|
|
137
|
+
* how `areaName` is already stored, and it means a form produces one object
|
|
138
|
+
* rather than two that can disagree.
|
|
139
|
+
*/
|
|
140
|
+
interface AddressExtensionFieldSpec {
|
|
141
|
+
/** Discriminant. */
|
|
142
|
+
extension: true;
|
|
143
|
+
/**
|
|
144
|
+
* Free-form key. Not validated against `ContactAddress` — that is the point.
|
|
145
|
+
* The domain maps it to a column or to `metadata`.
|
|
146
|
+
*/
|
|
147
|
+
name: string;
|
|
148
|
+
label: string;
|
|
149
|
+
input: AddressFieldInput;
|
|
150
|
+
required?: boolean;
|
|
151
|
+
wide?: boolean;
|
|
152
|
+
placeholder?: string;
|
|
153
|
+
hint?: string;
|
|
154
|
+
taxonomy?: string;
|
|
155
|
+
/** May name a canonical key OR another extension. Must appear EARLIER. */
|
|
156
|
+
dependsOn?: string;
|
|
157
|
+
}
|
|
158
|
+
type AnyAddressFieldSpec = AddressFieldSpec | AddressExtensionFieldSpec;
|
|
159
|
+
/** The key a spec writes to, canonical or extension. */
|
|
160
|
+
declare function fieldName(spec: AnyAddressFieldSpec): string;
|
|
161
|
+
/** Narrowing helper — `true` when the spec is an extension. */
|
|
162
|
+
declare function isExtensionField(spec: AnyAddressFieldSpec): spec is AddressExtensionFieldSpec;
|
|
163
|
+
interface AddressFormat {
|
|
164
|
+
/** ISO 3166-1 alpha-2, or `'*'` for the international fallback. */
|
|
165
|
+
country: string;
|
|
166
|
+
/** Display name of the country, for a read-only country field. */
|
|
167
|
+
countryLabel: string;
|
|
168
|
+
/** Ordered — this is the form layout. Canonical and extension fields interleave. */
|
|
169
|
+
fields: AnyAddressFieldSpec[];
|
|
170
|
+
/**
|
|
171
|
+
* Validate `postalCode` when present. Omit for countries where the code is
|
|
172
|
+
* free-form or absent; a wrong pattern is worse than none, because it refuses
|
|
173
|
+
* addresses that are perfectly valid.
|
|
174
|
+
*/
|
|
175
|
+
postalCodePattern?: RegExp;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* A taxonomy level's options, narrowed by the parent selection.
|
|
179
|
+
*
|
|
180
|
+
* A HOST PORT, not data in this package: Bangladesh's divisions live in
|
|
181
|
+
* `@classytic/bd-areas`, and a kernel that imported them would be a
|
|
182
|
+
* country-agnostic package with one country's place names compiled into it.
|
|
183
|
+
*/
|
|
184
|
+
interface AddressTaxonomyPort {
|
|
185
|
+
/**
|
|
186
|
+
* @param level the `taxonomy` string from the field spec
|
|
187
|
+
* @param parentId the selected value of `dependsOn`, when the field has one
|
|
188
|
+
*/
|
|
189
|
+
options(level: string, parentId?: string): Array<{
|
|
190
|
+
id: string;
|
|
191
|
+
label: string;
|
|
192
|
+
hint?: string;
|
|
193
|
+
}>;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The generic international format — the FALLBACK, and the proof the contract is
|
|
197
|
+
* not BD-shaped.
|
|
198
|
+
*
|
|
199
|
+
* Deliberately conservative. `state` is present but optional because many
|
|
200
|
+
* countries have no meaningful subdivision in a postal address (Singapore,
|
|
201
|
+
* Vatican City), and `postalCode` is optional because several have no postal
|
|
202
|
+
* code at all (Ireland pre-Eircode, Hong Kong, UAE). Requiring either would make
|
|
203
|
+
* the DEFAULT refuse valid addresses — the failure mode a fallback must not have.
|
|
204
|
+
*
|
|
205
|
+
* The required set is exactly what `Fulfillment` needs to be deliverable:
|
|
206
|
+
* `line1`, `city`, `country`.
|
|
207
|
+
*/
|
|
208
|
+
declare const INTERNATIONAL_ADDRESS_FORMAT: AddressFormat;
|
|
209
|
+
/** Look a CANONICAL field up by its key. Extension fields have no `key`. */
|
|
210
|
+
declare function fieldSpec(format: AddressFormat, key: AddressFieldKey): AddressFieldSpec | undefined;
|
|
211
|
+
/**
|
|
212
|
+
* The CANONICAL keys a format marks required.
|
|
213
|
+
*
|
|
214
|
+
* Canonical only, deliberately: callers use this to check that a format can
|
|
215
|
+
* produce a deliverable address (`line1` + `city` + `country`), and an extension
|
|
216
|
+
* name has no meaning in that question. Use `requiredFieldNames` for everything
|
|
217
|
+
* a form must collect.
|
|
218
|
+
*/
|
|
219
|
+
declare function requiredKeys(format: AddressFormat): AddressFieldKey[];
|
|
220
|
+
/** Every field name a format requires, canonical and extension alike. */
|
|
221
|
+
declare function requiredFieldNames(format: AddressFormat): string[];
|
|
222
|
+
/**
|
|
223
|
+
* A capture in progress.
|
|
224
|
+
*
|
|
225
|
+
* Extension values sit FLAT beside the canonical ones — `areaName` alongside
|
|
226
|
+
* `city` — because that is how the order kernel already stores them, and a
|
|
227
|
+
* nested bag would be a second place for the same address to disagree with
|
|
228
|
+
* itself. The index signature is what lets a format declare a field this
|
|
229
|
+
* package has never heard of.
|
|
230
|
+
*/
|
|
231
|
+
type PartialAddress = Partial<Record<AddressFieldKey, string>> & Partial<Pick<ContactAddress, 'line1' | 'city' | 'country'>> & {
|
|
232
|
+
[extension: string]: unknown;
|
|
233
|
+
};
|
|
234
|
+
/**
|
|
235
|
+
* Country code → capture format. The host builds this, exactly as it builds the
|
|
236
|
+
* ledger's `REGISTRY` (`be-prod/.../accounting/country-pack.ts`).
|
|
237
|
+
*/
|
|
238
|
+
type AddressFormatRegistry = Readonly<Record<string, AddressFormat>>;
|
|
239
|
+
/**
|
|
240
|
+
* Resolve the format for a country, falling back to the international one.
|
|
241
|
+
*
|
|
242
|
+
* ## Falling back is the point, not a concession
|
|
243
|
+
*
|
|
244
|
+
* A deployment that has not authored a pack for Norway must still be able to
|
|
245
|
+
* ship to Norway. The fallback captures the deliverable minimum with neutral
|
|
246
|
+
* labels, which is strictly better than either refusing the address or showing
|
|
247
|
+
* a Bangladeshi form to a Norwegian.
|
|
248
|
+
*
|
|
249
|
+
* The lookup is case-insensitive because `country` reaches this from three
|
|
250
|
+
* directions — a form, a stored order, an API payload — and ISO codes get
|
|
251
|
+
* written `bd` about as often as `BD`. Normalising here beats a `?.toUpperCase()`
|
|
252
|
+
* at every call site, one of which will be forgotten and will silently fall back
|
|
253
|
+
* to international for its own home country.
|
|
254
|
+
*/
|
|
255
|
+
declare function resolveAddressFormat(country: string | null | undefined, registry: AddressFormatRegistry): AddressFormat;
|
|
256
|
+
/**
|
|
257
|
+
* The countries a deployment has packs for — for a country picker.
|
|
258
|
+
*
|
|
259
|
+
* A deployment that ships internationally should offer every ISO country and let
|
|
260
|
+
* unlisted ones resolve to the fallback; this is for the common case of an
|
|
261
|
+
* operator choosing among the markets actually configured.
|
|
262
|
+
*/
|
|
263
|
+
declare function supportedCountries(registry: AddressFormatRegistry): Array<{
|
|
264
|
+
code: string;
|
|
265
|
+
label: string;
|
|
266
|
+
}>;
|
|
267
|
+
interface AddressFieldError {
|
|
268
|
+
/**
|
|
269
|
+
* The field's write key — a canonical `AddressFieldKey` or an extension name.
|
|
270
|
+
* Typed `string` because a format may declare fields this package does not
|
|
271
|
+
* know; a form matches it against `fieldName(spec)`.
|
|
272
|
+
*/
|
|
273
|
+
key: string;
|
|
274
|
+
/** A closed code — callers map it to their own copy. */
|
|
275
|
+
code: 'required' | 'postal_code_format';
|
|
276
|
+
/** The field's own label, so a default message reads correctly per country. */
|
|
277
|
+
label: string;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Validate a captured address against its country's format.
|
|
281
|
+
*
|
|
282
|
+
* ## Returns errors; does not throw, and does not "clean"
|
|
283
|
+
*
|
|
284
|
+
* A form needs every failure at once to mark every field, so this returns a
|
|
285
|
+
* list. It also never mutates or drops anything: a validator that silently
|
|
286
|
+
* repaired input would make the stored value differ from what the operator
|
|
287
|
+
* typed, and they would have no way to see it.
|
|
288
|
+
*
|
|
289
|
+
* ## Codes, not sentences
|
|
290
|
+
*
|
|
291
|
+
* `code` is closed and stable so a host can translate. `label` rides along
|
|
292
|
+
* because the same `state` field is "Division" in Bangladesh and "Prefecture"
|
|
293
|
+
* in Japan, and a default message assembled from the code alone would say the
|
|
294
|
+
* wrong word in one of them.
|
|
295
|
+
*
|
|
296
|
+
* ## Whitespace is absence
|
|
297
|
+
*
|
|
298
|
+
* `' '` satisfies a naive truthiness check and then prints as a blank line on
|
|
299
|
+
* a courier label. Required means a value, not a character.
|
|
300
|
+
*/
|
|
301
|
+
declare function validateAddress(address: PartialAddress | null | undefined, format: AddressFormat): AddressFieldError[];
|
|
302
|
+
/** Convenience for a submit gate. */
|
|
303
|
+
declare function isAddressValid(address: PartialAddress | null | undefined, format: AddressFormat): boolean;
|
|
304
|
+
/** A readable default message, in the country's own vocabulary. */
|
|
305
|
+
declare function describeAddressError(error: AddressFieldError): string;
|
|
37
306
|
//#endregion
|
|
38
|
-
export { Address, ContactAddress, GeoJsonPoint, GeoPoint, fromGeoJsonPoint, toGeoJsonPoint };
|
|
307
|
+
export { Address, AddressExtensionFieldSpec, AddressFieldError, AddressFieldInput, AddressFieldKey, AddressFieldSpec, AddressFormat, AddressFormatRegistry, AddressTaxonomyPort, AnyAddressFieldSpec, ContactAddress, GeoJsonPoint, GeoPoint, INTERNATIONAL_ADDRESS_FORMAT, PartialAddress, describeAddressError, fieldName, fieldSpec, fromGeoJsonPoint, isAddressValid, isExtensionField, requiredFieldNames, requiredKeys, resolveAddressFormat, supportedCountries, toGeoJsonPoint, validateAddress };
|
package/dist/address.mjs
CHANGED
|
@@ -12,5 +12,194 @@ function fromGeoJsonPoint(g) {
|
|
|
12
12
|
latitude
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
|
+
/** The key a spec writes to, canonical or extension. */
|
|
16
|
+
function fieldName(spec) {
|
|
17
|
+
return "extension" in spec ? spec.name : spec.key;
|
|
18
|
+
}
|
|
19
|
+
/** Narrowing helper — `true` when the spec is an extension. */
|
|
20
|
+
function isExtensionField(spec) {
|
|
21
|
+
return "extension" in spec;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The generic international format — the FALLBACK, and the proof the contract is
|
|
25
|
+
* not BD-shaped.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately conservative. `state` is present but optional because many
|
|
28
|
+
* countries have no meaningful subdivision in a postal address (Singapore,
|
|
29
|
+
* Vatican City), and `postalCode` is optional because several have no postal
|
|
30
|
+
* code at all (Ireland pre-Eircode, Hong Kong, UAE). Requiring either would make
|
|
31
|
+
* the DEFAULT refuse valid addresses — the failure mode a fallback must not have.
|
|
32
|
+
*
|
|
33
|
+
* The required set is exactly what `Fulfillment` needs to be deliverable:
|
|
34
|
+
* `line1`, `city`, `country`.
|
|
35
|
+
*/
|
|
36
|
+
const INTERNATIONAL_ADDRESS_FORMAT = {
|
|
37
|
+
country: "*",
|
|
38
|
+
countryLabel: "Country",
|
|
39
|
+
fields: [
|
|
40
|
+
{
|
|
41
|
+
key: "name",
|
|
42
|
+
label: "Recipient Name",
|
|
43
|
+
input: "text",
|
|
44
|
+
required: true
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
key: "phone",
|
|
48
|
+
label: "Phone",
|
|
49
|
+
input: "tel",
|
|
50
|
+
required: true
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
key: "line1",
|
|
54
|
+
label: "Address Line 1",
|
|
55
|
+
input: "text",
|
|
56
|
+
required: true,
|
|
57
|
+
wide: true
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
key: "line2",
|
|
61
|
+
label: "Address Line 2",
|
|
62
|
+
input: "text",
|
|
63
|
+
wide: true
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
key: "city",
|
|
67
|
+
label: "City",
|
|
68
|
+
input: "text",
|
|
69
|
+
required: true
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
key: "state",
|
|
73
|
+
label: "State / Province / Region",
|
|
74
|
+
input: "text"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
key: "postalCode",
|
|
78
|
+
label: "Postal Code",
|
|
79
|
+
input: "text"
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
key: "country",
|
|
83
|
+
label: "Country",
|
|
84
|
+
input: "text",
|
|
85
|
+
required: true
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
};
|
|
89
|
+
/** Look a CANONICAL field up by its key. Extension fields have no `key`. */
|
|
90
|
+
function fieldSpec(format, key) {
|
|
91
|
+
return format.fields.find((f) => !isExtensionField(f) && f.key === key);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The CANONICAL keys a format marks required.
|
|
95
|
+
*
|
|
96
|
+
* Canonical only, deliberately: callers use this to check that a format can
|
|
97
|
+
* produce a deliverable address (`line1` + `city` + `country`), and an extension
|
|
98
|
+
* name has no meaning in that question. Use `requiredFieldNames` for everything
|
|
99
|
+
* a form must collect.
|
|
100
|
+
*/
|
|
101
|
+
function requiredKeys(format) {
|
|
102
|
+
return format.fields.filter((f) => !isExtensionField(f) && !!f.required).map((f) => f.key);
|
|
103
|
+
}
|
|
104
|
+
/** Every field name a format requires, canonical and extension alike. */
|
|
105
|
+
function requiredFieldNames(format) {
|
|
106
|
+
return format.fields.filter((f) => f.required).map(fieldName);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the format for a country, falling back to the international one.
|
|
110
|
+
*
|
|
111
|
+
* ## Falling back is the point, not a concession
|
|
112
|
+
*
|
|
113
|
+
* A deployment that has not authored a pack for Norway must still be able to
|
|
114
|
+
* ship to Norway. The fallback captures the deliverable minimum with neutral
|
|
115
|
+
* labels, which is strictly better than either refusing the address or showing
|
|
116
|
+
* a Bangladeshi form to a Norwegian.
|
|
117
|
+
*
|
|
118
|
+
* The lookup is case-insensitive because `country` reaches this from three
|
|
119
|
+
* directions — a form, a stored order, an API payload — and ISO codes get
|
|
120
|
+
* written `bd` about as often as `BD`. Normalising here beats a `?.toUpperCase()`
|
|
121
|
+
* at every call site, one of which will be forgotten and will silently fall back
|
|
122
|
+
* to international for its own home country.
|
|
123
|
+
*/
|
|
124
|
+
function resolveAddressFormat(country, registry) {
|
|
125
|
+
if (!country) return INTERNATIONAL_ADDRESS_FORMAT;
|
|
126
|
+
return registry[country.trim().toUpperCase()] ?? INTERNATIONAL_ADDRESS_FORMAT;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The countries a deployment has packs for — for a country picker.
|
|
130
|
+
*
|
|
131
|
+
* A deployment that ships internationally should offer every ISO country and let
|
|
132
|
+
* unlisted ones resolve to the fallback; this is for the common case of an
|
|
133
|
+
* operator choosing among the markets actually configured.
|
|
134
|
+
*/
|
|
135
|
+
function supportedCountries(registry) {
|
|
136
|
+
return Object.entries(registry).map(([code, format]) => ({
|
|
137
|
+
code,
|
|
138
|
+
label: format.countryLabel
|
|
139
|
+
})).sort((a, b) => a.label.localeCompare(b.label));
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Validate a captured address against its country's format.
|
|
143
|
+
*
|
|
144
|
+
* ## Returns errors; does not throw, and does not "clean"
|
|
145
|
+
*
|
|
146
|
+
* A form needs every failure at once to mark every field, so this returns a
|
|
147
|
+
* list. It also never mutates or drops anything: a validator that silently
|
|
148
|
+
* repaired input would make the stored value differ from what the operator
|
|
149
|
+
* typed, and they would have no way to see it.
|
|
150
|
+
*
|
|
151
|
+
* ## Codes, not sentences
|
|
152
|
+
*
|
|
153
|
+
* `code` is closed and stable so a host can translate. `label` rides along
|
|
154
|
+
* because the same `state` field is "Division" in Bangladesh and "Prefecture"
|
|
155
|
+
* in Japan, and a default message assembled from the code alone would say the
|
|
156
|
+
* wrong word in one of them.
|
|
157
|
+
*
|
|
158
|
+
* ## Whitespace is absence
|
|
159
|
+
*
|
|
160
|
+
* `' '` satisfies a naive truthiness check and then prints as a blank line on
|
|
161
|
+
* a courier label. Required means a value, not a character.
|
|
162
|
+
*/
|
|
163
|
+
function validateAddress(address, format) {
|
|
164
|
+
const errors = [];
|
|
165
|
+
const value = (k) => String(address?.[k] ?? "").trim();
|
|
166
|
+
/**
|
|
167
|
+
* Canonical and extension fields are checked IDENTICALLY, keyed by
|
|
168
|
+
* `fieldName`. A required Delivery Area is enforced exactly as a required
|
|
169
|
+
* city is — an extension that validated more weakly than a core field would
|
|
170
|
+
* be an optional field wearing a required label.
|
|
171
|
+
*/
|
|
172
|
+
for (const field of format.fields) {
|
|
173
|
+
const name = fieldName(field);
|
|
174
|
+
if (field.required && !value(name)) errors.push({
|
|
175
|
+
key: name,
|
|
176
|
+
code: "required",
|
|
177
|
+
label: field.label
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Only when a pattern is declared AND a value was given. An absent optional
|
|
182
|
+
* postal code is not a format error, and a country with no declared pattern
|
|
183
|
+
* must not have one invented for it — see `AddressFormat.postalCodePattern`.
|
|
184
|
+
*/
|
|
185
|
+
const postal = value("postalCode");
|
|
186
|
+
if (format.postalCodePattern && postal && !format.postalCodePattern.test(postal)) errors.push({
|
|
187
|
+
key: "postalCode",
|
|
188
|
+
code: "postal_code_format",
|
|
189
|
+
label: fieldSpec(format, "postalCode")?.label ?? "Postal Code"
|
|
190
|
+
});
|
|
191
|
+
return errors;
|
|
192
|
+
}
|
|
193
|
+
/** Convenience for a submit gate. */
|
|
194
|
+
function isAddressValid(address, format) {
|
|
195
|
+
return validateAddress(address, format).length === 0;
|
|
196
|
+
}
|
|
197
|
+
/** A readable default message, in the country's own vocabulary. */
|
|
198
|
+
function describeAddressError(error) {
|
|
199
|
+
switch (error.code) {
|
|
200
|
+
case "required": return `${error.label} is required`;
|
|
201
|
+
case "postal_code_format": return `${error.label} is not in the expected format`;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
15
204
|
//#endregion
|
|
16
|
-
export { fromGeoJsonPoint, toGeoJsonPoint };
|
|
205
|
+
export { INTERNATIONAL_ADDRESS_FORMAT, describeAddressError, fieldName, fieldSpec, fromGeoJsonPoint, isAddressValid, isExtensionField, requiredFieldNames, requiredKeys, resolveAddressFormat, supportedCountries, toGeoJsonPoint, validateAddress };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
//#region src/identity/item-identity.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* `sku` is a LABEL; `skuRef` is the stock IDENTITY. Never one field.
|
|
4
|
+
*
|
|
5
|
+
* | field | mutable? | job |
|
|
6
|
+
* |---|---|---|
|
|
7
|
+
* | `skuRef` | never | keys quants, moves and ledger rows |
|
|
8
|
+
* | `sku` | operator edits it | what a human reads, types, searches |
|
|
9
|
+
*
|
|
10
|
+
* Use `stockKeyOf` for anything that reserves or moves stock, `skuDisplay` for
|
|
11
|
+
* anything a human reads. Never hand-roll either: both fall back, in OPPOSITE
|
|
12
|
+
* directions, and getting one backwards type-checks and returns a plausible
|
|
13
|
+
* string.
|
|
14
|
+
*
|
|
15
|
+
* - stock key = `skuRef ?? sku` — absent `skuRef` means a pre-split producer
|
|
16
|
+
* whose `sku` still IS the key.
|
|
17
|
+
* - display = `skuLabel ?? sku ?? skuRef` — the ref last, and flagged, because
|
|
18
|
+
* it can be an opaque id with no merchandising meaning.
|
|
19
|
+
*
|
|
20
|
+
* Pinned by `tests/unit/item-identity.test.ts`.
|
|
21
|
+
*/
|
|
22
|
+
/** Identifier fields a record may carry — each shape populates a different subset. */
|
|
23
|
+
interface SkuFields {
|
|
24
|
+
/** Merchandising label — mutable, human-facing. */
|
|
25
|
+
sku?: string | undefined;
|
|
26
|
+
/** Stock/ledger identity — immutable, keys quants and moves. */
|
|
27
|
+
skuRef?: string | undefined;
|
|
28
|
+
/** Label resolved server-side for a read model keyed by `skuRef`. */
|
|
29
|
+
skuLabel?: string | undefined;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The STOCK key to reserve, decrement and post against: `skuRef ?? sku`,
|
|
33
|
+
* falling back to `fallback` (typically the owning entity's `_id`) when the
|
|
34
|
+
* record carries neither — the shape every "variant, else the product"
|
|
35
|
+
* ledger key already re-derives by hand across three packages.
|
|
36
|
+
*
|
|
37
|
+
* `undefined` = the record carries neither AND no `fallback` was given —
|
|
38
|
+
* malformed, not untracked. Use {@link requireStockKey} where that must stop
|
|
39
|
+
* the write.
|
|
40
|
+
*/
|
|
41
|
+
declare function stockKeyOf(item: SkuFields, fallback?: string | {
|
|
42
|
+
toString(): string;
|
|
43
|
+
}): string | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* {@link stockKeyOf} that THROWS. Use wherever the key feeds a stock movement:
|
|
46
|
+
* a reservation on `undefined` returns "out of stock" instead of failing.
|
|
47
|
+
*/
|
|
48
|
+
declare function requireStockKey(item: SkuFields, context?: string): string;
|
|
49
|
+
/** What a surface should render for an item, and whether it had a real label. */
|
|
50
|
+
interface SkuDisplay {
|
|
51
|
+
/** Best human-facing value. `undefined` = no identifier at all; render a dash. */
|
|
52
|
+
label: string | undefined;
|
|
53
|
+
/** The stock identity, for subtext or copy-to-clipboard. */
|
|
54
|
+
ref: string | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* TRUE when `label` is really the `ref` (nothing resolved). Style it as an id
|
|
57
|
+
* — monospace, muted — never as a SKU.
|
|
58
|
+
*/
|
|
59
|
+
isRefFallback: boolean;
|
|
60
|
+
}
|
|
61
|
+
/** What to SHOW: `skuLabel ?? sku ?? skuRef`. The ref is returned alongside,
|
|
62
|
+
* never dropped — it is the join key an operator quotes. */
|
|
63
|
+
declare function skuDisplay(item: SkuFields): SkuDisplay;
|
|
64
|
+
/**
|
|
65
|
+
* Looks like an opaque id (24-char hex) rather than a merchandising code.
|
|
66
|
+
* PRESENTATION HINT ONLY — never branch stock logic on it; a deployment may key
|
|
67
|
+
* quants by any stable string.
|
|
68
|
+
*/
|
|
69
|
+
declare function looksLikeOpaqueId(value: string | undefined): boolean;
|
|
70
|
+
//#endregion
|
|
71
|
+
export { SkuDisplay, SkuFields, looksLikeOpaqueId, requireStockKey, skuDisplay, stockKeyOf };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//#region src/identity/item-identity.ts
|
|
2
|
+
const nonEmpty = (value) => typeof value === "string" && value.trim().length > 0 ? value : void 0;
|
|
3
|
+
/**
|
|
4
|
+
* The STOCK key to reserve, decrement and post against: `skuRef ?? sku`,
|
|
5
|
+
* falling back to `fallback` (typically the owning entity's `_id`) when the
|
|
6
|
+
* record carries neither — the shape every "variant, else the product"
|
|
7
|
+
* ledger key already re-derives by hand across three packages.
|
|
8
|
+
*
|
|
9
|
+
* `undefined` = the record carries neither AND no `fallback` was given —
|
|
10
|
+
* malformed, not untracked. Use {@link requireStockKey} where that must stop
|
|
11
|
+
* the write.
|
|
12
|
+
*/
|
|
13
|
+
function stockKeyOf(item, fallback) {
|
|
14
|
+
return nonEmpty(item.skuRef) ?? nonEmpty(item.sku) ?? (fallback !== void 0 ? String(fallback) : void 0);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* {@link stockKeyOf} that THROWS. Use wherever the key feeds a stock movement:
|
|
18
|
+
* a reservation on `undefined` returns "out of stock" instead of failing.
|
|
19
|
+
*/
|
|
20
|
+
function requireStockKey(item, context) {
|
|
21
|
+
const key = stockKeyOf(item);
|
|
22
|
+
if (key === void 0) throw new Error(`stock key missing: neither skuRef nor sku is set${context ? ` (${context})` : ""}`);
|
|
23
|
+
return key;
|
|
24
|
+
}
|
|
25
|
+
/** What to SHOW: `skuLabel ?? sku ?? skuRef`. The ref is returned alongside,
|
|
26
|
+
* never dropped — it is the join key an operator quotes. */
|
|
27
|
+
function skuDisplay(item) {
|
|
28
|
+
const ref = nonEmpty(item.skuRef);
|
|
29
|
+
const label = nonEmpty(item.skuLabel) ?? nonEmpty(item.sku);
|
|
30
|
+
if (label !== void 0) return {
|
|
31
|
+
label,
|
|
32
|
+
ref,
|
|
33
|
+
isRefFallback: false
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
label: ref,
|
|
37
|
+
ref,
|
|
38
|
+
isRefFallback: ref !== void 0
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Looks like an opaque id (24-char hex) rather than a merchandising code.
|
|
43
|
+
* PRESENTATION HINT ONLY — never branch stock logic on it; a deployment may key
|
|
44
|
+
* quants by any stable string.
|
|
45
|
+
*/
|
|
46
|
+
function looksLikeOpaqueId(value) {
|
|
47
|
+
return typeof value === "string" && /^[0-9a-f]{24}$/i.test(value);
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
export { looksLikeOpaqueId, requireStockKey, skuDisplay, stockKeyOf };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/primitives",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Framework-agnostic TypeScript primitives shared across Classytic packages — Money, events, pagination, tenant config, and other building blocks.",
|
|
5
5
|
"author": "Classytic",
|
|
6
6
|
"license": "MIT",
|
|
@@ -209,6 +209,10 @@
|
|
|
209
209
|
"./subject": {
|
|
210
210
|
"types": "./dist/subject.d.mts",
|
|
211
211
|
"default": "./dist/subject.mjs"
|
|
212
|
+
},
|
|
213
|
+
"./item-identity": {
|
|
214
|
+
"types": "./dist/item-identity.d.mts",
|
|
215
|
+
"default": "./dist/item-identity.mjs"
|
|
212
216
|
}
|
|
213
217
|
},
|
|
214
218
|
"files": [
|