@classytic/primitives 0.3.0 → 0.4.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,70 @@
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.3.1] - 2026-05-02
7
+
8
+ ### Added — state-machine ↔ mongokit/sqlitekit integration
9
+
10
+ State-machine primitives now compose directly with the kit-level CAS surface shipped in mongokit 3.13 and the sqlitekit follow-up. Three additions:
11
+
12
+ - **`StateMachine.validTargets(from)`** — every status the aggregate can move TO from `from`. Useful for UI dropdowns ("which actions are legal right now?") and for fanning a single source state into a multi-source CAS via a repo's `claim({ from: machine.validTargets(current), to })`.
13
+ - **`StateMachine.validSources(to)`** — every status that can transition INTO `to` (reverse adjacency). Pair with a repo's multi-source `claim({ from: machine.validSources(to), to })` when the target is fixed but the caller doesn't yet know the current state — the canonical "cancel from any non-terminal" / "error from any in-flight" pattern. Pre-computed at definition time, O(1) lookup, frozen arrays safe to expose by reference.
14
+ - **`assertAndClaim(machine, repo, id, args)`** — pairs `machine.assertTransition` (sync domain check) with `repo.claim()` (atomic CAS) in one call. The canonical state-machine-backed CAS pattern. Skipping either layer leaves a hole: skip `assertTransition` and bad transitions reach storage; skip `claim` and concurrent writers race.
15
+
16
+ ```ts
17
+ import { defineStateMachine, assertAndClaim } from '@classytic/primitives/state-machine';
18
+
19
+ const ORDER_MACHINE = defineStateMachine<OrderStatus>({
20
+ name: 'Order',
21
+ transitions: {
22
+ draft: ['approved', 'cancelled'],
23
+ approved: ['shipped', 'cancelled'],
24
+ shipped: [],
25
+ cancelled: [],
26
+ },
27
+ });
28
+
29
+ // Single-source:
30
+ const updated = await assertAndClaim(ORDER_MACHINE, orderRepo, orderId, {
31
+ from: 'draft',
32
+ to: 'approved',
33
+ patch: { approvedAt: new Date() },
34
+ options: { organizationId: ctx.organizationId, session },
35
+ });
36
+
37
+ // Multi-source via reverse adjacency — "cancel from any legal predecessor":
38
+ await assertAndClaim(ORDER_MACHINE, orderRepo, orderId, {
39
+ from: ORDER_MACHINE.validSources('cancelled'), // ['draft', 'approved']
40
+ to: 'cancelled',
41
+ patch: { cancelledAt: new Date() },
42
+ options: { organizationId: ctx.organizationId },
43
+ });
44
+ ```
45
+
46
+ #### `ClaimableRepo<TDoc>` — structural dependency on the kit
47
+
48
+ `assertAndClaim` accepts any object matching `ClaimableRepo<TDoc>` — a structural type that mirrors the mongokit 3.13 `Repository.claim()` signature. Primitives doesn't take a hard peer dep on mongokit; consumers wire the kit they want. Sqlitekit's `claim()` matches the same shape (verified by mongokit/sqlitekit cross-conformance).
49
+
50
+ ```ts
51
+ export interface ClaimableRepo<TDoc> {
52
+ claim(
53
+ id: string,
54
+ transition: {
55
+ field?: string;
56
+ from: unknown | readonly unknown[];
57
+ to: unknown;
58
+ where?: Record<string, unknown>;
59
+ },
60
+ patch?: Record<string, unknown>,
61
+ options?: Record<string, unknown>,
62
+ ): Promise<TDoc | null>;
63
+ }
64
+ ```
65
+
66
+ #### Why a 0.3.1 (not 0.4.0)
67
+
68
+ Purely additive — no removals, no signature changes. `defineStateMachine` callers from 0.3.0 see two new methods on the returned `StateMachine` (`validTargets`, `validSources`) and one new free function (`assertAndClaim`). Backwards-compatible.
69
+
6
70
  ## [0.3.0] - 2026-04-29
7
71
 
8
72
  ### Removed — `/errors` subpath (BREAKING)
