@classytic/primitives 0.2.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,130 @@
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
+
70
+ ## [0.3.0] - 2026-04-29
71
+
72
+ ### Removed — `/errors` subpath (BREAKING)
73
+
74
+ - Deleted `@classytic/primitives/errors` (`ErrorContract`, `ErrorDetail`, `ErrorCode`, `ERROR_CODES`).
75
+ - Errors are infrastructure-shaped (HTTP-coupled, repository-coupled, RFC 7807-shaped). Moved to `@classytic/repo-core/errors` where they sit next to `pagination`, `tenant`, `cache`, `hooks`, `schema` — every other repository contract — and join the existing `HttpError` throwable contract there. Single canonical home for both the wire (`ErrorContract`) and throwable (`HttpError`) shapes.
76
+
77
+ #### Migration
78
+
79
+ ```diff
80
+ - import type { ErrorContract, ErrorDetail, ErrorCode } from '@classytic/primitives/errors';
81
+ - import { ERROR_CODES } from '@classytic/primitives/errors';
82
+ + import type { ErrorContract, ErrorDetail, ErrorCode } from '@classytic/repo-core/errors';
83
+ + import { ERROR_CODES } from '@classytic/repo-core/errors';
84
+ ```
85
+
86
+ Field shapes and code values are unchanged — pure relocation.
87
+
88
+ ### Removed — `/tenant` subpath (BREAKING)
89
+
90
+ - Deleted `@classytic/primitives/tenant` (`TenantConfig`, `TenantStrategy`, `TenantFieldType`, `DEFAULT_TENANT_CONFIG`, `resolveTenantConfig`, `ResolvedTenantConfig`).
91
+ - Tenant scope is **infrastructure-shaped** (describes how queries get scoped to a database / repository), not a domain primitive like Money or Address. Moved to `@classytic/repo-core/tenant` where it sits next to `context`, `filter`, `hooks`, `schema`, `cache` — every other repository contract.
92
+ - Mongokit and sqlitekit already peer-dep `@classytic/repo-core`, so consuming `TenantConfig` from there costs zero extra peer deps for those kits.
93
+
94
+ #### Migration
95
+
96
+ ```diff
97
+ - import type { TenantConfig } from '@classytic/primitives/tenant';
98
+ - import { resolveTenantConfig, DEFAULT_TENANT_CONFIG } from '@classytic/primitives/tenant';
99
+ + import type { TenantConfig } from '@classytic/repo-core/tenant';
100
+ + import { resolveTenantConfig, DEFAULT_TENANT_CONFIG } from '@classytic/repo-core/tenant';
101
+ ```
102
+
103
+ Field shapes, runtime semantics, and defaults are unchanged. The custom-strategy escape hatch (`strategy: 'custom'` + `resolve: (ctx) => filterShape`) works identically.
104
+
105
+ ### Removed — `/pagination` subpath (BREAKING)
106
+
107
+ - Deleted `@classytic/primitives/pagination` (`PageParams`, `KeysetParams`, `SortSpec`, `SortDirection`, `OffsetPage<T>`, `KeysetPage<T>`, `AggregatePage<T>`, `emptyOffsetPage`, `emptyKeysetPage`).
108
+ - Pagination shapes are owned by **`@classytic/repo-core/pagination`** — that package ships both the types AND the cursor encoding/skip-math/keyset-validation algorithms. Primitives' duplicate type-only declarations existed before the contract was canonised in repo-core; keeping them invited drift.
109
+
110
+ ### Migration
111
+
112
+ ```diff
113
+ - import type { OffsetPage, KeysetPage, AggregatePage, SortSpec } from '@classytic/primitives/pagination';
114
+ + import type {
115
+ + OffsetPaginationResult,
116
+ + KeysetPaginationResult,
117
+ + AggregatePaginationResult,
118
+ + SortSpec,
119
+ + } from '@classytic/repo-core/pagination';
120
+ ```
121
+
122
+ Note: shape names also changed (`OffsetPage` → `OffsetPaginationResult`, etc.) to match repo-core's naming. Field names align with what mongokit, sqlitekit, and arc already produced — those packages always returned repo-core shapes; primitives' types were a parallel, never-quite-aligned vocabulary.
123
+
124
+ If you also need HTTP wire envelopes (`{ success: true } & Result`), repo-core 0.3.0 ships `OffsetPaginationResponse`, `KeysetPaginationResponse`, `AggregatePaginationResponse`, `BareListResponse`, `PaginatedResponse`, plus a `toCanonicalList()` runtime normalizer.
125
+
126
+ ### Why now (and why this is the only breaking change in this release)
127
+
128
+ Two layers of pagination shapes drifted across the org: primitives declared one set, repo-core declared a different set, mongokit + sqlitekit + arc all consumed repo-core's. Removing primitives' set is purely subtractive — nobody internally imports from `@classytic/primitives/pagination`, and external consumers get a clear migration path to repo-core. No new ground to break later.
129
+
6
130
  ## [0.2.0]
