@classytic/primitives 0.22.0 → 0.24.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 +48 -0
- package/dist/address.d.mts +270 -1
- package/dist/address.mjs +190 -1
- package/dist/approval.d.mts +59 -1
- package/dist/approval.mjs +60 -1
- package/dist/calendar-facts.d.mts +176 -0
- package/dist/calendar-facts.mjs +187 -0
- package/dist/fault-attribution.d.mts +98 -0
- package/dist/fault-attribution.mjs +51 -0
- package/dist/memory-outbox.d.mts +4 -1
- package/dist/memory-outbox.mjs +9 -1
- package/dist/outbox.d.mts +28 -1
- package/dist/unit-cost-rate.d.mts +18 -1
- package/dist/unit-cost-rate.mjs +30 -1
- package/package.json +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,8 +3,56 @@
|
|
|
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.24.0] - 2026-08-24
|
|
7
|
+
|
|
8
|
+
### Added — `@classytic/primitives/address`: capture-format system
|
|
9
|
+
|
|
10
|
+
`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.
|
|
11
|
+
|
|
12
|
+
`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.
|
|
13
|
+
|
|
14
|
+
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.
|
|
15
|
+
|
|
16
|
+
## [0.23.0] - 2026-08-15
|
|
17
|
+
|
|
18
|
+
### Added — `./approval`: `notRequiredChain(reason)` + `isNotRequired(chain)`
|
|
19
|
+
|
|
20
|
+
The chain for a subject that needs **no** approval — zero steps, already resolved. The
|
|
21
|
+
explicit, greppable answer to "the policy engine ran and decided this document does not
|
|
22
|
+
require approval", which callers previously had no way to express.
|
|
23
|
+
|
|
24
|
+
This states a conclusion the module already reached rather than inventing one:
|
|
25
|
+
`computeChainStatus` folds an empty step list to `'approved'` — "nothing outstanding" is
|
|
26
|
+
what approved MEANS here, and that fold predates this function. `createChain` still throws
|
|
27
|
+
`EMPTY_STEPS`: building a ROUTING chain that routes nowhere is a different mistake and
|
|
28
|
+
stays refused. The two are deliberately not interchangeable, so reaching "approved with no
|
|
29
|
+
approvers" requires saying so by name.
|
|
30
|
+
|
|
31
|
+
**Why a chain rather than leaving the field unset.** Absence is the one input that occurs
|
|
32
|
+
by accident — a schema that never declared `approvals` strips it on write — and
|
|
33
|
+
`assertApproved` must keep treating that as `CHAIN_MISSING`. A present zero-step chain is
|
|
34
|
+
distinguishable from a lost chain (absent) *and* from a granted one (has steps and
|
|
35
|
+
decisions), so existing gates keep working through `isApproved` alone. No consumer needs a
|
|
36
|
+
second predicate, which is what would drift.
|
|
37
|
+
|
|
38
|
+
`isNotRequired` tests the STRUCTURE (`steps.length === 0`), not the new
|
|
39
|
+
`notRequiredReason` field: a subject schema that never declared that field drops it on
|
|
40
|
+
write, so a predicate reading it would answer differently before and after the save. The
|
|
41
|
+
reason is provenance for the audit trail only — pinned by a test that strips it and asserts
|
|
42
|
+
the predicate is unmoved.
|
|
43
|
+
|
|
44
|
+
**The hazard, stated in the docblock:** this makes `isApproved` true with no human
|
|
45
|
+
decision. It is correct only where a policy engine ASKED and got "no policy applies" —
|
|
46
|
+
never as a fallback for a lookup that failed or was never configured. First consumer:
|
|
47
|
+
`@classytic/arc-approval` 0.4.0 `whenNoPolicy: 'not-required'`.
|
|
48
|
+
|
|
6
49
|
## [0.22.0] - 2026-08-12
|
|
7
50
|
|
|
51
|
+
### Added — outbox fencing tokens (`claimPendingFenced`)
|
|
52
|
+
|
|
53
|
+
- **`OutboxStore.claimPendingFenced?`** (additive, feature-detected — mirrors repo-core's `LockAdapter.tryAcquireFenced`): same atomic semantics as `claimPending`, returning `OutboxClaimedEvent { event, fencingToken }`. The token is minted by the STORE per claim epoch — a takeover after lease expiry mints STRICTLY GREATER, so a stale ex-holder's `acknowledge`/`fail` (now accepting `fencingToken?`) throws `OutboxOwnershipError`. This closes the overlap that consumer-id ownership checks narrow but cannot close: holder ids recur; epochs do not.
|
|
54
|
+
- `MemoryOutboxStore` implements it; `claimPending` delegates to the fenced path (one claim implementation). Stale-ack rejection is mutation-proven.
|
|
55
|
+
|
|
8
56
|
### Added — `./period`: `shiftPeriod(period, n)` — calendar arithmetic without instants
|
|
9
57
|
|
|
10
58
|
Shift a MONTH or QUARTER period by `n` of its own units (negative = back); YEAR shifts by years; a `custom` (ad-hoc start/end) period has no unit to shift and throws rather than guessing one. No `Date` is constructed and no zone is consulted — shifting a month is pure `(year, month)` arithmetic, and the `timezone` rides along unchanged so the shifted period still resolves against the calendar it was defined in.
|
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 };
|
package/dist/approval.d.mts
CHANGED
|
@@ -71,6 +71,14 @@ interface ApprovalChain {
|
|
|
71
71
|
readonly order: ChainOrder;
|
|
72
72
|
readonly steps: readonly ApprovalStep[];
|
|
73
73
|
readonly status: ApprovalStatus;
|
|
74
|
+
/**
|
|
75
|
+
* Provenance for a chain built by {@link notRequiredChain} — why no approval
|
|
76
|
+
* was needed. PROVENANCE ONLY: never branch on it. A subject schema that
|
|
77
|
+
* does not declare this field STRIPS it on write (Mongoose strict mode), so
|
|
78
|
+
* a predicate reading it would answer differently before and after the save.
|
|
79
|
+
* {@link isNotRequired} tests the structure instead, which no schema can drop.
|
|
80
|
+
*/
|
|
81
|
+
readonly notRequiredReason?: string;
|
|
74
82
|
}
|
|
75
83
|
interface CreateChainInput {
|
|
76
84
|
readonly order: ChainOrder;
|
|
@@ -101,6 +109,42 @@ declare class ApprovalError extends Error {
|
|
|
101
109
|
}
|
|
102
110
|
/** Build a fresh chain in `pending` state. Validates inputs. */
|
|
103
111
|
declare function createChain(input: CreateChainInput): ApprovalChain;
|
|
112
|
+
/**
|
|
113
|
+
* The chain for a subject that needs NO approval — zero steps, already
|
|
114
|
+
* resolved. The explicit, greppable answer to "the policy engine ran and
|
|
115
|
+
* decided this document does not require approval."
|
|
116
|
+
*
|
|
117
|
+
* ## Why this is not a fabricated approval
|
|
118
|
+
*
|
|
119
|
+
* {@link computeChainStatus} already folds an empty step list to `'approved'`
|
|
120
|
+
* — "nothing is outstanding" is what approved MEANS to this primitive, and
|
|
121
|
+
* that fold predates this function. What {@link createChain} refuses
|
|
122
|
+
* (`EMPTY_STEPS`) is building a ROUTING chain that routes nowhere, which is a
|
|
123
|
+
* different mistake and stays refused. So this constructor states the same
|
|
124
|
+
* conclusion the fold reaches, under a name a reviewer can search for, rather
|
|
125
|
+
* than letting a caller reach it by handing `createChain` an empty array.
|
|
126
|
+
*
|
|
127
|
+
* ## Why a chain at all, instead of leaving `approvals` absent
|
|
128
|
+
*
|
|
129
|
+
* Because absence is the one input that occurs by accident. A schema that
|
|
130
|
+
* never declared the field strips it, a failed write leaves it unset — and
|
|
131
|
+
* {@link assertApproved} must keep treating that as CHAIN_MISSING. A present,
|
|
132
|
+
* zero-step chain is distinguishable from both a lost chain (absent) and a
|
|
133
|
+
* granted one (has steps and decisions), so every existing gate keeps working
|
|
134
|
+
* unchanged and no caller needs a second predicate beside `isApproved`.
|
|
135
|
+
*
|
|
136
|
+
* ## The hazard, stated plainly
|
|
137
|
+
*
|
|
138
|
+
* This makes {@link isApproved} true without a human deciding anything. It is
|
|
139
|
+
* only correct where a policy engine ASKED and got "no policy applies" — never
|
|
140
|
+
* as a fallback for a lookup that failed, errored, or was not configured. A
|
|
141
|
+
* resolver that cannot reach its policy store must throw; answering "not
|
|
142
|
+
* required" there would auto-approve every document in the deployment, which
|
|
143
|
+
* is precisely the permissive-default failure this codebase designs against.
|
|
144
|
+
*
|
|
145
|
+
* @param reason Why approval was not required, for the audit trail.
|
|
146
|
+
*/
|
|
147
|
+
declare function notRequiredChain(reason: string): ApprovalChain;
|
|
104
148
|
/**
|
|
105
149
|
* Returns the next step awaiting a decision — or `null` if none.
|
|
106
150
|
*
|
|
@@ -133,6 +177,20 @@ declare function applyDecision(chain: ApprovalChain, input: DecisionInput): Appr
|
|
|
133
177
|
*/
|
|
134
178
|
declare function skipStep(chain: ApprovalChain, stepId: string, reason?: string): ApprovalChain;
|
|
135
179
|
declare function isApproved(chain: ApprovalChain): boolean;
|
|
180
|
+
/**
|
|
181
|
+
* Was this chain built by {@link notRequiredChain} — i.e. approval did not
|
|
182
|
+
* apply, as opposed to having been granted?
|
|
183
|
+
*
|
|
184
|
+
* Both are `isApproved`, and for GATING that is correct: neither blocks the
|
|
185
|
+
* operation. Use this only where the difference is worth showing — an audit
|
|
186
|
+
* trail or an approvals UI, where rendering "Approved" for a document nobody
|
|
187
|
+
* approved misleads the reader.
|
|
188
|
+
*
|
|
189
|
+
* Tests the STRUCTURE (no steps ⇒ nobody was ever asked), not
|
|
190
|
+
* `notRequiredReason`, which a subject schema that never declared the field
|
|
191
|
+
* silently drops on write.
|
|
192
|
+
*/
|
|
193
|
+
declare function isNotRequired(chain: ApprovalChain): boolean;
|
|
136
194
|
interface AssertApprovedOptions {
|
|
137
195
|
/** Override the thrown message. */
|
|
138
196
|
readonly message?: string;
|
|
@@ -184,4 +242,4 @@ declare function isPending(chain: ApprovalChain): boolean;
|
|
|
184
242
|
*/
|
|
185
243
|
declare function decisionCount(chain: ApprovalChain): number;
|
|
186
244
|
//#endregion
|
|
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 };
|
|
245
|
+
export { ApprovalChain, ApprovalDecision, ApprovalError, ApprovalErrorCode, ApprovalStatus, ApprovalStep, ApprovalThreshold, Approver, AssertApprovedOptions, ChainOrder, CreateChainInput, CreateStepInput, DecisionInput, applyDecision, assertApproved, assertApprovedIfPresent, createChain, decisionCount, isApproved, isNotRequired, isPending, isRejected, nextPendingStep, notRequiredChain, pendingSteps, skipStep };
|