@classytic/primitives 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,123 @@
1
+ # Changelog
2
+
3
+ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
+ adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+
6
+ ## [0.1.0]
7
+
8
+ Initial release. Framework-agnostic TypeScript primitives shared across
9
+ Classytic packages. Zero runtime dependencies. Node 22+. ESM only.
10
+ 260 tests (unit + integration) across 20 files.
11
+
12
+ All subpaths are importable independently; there is **no root barrel
13
+ re-export** — consumers must use subpath imports (`@classytic/primitives/money`,
14
+ `/events`, `/tenant`, …) to keep the Node ESM loader + TypeScript compiler
15
+ from walking the full module graph on any convenience import.
16
+
17
+ ### Subpaths
18
+
19
+ - **`/money`** — `Money` type + integer-minor-unit arithmetic (`money`,
20
+ `addMoney`, `subtractMoney`, `multiplyMoney`, `sumMoney`, `negateMoney`,
21
+ `absMoney`, `compareMoney`, `equalsMoney`, `isZeroMoney`, `isPositiveMoney`,
22
+ `isNegativeMoney`, `isMoney`, `fromMajor`, `toMajor`); `CurrencyMismatchError`.
23
+ - **`/currency`** — `CurrencyCode` brand; `CURRENCIES` table; `MINOR_UNIT_FACTOR`,
24
+ `minorUnitFactor`, `isCurrencyCode`, `toCurrencyCode`.
25
+ - **`/address`** — `Address`, `ContactAddress`, `GeoPoint`, `GeoJsonPoint`;
26
+ `toGeoJsonPoint`, `fromGeoJsonPoint`.
27
+ - **`/period`** — `DateRange`, `Period`; `isDateRange`, `isWithin`,
28
+ `rangeDurationMs`.
29
+ - **`/pagination`** — `PageParams`, `OffsetPage<T>`, `KeysetPage<T>`,
30
+ `AggregatePage`, `KeysetParams`, `SortSpec`, `SortDirection`;
31
+ `emptyOffsetPage`, `emptyKeysetPage`.
32
+ - **`/reference`** — `ExternalRef`, `DocumentRef`, `ObjectIdLike`, `IdLike`;
33
+ `idToString`, `isExternalRef`.
34
+ - **`/context`** — `OperationContext` (the identity + tracing bag every
35
+ Classytic package accepts); `ActorRef`.
36
+ - **`/events`** — `EventMeta` (arc v2.9-aligned — 13 fields including
37
+ `schemaVersion`, `causationId`, `partitionKey`, `source`, `idempotencyKey`,
38
+ `aggregate`), `DomainEvent<T>`, `EventTransport`, `EventHandler`,
39
+ `EventLogger`, `DeadLetteredEvent<T>`, `PublishManyResult`; `createEvent`,
40
+ `createChildEvent` (auto-chains `causationId`, inherits `correlationId` /
41
+ `userId` / `organizationId` / `source` / `idempotencyKey` — NOT
42
+ `aggregate`), `matchEventPattern`.
43
+ - **`/outbox`** — transactional outbox **contract** mirrored from arc v2.9:
44
+ `OutboxStore`, `OutboxWriteOptions`, `OutboxClaimOptions`,
45
+ `OutboxAcknowledgeOptions`, `OutboxFailOptions`, `OutboxErrorInfo`,
46
+ `OutboxFailureContext`, `OutboxFailureDecision`, `OutboxFailurePolicy`;
47
+ `OutboxOwnershipError`, `InvalidOutboxEventError`. **Contract only** —
48
+ runtime (`EventOutbox`, `MemoryOutboxStore`, `MongoOutboxStore`,
49
+ `exponentialBackoff`) ships in `@classytic/arc/events` and
50
+ `@classytic/arc/events/mongo`. Domain packages implement `OutboxStore`
51
+ without peer-depping arc.
52
+ - **`/tenant`** — `TenantConfig` (with `strategy: 'field' | 'none' | 'custom'`
53
+ and optional `resolve(ctx)` for custom strategy), `TenantFieldType`,
54
+ `TenantStrategy`, `ResolvedTenantConfig`; `DEFAULT_TENANT_CONFIG`,
55
+ `resolveTenantConfig`. Field names match `@classytic/mongokit`'s
56
+ `MultiTenantOptions` so a resolved config threads into
57
+ `multiTenantPlugin({ ... })` without translation.
58
+ - **`/result`** — `Result<T, E>`; `ok`, `err`, `isOk`, `isErr`, `unwrap`,
59
+ `mapResult`, `mapError`.
60
+ - **`/brand`** — `Brand<T, B>`, `Prettify`, `DeepPartial`, `DeepReadonly`,
61
+ `RequireKeys`, `OptionalKeys`, `NonEmptyArray`.
62
+ - **`/errors`** — `ErrorContract`, `ErrorDetail`, `ErrorCode`, `ERROR_CODES`.
63
+ - **`/split-allocation`** — `allocate(total, subjects, method)` returns a
64
+ `SplitResult` whose parts sum exactly to `total` via the largest-remainder
65
+ method. Methods: `equal`, `by-qty`, `by-weight`, `by-volume`, `by-value`,
66
+ `by-percent`. Deterministic — identical inputs produce identical outputs;
67
+ ties broken by input order. Handles negative totals by sign-flipping.
68
+ Exports: `allocate`, `isBalanced`, `SplitAllocationError`, types
69
+ `SplitMethod`, `SplitSubject`, `SplitPart`, `SplitResult`,
70
+ `SplitAllocationErrorCode`.
71
+ - **`/approval`** — `ApprovalChain` value object with pure FSM helpers.
72
+ Supports sequential and parallel chains, per-step quorum
73
+ (`requiredApprovals`), threshold-gated skips. Any step-level rejection
74
+ rejects the chain; all steps `approved`|`skipped` approves it. Exports:
75
+ `createChain`, `applyDecision`, `skipStep`, `nextPendingStep`,
76
+ `pendingSteps`, `isApproved`, `isRejected`, `isPending`, `decisionCount`,
77
+ `ApprovalError`, types `ApprovalChain`, `ApprovalStep`, `ApprovalDecision`,
78
+ `Approver`, `ApprovalStatus`, `ChainOrder`, `ApprovalThreshold`,
79
+ `CreateChainInput`, `CreateStepInput`, `DecisionInput`, `ApprovalErrorCode`.
80
+ - **`/cadence`** — recurrence spec with pure UTC date arithmetic. Kinds:
81
+ `daily`, `weekly`, `monthly`, `yearly`, `cron` (opaque — host parses).
82
+ `dayOfMonth: 31` snaps to the last day of shorter months; Feb 29 snaps
83
+ to Feb 28 in non-leap years. Exports: `nextOccurrence`,
84
+ `occurrencesBetween`, `validateCadence`, `CadenceError`, types `Cadence`,
85
+ `DailyCadence`, `WeeklyCadence`, `MonthlyCadence`, `YearlyCadence`,
86
+ `CronCadence`, `CadenceKind`, `IsoWeekday`, `CadenceErrorCode`.
87
+ - **`/hold`** — `HoldReason` + collection helpers. Host embeds
88
+ `HoldReason[]` on documents. Exports: `addHold`, `resolveHold`,
89
+ `activeHolds`, `resolvedHolds`, `isOnHold`, `hasActiveHoldOfCode`,
90
+ `HoldError`, types `HoldReason`, `HoldActor`, `HoldActorKind`,
91
+ `AddHoldInput`, `ResolveHoldInput`, `HoldErrorCode`.
92
+ - **`/sla`** — target duration + breach-detection arithmetic. Policy is
93
+ carried but not executed — host reads `breachPolicy` (`warn` | `escalate`
94
+ | `block`) and acts. Exports: `breachedAt`, `remainingMs`, `elapsedMs`,
95
+ `isBreached`, `consumedFraction`, `validateSLA`, `SLAError`, types
96
+ `SLA`, `BreachPolicy`, `SLAErrorCode`.
97
+ - **`/person`** — person/contact shapes shared across order, crm, hr.
98
+ `PersonName`, `ContactInfo`, `EmergencyContact`, `Gender`; formatters
99
+ `formatFullName`, `formatDisplayName`.
100
+
101
+ ### Design principles
102
+
103
+ - Pure functions on plain data. No I/O, no Mongo, no events.
104
+ - Immutable return values. Helpers return new collections; originals are never mutated.
105
+ - Deterministic. Same inputs → same outputs. Clock sources injected
106
+ explicitly so tests don't flake.
107
+ - Errors are typed. Every module ships an `<Name>Error` class with a
108
+ discriminated `code: <Name>ErrorCode` string union.
109
+ - Subpath-imports only — no root barrel.
110
+
111
+ ### Ownership direction
112
+
113
+ Arc is the source of truth for event types. Primitives **mirrors** arc
114
+ (`@classytic/arc/events/EventTransport.ts`, `@classytic/arc/events/outbox.ts`)
115
+ verbatim for `EventMeta`, `DomainEvent`, `EventTransport`,
116
+ `DeadLetteredEvent`, `EventLogger`, `PublishManyResult`, and the full
117
+ `OutboxStore` contract surface. Helpers (`createEvent`, `createChildEvent`)
118
+ are allowed to be more defensive but match arc's signatures.
119
+
120
+ Packages that peer-dep `@classytic/primitives` can write
121
+ `class FooOutboxRepository implements OutboxStore` without pulling arc into
122
+ their dependency graph; hosts import `EventOutbox` + `MongoOutboxStore` from
123
+ arc and wire them against package-provided stores.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Classytic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # @classytic/primitives
2
+
3
+ Framework-agnostic TypeScript primitives shared across Classytic packages.
4
+ Zero runtime dependencies. ESM only. Node 22+.
5
+
6
+ These are the building blocks every Classytic package speaks in common:
7
+ `Money`, `DomainEvent`, `TenantConfig`, `OperationContext`, pagination shapes,
8
+ polymorphic references. When a package duplicates one of these shapes, it
9
+ drifts — this package exists so it doesn't.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @classytic/primitives
15
+ ```
16
+
17
+ ## Use
18
+
19
+ Prefer **subpath imports** — each concern is its own entry point, types erase
20
+ at compile time, runtime helpers are tiny.
21
+
22
+ ```ts
23
+ import { addMoney, fromMajor, type Money } from '@classytic/primitives/money';
24
+ import { createEvent, type DomainEvent, type EventTransport } from '@classytic/primitives/events';
25
+ import { resolveTenantConfig, type TenantConfig } from '@classytic/primitives/tenant';
26
+ import type { OperationContext } from '@classytic/primitives/context';
27
+ import type { OffsetPage, PageParams } from '@classytic/primitives/pagination';
28
+ import { allocate, type SplitResult } from '@classytic/primitives/split-allocation';
29
+ ```
30
+
31
+ There is intentionally **no root barrel** — a barrel forces the Node ESM
32
+ loader and TypeScript compiler to walk every module on a single convenience
33
+ import, which slows CI and pulls unused code into the consumer's graph. Use
34
+ subpath imports only.
35
+
36
+ ## What's inside
37
+
38
+ | Subpath | Ships |
39
+ |---|---|
40
+ | `/money` | `Money`, `fromMajor`, `toMajor`, `addMoney`, `subtractMoney`, `multiplyMoney`, `sumMoney`, `equalsMoney`, `compareMoney`, `negateMoney`, `absMoney`, `isMoney`, `CurrencyMismatchError` |
41
+ | `/currency` | `CurrencyCode` brand, `CURRENCIES`, `MINOR_UNIT_FACTOR`, `minorUnitFactor`, `toCurrencyCode`, `isCurrencyCode` |
42
+ | `/address` | `Address`, `ContactAddress`, `GeoPoint`, `GeoJsonPoint`, `toGeoJsonPoint`, `fromGeoJsonPoint` |
43
+ | `/period` | `DateRange`, `Period`, `isDateRange`, `isWithin`, `rangeDurationMs` |
44
+ | `/pagination` | `PageParams`, `KeysetParams`, `SortSpec`, `SortDirection`, `OffsetPage<T>`, `KeysetPage<T>`, `AggregatePage<T>`, `emptyOffsetPage`, `emptyKeysetPage` |
45
+ | `/reference` | `ExternalRef`, `ObjectIdLike`, `IdLike`, `DocumentRef`, `idToString`, `isExternalRef` |
46
+ | `/context` | `OperationContext`, `ActorRef` |
47
+ | `/events` | `DomainEvent`, `EventMeta`, `EventHandler`, `EventTransport`, `createEvent`, `matchEventPattern` |
48
+ | `/tenant` | `TenantConfig`, `TenantFieldType`, `DEFAULT_TENANT_CONFIG`, `resolveTenantConfig` |
49
+ | `/result` | `Result<T, E>`, `ok`, `err`, `isOk`, `isErr`, `mapResult`, `mapError`, `unwrap` |
50
+ | `/brand` | `Brand<T, B>`, `Prettify`, `DeepPartial`, `DeepReadonly`, `RequireKeys`, `OptionalKeys`, `Nullable`, `KeysMatching`, `NonEmptyArray` |
51
+ | `/errors` | `ErrorContract`, `ErrorDetail`, `ErrorCode`, `ERROR_CODES` |
52
+ | `/split-allocation` | `allocate`, `isBalanced`, `SplitAllocationError`, `SplitMethod`, `SplitSubject`, `SplitPart`, `SplitResult` |
53
+ | `/approval` | `createChain`, `applyDecision`, `skipStep`, `nextPendingStep`, `pendingSteps`, `isApproved`, `isRejected`, `isPending`, `decisionCount`, `ApprovalError`, `ApprovalChain`, `ApprovalStep`, `ApprovalDecision`, `Approver`, `ApprovalThreshold` |
54
+ | `/cadence` | `nextOccurrence`, `occurrencesBetween`, `validateCadence`, `CadenceError`, `Cadence`, `DailyCadence`, `WeeklyCadence`, `MonthlyCadence`, `YearlyCadence`, `CronCadence`, `IsoWeekday` |
55
+ | `/hold` | `addHold`, `resolveHold`, `activeHolds`, `resolvedHolds`, `isOnHold`, `hasActiveHoldOfCode`, `HoldError`, `HoldReason`, `HoldActor` |
56
+ | `/sla` | `breachedAt`, `remainingMs`, `elapsedMs`, `isBreached`, `consumedFraction`, `validateSLA`, `SLAError`, `SLA`, `BreachPolicy` |
57
+
58
+ ## Design
59
+
60
+ ### Money — integer minor units, always
61
+
62
+ ```ts
63
+ import { fromMajor, addMoney, toMajor } from '@classytic/primitives/money';
64
+
65
+ const subtotal = fromMajor(19.99, 'USD'); // { amount: 1999, currency: 'USD' }
66
+ const shipping = fromMajor(5.00, 'USD'); // { amount: 500, currency: 'USD' }
67
+ const total = addMoney(subtotal, shipping); // { amount: 2499, currency: 'USD' }
68
+ toMajor(total); // 24.99
69
+ ```
70
+
71
+ `Money.amount` is always an integer in the currency's minor unit — `1` = one
72
+ cent for USD, one yen for JPY, one fils for KWD. Mixing currencies throws
73
+ `CurrencyMismatchError`. This eliminates the float-rounding drift that happens
74
+ when every package rolls its own `{ amount: number, currency: string }`.
75
+
76
+ ### DomainEvent — structurally identical to Arc
77
+
78
+ ```ts
79
+ import { createEvent, type EventTransport } from '@classytic/primitives/events';
80
+
81
+ const event = createEvent('order:placed', { orderId: 'ord_123' }, {
82
+ organizationId: ctx.organizationId,
83
+ userId: ctx.actorId,
84
+ correlationId: ctx.correlationId,
85
+ });
86
+
87
+ await transport.publish(event);
88
+ ```
89
+
90
+ Shape matches `@classytic/arc`'s `DomainEvent` exactly — any Arc transport
91
+ (memory, Redis pub/sub, Redis Streams, Kafka) plugs into a package expecting
92
+ this interface without adapters. See PACKAGE_RULES.md §11.
93
+
94
+ ### TenantConfig — one shape, every package
95
+
96
+ ```ts
97
+ import { resolveTenantConfig } from '@classytic/primitives/tenant';
98
+
99
+ const tenant = resolveTenantConfig(config.tenant);
100
+ // { enabled: true, tenantField: 'organizationId', fieldType: 'objectId',
101
+ // ref: 'organization', contextKey: 'organizationId', required: true }
102
+ ```
103
+
104
+ Field names match `@classytic/mongokit`'s `MultiTenantOptions` so you can
105
+ forward the resolved config straight into `multiTenantPlugin(resolved)`.
106
+
107
+ Packages stop rolling five flavours of `{ field, type, ref, contextKey,
108
+ enabled }`.
109
+
110
+ ### ExternalRef — polymorphic, framework-free
111
+
112
+ ```ts
113
+ interface ExternalRef {
114
+ sourceId: string;
115
+ sourceModel: string;
116
+ }
117
+ ```
118
+
119
+ For references that cross trust boundaries (another package, another service,
120
+ Stripe, Postgres) — paired with a host-implemented `SourceBridge`. Works for
121
+ ObjectId, UUID, Stripe ID, anything. See PACKAGE_RULES.md §7.
122
+
123
+ ## What's not here (on purpose)
124
+
125
+ - **Mongoose types** — use `ObjectIdLike` from `/reference`. Any Mongoose
126
+ `Types.ObjectId` satisfies it structurally.
127
+ - **Transaction / Order / Cart shapes** — those are domain types, not
128
+ primitives. They live in their respective packages.
129
+ - **Error subclasses** — throw plain `Error`s; hosts serialize them into
130
+ `ErrorContract` at the HTTP boundary.
131
+ - **Runtime currency database** — ship a whitelist at the app layer if you
132
+ need one. `MINOR_UNIT_FACTOR` covers common cases.
133
+
134
+ ## License
135
+
136
+ MIT © Classytic
@@ -0,0 +1,38 @@
1
+ //#region src/address.d.ts
2
+ /**
3
+ * Postal address.
4
+ *
5
+ * Kept deliberately generic — international postal formats vary too much to
6
+ * enforce per-country rules in a shared type. Consumers can extend or narrow
7
+ * as needed (e.g. a BD-specific schema might add `district`, `upazila`).
8
+ */
9
+ interface Address {
10
+ line1: string;
11
+ line2?: string;
12
+ city: string;
13
+ /** State / province / region. Optional for countries without subdivisions. */
14
+ state?: string;
15
+ postalCode?: string;
16
+ /** ISO 3166-1 alpha-2 country code (e.g. 'BD', 'US', 'GB'). */
17
+ country: string;
18
+ }
19
+ interface ContactAddress extends Address {
20
+ name?: string;
21
+ phone?: string;
22
+ email?: string;
23
+ }
24
+ /** WGS84 geographic coordinates. */
25
+ interface GeoPoint {
26
+ latitude: number;
27
+ longitude: number;
28
+ }
29
+ /** A GeoJSON-flavoured point — convenient when storing in Mongo `2dsphere` indexes. */
30
+ interface GeoJsonPoint {
31
+ type: 'Point';
32
+ /** [longitude, latitude] — note the GeoJSON ordering. */
33
+ coordinates: readonly [number, number];
34
+ }
35
+ declare function toGeoJsonPoint(p: GeoPoint): GeoJsonPoint;
36
+ declare function fromGeoJsonPoint(g: GeoJsonPoint): GeoPoint;
37
+ //#endregion
38
+ export { Address, ContactAddress, GeoJsonPoint, GeoPoint, fromGeoJsonPoint, toGeoJsonPoint };
@@ -0,0 +1,16 @@
1
+ //#region src/address.ts
2
+ function toGeoJsonPoint(p) {
3
+ return {
4
+ type: "Point",
5
+ coordinates: [p.longitude, p.latitude]
6
+ };
7
+ }
8
+ function fromGeoJsonPoint(g) {
9
+ const [longitude, latitude] = g.coordinates;
10
+ return {
11
+ longitude,
12
+ latitude
13
+ };
14
+ }
15
+ //#endregion
16
+ export { fromGeoJsonPoint, toGeoJsonPoint };
@@ -0,0 +1,133 @@
1
+ //#region src/approval.d.ts
2
+ /**
3
+ * Approval chain value object and pure state-transition helpers.
4
+ *
5
+ * Hosts embed an `ApprovalChain` on domain documents (quotation, return,
6
+ * high-value transaction, etc.) as a subdocument — this primitive is
7
+ * purely data + pure functions; no persistence, no I/O, no events.
8
+ *
9
+ * Supports:
10
+ * - sequential chains (step 2 activates only after step 1 approves)
11
+ * - parallel chains (all pending steps active at once)
12
+ * - per-step quorum (requiredApprovals out of N approvers)
13
+ * - skipping steps when a host-evaluated condition fails
14
+ *
15
+ * Chain status is computed from step statuses:
16
+ * - any step `rejected` → chain `rejected`
17
+ * - all required steps `approved`|`skipped` → chain `approved`
18
+ * - otherwise → chain `pending`
19
+ *
20
+ * @example
21
+ * import { createChain, applyDecision, isApproved } from '@classytic/primitives/approval';
22
+ *
23
+ * let chain = createChain({
24
+ * order: 'sequential',
25
+ * steps: [
26
+ * { id: 'sales', approvers: [{ id: 'rep1' }] },
27
+ * { id: 'finance', approvers: [{ id: 'cfo1' }, { id: 'cfo2' }], requiredApprovals: 1 },
28
+ * ],
29
+ * });
30
+ * chain = applyDecision(chain, { stepId: 'sales', approverId: 'rep1', decision: 'approved' });
31
+ * chain = applyDecision(chain, { stepId: 'finance', approverId: 'cfo1', decision: 'approved' });
32
+ * isApproved(chain); // → true
33
+ */
34
+ type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'skipped';
35
+ type ChainOrder = 'sequential' | 'parallel';
36
+ interface Approver {
37
+ readonly id: string;
38
+ readonly name?: string;
39
+ readonly role?: string;
40
+ }
41
+ interface ApprovalDecision {
42
+ readonly approverId: string;
43
+ readonly decision: 'approved' | 'rejected';
44
+ readonly note?: string;
45
+ readonly decidedAt: Date;
46
+ }
47
+ /**
48
+ * Optional threshold gate. The primitive does not evaluate this — it's
49
+ * metadata the host reads when deciding whether to mark a step `skipped`
50
+ * (via {@link skipStep}). Captures the intent in structured form so the
51
+ * reason for the skip is auditable.
52
+ */
53
+ interface ApprovalThreshold {
54
+ readonly field: string;
55
+ readonly op: 'gt' | 'gte' | 'lt' | 'lte' | 'eq';
56
+ readonly value: number;
57
+ }
58
+ interface ApprovalStep {
59
+ readonly id: string;
60
+ readonly name?: string;
61
+ readonly approvers: readonly Approver[];
62
+ /** How many distinct approver decisions of `approved` are needed. Default: 1. */
63
+ readonly requiredApprovals: number;
64
+ readonly threshold?: ApprovalThreshold;
65
+ readonly status: ApprovalStatus;
66
+ readonly decisions: readonly ApprovalDecision[];
67
+ /** Populated when status transitions to `skipped`. */
68
+ readonly skippedReason?: string;
69
+ }
70
+ interface ApprovalChain {
71
+ readonly order: ChainOrder;
72
+ readonly steps: readonly ApprovalStep[];
73
+ readonly status: ApprovalStatus;
74
+ }
75
+ interface CreateChainInput {
76
+ readonly order: ChainOrder;
77
+ readonly steps: readonly CreateStepInput[];
78
+ }
79
+ interface CreateStepInput {
80
+ readonly id: string;
81
+ readonly name?: string;
82
+ readonly approvers: readonly Approver[];
83
+ readonly requiredApprovals?: number;
84
+ readonly threshold?: ApprovalThreshold;
85
+ }
86
+ type ApprovalErrorCode = 'EMPTY_STEPS' | 'DUPLICATE_STEP_ID' | 'EMPTY_APPROVERS' | 'DUPLICATE_APPROVER_ID' | 'INVALID_QUORUM' | 'UNKNOWN_STEP' | 'UNAUTHORIZED_APPROVER' | 'STEP_NOT_ACTIVE' | 'STEP_ALREADY_DECIDED_BY_APPROVER';
87
+ declare class ApprovalError extends Error {
88
+ readonly name = "ApprovalError";
89
+ readonly code: ApprovalErrorCode;
90
+ constructor(code: ApprovalErrorCode, message: string);
91
+ }
92
+ /** Build a fresh chain in `pending` state. Validates inputs. */
93
+ declare function createChain(input: CreateChainInput): ApprovalChain;
94
+ /**
95
+ * Returns the next step awaiting a decision — or `null` if none.
96
+ *
97
+ * For `sequential` chains: the first `pending` step in order; later steps
98
+ * remain inactive until earlier ones finalise.
99
+ *
100
+ * For `parallel` chains: returns the first pending step (all pending steps
101
+ * are active simultaneously; host typically iterates with {@link pendingSteps}).
102
+ */
103
+ declare function nextPendingStep(chain: ApprovalChain): ApprovalStep | null;
104
+ /** All steps currently eligible to receive a decision. */
105
+ declare function pendingSteps(chain: ApprovalChain): readonly ApprovalStep[];
106
+ interface DecisionInput {
107
+ readonly stepId: string;
108
+ readonly approverId: string;
109
+ readonly decision: 'approved' | 'rejected';
110
+ readonly note?: string;
111
+ readonly decidedAt?: Date;
112
+ }
113
+ /**
114
+ * Apply a decision to a step. Returns a new chain — original is not mutated.
115
+ * Throws if the step is unknown, not active, the approver isn't listed, or
116
+ * the approver has already decided.
117
+ */
118
+ declare function applyDecision(chain: ApprovalChain, input: DecisionInput): ApprovalChain;
119
+ /**
120
+ * Mark a step as skipped — for when a host-evaluated condition determines
121
+ * the step is not required (e.g., amount < threshold). Skipped steps count
122
+ * as "completed" for chain approval.
123
+ */
124
+ declare function skipStep(chain: ApprovalChain, stepId: string, reason?: string): ApprovalChain;
125
+ declare function isApproved(chain: ApprovalChain): boolean;
126
+ declare function isRejected(chain: ApprovalChain): boolean;
127
+ declare function isPending(chain: ApprovalChain): boolean;
128
+ /**
129
+ * Count total decisions across all steps. Useful for audit summaries.
130
+ */
131
+ declare function decisionCount(chain: ApprovalChain): number;
132
+ //#endregion
133
+ export { ApprovalChain, ApprovalDecision, ApprovalError, ApprovalErrorCode, ApprovalStatus, ApprovalStep, ApprovalThreshold, Approver, ChainOrder, CreateChainInput, CreateStepInput, DecisionInput, applyDecision, createChain, decisionCount, isApproved, isPending, isRejected, nextPendingStep, pendingSteps, skipStep };
@@ -0,0 +1,146 @@
1
+ //#region src/approval.ts
2
+ var ApprovalError = class extends Error {
3
+ name = "ApprovalError";
4
+ code;
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.code = code;
8
+ }
9
+ };
10
+ /** Build a fresh chain in `pending` state. Validates inputs. */
11
+ function createChain(input) {
12
+ if (input.steps.length === 0) throw new ApprovalError("EMPTY_STEPS", "chain must have at least one step");
13
+ const seenStepIds = /* @__PURE__ */ new Set();
14
+ const steps = input.steps.map((s) => {
15
+ if (seenStepIds.has(s.id)) throw new ApprovalError("DUPLICATE_STEP_ID", `duplicate step id: ${s.id}`);
16
+ seenStepIds.add(s.id);
17
+ if (s.approvers.length === 0) throw new ApprovalError("EMPTY_APPROVERS", `step ${s.id} has no approvers`);
18
+ const seenApproverIds = /* @__PURE__ */ new Set();
19
+ for (const a of s.approvers) {
20
+ if (seenApproverIds.has(a.id)) throw new ApprovalError("DUPLICATE_APPROVER_ID", `step ${s.id} has duplicate approver id: ${a.id}`);
21
+ seenApproverIds.add(a.id);
22
+ }
23
+ const quorum = s.requiredApprovals ?? 1;
24
+ if (!Number.isInteger(quorum) || quorum < 1 || quorum > s.approvers.length) throw new ApprovalError("INVALID_QUORUM", `step ${s.id}: requiredApprovals must be integer in [1, ${s.approvers.length}], got ${quorum}`);
25
+ return {
26
+ id: s.id,
27
+ ...s.name !== void 0 ? { name: s.name } : {},
28
+ approvers: s.approvers,
29
+ requiredApprovals: quorum,
30
+ ...s.threshold !== void 0 ? { threshold: s.threshold } : {},
31
+ status: "pending",
32
+ decisions: []
33
+ };
34
+ });
35
+ return {
36
+ order: input.order,
37
+ steps,
38
+ status: "pending"
39
+ };
40
+ }
41
+ /**
42
+ * Returns the next step awaiting a decision — or `null` if none.
43
+ *
44
+ * For `sequential` chains: the first `pending` step in order; later steps
45
+ * remain inactive until earlier ones finalise.
46
+ *
47
+ * For `parallel` chains: returns the first pending step (all pending steps
48
+ * are active simultaneously; host typically iterates with {@link pendingSteps}).
49
+ */
50
+ function nextPendingStep(chain) {
51
+ for (const step of chain.steps) if (step.status === "pending") return step;
52
+ return null;
53
+ }
54
+ /** All steps currently eligible to receive a decision. */
55
+ function pendingSteps(chain) {
56
+ if (chain.order === "sequential") {
57
+ const next = nextPendingStep(chain);
58
+ return next ? [next] : [];
59
+ }
60
+ return chain.steps.filter((s) => s.status === "pending");
61
+ }
62
+ /**
63
+ * Apply a decision to a step. Returns a new chain — original is not mutated.
64
+ * Throws if the step is unknown, not active, the approver isn't listed, or
65
+ * the approver has already decided.
66
+ */
67
+ function applyDecision(chain, input) {
68
+ const stepIndex = chain.steps.findIndex((s) => s.id === input.stepId);
69
+ if (stepIndex === -1) throw new ApprovalError("UNKNOWN_STEP", `no step with id ${input.stepId}`);
70
+ const step = chain.steps[stepIndex];
71
+ if (!step) throw new ApprovalError("UNKNOWN_STEP", `no step with id ${input.stepId}`);
72
+ if (!pendingSteps(chain).some((s) => s.id === input.stepId)) throw new ApprovalError("STEP_NOT_ACTIVE", `step ${input.stepId} is not currently active (status=${step.status})`);
73
+ if (!step.approvers.some((a) => a.id === input.approverId)) throw new ApprovalError("UNAUTHORIZED_APPROVER", `approver ${input.approverId} is not listed on step ${input.stepId}`);
74
+ if (step.decisions.some((d) => d.approverId === input.approverId)) throw new ApprovalError("STEP_ALREADY_DECIDED_BY_APPROVER", `approver ${input.approverId} has already decided on step ${input.stepId}`);
75
+ const decision = {
76
+ approverId: input.approverId,
77
+ decision: input.decision,
78
+ ...input.note !== void 0 ? { note: input.note } : {},
79
+ decidedAt: input.decidedAt ?? /* @__PURE__ */ new Date()
80
+ };
81
+ const updatedStep = computeStepStatus({
82
+ ...step,
83
+ decisions: [...step.decisions, decision]
84
+ });
85
+ const updatedSteps = chain.steps.map((s, i) => i === stepIndex ? updatedStep : s);
86
+ return {
87
+ order: chain.order,
88
+ steps: updatedSteps,
89
+ status: computeChainStatus(updatedSteps)
90
+ };
91
+ }
92
+ /**
93
+ * Mark a step as skipped — for when a host-evaluated condition determines
94
+ * the step is not required (e.g., amount < threshold). Skipped steps count
95
+ * as "completed" for chain approval.
96
+ */
97
+ function skipStep(chain, stepId, reason) {
98
+ const stepIndex = chain.steps.findIndex((s) => s.id === stepId);
99
+ if (stepIndex === -1) throw new ApprovalError("UNKNOWN_STEP", `no step with id ${stepId}`);
100
+ const step = chain.steps[stepIndex];
101
+ if (!step) throw new ApprovalError("UNKNOWN_STEP", `no step with id ${stepId}`);
102
+ if (step.status !== "pending") throw new ApprovalError("STEP_NOT_ACTIVE", `step ${stepId} cannot be skipped (status=${step.status})`);
103
+ const updatedStep = {
104
+ ...step,
105
+ status: "skipped",
106
+ ...reason !== void 0 ? { skippedReason: reason } : {}
107
+ };
108
+ const updatedSteps = chain.steps.map((s, i) => i === stepIndex ? updatedStep : s);
109
+ return {
110
+ order: chain.order,
111
+ steps: updatedSteps,
112
+ status: computeChainStatus(updatedSteps)
113
+ };
114
+ }
115
+ function isApproved(chain) {
116
+ return chain.status === "approved";
117
+ }
118
+ function isRejected(chain) {
119
+ return chain.status === "rejected";
120
+ }
121
+ function isPending(chain) {
122
+ return chain.status === "pending";
123
+ }
124
+ /**
125
+ * Count total decisions across all steps. Useful for audit summaries.
126
+ */
127
+ function decisionCount(chain) {
128
+ return chain.steps.reduce((n, s) => n + s.decisions.length, 0);
129
+ }
130
+ function computeStepStatus(step) {
131
+ if (step.decisions.some((d) => d.decision === "rejected")) return {
132
+ ...step,
133
+ status: "rejected"
134
+ };
135
+ if (step.decisions.filter((d) => d.decision === "approved").length >= step.requiredApprovals) return {
136
+ ...step,
137
+ status: "approved"
138
+ };
139
+ return step;
140
+ }
141
+ function computeChainStatus(steps) {
142
+ if (steps.some((s) => s.status === "rejected")) return "rejected";
143
+ return steps.every((s) => s.status === "approved" || s.status === "skipped") ? "approved" : "pending";
144
+ }
145
+ //#endregion
146
+ export { ApprovalError, applyDecision, createChain, decisionCount, isApproved, isPending, isRejected, nextPendingStep, pendingSteps, skipStep };
@@ -0,0 +1,37 @@
1
+ //#region src/brand.d.ts
2
+ /**
3
+ * Type utilities for building nominal types and transforming shapes.
4
+ *
5
+ * These have zero runtime cost — all erase at compile time.
6
+ */
7
+ declare const __brand: unique symbol;
8
+ /**
9
+ * Nominal typing via an intersection brand.
10
+ *
11
+ * @example
12
+ * type UserId = Brand<string, 'UserId'>;
13
+ * type OrderId = Brand<string, 'OrderId'>;
14
+ * const u: UserId = 'abc' as UserId;
15
+ * const o: OrderId = u; // type error — brands differ
16
+ */
17
+ type Brand<T, B extends string> = T & {
18
+ readonly [__brand]: B;
19
+ };
20
+ /** Flattens intersection types for nicer IDE hover output. */
21
+ type Prettify<T> = { [K in keyof T]: T[K] } & {};
22
+ /** Recursive partial — every leaf becomes optional. */
23
+ type DeepPartial<T> = T extends ((...args: readonly unknown[]) => unknown) ? T : T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>> : T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
24
+ /** Recursive readonly — every leaf becomes immutable. */
25
+ type DeepReadonly<T> = T extends ((...args: readonly unknown[]) => unknown) ? T : T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepReadonly<U>> : T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;
26
+ /** Make specific keys required while leaving the rest as-is. */
27
+ type RequireKeys<T, K extends keyof T> = T & { [P in K]-?: T[P] };
28
+ /** Make specific keys optional while leaving the rest as-is. */
29
+ type OptionalKeys<T, K extends keyof T> = Omit<T, K> & { [P in K]?: T[P] };
30
+ /** A value that may be absent in three flavours — `T | null | undefined`. */
31
+ type Nullable<T> = T | null | undefined;
32
+ /** Keys of T whose value type is assignable to V. */
33
+ type KeysMatching<T, V> = { [K in keyof T]-?: T[K] extends V ? K : never }[keyof T];
34
+ /** Non-empty array guard type. */
35
+ type NonEmptyArray<T> = readonly [T, ...T[]];
36
+ //#endregion
37
+ export { NonEmptyArray as a, Prettify as c, KeysMatching as i, RequireKeys as l, DeepPartial as n, Nullable as o, DeepReadonly as r, OptionalKeys as s, Brand as t };
@@ -0,0 +1,2 @@
1
+ import { a as NonEmptyArray, c as Prettify, i as KeysMatching, l as RequireKeys, n as DeepPartial, o as Nullable, r as DeepReadonly, s as OptionalKeys, t as Brand } from "./brand-DApaegoR.mjs";
2
+ export { Brand, DeepPartial, DeepReadonly, KeysMatching, NonEmptyArray, Nullable, OptionalKeys, Prettify, RequireKeys };
package/dist/brand.mjs ADDED
@@ -0,0 +1 @@
1
+ export {};