7
131
 
8
132
  ### Added
package/README.md CHANGED
@@ -4,9 +4,30 @@ Framework-agnostic TypeScript primitives shared across Classytic packages.
4
4
  Zero runtime dependencies. ESM only. Node 22+.
5
5
 
6
6
  These are the building blocks every Classytic package speaks in common:
7
- `Money`, `DomainEvent`, `TenantConfig`, `OperationContext`, pagination shapes,
8
- polymorphic references. When a package duplicates one of these shapes, it
9
- drifts — this package exists so it doesn't.
7
+ `Money`, `DomainEvent`, `OperationContext`, polymorphic references. When a
8
+ package duplicates one of these shapes, it drifts — this package exists so it
9
+ doesn't.
10
+
11
+ > **Canonical-contract relocations in 0.3.0.** Three modules moved out to
12
+ > `@classytic/repo-core` so kits and arc see one source of truth instead of
13
+ > two:
14
+ > - **Pagination** (`OffsetPaginationResult`, `KeysetPaginationResult`,
15
+ > `AggregatePaginationResult`, `PaginationResult`, `toCanonicalList()`) →
16
+ > `@classytic/repo-core/pagination`.
17
+ > - **Tenant config** (`TenantConfig`, `TenantStrategy`, `TenantFieldType`,
18
+ > `resolveTenantConfig`, `DEFAULT_TENANT_CONFIG`, `ResolvedTenantConfig`) →
19
+ > `@classytic/repo-core/tenant`. Mongokit and sqlitekit's
20
+ > `MultiTenantOptions extends Pick<TenantConfig, ...>`.
21
+ > - **Error contracts** (`HttpError` throwable, `ErrorContract` wire shape,
22
+ > `ErrorDetail`, `ErrorCode`, `ERROR_CODES`, `toErrorContract()`,
23
+ > `statusToErrorCode()`) → `@classytic/repo-core/errors`.
24
+ > `ArcError implements HttpError`.
25
+ >
26
+ > Events stay here. `@classytic/primitives/events` remains the canonical
27
+ > source for `EventMeta`, `DomainEvent`, `EventHandler`, `EventLogger`,
28
+ > `EventTransport`, `DeadLetteredEvent`, `PublishManyResult`, `createEvent`,
29
+ > `createChildEvent`, `matchEventPattern`. Arc 2.12 re-exports only the
30
+ > runtime `MemoryEventTransport`.
10
31
 
11
32
  ## Install
12
33
 
@@ -22,10 +43,14 @@ at compile time, runtime helpers are tiny.
22
43
  ```ts
23
44
  import { addMoney, fromMajor, type Money } from '@classytic/primitives/money';
24
45
  import { createEvent, type DomainEvent, type EventTransport } from '@classytic/primitives/events';
25
- import { resolveTenantConfig, type TenantConfig } from '@classytic/primitives/tenant';
26
46
  import type { OperationContext } from '@classytic/primitives/context';
27
- import type { OffsetPage, PageParams } from '@classytic/primitives/pagination';
28
47
  import { allocate, type SplitResult } from '@classytic/primitives/split-allocation';
48
+ // Tenant config, error contracts, and pagination now live in repo-core:
49
+ import { resolveTenantConfig, type TenantConfig } from '@classytic/repo-core/tenant';
50
+ import type { HttpError, ErrorContract } from '@classytic/repo-core/errors';
51
+ import type {
52
+ OffsetPaginationResult, KeysetPaginationResult, AggregatePaginationResult,
53
+ } from '@classytic/repo-core/pagination';
29
54
  ```
30
55
 
31
56
  There is intentionally **no root barrel** — a barrel forces the Node ESM