@@ -95,9 +95,89 @@ interface StateMachine<TStatus extends string> {
95
95
  * `entityId` is included in the error for traceability.
96
96
  */
97
97
  assertTransition(entityId: string, from: TStatus, to: TStatus): void;
98
+ /**
99
+ * Forward lookup — every status the aggregate can move TO from `from`.
100
+ * Useful for UI dropdowns ("which actions are legal right now?") and
101
+ * for fanning a single source state into a multi-source CAS via a
102
+ * repo's `claim({ from: machine.validTargets(current), to })`.
103
+ */
104
+ validTargets(from: TStatus): readonly TStatus[];
105
+ /**
106
+ * Reverse lookup — every status that can transition INTO `to`. Pair
107
+ * with a repo's multi-source `claim({ from: machine.validSources(to), to })`
108
+ * when the target is fixed but the caller doesn't yet know the
109
+ * current state (or wants to authorize ALL legal predecessors in one
110
+ * round-trip).
111
+ */
112
+ validSources(to: TStatus): readonly TStatus[];
98
113
  /** The raw transition table — exposed read-only for advanced callers. */
99
114
  readonly transitions: Readonly<Record<TStatus, readonly TStatus[]>>;
100
115
  }
101
116
  declare function defineStateMachine<TStatus extends string>(definition: StateMachineDefinition<TStatus>): StateMachine<TStatus>;
117
+ /**
118
+ * Minimal structural type for any kit's `claim()` operation. Lets
119
+ * `assertAndClaim` work against `@classytic/mongokit` (and future
120
+ * sqlitekit / pgkit) without primitives taking a hard peer dep.
121
+ *
122
+ * Mirrors the mongokit 3.13 `Repository.claim()` signature with
123
+ * `unknown`-typed values where the kit-specific shape (ObjectId, etc.)
124
+ * doesn't concern the state-machine layer.
125
+ */
126
+ interface ClaimableRepo<TDoc> {
127
+ claim(id: string, transition: {
128
+ field?: string;
129
+ from: unknown | readonly unknown[];
130
+ to: unknown;
131
+ where?: Record<string, unknown>;
132
+ }, patch?: Record<string, unknown>, options?: Record<string, unknown>): Promise<TDoc | null>;
133
+ }
134
+ /**
135
+ * Pair a state-machine `assertTransition()` (sync domain check) with a
136
+ * repo's `claim()` (runtime concurrency CAS) in one call. This is the
137
+ * canonical state-machine-backed CAS pattern documented in mongokit
138
+ * 3.13's CLAUDE.md.
139
+ *
140
+ * - `assertTransition` rejects malformed transitions BEFORE we hit the
141
+ * database (compile-time-typed targets, sync, fast).
142
+ * - `claim` rejects concurrent writers AT the database (atomic
143
+ * CAS, returns `null` on race-loss).
144
+ *
145
+ * Skipping either layer leaves a hole: skip `assertTransition` and bad
146
+ * transitions reach storage; skip `claim` and concurrent writers race.
147
+ *
148
+ * `from` accepts a single status OR an array of statuses (multi-source
149
+ * CAS). When the array form is used, `assertTransition` is asserted for
150
+ * EVERY listed source — a single illegal source aborts the call before
151
+ * the round-trip.
152
+ *
153
+ * @example Single-source
154
+ * ```ts
155
+ * const updated = await assertAndClaim(WAVE_MACHINE, repo, waveId, {
156
+ * from: 'planned',
157
+ * to: 'released',
158
+ * patch: { releasedAt: new Date() },
159
+ * options: { organizationId: ctx.organizationId, session },
160
+ * });
161
+ * if (!updated) throw new ConcurrencyError(); // race-loss
162
+ * ```
163
+ *
164
+ * @example Multi-source via `validSources`
165
+ * ```ts
166
+ * const updated = await assertAndClaim(WAVE_MACHINE, repo, waveId, {
167
+ * from: WAVE_MACHINE.validSources('cancelled'), // every legal predecessor
168
+ * to: 'cancelled',
169
+ * patch: { cancelledAt: new Date() },
170
+ * options: { organizationId: ctx.organizationId },
171
+ * });
172
+ * ```
173
+ */
174
+ declare function assertAndClaim<TDoc, TStatus extends string>(machine: StateMachine<TStatus>, repo: ClaimableRepo<TDoc>, id: string, args: {
175
+ from: TStatus | readonly TStatus[];
176
+ to: TStatus;
177
+ field?: string;
178
+ patch?: Record<string, unknown>;
179
+ where?: Record<string, unknown>;
180
+ options?: Record<string, unknown>;
181
+ }): Promise<TDoc | null>;
102
182
  //#endregion
