@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 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
@@ -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 };
@@ -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[]>;
@@ -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
@@ -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 };
@@ -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.15.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
- "files": [
171
- "dist",
172
- "README.md",
173
- "CHANGELOG.md",
174
- "LICENSE"
175
- ],
176
- "scripts": {
177
- "build": "tsdown",
178
- "dev": "tsdown --watch",
179
- "typecheck": "tsc --noEmit",
180
- "lint": "biome check src/ tests/",
181
- "lint:fix": "biome check --write src/ tests/",
182
- "test": "vitest run --project unit --project integration",
183
- "test:unit": "vitest run --project unit",
184
- "test:integration": "vitest run --project integration",
185
- "test:all": "vitest run",
186
- "test:watch": "vitest --project unit --project integration",
187
- "test:smoke": "node tests/smoke/smoke.mjs",
188
- "push": "classytic-push",
189
- "prepublishOnly": "npm run typecheck && npm run test && npm run build && npm run test:smoke",
190
- "release": "npm run build && npm run typecheck && npm publish --access public"
191
- },
192
- "devDependencies": {
193
- "@biomejs/biome": "^2.4.9",
194
- "@classytic/dev-tools": "^0.2.0",
195
- "@types/node": "^25.5.0",
196
- "tsdown": "^0.21.7",
197
- "typescript": "^5.7.0",
198
- "vitest": "^3.0.0"
199
- },
200
- "keywords": [
201
- "typescript",
202
- "types",
203
- "primitives",
204
- "money",
205
- "currency",
206
- "pagination",
207
- "domain-event",
208
- "tenant",
209
- "classytic",
210
- "commerce"
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
+ }