@@ -41,14 +66,11 @@ subpath imports only.
41
66
  | `/currency` | `CurrencyCode` brand, `CURRENCIES`, `MINOR_UNIT_FACTOR`, `minorUnitFactor`, `toCurrencyCode`, `isCurrencyCode` |
42
67
  | `/address` | `Address`, `ContactAddress`, `GeoPoint`, `GeoJsonPoint`, `toGeoJsonPoint`, `fromGeoJsonPoint` |
43
68
  | `/period` | `DateRange`, `Period`, `isDateRange`, `isWithin`, `rangeDurationMs` |
44
- | `/pagination` | `PageParams`, `KeysetParams`, `SortSpec`, `SortDirection`, `OffsetPage<T>`, `KeysetPage<T>`, `AggregatePage<T>`, `emptyOffsetPage`, `emptyKeysetPage` |
45
69
  | `/reference` | `ExternalRef`, `ObjectIdLike`, `IdLike`, `DocumentRef`, `idToString`, `isExternalRef` |
46
70
  | `/context` | `OperationContext`, `ActorRef` |
47
- | `/events` | `DomainEvent`, `EventMeta`, `EventHandler`, `EventTransport`, `createEvent`, `matchEventPattern` |
48
- | `/tenant` | `TenantConfig`, `TenantFieldType`, `DEFAULT_TENANT_CONFIG`, `resolveTenantConfig` |
71
+ | `/events` | `DomainEvent`, `EventMeta`, `EventHandler`, `EventLogger`, `EventTransport`, `DeadLetteredEvent`, `PublishManyResult`, `createEvent`, `createChildEvent`, `matchEventPattern` |
49
72
  | `/result` | `Result<T, E>`, `ok`, `err`, `isOk`, `isErr`, `mapResult`, `mapError`, `unwrap` |
50
73
  | `/brand` | `Brand<T, B>`, `Prettify`, `DeepPartial`, `DeepReadonly`, `RequireKeys`, `OptionalKeys`, `Nullable`, `KeysMatching`, `NonEmptyArray` |
51
- | `/errors` | `ErrorContract`, `ErrorDetail`, `ErrorCode`, `ERROR_CODES` |
52
74
  | `/split-allocation` | `allocate`, `isBalanced`, `SplitAllocationError`, `SplitMethod`, `SplitSubject`, `SplitPart`, `SplitResult` |
53
75
  | `/approval` | `createChain`, `applyDecision`, `skipStep`, `nextPendingStep`, `pendingSteps`, `isApproved`, `isRejected`, `isPending`, `decisionCount`, `ApprovalError`, `ApprovalChain`, `ApprovalStep`, `ApprovalDecision`, `Approver`, `ApprovalThreshold` |
54
76
  | `/cadence` | `nextOccurrence`, `occurrencesBetween`, `validateCadence`, `CadenceError`, `Cadence`, `DailyCadence`, `WeeklyCadence`, `MonthlyCadence`, `YearlyCadence`, `CronCadence`, `IsoWeekday` |
@@ -91,21 +113,19 @@ Shape matches `@classytic/arc`'s `DomainEvent` exactly — any Arc transport
91
113
  (memory, Redis pub/sub, Redis Streams, Kafka) plugs into a package expecting
92
114
  this interface without adapters. See PACKAGE_RULES.md §11.
93
115
 
94
- ### TenantConfig — one shape, every package
116
+ ### TenantConfig — moved to `@classytic/repo-core/tenant`
95
117
 
96
118
  ```ts
97
- import { resolveTenantConfig } from '@classytic/primitives/tenant';
119
+ import { resolveTenantConfig } from '@classytic/repo-core/tenant';
98
120
 
99
121
  const tenant = resolveTenantConfig(config.tenant);
100
122
  // { enabled: true, tenantField: 'organizationId', fieldType: 'objectId',
101
123
  // ref: 'organization', contextKey: 'organizationId', required: true }
102
124
  ```
103
125
 
104
- Field names match `@classytic/mongokit`'s `MultiTenantOptions` so you can
105
- forward the resolved config straight into `multiTenantPlugin(resolved)`.
106
-
107
- Packages stop rolling five flavours of `{ field, type, ref, contextKey,
108
- enabled }`.
126
+ Mongokit's `MultiTenantOptions` and sqlitekit's equivalent both
127
+ `extends Pick<TenantConfig, ...>` from repo-core — pass the resolved config
128
+ straight into `multiTenantPlugin(resolved)`.
109
129
 