103
- export { IllegalTransitionError, StateMachine, StateMachineDefinition, TransitionErrorContext, defineStateMachine };
183
+ export { ClaimableRepo, IllegalTransitionError, StateMachine, StateMachineDefinition, TransitionErrorContext, assertAndClaim, defineStateMachine };
@@ -68,7 +68,18 @@ var IllegalTransitionError = class extends Error {
68
68
  function defineStateMachine(definition) {
69
69
  const { name, transitions, errorFactory } = definition;
70
70
  const terminal = /* @__PURE__ */ new Set();
71
- for (const [status, allowed] of Object.entries(transitions)) if (allowed.length === 0) terminal.add(status);
71
+ const reverse = /* @__PURE__ */ new Map();
72
+ for (const [status, allowed] of Object.entries(transitions)) {
73
+ if (allowed.length === 0) terminal.add(status);
74
+ for (const target of allowed) {
75
+ const sources = reverse.get(target);
76
+ if (sources) sources.push(status);
77
+ else reverse.set(target, [status]);
78
+ }
79
+ }
80
+ const frozenReverse = /* @__PURE__ */ new Map();
81
+ for (const [target, sources] of reverse) frozenReverse.set(target, Object.freeze(sources.slice()));
82
+ const EMPTY = Object.freeze([]);
72
83
  const buildError = errorFactory ? errorFactory : (ctx) => new IllegalTransitionError(ctx.entityType, ctx.entityId, ctx.from, ctx.to);
73
84
  return {
74
85
  name,
@@ -87,8 +98,64 @@ function defineStateMachine(definition) {
87
98
  from,
88
99
  to
89
100
  });
101
+ },
102
+ validTargets(from) {
103
+ return transitions[from] ?? EMPTY;
104
+ },
105
+ validSources(to) {
106
+ return frozenReverse.get(to) ?? EMPTY;
90
107
  }
91
108
  };
92
109
  }
110
+ /**
111
+ * Pair a state-machine `assertTransition()` (sync domain check) with a
112
+ * repo's `claim()` (runtime concurrency CAS) in one call. This is the
113
+ * canonical state-machine-backed CAS pattern documented in mongokit
114
+ * 3.13's CLAUDE.md.
115
+ *
116
+ * - `assertTransition` rejects malformed transitions BEFORE we hit the
117
+ * database (compile-time-typed targets, sync, fast).
118
+ * - `claim` rejects concurrent writers AT the database (atomic
119
+ * CAS, returns `null` on race-loss).
120
+ *
121
+ * Skipping either layer leaves a hole: skip `assertTransition` and bad
122
+ * transitions reach storage; skip `claim` and concurrent writers race.
123
+ *
124
+ * `from` accepts a single status OR an array of statuses (multi-source
125
+ * CAS). When the array form is used, `assertTransition` is asserted for
126
+ * EVERY listed source — a single illegal source aborts the call before
127
+ * the round-trip.
128
+ *
129
+ * @example Single-source
130
+ * ```ts
131
+ * const updated = await assertAndClaim(WAVE_MACHINE, repo, waveId, {
132
+ * from: 'planned',
133
+ * to: 'released',
134
+ * patch: { releasedAt: new Date() },
135
+ * options: { organizationId: ctx.organizationId, session },
136
+ * });
137
+ * if (!updated) throw new ConcurrencyError(); // race-loss
138
+ * ```
139
+ *
140
+ * @example Multi-source via `validSources`
141
+ * ```ts
142
+ * const updated = await assertAndClaim(WAVE_MACHINE, repo, waveId, {
143
+ * from: WAVE_MACHINE.validSources('cancelled'), // every legal predecessor
144
+ * to: 'cancelled',
145
+ * patch: { cancelledAt: new Date() },
146
+ * options: { organizationId: ctx.organizationId },
147
+ * });
148
+ * ```
149
+ */
150
+ async function assertAndClaim(machine, repo, id, args) {
151
+ const sources = Array.isArray(args.from) ? args.from : [args.from];
152
+ for (const from of sources) machine.assertTransition(id, from, args.to);
153
+ return repo.claim(id, {
154
+ from: Array.isArray(args.from) ? args.from : args.from,
155
+ to: args.to,
156
+ ...args.field !== void 0 ? { field: args.field } : {},
157
+ ...args.where !== void 0 ? { where: args.where } : {}
158
+ }, args.patch, args.options);
159
+ }
93
160
  //#endregion
94
- export { IllegalTransitionError, defineStateMachine };
161
+ export { IllegalTransitionError, assertAndClaim, defineStateMachine };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/primitives",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Framework-agnostic TypeScript primitives shared across Classytic packages — Money, events, pagination, tenant config, and other building blocks.",
5
5
  "author": "Classytic",
6
6
  "license": "MIT",