@classytic/primitives 0.15.0 → 0.18.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 +46 -0
- package/dist/approval.d.mts +17 -2
- package/dist/approval.mjs +13 -1
- package/dist/event-infra.d.mts +10 -0
- package/dist/event-infra.mjs +10 -0
- package/dist/outbox.d.mts +25 -0
- package/dist/shelf-life.mjs +36 -3
- package/dist/state-diagram.d.mts +68 -0
- package/dist/state-diagram.mjs +66 -0
- package/dist/subject.d.mts +65 -0
- package/dist/subject.mjs +40 -0
- package/dist/suspension.d.mts +151 -0
- package/dist/suspension.mjs +194 -0
- package/package.json +224 -212
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,52 @@
|
|
|
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.18.0] - 2026-07-29
|
|
7
|
+
|
|
8
|
+
### Added — `./subject`: `SubjectRef` polymorphic identity pointer
|
|
9
|
+
|
|
10
|
+
- **`@classytic/primitives/subject`** — `SubjectRef` (`{ subjectModel, subjectRef }`) — WHO a record is about, polymorphically. The pairing spans every kernel that holds data about a person without owning them (attendance, access, loyalty, credentials). Deliberately kept distinct from `ExternalRef` (provenance — "came from") even though the shapes are identical: a grant carries both at once and they mean different things.
|
|
11
|
+
- **`buildSubjectRef(model, id)`** — returns `SubjectRef | null`; `null` when `id` is absent/blank, because a malformed binding silently matches nothing (an entitlement nobody holds, a credential no revoke can find) rather than carrying `"undefined"` as a string.
|
|
12
|
+
- **`SubjectRef`** type exported from `@classytic/primitives/subject`.
|
|
13
|
+
|
|
14
|
+
### Added — `./state-diagram`: Mermaid diagram from a `StateMachine`
|
|
15
|
+
|
|
16
|
+
- **`@classytic/primitives/state-diagram`** — `renderStateDiagram(machine, opts?)` — generates a `stateDiagram-v2` Mermaid string from the same `StateMachine` object the runtime CAS enforces. Removes the class of drift where a hand-drawn prose comment diverges from the actual transition table. Pure: string in, string out, no I/O.
|
|
17
|
+
- **`StateDiagramOptions`** — `title?`, `highlight?` (mark specific states), `noteOn?` (per-state annotations).
|
|
18
|
+
|
|
19
|
+
### Added — `./suspension`: time-bounded suspension/pause policy
|
|
20
|
+
|
|
21
|
+
- **`@classytic/primitives/suspension`** — pure suspension policy computation (no persistence). Models a pause the subject is ENTITLED to take, bounded by an annual allowance, that ends by itself — distinct from `/hold` (an indefinite blocker needing manual resolution).
|
|
22
|
+
- **`evaluateSuspension(policy, history, opts)`** — always returns `autoResumeAt` (the instant the pause MUST end) so a sweep can resume it whether or not the subject ever comes back. Covers: gym membership freeze, subscription pause, unpaid leave.
|
|
23
|
+
- **`SuspensionPolicy`** — `maxDaysPerPeriod`, `minDurationDays`, `period` ('annual' | 'rolling').
|
|
24
|
+
- **`SuspensionEntry`** — a historical pause record (`startedAt`, `endedAt?`, `reason?`).
|
|
25
|
+
- **`SuspensionResult`** — `autoResumeAt`, `daysUsed`, `daysRemaining`, `status`.
|
|
26
|
+
|
|
27
|
+
### Added — `OutboxStore.transactionalSave` (`/outbox`)
|
|
28
|
+
|
|
29
|
+
- **`OutboxStore.transactionalSave?: boolean`** — declares whether `save` enlists `options.session` in the caller's transaction. Absent/`false` = "assume NOT atomic". Set `true` ONLY when `save` passes the session to the same transactional resource the caller writes (e.g. a mongoose model on the same connection). Callers that need atomicity (`@classytic/access`'s `atomically()`) check this flag and fail at boot rather than claiming a guarantee they cannot keep.
|
|
30
|
+
- **`MemoryOutboxStore.transactionalSave = false`** — explicitly declared; an in-memory store cannot participate in a Mongo transaction, so its write survives a rollback.
|
|
31
|
+
|
|
32
|
+
### Changed — `shelf-life.ts` deduplicates `addDays`
|
|
33
|
+
|
|
34
|
+
- `addDays` is now imported from `./calendar` instead of being re-defined inline. No behavior change.
|
|
35
|
+
|
|
36
|
+
## [0.16.0] - 2026-07-27
|
|
37
|
+
|
|
38
|
+
### Added — `assertApproved` + `CHAIN_INCOMPLETE` (`/approval`)
|
|
39
|
+
|
|
40
|
+
- **`ApprovalErrorCode` gains `'CHAIN_INCOMPLETE'`** — the single canonical
|
|
41
|
+
error code for "finalize/commit attempted before the chain reached `approved`".
|
|
42
|
+
Hosts route it through `rethrowApprovalError` to the `approval.chain_incomplete`
|
|
43
|
+
wire code so all commit/post/finalize boundaries raise the same structured error.
|
|
44
|
+
- **`assertApproved(chain, message?)`** — canonical finalize-time gate: asserts
|
|
45
|
+
the chain has reached `approved`, else throws `ApprovalError('CHAIN_INCOMPLETE')`.
|
|
46
|
+
A `null`/absent chain passes (no chain required). Use at every journal-entry
|
|
47
|
+
post, PO approve, transfer dispatch, etc. to replace per-domain hand-rolled
|
|
48
|
+
divergent codes with one definition.
|
|
49
|
+
|
|
50
|
+
Purely additive — no existing exports changed.
|
|
51
|
+
|
|
6
52
|
## [0.15.0] - 2026-07-25
|
|
7
53
|
|
|
8
54
|
### Added — `/retention`: `PurgeEvidence` value object
|
package/dist/approval.d.mts
CHANGED
|
@@ -83,7 +83,12 @@ interface CreateStepInput {
|
|
|
83
83
|
readonly requiredApprovals?: number;
|
|
84
84
|
readonly threshold?: ApprovalThreshold;
|
|
85
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'
|
|
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
|
+
/** A finalize/commit was attempted while the chain has not reached
|
|
88
|
+
* `approved` (still `pending`, or `rejected`). The single canonical
|
|
89
|
+
* "not yet approved" gate — hosts route it through arc-approval's
|
|
90
|
+
* `rethrowApprovalError` to the `approval.chain_incomplete` wire code. */
|
|
91
|
+
| 'CHAIN_INCOMPLETE';
|
|
87
92
|
declare class ApprovalError extends Error {
|
|
88
93
|
readonly name = "ApprovalError";
|
|
89
94
|
readonly code: ApprovalErrorCode;
|
|
@@ -123,6 +128,16 @@ declare function applyDecision(chain: ApprovalChain, input: DecisionInput): Appr
|
|
|
123
128
|
*/
|
|
124
129
|
declare function skipStep(chain: ApprovalChain, stepId: string, reason?: string): ApprovalChain;
|
|
125
130
|
declare function isApproved(chain: ApprovalChain): boolean;
|
|
131
|
+
/**
|
|
132
|
+
* The canonical finalize-time gate: assert `chain` has reached `approved`,
|
|
133
|
+
* else throw `ApprovalError('CHAIN_INCOMPLETE')`. Use this at every
|
|
134
|
+
* commit/post/finalize boundary that must not proceed on an unapproved chain
|
|
135
|
+
* (journal-entry post, PO approve, transfer dispatch, …) so they all raise the
|
|
136
|
+
* SAME error instead of hand-rolling divergent codes. A `null`/absent chain is
|
|
137
|
+
* treated as "no chain required" and passes — callers that require a chain
|
|
138
|
+
* check presence separately.
|
|
139
|
+
*/
|
|
140
|
+
declare function assertApproved(chain: ApprovalChain | null | undefined, message?: string): void;
|
|
126
141
|
declare function isRejected(chain: ApprovalChain): boolean;
|
|
127
142
|
declare function isPending(chain: ApprovalChain): boolean;
|
|
128
143
|
/**
|
|
@@ -130,4 +145,4 @@ declare function isPending(chain: ApprovalChain): boolean;
|
|
|
130
145
|
*/
|
|
131
146
|
declare function decisionCount(chain: ApprovalChain): number;
|
|
132
147
|
//#endregion
|
|
133
|
-
export { ApprovalChain, ApprovalDecision, ApprovalError, ApprovalErrorCode, ApprovalStatus, ApprovalStep, ApprovalThreshold, Approver, ChainOrder, CreateChainInput, CreateStepInput, DecisionInput, applyDecision, createChain, decisionCount, isApproved, isPending, isRejected, nextPendingStep, pendingSteps, skipStep };
|
|
148
|
+
export { ApprovalChain, ApprovalDecision, ApprovalError, ApprovalErrorCode, ApprovalStatus, ApprovalStep, ApprovalThreshold, Approver, ChainOrder, CreateChainInput, CreateStepInput, DecisionInput, applyDecision, assertApproved, createChain, decisionCount, isApproved, isPending, isRejected, nextPendingStep, pendingSteps, skipStep };
|
package/dist/approval.mjs
CHANGED
|
@@ -115,6 +115,18 @@ function skipStep(chain, stepId, reason) {
|
|
|
115
115
|
function isApproved(chain) {
|
|
116
116
|
return chain.status === "approved";
|
|
117
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* The canonical finalize-time gate: assert `chain` has reached `approved`,
|
|
120
|
+
* else throw `ApprovalError('CHAIN_INCOMPLETE')`. Use this at every
|
|
121
|
+
* commit/post/finalize boundary that must not proceed on an unapproved chain
|
|
122
|
+
* (journal-entry post, PO approve, transfer dispatch, …) so they all raise the
|
|
123
|
+
* SAME error instead of hand-rolling divergent codes. A `null`/absent chain is
|
|
124
|
+
* treated as "no chain required" and passes — callers that require a chain
|
|
125
|
+
* check presence separately.
|
|
126
|
+
*/
|
|
127
|
+
function assertApproved(chain, message) {
|
|
128
|
+
if (chain != null && !isApproved(chain)) throw new ApprovalError("CHAIN_INCOMPLETE", message ?? `approval chain has not reached 'approved' (status='${chain.status}')`);
|
|
129
|
+
}
|
|
118
130
|
function isRejected(chain) {
|
|
119
131
|
return chain.status === "rejected";
|
|
120
132
|
}
|
|
@@ -143,4 +155,4 @@ function computeChainStatus(steps) {
|
|
|
143
155
|
return steps.every((s) => s.status === "approved" || s.status === "skipped") ? "approved" : "pending";
|
|
144
156
|
}
|
|
145
157
|
//#endregion
|
|
146
|
-
export { ApprovalError, applyDecision, createChain, decisionCount, isApproved, isPending, isRejected, nextPendingStep, pendingSteps, skipStep };
|
|
158
|
+
export { ApprovalError, applyDecision, assertApproved, createChain, decisionCount, isApproved, isPending, isRejected, nextPendingStep, pendingSteps, skipStep };
|
package/dist/event-infra.d.mts
CHANGED
|
@@ -98,6 +98,16 @@ declare function createInProcessBus(options?: InProcessEventBusOptions): InProce
|
|
|
98
98
|
* are test-inspection helpers (not part of the contract).
|
|
99
99
|
*/
|
|
100
100
|
declare class MemoryOutboxStore implements OutboxStore {
|
|
101
|
+
/**
|
|
102
|
+
* Deliberately NOT transactional — and saying so is the point.
|
|
103
|
+
*
|
|
104
|
+
* This store pushes onto an in-process array, which no database transaction
|
|
105
|
+
* can roll back. If a caller's Mongo write aborts after `save`, the event
|
|
106
|
+
* SURVIVES here and describes something that never happened. Declaring
|
|
107
|
+
* `false` lets a caller that needs real atomicity (`@classytic/access`) refuse
|
|
108
|
+
* at boot instead of silently inheriting a ghost-event path.
|
|
109
|
+
*/
|
|
110
|
+
readonly transactionalSave = false;
|
|
101
111
|
private events;
|
|
102
112
|
save(event: DomainEvent, _options?: OutboxWriteOptions): Promise<void>;
|
|
103
113
|
getPending(limit: number): Promise<DomainEvent[]>;
|
package/dist/event-infra.mjs
CHANGED
|
@@ -89,6 +89,16 @@ function createInProcessBus(options) {
|
|
|
89
89
|
* are test-inspection helpers (not part of the contract).
|
|
90
90
|
*/
|
|
91
91
|
var MemoryOutboxStore = class {
|
|
92
|
+
/**
|
|
93
|
+
* Deliberately NOT transactional — and saying so is the point.
|
|
94
|
+
*
|
|
95
|
+
* This store pushes onto an in-process array, which no database transaction
|
|
96
|
+
* can roll back. If a caller's Mongo write aborts after `save`, the event
|
|
97
|
+
* SURVIVES here and describes something that never happened. Declaring
|
|
98
|
+
* `false` lets a caller that needs real atomicity (`@classytic/access`) refuse
|
|
99
|
+
* at boot instead of silently inheriting a ghost-event path.
|
|
100
|
+
*/
|
|
101
|
+
transactionalSave = false;
|
|
92
102
|
events = [];
|
|
93
103
|
async save(event, _options) {
|
|
94
104
|
this.events.push({ event });
|
package/dist/outbox.d.mts
CHANGED
|
@@ -158,6 +158,31 @@ declare class InvalidOutboxEventError extends Error {
|
|
|
158
158
|
* store is responsible for quarantining them.
|
|
159
159
|
*/
|
|
160
160
|
interface OutboxStore {
|
|
161
|
+
/**
|
|
162
|
+
* Declares that `save` ENLISTS `options.session` in the caller's transaction,
|
|
163
|
+
* so the event row and the caller's domain write commit or roll back together.
|
|
164
|
+
*
|
|
165
|
+
* ## Why this must be declared rather than assumed
|
|
166
|
+
*
|
|
167
|
+
* `OutboxWriteOptions.session` is documented above as BEST-EFFORT — "stores may
|
|
168
|
+
* ignore fields they don't support". That is the right contract for a
|
|
169
|
+
* multi-backend interface, but it means a caller cannot infer atomicity from
|
|
170
|
+
* the mere presence of an outbox. A Redis-backed store, a store on a second
|
|
171
|
+
* Mongo connection, or one that simply drops the option will happily persist an
|
|
172
|
+
* event for a domain write that later rolls back — a GHOST event describing
|
|
173
|
+
* something that never happened. For an access-control or ledger consumer that
|
|
174
|
+
* is worse than a lost event, because downstream acts on it.
|
|
175
|
+
*
|
|
176
|
+
* Absent or `false` therefore means "assume NOT atomic". Callers that need the
|
|
177
|
+
* guarantee (see `@classytic/access`'s `atomically()`) check this flag and say
|
|
178
|
+
* so plainly at boot rather than claiming a property they cannot keep.
|
|
179
|
+
*
|
|
180
|
+
* Set `true` ONLY when `save` passes the session through to the same
|
|
181
|
+
* transactional resource the caller is writing (e.g. a mongoose model on the
|
|
182
|
+
* SAME connection). An in-memory store must NOT set it: it cannot participate
|
|
183
|
+
* in a Mongo transaction, so its write survives a rollback.
|
|
184
|
+
*/
|
|
185
|
+
readonly transactionalSave?: boolean;
|
|
161
186
|
/**
|
|
162
187
|
* Save an event to the outbox (typically called inside a business
|
|
163
188
|
* transaction). MUST reject events missing `type` or `meta.id` — throw
|
package/dist/shelf-life.mjs
CHANGED
|
@@ -1,4 +1,40 @@
|
|
|
1
|
+
import { addDays } from "./calendar.mjs";
|
|
1
2
|
//#region src/scheduling/shelf-life.ts
|
|
3
|
+
/**
|
|
4
|
+
* Shelf-life / lot-tracking policy + pure expiry-date derivation.
|
|
5
|
+
*
|
|
6
|
+
* Describes whether a SKU is tracked by lot/serial, whether it carries an
|
|
7
|
+
* expiration date, and the day-offsets used to derive the four lifecycle
|
|
8
|
+
* dates of a freshly-received lot. The *policy* is product-level metadata
|
|
9
|
+
* (owned by a catalog); the *derived dates* are per-lot facts (owned by a
|
|
10
|
+
* WMS). This module is the shared contract both speak so the shape can't
|
|
11
|
+
* drift between packages.
|
|
12
|
+
*
|
|
13
|
+
* Modeled on Odoo's `product_expiry` addon, which puts `tracking` +
|
|
14
|
+
* `use_expiration_date` and four integer day-deltas on `product.template`,
|
|
15
|
+
* then computes four dates on each `stock.lot`:
|
|
16
|
+
*
|
|
17
|
+
* expiration = receivedAt + shelfLifeDays
|
|
18
|
+
* bestBefore = expiration − bestBeforeDays (still safe, starts deteriorating)
|
|
19
|
+
* removal = expiration − removalDays (stop selling — FEFO sorts on THIS)
|
|
20
|
+
* alert = expiration − alertDays (raise a reminder)
|
|
21
|
+
*
|
|
22
|
+
* Consumed by `@classytic/catalog` (variant tracking policy) and
|
|
23
|
+
* `@classytic/flow` (lot date derivation at receipt, FEFO, fresh-on-hand).
|
|
24
|
+
*
|
|
25
|
+
* Zero-dep, pure UTC day arithmetic — safe in unit tests with no clock.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* const policy: ShelfLifePolicy = {
|
|
29
|
+
* mode: 'lot',
|
|
30
|
+
* useExpiration: true,
|
|
31
|
+
* shelfLifeDays: 540, // 18-month medicine
|
|
32
|
+
* removalDays: 30, // pull 30 days before expiry
|
|
33
|
+
* alertDays: 60, // remind 60 days before expiry
|
|
34
|
+
* };
|
|
35
|
+
* deriveShelfLifeDates(new Date('2026-01-01T00:00:00Z'), policy);
|
|
36
|
+
* // → { expiresAt: 2027-06-25…, removalDate: 2027-05-26…, alertDate: 2027-04-26… }
|
|
37
|
+
*/
|
|
2
38
|
var ShelfLifeError = class extends Error {
|
|
3
39
|
name = "ShelfLifeError";
|
|
4
40
|
code;
|
|
@@ -78,9 +114,6 @@ function shelfLifeStatus(dates, asOf) {
|
|
|
78
114
|
if (dates.alertDate && now >= dates.alertDate.getTime()) return "alert";
|
|
79
115
|
return "fresh";
|
|
80
116
|
}
|
|
81
|
-
function addDays(d, days) {
|
|
82
|
-
return new Date(d.getTime() + days * DAY_MS);
|
|
83
|
-
}
|
|
84
117
|
function defined(key, val) {
|
|
85
118
|
return val === void 0 ? {} : { [key]: val };
|
|
86
119
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { StateMachine } from "./state-machine.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/workflow/state-diagram.d.ts
|
|
4
|
+
interface StateDiagramOptions<TStatus extends string> {
|
|
5
|
+
/**
|
|
6
|
+
* The state a new aggregate starts in. Drawn as `[*] --> initial`.
|
|
7
|
+
*
|
|
8
|
+
* NOT inferred: a machine's entry point is a domain fact the adjacency list does
|
|
9
|
+
* not carry. "The state with no inbound transitions" guesses wrong the moment a
|
|
10
|
+
* machine has a legal return path into its own start (a retry going
|
|
11
|
+
* `sent → pending`), and silently drawing the wrong entry point is worse than
|
|
12
|
+
* drawing none.
|
|
13
|
+
*/
|
|
14
|
+
readonly initial?: TStatus;
|
|
15
|
+
/**
|
|
16
|
+
* Highlight one state — "you are here" for an operator looking at a record.
|
|
17
|
+
*/
|
|
18
|
+
readonly current?: TStatus;
|
|
19
|
+
/** Diagram title, rendered above the graph. */
|
|
20
|
+
readonly title?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Label a transition, keyed `"from->to"`. For naming the VERB that causes it
|
|
23
|
+
* (`"sent->acked": "device confirmed"`), which is the part a reader actually
|
|
24
|
+
* wants and the table cannot know.
|
|
25
|
+
*/
|
|
26
|
+
readonly labels?: Readonly<Record<string, string>>;
|
|
27
|
+
/**
|
|
28
|
+
* Draw `terminal --> [*]` edges. Default true — a reader needs to see which
|
|
29
|
+
* states end the lifecycle. Turn off for a dense machine where the extra edges
|
|
30
|
+
* cost more than they explain.
|
|
31
|
+
*/
|
|
32
|
+
readonly showTerminal?: boolean;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A `stateDiagram-v2` for this machine.
|
|
36
|
+
*
|
|
37
|
+
* Self-transitions are emitted as-is — mermaid draws them as a loop, and they are
|
|
38
|
+
* meaningful (`sent → sent` is the device-command re-lease after a lapsed lease),
|
|
39
|
+
* so suppressing them would hide a real rule.
|
|
40
|
+
*/
|
|
41
|
+
declare function toMermaid<TStatus extends string>(machine: StateMachine<TStatus>, options?: StateDiagramOptions<TStatus>): string;
|
|
42
|
+
/** One row of `describeStateMachine` — a table a UI or a doc can render directly. */
|
|
43
|
+
interface StateDescription<TStatus extends string> {
|
|
44
|
+
readonly status: TStatus;
|
|
45
|
+
readonly targets: readonly TStatus[];
|
|
46
|
+
readonly sources: readonly TStatus[];
|
|
47
|
+
readonly terminal: boolean;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The machine as data, for a UI that wants a table rather than a picture — an
|
|
51
|
+
* action bar asking "which transitions are legal from where this record is now"
|
|
52
|
+
* reads `targets`, and never has to hardcode a list that drifts from the kernel.
|
|
53
|
+
*/
|
|
54
|
+
declare function describeStateMachine<TStatus extends string>(machine: StateMachine<TStatus>): {
|
|
55
|
+
readonly name: string;
|
|
56
|
+
readonly states: readonly StateDescription<TStatus>[];
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* States that can never be reached from `initial`.
|
|
60
|
+
*
|
|
61
|
+
* A design smell worth a CI check: an unreachable state is either a transition
|
|
62
|
+
* somebody forgot to declare, or dead vocabulary nobody removed. Both are cheap to
|
|
63
|
+
* find here and expensive to notice in production, where the symptom is a record
|
|
64
|
+
* that can never leave the state it is stuck in.
|
|
65
|
+
*/
|
|
66
|
+
declare function unreachableStates<TStatus extends string>(machine: StateMachine<TStatus>, initial: TStatus): readonly TStatus[];
|
|
67
|
+
//#endregion
|
|
68
|
+
export { StateDescription, StateDiagramOptions, describeStateMachine, toMermaid, unreachableStates };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
//#region src/workflow/state-diagram.ts
|
|
2
|
+
/** Mermaid ids must be identifier-safe; domain statuses are usually snake_case. */
|
|
3
|
+
function safeId(status) {
|
|
4
|
+
const id = status.replace(/[^A-Za-z0-9_]/g, "_");
|
|
5
|
+
return /^[0-9]/.test(id) ? `s_${id}` : id;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A `stateDiagram-v2` for this machine.
|
|
9
|
+
*
|
|
10
|
+
* Self-transitions are emitted as-is — mermaid draws them as a loop, and they are
|
|
11
|
+
* meaningful (`sent → sent` is the device-command re-lease after a lapsed lease),
|
|
12
|
+
* so suppressing them would hide a real rule.
|
|
13
|
+
*/
|
|
14
|
+
function toMermaid(machine, options = {}) {
|
|
15
|
+
const { initial, current, title, labels = {}, showTerminal = true } = options;
|
|
16
|
+
const lines = [];
|
|
17
|
+
if (title !== void 0) lines.push("---", `title: ${title}`, "---");
|
|
18
|
+
lines.push("stateDiagram-v2");
|
|
19
|
+
if (initial !== void 0) lines.push(` [*] --> ${safeId(initial)}`);
|
|
20
|
+
const states = Object.keys(machine.transitions).sort();
|
|
21
|
+
for (const from of states) for (const to of [...machine.validTargets(from)].sort()) {
|
|
22
|
+
const label = labels[`${from}->${to}`];
|
|
23
|
+
lines.push(` ${safeId(from)} --> ${safeId(to)}${label !== void 0 ? `: ${label}` : ""}`);
|
|
24
|
+
}
|
|
25
|
+
if (showTerminal) {
|
|
26
|
+
for (const state of states) if (machine.isTerminal(state)) lines.push(` ${safeId(state)} --> [*]`);
|
|
27
|
+
}
|
|
28
|
+
if (current !== void 0) lines.push(" classDef current fill:#2563eb,stroke:#1e40af,color:#fff,font-weight:bold", ` class ${safeId(current)} current`);
|
|
29
|
+
return lines.join("\n");
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The machine as data, for a UI that wants a table rather than a picture — an
|
|
33
|
+
* action bar asking "which transitions are legal from where this record is now"
|
|
34
|
+
* reads `targets`, and never has to hardcode a list that drifts from the kernel.
|
|
35
|
+
*/
|
|
36
|
+
function describeStateMachine(machine) {
|
|
37
|
+
const states = Object.keys(machine.transitions).sort();
|
|
38
|
+
return {
|
|
39
|
+
name: machine.name,
|
|
40
|
+
states: states.map((status) => ({
|
|
41
|
+
status,
|
|
42
|
+
targets: [...machine.validTargets(status)].sort(),
|
|
43
|
+
sources: [...machine.validSources(status)].sort(),
|
|
44
|
+
terminal: machine.isTerminal(status)
|
|
45
|
+
}))
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* States that can never be reached from `initial`.
|
|
50
|
+
*
|
|
51
|
+
* A design smell worth a CI check: an unreachable state is either a transition
|
|
52
|
+
* somebody forgot to declare, or dead vocabulary nobody removed. Both are cheap to
|
|
53
|
+
* find here and expensive to notice in production, where the symptom is a record
|
|
54
|
+
* that can never leave the state it is stuck in.
|
|
55
|
+
*/
|
|
56
|
+
function unreachableStates(machine, initial) {
|
|
57
|
+
const seen = new Set([initial]);
|
|
58
|
+
const queue = [initial];
|
|
59
|
+
while (queue.length > 0) for (const next of machine.validTargets(queue.pop())) if (!seen.has(next)) {
|
|
60
|
+
seen.add(next);
|
|
61
|
+
queue.push(next);
|
|
62
|
+
}
|
|
63
|
+
return Object.keys(machine.transitions).filter((s) => !seen.has(s)).sort();
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
export { describeStateMachine, toMermaid, unreachableStates };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
//#region src/identity/subject.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* `SubjectRef` — WHO a record is about, polymorphically.
|
|
4
|
+
*
|
|
5
|
+
* The pairing `{ subjectModel, subjectRef }` appears across every package that
|
|
6
|
+
* has to talk about a person without owning them: an attendance punch, an access
|
|
7
|
+
* entitlement, a device credential, a loyalty balance. Each of those kernels
|
|
8
|
+
* deliberately keeps the model a plain `string`, because attendance serves
|
|
9
|
+
* employees, students and gym members and no kernel can know which.
|
|
10
|
+
*
|
|
11
|
+
* ## Why this is NOT `ExternalRef`
|
|
12
|
+
*
|
|
13
|
+
* `composition/reference.ts` already models `{ sourceId, sourceModel }` — the
|
|
14
|
+
* identical SHAPE. They are kept apart because they answer different questions and
|
|
15
|
+
* that difference is load-bearing:
|
|
16
|
+
*
|
|
17
|
+
* - `ExternalRef` — "this record CAME FROM there" (an Order, a Stripe charge, a
|
|
18
|
+
* Postgres invoice). Provenance. Used for reconciliation and cascades.
|
|
19
|
+
* - `SubjectRef` — "this record is ABOUT them". Identity. Used for authorization
|
|
20
|
+
* and lookup.
|
|
21
|
+
*
|
|
22
|
+
* A grant carries both at once and they mean different things: `originModel:
|
|
23
|
+
* 'Order'` (what paid) and `subjectModel: 'Party'` (who holds it). Collapsing them
|
|
24
|
+
* into one type would make that sentence unsayable, and renaming the persisted
|
|
25
|
+
* `subjectModel`/`subjectRef` fields to `sourceModel`/`sourceId` is the harmful
|
|
26
|
+
* rename — a migration across many collections that buys nothing.
|
|
27
|
+
*
|
|
28
|
+
* ## Why a shared type at all
|
|
29
|
+
*
|
|
30
|
+
* The pairing spans SERVER and CLIENT. A server writes it when a membership grant
|
|
31
|
+
* projects an entitlement; a web app writes it when staff bind a card. When the two
|
|
32
|
+
* disagree nothing throws — the synchronizer matches on both fields, so it finds
|
|
33
|
+
* ZERO rows, and freezing a membership silently stops touching the member's card.
|
|
34
|
+
* That bug shipped: a UI hardcoded `'Customer'` against a server writing `'Party'`.
|
|
35
|
+
* One shared type plus one shared constant per domain is the fix.
|
|
36
|
+
*/
|
|
37
|
+
/** A polymorphic reference to the subject a record is about. */
|
|
38
|
+
interface SubjectRef {
|
|
39
|
+
/** Logical model / type name — e.g. 'Party', 'Employee', 'Student', 'ApiKey'. */
|
|
40
|
+
subjectModel: string;
|
|
41
|
+
/** Opaque identifier of the subject within that model. */
|
|
42
|
+
subjectRef: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Build a `SubjectRef`, or `null` when the id is absent/blank.
|
|
46
|
+
*
|
|
47
|
+
* Returning null rather than a ref containing `"undefined"` or `""` is the whole
|
|
48
|
+
* point: a malformed binding does not fail loudly, it silently matches nothing
|
|
49
|
+
* forever — an entitlement nobody holds, a credential no revoke can find.
|
|
50
|
+
*/
|
|
51
|
+
declare function makeSubjectRef(subjectModel: string, id: string | number | null | undefined): SubjectRef | null;
|
|
52
|
+
/**
|
|
53
|
+
* The canonical `"Model:id"` string form.
|
|
54
|
+
*
|
|
55
|
+
* One formatter so log lines, dedupe keys and command payloads agree. Two
|
|
56
|
+
* hand-rolled variants (`Party:1` vs `party/1`) look equivalent until one is used
|
|
57
|
+
* as an idempotency key and the other as a lookup.
|
|
58
|
+
*/
|
|
59
|
+
declare function formatSubjectRef(subject: SubjectRef): string;
|
|
60
|
+
/** Exact equality on both halves — never on the id alone. */
|
|
61
|
+
declare function subjectRefsEqual(a: SubjectRef | null, b: SubjectRef | null): boolean;
|
|
62
|
+
/** Structural guard for data crossing a wire. */
|
|
63
|
+
declare function isSubjectRef(value: unknown): value is SubjectRef;
|
|
64
|
+
//#endregion
|
|
65
|
+
export { SubjectRef, formatSubjectRef, isSubjectRef, makeSubjectRef, subjectRefsEqual };
|
package/dist/subject.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//#region src/identity/subject.ts
|
|
2
|
+
/**
|
|
3
|
+
* Build a `SubjectRef`, or `null` when the id is absent/blank.
|
|
4
|
+
*
|
|
5
|
+
* Returning null rather than a ref containing `"undefined"` or `""` is the whole
|
|
6
|
+
* point: a malformed binding does not fail loudly, it silently matches nothing
|
|
7
|
+
* forever — an entitlement nobody holds, a credential no revoke can find.
|
|
8
|
+
*/
|
|
9
|
+
function makeSubjectRef(subjectModel, id) {
|
|
10
|
+
const subjectRef = id === null || id === void 0 ? "" : String(id).trim();
|
|
11
|
+
const model = subjectModel.trim();
|
|
12
|
+
if (subjectRef.length === 0 || model.length === 0) return null;
|
|
13
|
+
return {
|
|
14
|
+
subjectModel: model,
|
|
15
|
+
subjectRef
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The canonical `"Model:id"` string form.
|
|
20
|
+
*
|
|
21
|
+
* One formatter so log lines, dedupe keys and command payloads agree. Two
|
|
22
|
+
* hand-rolled variants (`Party:1` vs `party/1`) look equivalent until one is used
|
|
23
|
+
* as an idempotency key and the other as a lookup.
|
|
24
|
+
*/
|
|
25
|
+
function formatSubjectRef(subject) {
|
|
26
|
+
return `${subject.subjectModel}:${subject.subjectRef}`;
|
|
27
|
+
}
|
|
28
|
+
/** Exact equality on both halves — never on the id alone. */
|
|
29
|
+
function subjectRefsEqual(a, b) {
|
|
30
|
+
if (!a || !b) return false;
|
|
31
|
+
return a.subjectModel === b.subjectModel && a.subjectRef === b.subjectRef;
|
|
32
|
+
}
|
|
33
|
+
/** Structural guard for data crossing a wire. */
|
|
34
|
+
function isSubjectRef(value) {
|
|
35
|
+
if (typeof value !== "object" || value === null) return false;
|
|
36
|
+
const v = value;
|
|
37
|
+
return typeof v.subjectModel === "string" && v.subjectModel.length > 0 && typeof v.subjectRef === "string" && v.subjectRef.length > 0;
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
export { formatSubjectRef, isSubjectRef, makeSubjectRef, subjectRefsEqual };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { UtcOffsetMinutes } from "./calendar.mjs";
|
|
2
|
+
import { DateRange } from "./period.mjs";
|
|
3
|
+
import { StatusChangeEntry } from "./status-history.mjs";
|
|
4
|
+
|
|
5
|
+
//#region src/workflow/suspension.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* One suspension, past or current.
|
|
8
|
+
*
|
|
9
|
+
* `endedAt` absent or `null` means STILL OPEN — counted up to the evaluation
|
|
10
|
+
* instant, because an open freeze is consuming allowance right now. Treating an
|
|
11
|
+
* open span as zero is how an indefinite pause looks free.
|
|
12
|
+
*/
|
|
13
|
+
interface SuspensionSpan {
|
|
14
|
+
readonly startedAt: Date;
|
|
15
|
+
readonly endedAt?: Date | null;
|
|
16
|
+
readonly reason?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The rulebook. Every field optional; an empty policy permits an unbounded pause,
|
|
20
|
+
* so a host that has not decided yet gets today's (lenient) behaviour rather than
|
|
21
|
+
* a surprise refusal.
|
|
22
|
+
*/
|
|
23
|
+
interface SuspensionPolicy {
|
|
24
|
+
/** `false` refuses outright — many gyms allow a freeze only on medical grounds. */
|
|
25
|
+
readonly allowed?: boolean;
|
|
26
|
+
/** Total suspended days permitted per period. Omit for unlimited. */
|
|
27
|
+
readonly maxDaysPerPeriod?: number;
|
|
28
|
+
/** Length of the allowance period in months. Default 12. */
|
|
29
|
+
readonly periodMonths?: number;
|
|
30
|
+
/** Shortest permitted single pause — stops pause-every-weekend gaming. */
|
|
31
|
+
readonly minDays?: number;
|
|
32
|
+
/** Longest permitted single pause, independent of the period allowance. */
|
|
33
|
+
readonly maxDays?: number;
|
|
34
|
+
/** How many may be open at once. Default 1. */
|
|
35
|
+
readonly maxConcurrent?: number;
|
|
36
|
+
/** Days of advance notice required before a pause may start. */
|
|
37
|
+
readonly noticeDays?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Does resuming push the access window out by the frozen duration?
|
|
40
|
+
*
|
|
41
|
+
* Carried here because it is the same decision, made by the same person, as the
|
|
42
|
+
* rest of the pause rules — but note this primitive only REPORTS it. Applying it
|
|
43
|
+
* is the entitlement kernel's job (`resume({ creditFrozenDays })`), because only
|
|
44
|
+
* that kernel owns the window.
|
|
45
|
+
*/
|
|
46
|
+
readonly creditsWindow?: boolean;
|
|
47
|
+
}
|
|
48
|
+
type SuspensionRefusal = 'not_allowed' | 'allowance_exhausted' | 'below_minimum' | 'above_maximum' | 'already_suspended' | 'insufficient_notice';
|
|
49
|
+
interface SuspensionDecision {
|
|
50
|
+
readonly allowed: boolean;
|
|
51
|
+
readonly reason: SuspensionRefusal | 'approved';
|
|
52
|
+
/** Operator-facing explanation. Present on every refusal. */
|
|
53
|
+
readonly message?: string;
|
|
54
|
+
/**
|
|
55
|
+
* When the pause must end — `min(requested end, allowance cap)`.
|
|
56
|
+
*
|
|
57
|
+
* `null` only when the policy sets no bound at all. A sweep resumes on this
|
|
58
|
+
* instant regardless of whether the subject asks.
|
|
59
|
+
*/
|
|
60
|
+
readonly autoResumeAt?: Date | null;
|
|
61
|
+
/** Days already consumed in the period containing the requested start. */
|
|
62
|
+
readonly daysUsedInPeriod: number;
|
|
63
|
+
/** Remaining allowance, or `null` when unlimited. */
|
|
64
|
+
readonly daysRemainingInPeriod: number | null;
|
|
65
|
+
}
|
|
66
|
+
interface SuspensionRequest {
|
|
67
|
+
readonly startsAt: Date;
|
|
68
|
+
/** Requested end. Omit for open-ended — the allowance then decides. */
|
|
69
|
+
readonly endsAt?: Date | null;
|
|
70
|
+
/** When the request is being made, for the notice check. Defaults to `startsAt`. */
|
|
71
|
+
readonly requestedAt?: Date;
|
|
72
|
+
}
|
|
73
|
+
interface SuspensionEvalOptions {
|
|
74
|
+
/** Minutes east of UTC for day boundaries — the BUSINESS timezone offset. */
|
|
75
|
+
readonly offsetMinutes?: UtcOffsetMinutes;
|
|
76
|
+
}
|
|
77
|
+
/** The allowance period containing `at`, as a half-open range. */
|
|
78
|
+
declare function allowancePeriod(at: Date, periodMonths?: number, offsetMinutes?: UtcOffsetMinutes): DateRange;
|
|
79
|
+
/**
|
|
80
|
+
* Whole days of suspension inside `period`.
|
|
81
|
+
*
|
|
82
|
+
* Counts the OVERLAP, not the spans that merely start inside — a freeze running
|
|
83
|
+
* 15 Dec → 15 Feb must consume only its December days against the period ending in
|
|
84
|
+
* December. Ignoring that double-counts a straddling pause in both periods and
|
|
85
|
+
* refuses a member who is within their allowance.
|
|
86
|
+
*/
|
|
87
|
+
declare function suspendedDaysInPeriod(history: readonly SuspensionSpan[], period: DateRange, /** Now — bounds still-open spans. Defaults to the period end. */at?: Date): number;
|
|
88
|
+
/** Is any span still open at `at`? */
|
|
89
|
+
declare function openSpans(history: readonly SuspensionSpan[], at: Date): readonly SuspensionSpan[];
|
|
90
|
+
/**
|
|
91
|
+
* Decide a pause request, and say when it must end.
|
|
92
|
+
*
|
|
93
|
+
* Refusal precedence is deliberate — most categorical first, so the message names
|
|
94
|
+
* the reason a human would give:
|
|
95
|
+
* 1. `not_allowed` — the product has no freeze at all
|
|
96
|
+
* 2. `already_suspended` — concurrency
|
|
97
|
+
* 3. `insufficient_notice` — asked too late
|
|
98
|
+
* 4. `below_minimum` / `above_maximum` — this single pause is the wrong length
|
|
99
|
+
* 5. `allowance_exhausted` — nothing left in the period
|
|
100
|
+
*/
|
|
101
|
+
declare function evaluateSuspension(request: SuspensionRequest, history: readonly SuspensionSpan[], policy?: SuspensionPolicy, options?: SuspensionEvalOptions): SuspensionDecision;
|
|
102
|
+
/**
|
|
103
|
+
* Is this open pause past the instant it should have ended?
|
|
104
|
+
*
|
|
105
|
+
* What an auto-resume sweep asks. Kept separate from `evaluateSuspension` because
|
|
106
|
+
* the sweep has an ALREADY-DECIDED `autoResumeAt` and must not re-derive it — the
|
|
107
|
+
* policy may have changed since the pause began, and silently re-deciding would
|
|
108
|
+
* move a date the member was told.
|
|
109
|
+
*/
|
|
110
|
+
declare function dueForAutoResume(span: SuspensionSpan & {
|
|
111
|
+
readonly autoResumeAt?: Date | null;
|
|
112
|
+
}, at: Date): boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Derive suspension spans from a status log — the seam that makes an allowance
|
|
115
|
+
* ENFORCEABLE rather than caller-supplied.
|
|
116
|
+
*
|
|
117
|
+
* ## Why this exists
|
|
118
|
+
*
|
|
119
|
+
* `evaluateSuspension` needs the history of every past pause. Aggregates do not
|
|
120
|
+
* carry that: they carry a single `suspendedAt` that each new freeze overwrites, so
|
|
121
|
+
* "60 days per year" is unenforceable — a member could freeze twelve times and the
|
|
122
|
+
* eleventh looks identical to the first.
|
|
123
|
+
*
|
|
124
|
+
* What they DO carry (or should, per `/status-history`) is an append-only status
|
|
125
|
+
* log. Every pause is already in there as a transition INTO the paused status, and
|
|
126
|
+
* every resume as the transition out. This turns that log into the spans the
|
|
127
|
+
* allowance maths needs, so nothing has to be denormalised twice and the two
|
|
128
|
+
* primitives compose instead of overlapping.
|
|
129
|
+
*
|
|
130
|
+
* ## Assumes the log is chronological
|
|
131
|
+
*
|
|
132
|
+
* `appendStatus` guarantees that by construction (it appends and computes
|
|
133
|
+
* `durationInPriorMs` from the previous element), so this does not re-sort — a sort
|
|
134
|
+
* here would silently paper over a corrupt log that the caller should be told
|
|
135
|
+
* about, and would cost O(n log n) on a hot read.
|
|
136
|
+
*
|
|
137
|
+
* @param pausedStatus the status that MEANS suspended (`'suspended'`, `'paused'`,
|
|
138
|
+
* `'on_hold'` — domains name it differently, so it is a parameter)
|
|
139
|
+
*/
|
|
140
|
+
declare function spansFromStatusHistory<TStatus extends string>(history: readonly StatusChangeEntry<TStatus>[], pausedStatus: TStatus, options?: {
|
|
141
|
+
/**
|
|
142
|
+
* The aggregate's status NOW. When it is not the paused status, a trailing
|
|
143
|
+
* paused entry is treated as CLOSED — the log simply has not recorded the
|
|
144
|
+
* resume yet (a crash between the write and the append). Leaving it open would
|
|
145
|
+
* charge the member allowance for time they were not frozen.
|
|
146
|
+
*/
|
|
147
|
+
readonly currentStatus?: TStatus | undefined; /** Closes a genuinely-open trailing span for counting. Defaults to open. */
|
|
148
|
+
readonly openEndedAt?: Date | undefined;
|
|
149
|
+
}): SuspensionSpan[];
|
|
150
|
+
//#endregion
|
|
151
|
+
export { SuspensionDecision, SuspensionEvalOptions, SuspensionPolicy, SuspensionRefusal, SuspensionRequest, SuspensionSpan, allowancePeriod, dueForAutoResume, evaluateSuspension, openSpans, spansFromStatusHistory, suspendedDaysInPeriod };
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { rangesOverlap } from "./period.mjs";
|
|
2
|
+
import { addMonths, startOfDay } from "./calendar.mjs";
|
|
3
|
+
//#region src/workflow/suspension.ts
|
|
4
|
+
/**
|
|
5
|
+
* Suspension policy — a time-bounded pause with a per-period allowance.
|
|
6
|
+
*
|
|
7
|
+
* ## What this is, and what it is not
|
|
8
|
+
*
|
|
9
|
+
* `/hold` already models a BLOCKING hold: a document cannot progress until someone
|
|
10
|
+
* resolves it (fraud review, customs detention). It has no duration, no allowance
|
|
11
|
+
* and no automatic end, because those are meaningless for a blocker.
|
|
12
|
+
*
|
|
13
|
+
* This models the other thing: a pause the subject is ENTITLED to take, bounded by
|
|
14
|
+
* an allowance, that ends by itself. Three unrelated kernels need exactly that:
|
|
15
|
+
*
|
|
16
|
+
* - a gym membership freeze — "60 days a year, minimum a week"
|
|
17
|
+
* - a subscription pause — the Netflix-style hold
|
|
18
|
+
* - unpaid leave — an annual cap, distinct from accrued paid leave
|
|
19
|
+
*
|
|
20
|
+
* (`hr/leave`'s balance is an accruing PERSISTED document — a different shape.
|
|
21
|
+
* This is pure computation over a history, so it stays a primitive.)
|
|
22
|
+
*
|
|
23
|
+
* ## `autoResumeAt` is the whole point
|
|
24
|
+
*
|
|
25
|
+
* Without a computed end, "pause" is an indefinite free hold. Someone freezes in
|
|
26
|
+
* February and the business quietly stops earning until an audit notices — which
|
|
27
|
+
* is a revenue hole, not a bug report, so nobody files it. `evaluateSuspension`
|
|
28
|
+
* always returns the instant the pause MUST end, so a sweep can resume it whether
|
|
29
|
+
* or not the subject ever comes back.
|
|
30
|
+
*
|
|
31
|
+
* ## Dates
|
|
32
|
+
*
|
|
33
|
+
* Day boundaries come from `/calendar`'s offset-based helpers and periods from
|
|
34
|
+
* `/period` — never server-local getters. A "60 days per year" allowance computed
|
|
35
|
+
* with `getMonth()` shifts with the deploy machine's `TZ`, so the same member would
|
|
36
|
+
* have a different balance in two regions. The offset is a CALLER decision
|
|
37
|
+
* (business timezone), which is why it is a parameter with no default beyond UTC.
|
|
38
|
+
*
|
|
39
|
+
* PURE: no clock reads beyond the `at` you pass, no I/O. The same evaluation runs
|
|
40
|
+
* on the server, in an admin preview, and in a test.
|
|
41
|
+
*/
|
|
42
|
+
const MS_PER_DAY = 1440 * 60 * 1e3;
|
|
43
|
+
/** The allowance period containing `at`, as a half-open range. */
|
|
44
|
+
function allowancePeriod(at, periodMonths = 12, offsetMinutes = 0) {
|
|
45
|
+
return {
|
|
46
|
+
start: addMonths(startOfDay(at, offsetMinutes), -periodMonths),
|
|
47
|
+
end: at
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Whole days of suspension inside `period`.
|
|
52
|
+
*
|
|
53
|
+
* Counts the OVERLAP, not the spans that merely start inside — a freeze running
|
|
54
|
+
* 15 Dec → 15 Feb must consume only its December days against the period ending in
|
|
55
|
+
* December. Ignoring that double-counts a straddling pause in both periods and
|
|
56
|
+
* refuses a member who is within their allowance.
|
|
57
|
+
*/
|
|
58
|
+
function suspendedDaysInPeriod(history, period, at) {
|
|
59
|
+
const openEnd = at ?? period.end;
|
|
60
|
+
let ms = 0;
|
|
61
|
+
for (const span of history) {
|
|
62
|
+
const spanEnd = span.endedAt ?? openEnd;
|
|
63
|
+
if (spanEnd.getTime() <= span.startedAt.getTime()) continue;
|
|
64
|
+
const range = {
|
|
65
|
+
start: span.startedAt,
|
|
66
|
+
end: spanEnd
|
|
67
|
+
};
|
|
68
|
+
if (!rangesOverlap(range, period)) continue;
|
|
69
|
+
const from = Math.max(range.start.getTime(), period.start.getTime());
|
|
70
|
+
const to = Math.min(range.end.getTime(), period.end.getTime());
|
|
71
|
+
ms += Math.max(0, to - from);
|
|
72
|
+
}
|
|
73
|
+
return Math.round(ms / MS_PER_DAY);
|
|
74
|
+
}
|
|
75
|
+
/** Is any span still open at `at`? */
|
|
76
|
+
function openSpans(history, at) {
|
|
77
|
+
return history.filter((s) => (s.endedAt ?? null) === null && s.startedAt.getTime() <= at.getTime());
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Decide a pause request, and say when it must end.
|
|
81
|
+
*
|
|
82
|
+
* Refusal precedence is deliberate — most categorical first, so the message names
|
|
83
|
+
* the reason a human would give:
|
|
84
|
+
* 1. `not_allowed` — the product has no freeze at all
|
|
85
|
+
* 2. `already_suspended` — concurrency
|
|
86
|
+
* 3. `insufficient_notice` — asked too late
|
|
87
|
+
* 4. `below_minimum` / `above_maximum` — this single pause is the wrong length
|
|
88
|
+
* 5. `allowance_exhausted` — nothing left in the period
|
|
89
|
+
*/
|
|
90
|
+
function evaluateSuspension(request, history, policy = {}, options = {}) {
|
|
91
|
+
const offset = options.offsetMinutes ?? 0;
|
|
92
|
+
const periodMonths = policy.periodMonths ?? 12;
|
|
93
|
+
const daysUsed = suspendedDaysInPeriod(history, allowancePeriod(request.startsAt, periodMonths, offset), request.startsAt);
|
|
94
|
+
const cap = policy.maxDaysPerPeriod;
|
|
95
|
+
const remaining = cap === void 0 ? null : Math.max(0, cap - daysUsed);
|
|
96
|
+
const refuse = (reason, message) => ({
|
|
97
|
+
allowed: false,
|
|
98
|
+
reason,
|
|
99
|
+
message,
|
|
100
|
+
daysUsedInPeriod: daysUsed,
|
|
101
|
+
daysRemainingInPeriod: remaining
|
|
102
|
+
});
|
|
103
|
+
if (policy.allowed === false) return refuse("not_allowed", "Pausing is not available on this product.");
|
|
104
|
+
const maxConcurrent = policy.maxConcurrent ?? 1;
|
|
105
|
+
if (openSpans(history, request.startsAt).length >= maxConcurrent) return refuse("already_suspended", "There is already an active pause.");
|
|
106
|
+
if (policy.noticeDays !== void 0 && policy.noticeDays > 0) {
|
|
107
|
+
const requestedAt = request.requestedAt ?? request.startsAt;
|
|
108
|
+
const noticeGiven = Math.round((startOfDay(request.startsAt, offset).getTime() - startOfDay(requestedAt, offset).getTime()) / MS_PER_DAY);
|
|
109
|
+
if (noticeGiven < policy.noticeDays) return refuse("insufficient_notice", `${policy.noticeDays} day(s) notice required; ${noticeGiven} given.`);
|
|
110
|
+
}
|
|
111
|
+
const requestedDays = request.endsAt != null ? Math.round((request.endsAt.getTime() - request.startsAt.getTime()) / MS_PER_DAY) : null;
|
|
112
|
+
if (requestedDays !== null) {
|
|
113
|
+
if (requestedDays <= 0) return refuse("below_minimum", "A pause must end after it starts.");
|
|
114
|
+
if (policy.minDays !== void 0 && requestedDays < policy.minDays) return refuse("below_minimum", `The shortest pause is ${policy.minDays} day(s); ${requestedDays} requested.`);
|
|
115
|
+
if (policy.maxDays !== void 0 && requestedDays > policy.maxDays) return refuse("above_maximum", `The longest single pause is ${policy.maxDays} day(s); ${requestedDays} requested.`);
|
|
116
|
+
}
|
|
117
|
+
if (remaining !== null && remaining <= 0) return refuse("allowance_exhausted", `The ${periodMonths}-month allowance of ${cap} day(s) is used up.`);
|
|
118
|
+
const candidates = [];
|
|
119
|
+
if (request.endsAt != null) candidates.push(request.endsAt.getTime());
|
|
120
|
+
if (remaining !== null) candidates.push(request.startsAt.getTime() + remaining * MS_PER_DAY);
|
|
121
|
+
if (policy.maxDays !== void 0) candidates.push(request.startsAt.getTime() + policy.maxDays * MS_PER_DAY);
|
|
122
|
+
return {
|
|
123
|
+
allowed: true,
|
|
124
|
+
reason: "approved",
|
|
125
|
+
autoResumeAt: candidates.length > 0 ? new Date(Math.min(...candidates)) : null,
|
|
126
|
+
daysUsedInPeriod: daysUsed,
|
|
127
|
+
daysRemainingInPeriod: remaining
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Is this open pause past the instant it should have ended?
|
|
132
|
+
*
|
|
133
|
+
* What an auto-resume sweep asks. Kept separate from `evaluateSuspension` because
|
|
134
|
+
* the sweep has an ALREADY-DECIDED `autoResumeAt` and must not re-derive it — the
|
|
135
|
+
* policy may have changed since the pause began, and silently re-deciding would
|
|
136
|
+
* move a date the member was told.
|
|
137
|
+
*/
|
|
138
|
+
function dueForAutoResume(span, at) {
|
|
139
|
+
if ((span.endedAt ?? null) !== null) return false;
|
|
140
|
+
const due = span.autoResumeAt ?? null;
|
|
141
|
+
return due !== null && due.getTime() <= at.getTime();
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Derive suspension spans from a status log — the seam that makes an allowance
|
|
145
|
+
* ENFORCEABLE rather than caller-supplied.
|
|
146
|
+
*
|
|
147
|
+
* ## Why this exists
|
|
148
|
+
*
|
|
149
|
+
* `evaluateSuspension` needs the history of every past pause. Aggregates do not
|
|
150
|
+
* carry that: they carry a single `suspendedAt` that each new freeze overwrites, so
|
|
151
|
+
* "60 days per year" is unenforceable — a member could freeze twelve times and the
|
|
152
|
+
* eleventh looks identical to the first.
|
|
153
|
+
*
|
|
154
|
+
* What they DO carry (or should, per `/status-history`) is an append-only status
|
|
155
|
+
* log. Every pause is already in there as a transition INTO the paused status, and
|
|
156
|
+
* every resume as the transition out. This turns that log into the spans the
|
|
157
|
+
* allowance maths needs, so nothing has to be denormalised twice and the two
|
|
158
|
+
* primitives compose instead of overlapping.
|
|
159
|
+
*
|
|
160
|
+
* ## Assumes the log is chronological
|
|
161
|
+
*
|
|
162
|
+
* `appendStatus` guarantees that by construction (it appends and computes
|
|
163
|
+
* `durationInPriorMs` from the previous element), so this does not re-sort — a sort
|
|
164
|
+
* here would silently paper over a corrupt log that the caller should be told
|
|
165
|
+
* about, and would cost O(n log n) on a hot read.
|
|
166
|
+
*
|
|
167
|
+
* @param pausedStatus the status that MEANS suspended (`'suspended'`, `'paused'`,
|
|
168
|
+
* `'on_hold'` — domains name it differently, so it is a parameter)
|
|
169
|
+
*/
|
|
170
|
+
function spansFromStatusHistory(history, pausedStatus, options = {}) {
|
|
171
|
+
const spans = [];
|
|
172
|
+
for (let i = 0; i < history.length; i += 1) {
|
|
173
|
+
const entry = history[i];
|
|
174
|
+
if (entry.status !== pausedStatus) continue;
|
|
175
|
+
const next = history[i + 1];
|
|
176
|
+
if (next !== void 0) {
|
|
177
|
+
spans.push({
|
|
178
|
+
startedAt: entry.occurredAt,
|
|
179
|
+
endedAt: next.occurredAt,
|
|
180
|
+
...entry.note !== void 0 ? { reason: entry.note } : {}
|
|
181
|
+
});
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const stillPaused = options.currentStatus === void 0 || options.currentStatus === pausedStatus;
|
|
185
|
+
spans.push({
|
|
186
|
+
startedAt: entry.occurredAt,
|
|
187
|
+
endedAt: stillPaused ? options.openEndedAt ?? null : entry.occurredAt,
|
|
188
|
+
...entry.note !== void 0 ? { reason: entry.note } : {}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return spans;
|
|
192
|
+
}
|
|
193
|
+
//#endregion
|
|
194
|
+
export { allowancePeriod, dueForAutoResume, evaluateSuspension, openSpans, spansFromStatusHistory, suspendedDaysInPeriod };
|
package/package.json
CHANGED
|
@@ -1,212 +1,224 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@classytic/primitives",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Framework-agnostic TypeScript primitives shared across Classytic packages — Money, events, pagination, tenant config, and other building blocks.",
|
|
5
|
-
"author": "Classytic",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"homepage": "https://www.npmjs.com/package/@classytic/primitives",
|
|
8
|
-
"bugs": {
|
|
9
|
-
"url": "https://github.com/classytic/primitives/issues"
|
|
10
|
-
},
|
|
11
|
-
"repository": {
|
|
12
|
-
"type": "git",
|
|
13
|
-
"url": "git+https://github.com/classytic/primitives.git"
|
|
14
|
-
},
|
|
15
|
-
"publishConfig": {
|
|
16
|
-
"access": "public"
|
|
17
|
-
},
|
|
18
|
-
"type": "module",
|
|
19
|
-
"sideEffects": false,
|
|
20
|
-
"engines": {
|
|
21
|
-
"node": ">=22"
|
|
22
|
-
},
|
|
23
|
-
"exports": {
|
|
24
|
-
"./money": {
|
|
25
|
-
"types": "./dist/money.d.mts",
|
|
26
|
-
"default": "./dist/money.mjs"
|
|
27
|
-
},
|
|
28
|
-
"./currency": {
|
|
29
|
-
"types": "./dist/currency.d.mts",
|
|
30
|
-
"default": "./dist/currency.mjs"
|
|
31
|
-
},
|
|
32
|
-
"./unit-cost-rate": {
|
|
33
|
-
"types": "./dist/unit-cost-rate.d.mts",
|
|
34
|
-
"default": "./dist/unit-cost-rate.mjs"
|
|
35
|
-
},
|
|
36
|
-
"./address": {
|
|
37
|
-
"types": "./dist/address.d.mts",
|
|
38
|
-
"default": "./dist/address.mjs"
|
|
39
|
-
},
|
|
40
|
-
"./period": {
|
|
41
|
-
"types": "./dist/period.d.mts",
|
|
42
|
-
"default": "./dist/period.mjs"
|
|
43
|
-
},
|
|
44
|
-
"./calendar": {
|
|
45
|
-
"types": "./dist/calendar.d.mts",
|
|
46
|
-
"default": "./dist/calendar.mjs"
|
|
47
|
-
},
|
|
48
|
-
"./timezone": {
|
|
49
|
-
"types": "./dist/timezone.d.mts",
|
|
50
|
-
"default": "./dist/timezone.mjs"
|
|
51
|
-
},
|
|
52
|
-
"./reference": {
|
|
53
|
-
"types": "./dist/reference.d.mts",
|
|
54
|
-
"default": "./dist/reference.mjs"
|
|
55
|
-
},
|
|
56
|
-
"./context": {
|
|
57
|
-
"types": "./dist/context.d.mts",
|
|
58
|
-
"default": "./dist/context.mjs"
|
|
59
|
-
},
|
|
60
|
-
"./events": {
|
|
61
|
-
"types": "./dist/events.d.mts",
|
|
62
|
-
"default": "./dist/events.mjs"
|
|
63
|
-
},
|
|
64
|
-
"./event-infra": {
|
|
65
|
-
"types": "./dist/event-infra.d.mts",
|
|
66
|
-
"default": "./dist/event-infra.mjs"
|
|
67
|
-
},
|
|
68
|
-
"./outbox": {
|
|
69
|
-
"types": "./dist/outbox.d.mts",
|
|
70
|
-
"default": "./dist/outbox.mjs"
|
|
71
|
-
},
|
|
72
|
-
"./result": {
|
|
73
|
-
"types": "./dist/result.d.mts",
|
|
74
|
-
"default": "./dist/result.mjs"
|
|
75
|
-
},
|
|
76
|
-
"./brand": {
|
|
77
|
-
"types": "./dist/brand.d.mts",
|
|
78
|
-
"default": "./dist/brand.mjs"
|
|
79
|
-
},
|
|
80
|
-
"./split-allocation": {
|
|
81
|
-
"types": "./dist/split-allocation.d.mts",
|
|
82
|
-
"default": "./dist/split-allocation.mjs"
|
|
83
|
-
},
|
|
84
|
-
"./approval": {
|
|
85
|
-
"types": "./dist/approval.d.mts",
|
|
86
|
-
"default": "./dist/approval.mjs"
|
|
87
|
-
},
|
|
88
|
-
"./cadence": {
|
|
89
|
-
"types": "./dist/cadence.d.mts",
|
|
90
|
-
"default": "./dist/cadence.mjs"
|
|
91
|
-
},
|
|
92
|
-
"./hold": {
|
|
93
|
-
"types": "./dist/hold.d.mts",
|
|
94
|
-
"default": "./dist/hold.mjs"
|
|
95
|
-
},
|
|
96
|
-
"./sla": {
|
|
97
|
-
"types": "./dist/sla.d.mts",
|
|
98
|
-
"default": "./dist/sla.mjs"
|
|
99
|
-
},
|
|
100
|
-
"./shelf-life": {
|
|
101
|
-
"types": "./dist/shelf-life.d.mts",
|
|
102
|
-
"default": "./dist/shelf-life.mjs"
|
|
103
|
-
},
|
|
104
|
-
"./person": {
|
|
105
|
-
"types": "./dist/person.d.mts",
|
|
106
|
-
"default": "./dist/person.mjs"
|
|
107
|
-
},
|
|
108
|
-
"./identity-verification": {
|
|
109
|
-
"types": "./dist/identity-verification.d.mts",
|
|
110
|
-
"default": "./dist/identity-verification.mjs"
|
|
111
|
-
},
|
|
112
|
-
"./otp": {
|
|
113
|
-
"types": "./dist/otp.d.mts",
|
|
114
|
-
"default": "./dist/otp.mjs"
|
|
115
|
-
},
|
|
116
|
-
"./state-machine": {
|
|
117
|
-
"types": "./dist/state-machine.d.mts",
|
|
118
|
-
"default": "./dist/state-machine.mjs"
|
|
119
|
-
},
|
|
120
|
-
"./bank-transaction": {
|
|
121
|
-
"types": "./dist/bank-transaction.d.mts",
|
|
122
|
-
"default": "./dist/bank-transaction.mjs"
|
|
123
|
-
},
|
|
124
|
-
"./payment-gateway": {
|
|
125
|
-
"types": "./dist/payment-gateway.d.mts",
|
|
126
|
-
"default": "./dist/payment-gateway.mjs"
|
|
127
|
-
},
|
|
128
|
-
"./payment-method-kind": {
|
|
129
|
-
"types": "./dist/payment-method-kind.d.mts",
|
|
130
|
-
"default": "./dist/payment-method-kind.mjs"
|
|
131
|
-
},
|
|
132
|
-
"./payment-allocation-status": {
|
|
133
|
-
"types": "./dist/payment-allocation-status.d.mts",
|
|
134
|
-
"default": "./dist/payment-allocation-status.mjs"
|
|
135
|
-
},
|
|
136
|
-
"./payment-events": {
|
|
137
|
-
"types": "./dist/payment-events.d.mts",
|
|
138
|
-
"default": "./dist/payment-events.mjs"
|
|
139
|
-
},
|
|
140
|
-
"./phone": {
|
|
141
|
-
"types": "./dist/phone.d.mts",
|
|
142
|
-
"default": "./dist/phone.mjs"
|
|
143
|
-
},
|
|
144
|
-
"./status-history": {
|
|
145
|
-
"types": "./dist/status-history.d.mts",
|
|
146
|
-
"default": "./dist/status-history.mjs"
|
|
147
|
-
},
|
|
148
|
-
"./condition": {
|
|
149
|
-
"types": "./dist/condition.d.mts",
|
|
150
|
-
"default": "./dist/condition.mjs"
|
|
151
|
-
},
|
|
152
|
-
"./mixin": {
|
|
153
|
-
"types": "./dist/mixin.d.mts",
|
|
154
|
-
"default": "./dist/mixin.mjs"
|
|
155
|
-
},
|
|
156
|
-
"./sla-policy": {
|
|
157
|
-
"types": "./dist/sla-policy.d.mts",
|
|
158
|
-
"default": "./dist/sla-policy.mjs"
|
|
159
|
-
},
|
|
160
|
-
"./retention": {
|
|
161
|
-
"types": "./dist/retention.d.mts",
|
|
162
|
-
"default": "./dist/retention.mjs"
|
|
163
|
-
},
|
|
164
|
-
"./canonical": {
|
|
165
|
-
"types": "./dist/canonical.d.mts",
|
|
166
|
-
"default": "./dist/canonical.mjs"
|
|
167
|
-
},
|
|
168
|
-
"./package.json": "./package.json"
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
"
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
"
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
"
|
|
184
|
-
"
|
|
185
|
-
"
|
|
186
|
-
"
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
"
|
|
190
|
-
"
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
"
|
|
194
|
-
"
|
|
195
|
-
"
|
|
196
|
-
"
|
|
197
|
-
"
|
|
198
|
-
"
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
"
|
|
202
|
-
"
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
"
|
|
206
|
-
"
|
|
207
|
-
"
|
|
208
|
-
"
|
|
209
|
-
"
|
|
210
|
-
"
|
|
211
|
-
|
|
212
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@classytic/primitives",
|
|
3
|
+
"version": "0.18.0",
|
|
4
|
+
"description": "Framework-agnostic TypeScript primitives shared across Classytic packages — Money, events, pagination, tenant config, and other building blocks.",
|
|
5
|
+
"author": "Classytic",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://www.npmjs.com/package/@classytic/primitives",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"url": "https://github.com/classytic/primitives/issues"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/classytic/primitives.git"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=22"
|
|
22
|
+
},
|
|
23
|
+
"exports": {
|
|
24
|
+
"./money": {
|
|
25
|
+
"types": "./dist/money.d.mts",
|
|
26
|
+
"default": "./dist/money.mjs"
|
|
27
|
+
},
|
|
28
|
+
"./currency": {
|
|
29
|
+
"types": "./dist/currency.d.mts",
|
|
30
|
+
"default": "./dist/currency.mjs"
|
|
31
|
+
},
|
|
32
|
+
"./unit-cost-rate": {
|
|
33
|
+
"types": "./dist/unit-cost-rate.d.mts",
|
|
34
|
+
"default": "./dist/unit-cost-rate.mjs"
|
|
35
|
+
},
|
|
36
|
+
"./address": {
|
|
37
|
+
"types": "./dist/address.d.mts",
|
|
38
|
+
"default": "./dist/address.mjs"
|
|
39
|
+
},
|
|
40
|
+
"./period": {
|
|
41
|
+
"types": "./dist/period.d.mts",
|
|
42
|
+
"default": "./dist/period.mjs"
|
|
43
|
+
},
|
|
44
|
+
"./calendar": {
|
|
45
|
+
"types": "./dist/calendar.d.mts",
|
|
46
|
+
"default": "./dist/calendar.mjs"
|
|
47
|
+
},
|
|
48
|
+
"./timezone": {
|
|
49
|
+
"types": "./dist/timezone.d.mts",
|
|
50
|
+
"default": "./dist/timezone.mjs"
|
|
51
|
+
},
|
|
52
|
+
"./reference": {
|
|
53
|
+
"types": "./dist/reference.d.mts",
|
|
54
|
+
"default": "./dist/reference.mjs"
|
|
55
|
+
},
|
|
56
|
+
"./context": {
|
|
57
|
+
"types": "./dist/context.d.mts",
|
|
58
|
+
"default": "./dist/context.mjs"
|
|
59
|
+
},
|
|
60
|
+
"./events": {
|
|
61
|
+
"types": "./dist/events.d.mts",
|
|
62
|
+
"default": "./dist/events.mjs"
|
|
63
|
+
},
|
|
64
|
+
"./event-infra": {
|
|
65
|
+
"types": "./dist/event-infra.d.mts",
|
|
66
|
+
"default": "./dist/event-infra.mjs"
|
|
67
|
+
},
|
|
68
|
+
"./outbox": {
|
|
69
|
+
"types": "./dist/outbox.d.mts",
|
|
70
|
+
"default": "./dist/outbox.mjs"
|
|
71
|
+
},
|
|
72
|
+
"./result": {
|
|
73
|
+
"types": "./dist/result.d.mts",
|
|
74
|
+
"default": "./dist/result.mjs"
|
|
75
|
+
},
|
|
76
|
+
"./brand": {
|
|
77
|
+
"types": "./dist/brand.d.mts",
|
|
78
|
+
"default": "./dist/brand.mjs"
|
|
79
|
+
},
|
|
80
|
+
"./split-allocation": {
|
|
81
|
+
"types": "./dist/split-allocation.d.mts",
|
|
82
|
+
"default": "./dist/split-allocation.mjs"
|
|
83
|
+
},
|
|
84
|
+
"./approval": {
|
|
85
|
+
"types": "./dist/approval.d.mts",
|
|
86
|
+
"default": "./dist/approval.mjs"
|
|
87
|
+
},
|
|
88
|
+
"./cadence": {
|
|
89
|
+
"types": "./dist/cadence.d.mts",
|
|
90
|
+
"default": "./dist/cadence.mjs"
|
|
91
|
+
},
|
|
92
|
+
"./hold": {
|
|
93
|
+
"types": "./dist/hold.d.mts",
|
|
94
|
+
"default": "./dist/hold.mjs"
|
|
95
|
+
},
|
|
96
|
+
"./sla": {
|
|
97
|
+
"types": "./dist/sla.d.mts",
|
|
98
|
+
"default": "./dist/sla.mjs"
|
|
99
|
+
},
|
|
100
|
+
"./shelf-life": {
|
|
101
|
+
"types": "./dist/shelf-life.d.mts",
|
|
102
|
+
"default": "./dist/shelf-life.mjs"
|
|
103
|
+
},
|
|
104
|
+
"./person": {
|
|
105
|
+
"types": "./dist/person.d.mts",
|
|
106
|
+
"default": "./dist/person.mjs"
|
|
107
|
+
},
|
|
108
|
+
"./identity-verification": {
|
|
109
|
+
"types": "./dist/identity-verification.d.mts",
|
|
110
|
+
"default": "./dist/identity-verification.mjs"
|
|
111
|
+
},
|
|
112
|
+
"./otp": {
|
|
113
|
+
"types": "./dist/otp.d.mts",
|
|
114
|
+
"default": "./dist/otp.mjs"
|
|
115
|
+
},
|
|
116
|
+
"./state-machine": {
|
|
117
|
+
"types": "./dist/state-machine.d.mts",
|
|
118
|
+
"default": "./dist/state-machine.mjs"
|
|
119
|
+
},
|
|
120
|
+
"./bank-transaction": {
|
|
121
|
+
"types": "./dist/bank-transaction.d.mts",
|
|
122
|
+
"default": "./dist/bank-transaction.mjs"
|
|
123
|
+
},
|
|
124
|
+
"./payment-gateway": {
|
|
125
|
+
"types": "./dist/payment-gateway.d.mts",
|
|
126
|
+
"default": "./dist/payment-gateway.mjs"
|
|
127
|
+
},
|
|
128
|
+
"./payment-method-kind": {
|
|
129
|
+
"types": "./dist/payment-method-kind.d.mts",
|
|
130
|
+
"default": "./dist/payment-method-kind.mjs"
|
|
131
|
+
},
|
|
132
|
+
"./payment-allocation-status": {
|
|
133
|
+
"types": "./dist/payment-allocation-status.d.mts",
|
|
134
|
+
"default": "./dist/payment-allocation-status.mjs"
|
|
135
|
+
},
|
|
136
|
+
"./payment-events": {
|
|
137
|
+
"types": "./dist/payment-events.d.mts",
|
|
138
|
+
"default": "./dist/payment-events.mjs"
|
|
139
|
+
},
|
|
140
|
+
"./phone": {
|
|
141
|
+
"types": "./dist/phone.d.mts",
|
|
142
|
+
"default": "./dist/phone.mjs"
|
|
143
|
+
},
|
|
144
|
+
"./status-history": {
|
|
145
|
+
"types": "./dist/status-history.d.mts",
|
|
146
|
+
"default": "./dist/status-history.mjs"
|
|
147
|
+
},
|
|
148
|
+
"./condition": {
|
|
149
|
+
"types": "./dist/condition.d.mts",
|
|
150
|
+
"default": "./dist/condition.mjs"
|
|
151
|
+
},
|
|
152
|
+
"./mixin": {
|
|
153
|
+
"types": "./dist/mixin.d.mts",
|
|
154
|
+
"default": "./dist/mixin.mjs"
|
|
155
|
+
},
|
|
156
|
+
"./sla-policy": {
|
|
157
|
+
"types": "./dist/sla-policy.d.mts",
|
|
158
|
+
"default": "./dist/sla-policy.mjs"
|
|
159
|
+
},
|
|
160
|
+
"./retention": {
|
|
161
|
+
"types": "./dist/retention.d.mts",
|
|
162
|
+
"default": "./dist/retention.mjs"
|
|
163
|
+
},
|
|
164
|
+
"./canonical": {
|
|
165
|
+
"types": "./dist/canonical.d.mts",
|
|
166
|
+
"default": "./dist/canonical.mjs"
|
|
167
|
+
},
|
|
168
|
+
"./package.json": "./package.json",
|
|
169
|
+
"./state-diagram": {
|
|
170
|
+
"types": "./dist/state-diagram.d.mts",
|
|
171
|
+
"default": "./dist/state-diagram.mjs"
|
|
172
|
+
},
|
|
173
|
+
"./suspension": {
|
|
174
|
+
"types": "./dist/suspension.d.mts",
|
|
175
|
+
"default": "./dist/suspension.mjs"
|
|
176
|
+
},
|
|
177
|
+
"./subject": {
|
|
178
|
+
"types": "./dist/subject.d.mts",
|
|
179
|
+
"default": "./dist/subject.mjs"
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
"files": [
|
|
183
|
+
"dist",
|
|
184
|
+
"README.md",
|
|
185
|
+
"CHANGELOG.md",
|
|
186
|
+
"LICENSE"
|
|
187
|
+
],
|
|
188
|
+
"scripts": {
|
|
189
|
+
"build": "tsdown",
|
|
190
|
+
"dev": "tsdown --watch",
|
|
191
|
+
"typecheck": "tsc --noEmit",
|
|
192
|
+
"lint": "biome check src/ tests/",
|
|
193
|
+
"lint:fix": "biome check --write src/ tests/",
|
|
194
|
+
"test": "vitest run --project unit --project integration",
|
|
195
|
+
"test:unit": "vitest run --project unit",
|
|
196
|
+
"test:integration": "vitest run --project integration",
|
|
197
|
+
"test:all": "vitest run",
|
|
198
|
+
"test:watch": "vitest --project unit --project integration",
|
|
199
|
+
"test:smoke": "node tests/smoke/smoke.mjs",
|
|
200
|
+
"push": "classytic-push",
|
|
201
|
+
"prepublishOnly": "npm run typecheck && npm run test && npm run build && npm run test:smoke",
|
|
202
|
+
"release": "npm run build && npm run typecheck && npm publish --access public"
|
|
203
|
+
},
|
|
204
|
+
"devDependencies": {
|
|
205
|
+
"@biomejs/biome": "^2.4.9",
|
|
206
|
+
"@classytic/dev-tools": "^0.2.0",
|
|
207
|
+
"@types/node": "^25.5.0",
|
|
208
|
+
"tsdown": "^0.21.7",
|
|
209
|
+
"typescript": "^5.7.0",
|
|
210
|
+
"vitest": "^3.0.0"
|
|
211
|
+
},
|
|
212
|
+
"keywords": [
|
|
213
|
+
"typescript",
|
|
214
|
+
"types",
|
|
215
|
+
"primitives",
|
|
216
|
+
"money",
|
|
217
|
+
"currency",
|
|
218
|
+
"pagination",
|
|
219
|
+
"domain-event",
|
|
220
|
+
"tenant",
|
|
221
|
+
"classytic",
|
|
222
|
+
"commerce"
|
|
223
|
+
]
|
|
224
|
+
}
|