110
130
  ### ExternalRef — polymorphic, framework-free
111
131
 
@@ -127,7 +147,8 @@ ObjectId, UUID, Stripe ID, anything. See PACKAGE_RULES.md §7.
127
147
  - **Transaction / Order / Cart shapes** — those are domain types, not
128
148
  primitives. They live in their respective packages.
129
149
  - **Error subclasses** — throw plain `Error`s; hosts serialize them into
130
- `ErrorContract` at the HTTP boundary.
150
+ `ErrorContract` (from `@classytic/repo-core/errors`) at the HTTP boundary
151
+ via `toErrorContract(err)`.
131
152
  - **Runtime currency database** — ship a whitelist at the app layer if you
132
153
  need one. `MINOR_UNIT_FACTOR` covers common cases.
133
154
 
package/dist/person.mjs CHANGED
@@ -10,7 +10,7 @@ function formatFullName(name) {
10
10
  }
11
11
  function formatDisplayName(name) {
12
12
  if (name.preferred?.trim()) return name.preferred;
13
- return `${name.given} ${name.family}`.trim();
13
+ return [name.given, name.family].filter((p) => Boolean(p?.trim())).join(" ");
14
14
  }
15
15
  //#endregion
16
16
  export { formatDisplayName, formatFullName };
@@ -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.2.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",
@@ -37,10 +37,6 @@
37
37
  "types": "./dist/period.d.mts",
38
38
  "default": "./dist/period.mjs"
39
39
  },
40
- "./pagination": {
41
- "types": "./dist/pagination.d.mts",
42
- "default": "./dist/pagination.mjs"
43
- },
44
40
  "./reference": {
45
41
  "types": "./dist/reference.d.mts",
46
42
  "default": "./dist/reference.mjs"
@@ -57,10 +53,6 @@
57
53
  "types": "./dist/outbox.d.mts",
58
54
  "default": "./dist/outbox.mjs"
59
55
  },
60
- "./tenant": {
61
- "types": "./dist/tenant.d.mts",
62
- "default": "./dist/tenant.mjs"
63
- },
64
56
  "./result": {
65
57
  "types": "./dist/result.d.mts",
66
58
  "default": "./dist/result.mjs"
@@ -69,10 +61,6 @@
69
61
  "types": "./dist/brand.d.mts",
70
62
  "default": "./dist/brand.mjs"
71
63
  },
72
- "./errors": {
73
- "types": "./dist/errors.d.mts",
74
- "default": "./dist/errors.mjs"
75
- },
76
64
  "./split-allocation": {
77
65
  "types": "./dist/split-allocation.d.mts",
78
66
  "default": "./dist/split-allocation.mjs"
package/dist/errors.d.mts DELETED
@@ -1,50 +0,0 @@
1
- //#region src/errors.d.ts
2
- /**
3
- * Standard error contract — a framework-agnostic JSON shape that maps cleanly
4
- * to HTTP responses, worker failure logs, and inter-service errors. Matches
5
- * RFC 7807 (`application/problem+json`) loosely.
6
- *
7
- * Packages should throw `Error` instances. Hosts (HTTP adapters, workers)
8
- * serialize those errors into this shape on the wire.
9
- */
10
- interface ErrorContract {
11
- /** Machine-readable, hierarchical code — e.g. 'order.validation.missing_line'. */
12
- code: string;
13
- /** Human-readable, safe-for-client message. */
14
- message: string;
15
- /** Suggested HTTP status code — hosts may override. */
16
- status?: number;
17
- /** Field-scoped validation errors. */
18
- details?: ReadonlyArray<ErrorDetail>;
19
- /** Correlation / trace identifier for support lookups. */
20
- correlationId?: string;
21
- /** Non-PII metadata (safe to log, safe to return to clients). */
22
- meta?: Readonly<Record<string, unknown>>;
23
- }
24
- interface ErrorDetail {
25
- /** Dot-path pointer to the offending field, e.g. 'lines.0.quantity'. */
26
- path?: string;
27
- code: string;
28
- message: string;
29
- meta?: Readonly<Record<string, unknown>>;
30
- }
31
- /**
32
- * Canonical error codes. Packages add their own (`order.validation.*`,
33
- * `payment.gateway.*`) — these are the cross-cutting ones.
34
- */
35
- declare const ERROR_CODES: {
36
- readonly VALIDATION: "validation_error";
37
- readonly NOT_FOUND: "not_found";
38
- readonly CONFLICT: "conflict";
39
- readonly UNAUTHORIZED: "unauthorized";
40
- readonly FORBIDDEN: "forbidden";
41
- readonly RATE_LIMITED: "rate_limited";
42
- readonly IDEMPOTENCY_CONFLICT: "idempotency_conflict";
43
- readonly PRECONDITION_FAILED: "precondition_failed";
44
- readonly INTERNAL: "internal_error";
45
- readonly UNAVAILABLE: "service_unavailable";
46
- readonly TIMEOUT: "timeout";
47
- };
48
- type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
49
- //#endregion
50
- export { ERROR_CODES, ErrorCode, ErrorContract, ErrorDetail };
package/dist/errors.mjs DELETED
@@ -1,20 +0,0 @@
1
- //#region src/errors.ts
2
- /**
3
- * Canonical error codes. Packages add their own (`order.validation.*`,
4
- * `payment.gateway.*`) — these are the cross-cutting ones.
5
- */
6
- const ERROR_CODES = {
7
- VALIDATION: "validation_error",
8
- NOT_FOUND: "not_found",
9
- CONFLICT: "conflict",
10
- UNAUTHORIZED: "unauthorized",
11
- FORBIDDEN: "forbidden",
12
- RATE_LIMITED: "rate_limited",
13
- IDEMPOTENCY_CONFLICT: "idempotency_conflict",
14
- PRECONDITION_FAILED: "precondition_failed",
15
- INTERNAL: "internal_error",
16
- UNAVAILABLE: "service_unavailable",
17
- TIMEOUT: "timeout"
18
- };
19
- //#endregion
20
- export { ERROR_CODES };
@@ -1,57 +0,0 @@
1
- //#region src/pagination.d.ts
2
- /**
3
- * Pagination and sorting primitives shared between repositories, HTTP
4
- * controllers, and SDKs. Shapes mirror `@classytic/mongokit`'s result types so
5
- * adapters can flow through without translation.
6
- */
7
- type SortDirection = 'asc' | 'desc' | 1 | -1;
8
- /**
9
- * Sort specification — accepts any of:
10
- * - `'field'` / `'-field'` (Mongoose-style prefix)
11
- * - `['field1', '-field2']`
12
- * - `{ field1: 1, field2: -1 }`
13
- */
14
- type SortSpec = string | ReadonlyArray<string> | Readonly<Record<string, SortDirection>>;
15
- interface PageParams {
16
- page?: number;
17
- limit?: number;
18
- sort?: SortSpec;
19
- }
20
- interface KeysetParams {
21
- cursor?: string;
22
- limit?: number;
23
- sort?: SortSpec;
24
- }
25
- /**
26
- * Offset-paginated response. Mirrors `mongokit.OffsetPaginationResult<T>`.
27
- */
28
- interface OffsetPage<T> {
29
- docs: ReadonlyArray<T>;
30
- total: number;
31
- page: number;
32
- limit: number;
33
- pages: number;
34
- hasNext: boolean;
35
- hasPrev: boolean;
36
- }
37
- /**
38
- * Cursor-paginated response. Mirrors `mongokit.KeysetPaginationResult<T>`.
39
- */
40
- interface KeysetPage<T> {
41
- docs: ReadonlyArray<T>;
42
- nextCursor: string | null;
43
- prevCursor: string | null;
44
- limit: number;
45
- }
46
- /** Aggregation-based pagination response. */
47
- interface AggregatePage<T> {
48
- docs: ReadonlyArray<T>;
49
- total: number;
50
- page: number;
51
- limit: number;
52
- pages: number;
53
- }
54
- declare function emptyOffsetPage<T>(limit?: number): OffsetPage<T>;
55
- declare function emptyKeysetPage<T>(limit?: number): KeysetPage<T>;
56
- //#endregion
57
- export { AggregatePage, KeysetPage, KeysetParams, OffsetPage, PageParams, SortDirection, SortSpec, emptyKeysetPage, emptyOffsetPage };
@@ -1,22 +0,0 @@
1
- //#region src/pagination.ts
2
- function emptyOffsetPage(limit = 20) {
3
- return {
4
- docs: [],
5
- total: 0,
6
- page: 1,
7
- limit,
8
- pages: 0,
9
- hasNext: false,
10
- hasPrev: false
11
- };
12
- }
13
- function emptyKeysetPage(limit = 20) {
14
- return {
15
- docs: [],
16
- nextCursor: null,
17
- prevCursor: null,
18
- limit
19
- };
20
- }
21
- //#endregion
22
- export { emptyKeysetPage, emptyOffsetPage };
package/dist/tenant.d.mts DELETED
@@ -1,129 +0,0 @@
1
- //#region src/tenant.d.ts
2
- /**
3
- * Tenant / scope configuration primitives.
4
- *
5
- * Every multi-tenant-capable package accepts a config block shaped like
6
- * {@link TenantConfig}. It controls how the package stores and casts the
7
- * tenant identifier on its models, and which key on
8
- * `OperationContext` to read the tenant value from.
9
- *
10
- * Field names match `@classytic/mongokit`'s `MultiTenantOptions` so the
11
- * resolved config can be forwarded straight into `multiTenantPlugin(...)`.
12
- *
13
- * Three strategies are supported:
14
- * - `'field'` (default) — filter every query by a scalar field on documents.
15
- * The common case; used by `multiTenantPlugin`.
16
- * - `'none'` — disable scoping entirely (single-tenant app). Equivalent to
17
- * `enabled: false`; `strategy: 'none'` is the explicit form.
18
- * - `'custom'` — caller supplies a `resolve(ctx)` function that returns the
19
- * filter shape to inject. Covers multi-field composite tenants, context-
20
- * derived filters (region + partner id), or non-scalar scope keys.
21
- *
22
- * See PACKAGE_RULES.md §9 for the full pattern.
23
- */
24
- /**
25
- * Mongo storage type for the tenant identifier.
26
- *
27
- * - `'objectId'` (recommended for new packages) — `Schema.Types.ObjectId` with
28
- * `ref`. Enables `$lookup`, `.populate()`, QueryParser `?lookup=...`.
29
- * - `'string'` — plain string. Use when the host auth system issues UUIDs or
30
- * slugs rather than ObjectIds.
31
- */
32
- type TenantFieldType = 'objectId' | 'string';
33
- /** Scope resolution strategy. */
34
- type TenantStrategy = 'field' | 'none' | 'custom';
35
- interface TenantConfig {
36
- /**
37
- * Scope strategy. Omit for the common `'field'` case — explicit `'none'`
38
- * / `'custom'` lets packages collapse what used to live in a separate
39
- * `ScopeConfig` type.
40
- *
41
- * @default 'field'
42
- */
43
- strategy?: TenantStrategy;
44
- /**
45
- * Whether tenant scoping is active. When `false`, the package runs in
46
- * single-tenant mode — no filter injection, no tenant field on documents.
47
- * Equivalent to `strategy: 'none'`.
48
- *
49
- * @default true
50
- */
51
- enabled?: boolean;
52
- /**
53
- * Document field name that stores the tenant id. Matches mongokit's
54
- * `MultiTenantOptions.tenantField`. Used when `strategy === 'field'`.
55
- *
56
- * @default 'organizationId'
57
- */
58
- tenantField?: string;
59
- /**
60
- * How to store / cast the tenant id. Matches mongokit's `fieldType`.
61
- *
62
- * @default 'objectId'
63
- */
64
- fieldType?: TenantFieldType;
65
- /**
66
- * Mongoose ref for `'objectId'` types. Ignored when `fieldType === 'string'`.
67
- *
68
- * @default 'organization'
69
- */
70
- ref?: string;
71
- /**
72
- * Which key on `OperationContext` to read the tenant id from.
73
- *
74
- * Defaults cascade: if omitted, falls back to the caller's `tenantField`
75
- * (if supplied), else to `'organizationId'`. Rationale: when a host renames
76
- * `tenantField` to e.g. `'branchId'`, their `OperationContext` almost always
77
- * carries the value under the same key — so mirroring `tenantField` is the
78
- * least-surprise behavior. Override explicitly if the context key diverges
79
- * from the document field (e.g. `tenantField: 'branchId'`, `contextKey: 'organizationId'`).
80
- *
81
- * @default tenantField ?? 'organizationId'
82
- */
83
- contextKey?: string;
84
- /**
85
- * Whether the field is required. When `false`, the package permits
86
- * unscoped / cross-tenant reads (typically only for admin paths).
87
- *
88
- * @default true
89
- */
90
- required?: boolean;
91
- /**
92
- * Custom resolver — called when `strategy === 'custom'` to produce the
93
- * filter object injected into queries. Packages pass the request context;
94
- * the resolver returns the filter shape.
95
- *
96
- * Example:
97
- * ```ts
98
- * {
99
- * strategy: 'custom',
100
- * resolve: (ctx) => ({
101
- * organizationId: ctx.organizationId,
102
- * region: ctx.region,
103
- * }),
104
- * }
105
- * ```
106
- */
107
- resolve?: (ctx: Record<string, unknown>) => Record<string, unknown>;
108
- }
109
- /** Sensible defaults for a freshly-built package (field strategy). */
110
- declare const DEFAULT_TENANT_CONFIG: Required<Pick<TenantConfig, 'strategy' | 'enabled' | 'tenantField' | 'fieldType' | 'ref' | 'contextKey' | 'required'>>;
111
- /**
112
- * Resolved shape returned by {@link resolveTenantConfig}. Always includes the
113
- * field defaults (so packages can inspect field names even when `enabled:
114
- * false`) and threads `resolve` when `strategy === 'custom'`.
115
- */
116
- type ResolvedTenantConfig = typeof DEFAULT_TENANT_CONFIG & {
117
- resolve?: TenantConfig['resolve'];
118
- };
119
- /**
120
- * Resolve a possibly-partial {@link TenantConfig} against the defaults.
121
- *
122
- * - `false` → `enabled: false`, `strategy: 'none'`.
123
- * - `true` / `undefined` → default field strategy.
124
- * - Object with `strategy: 'custom'` → `resolve` is required; throws otherwise.
125
- * - Object with `strategy: 'none'` → `enabled: false`.
126
- */
127
- declare function resolveTenantConfig(config?: TenantConfig | boolean): ResolvedTenantConfig;
128
- //#endregion
129
- export { DEFAULT_TENANT_CONFIG, ResolvedTenantConfig, TenantConfig, TenantFieldType, TenantStrategy, resolveTenantConfig };
package/dist/tenant.mjs DELETED
@@ -1,58 +0,0 @@
1
- //#region src/tenant.ts
2
- /** Sensible defaults for a freshly-built package (field strategy). */
3
- const DEFAULT_TENANT_CONFIG = {
4
- strategy: "field",
5
- enabled: true,
6
- tenantField: "organizationId",
7
- fieldType: "objectId",
8
- ref: "organization",
9
- contextKey: "organizationId",
10
- required: true
11
- };
12
- /**
13
- * Resolve a possibly-partial {@link TenantConfig} against the defaults.
14
- *
15
- * - `false` → `enabled: false`, `strategy: 'none'`.
16
- * - `true` / `undefined` → default field strategy.
17
- * - Object with `strategy: 'custom'` → `resolve` is required; throws otherwise.
18
- * - Object with `strategy: 'none'` → `enabled: false`.
19
- */
20
- function resolveTenantConfig(config) {
21
- if (config === false) return {
22
- ...DEFAULT_TENANT_CONFIG,
23
- strategy: "none",
24
- enabled: false,
25
- required: false
26
- };
27
- if (config === true || config === void 0) return { ...DEFAULT_TENANT_CONFIG };
28
- const strategy = config.strategy ?? (config.enabled === false ? "none" : "field");
29
- const contextKey = config.contextKey ?? config.tenantField ?? DEFAULT_TENANT_CONFIG.contextKey;
30
- if (strategy === "none") return {
31
- ...DEFAULT_TENANT_CONFIG,
32
- ...config,
33
- contextKey,
34
- strategy: "none",
35
- enabled: false,
36
- required: false
37
- };
38
- if (strategy === "custom") {
39
- if (typeof config.resolve !== "function") throw new Error("[primitives] TenantConfig.strategy 'custom' requires a 'resolve' function");
40
- return {
41
- ...DEFAULT_TENANT_CONFIG,
42
- ...config,
43
- contextKey,
44
- strategy: "custom",
45
- enabled: config.enabled ?? true,
46
- resolve: config.resolve
47
- };
48
- }
49
- return {
50
- ...DEFAULT_TENANT_CONFIG,
51
- ...config,
52
- contextKey,
53
- strategy: "field",
54
- enabled: config.enabled ?? true
55
- };
56
- }
57
- //#endregion
58
- export { DEFAULT_TENANT_CONFIG, resolveTenantConfig };