@classytic/primitives 0.1.1 → 0.3.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,103 @@
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.0] - 2026-04-29
7
+
8
+ ### Removed — `/errors` subpath (BREAKING)
9
+
10
+ - Deleted `@classytic/primitives/errors` (`ErrorContract`, `ErrorDetail`, `ErrorCode`, `ERROR_CODES`).
11
+ - 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.
12
+
13
+ #### Migration
14
+
15
+ ```diff
16
+ - import type { ErrorContract, ErrorDetail, ErrorCode } from '@classytic/primitives/errors';
17
+ - import { ERROR_CODES } from '@classytic/primitives/errors';
18
+ + import type { ErrorContract, ErrorDetail, ErrorCode } from '@classytic/repo-core/errors';
19
+ + import { ERROR_CODES } from '@classytic/repo-core/errors';
20
+ ```
21
+
22
+ Field shapes and code values are unchanged — pure relocation.
23
+
24
+ ### Removed — `/tenant` subpath (BREAKING)
25
+
26
+ - Deleted `@classytic/primitives/tenant` (`TenantConfig`, `TenantStrategy`, `TenantFieldType`, `DEFAULT_TENANT_CONFIG`, `resolveTenantConfig`, `ResolvedTenantConfig`).
27
+ - 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.
28
+ - Mongokit and sqlitekit already peer-dep `@classytic/repo-core`, so consuming `TenantConfig` from there costs zero extra peer deps for those kits.
29
+
30
+ #### Migration
31
+
32
+ ```diff
33
+ - import type { TenantConfig } from '@classytic/primitives/tenant';
34
+ - import { resolveTenantConfig, DEFAULT_TENANT_CONFIG } from '@classytic/primitives/tenant';
35
+ + import type { TenantConfig } from '@classytic/repo-core/tenant';
36
+ + import { resolveTenantConfig, DEFAULT_TENANT_CONFIG } from '@classytic/repo-core/tenant';
37
+ ```
38
+
39
+ Field shapes, runtime semantics, and defaults are unchanged. The custom-strategy escape hatch (`strategy: 'custom'` + `resolve: (ctx) => filterShape`) works identically.
40
+
41
+ ### Removed — `/pagination` subpath (BREAKING)
42
+
43
+ - Deleted `@classytic/primitives/pagination` (`PageParams`, `KeysetParams`, `SortSpec`, `SortDirection`, `OffsetPage<T>`, `KeysetPage<T>`, `AggregatePage<T>`, `emptyOffsetPage`, `emptyKeysetPage`).
44
+ - 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.
45
+
46
+ ### Migration
47
+
48
+ ```diff
49
+ - import type { OffsetPage, KeysetPage, AggregatePage, SortSpec } from '@classytic/primitives/pagination';
50
+ + import type {
51
+ + OffsetPaginationResult,
52
+ + KeysetPaginationResult,
53
+ + AggregatePaginationResult,
54
+ + SortSpec,
55
+ + } from '@classytic/repo-core/pagination';
56
+ ```
57
+
58
+ 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.
59
+
60
+ 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.
61
+
62
+ ### Why now (and why this is the only breaking change in this release)
63
+
64
+ 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.
65
+
66
+ ## [0.2.0]
67
+
68
+ ### Added
69
+
70
+ - **`/state-machine` subpath** — `defineStateMachine<TStatus>({ name, transitions, errorFactory? })`
71
+ declarative state-transition primitive. Replaces hand-rolled
72
+ `if (status === 'X') throw` blocks across kernels:
73
+
74
+ ```typescript
75
+ import { defineStateMachine } from '@classytic/primitives/state-machine';
76
+
77
+ const ORDER_MACHINE = defineStateMachine<OrderStatus>({
78
+ name: 'Order',
79
+ transitions: {
80
+ draft: ['approved', 'cancelled'],
81
+ approved: ['shipped', 'cancelled'],
82
+ shipped: [],
83
+ cancelled: [],
84
+ },
85
+ });
86
+
87
+ ORDER_MACHINE.assertTransition('order-1', 'draft', 'approved'); // OK
88
+ ORDER_MACHINE.assertTransition('order-1', 'shipped', 'draft'); // throws
89
+ ORDER_MACHINE.canTransition('draft', 'shipped'); // false
90
+ ORDER_MACHINE.isTerminal('shipped'); // true
91
+ ```
92
+
93
+ Generic over the status string-union — `Record<TStatus, readonly TStatus[]>`
94
+ on `transitions` enforces exhaustiveness at the call site. Kernels with their
95
+ own typed transition error class (e.g. flow's `InvalidTransitionError`) wire
96
+ it via `errorFactory` so the thrown type stays domain-consistent. Standalone
97
+ callers get the bundled `IllegalTransitionError` (carries `entityType` /
98
+ `entityId` / `from` / `to` + `code: 'illegal_transition'` + `status: 422`).
99
+
100
+ First consumer: `@classytic/flow` `PROCUREMENT_MACHINE` (procurement-order
101
+ approve / cancel transitions).
102
+
6
103
  ## [0.1.1]
7
104
 
8
105
  ### 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 };
@@ -0,0 +1,103 @@
1
+ //#region src/state-machine.d.ts
2
+ /**
3
+ * Declarative state-machine primitive.
4
+ *
5
+ * Replaces hand-rolled `if (status === 'X') throw` blocks scattered
6
+ * across domain services. Each aggregate declares one transition table;
7
+ * the helper provides `canTransition` / `assertTransition` / `isTerminal`
8
+ * accessors that the services share.
9
+ *
10
+ * Generic over the status string-union so calls stay type-safe:
11
+ * `defineStateMachine<ProcurementStatus>({ name: 'ProcurementOrder', transitions: { … } })`
12
+ * The TS compiler enforces exhaustiveness — every status string in the
13
+ * union MUST have an entry in `transitions` (terminal statuses map to `[]`).
14
+ *
15
+ * @example
16
+ * import { defineStateMachine } from '@classytic/primitives/state-machine';
17
+ *
18
+ * type OrderStatus = 'draft' | 'approved' | 'shipped' | 'cancelled';
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
+ * ORDER_MACHINE.assertTransition('order-1', 'draft', 'approved'); // OK
30
+ * ORDER_MACHINE.assertTransition('order-1', 'shipped', 'draft'); // throws
31
+ * ORDER_MACHINE.canTransition('draft', 'shipped'); // false
32
+ * ORDER_MACHINE.isTerminal('shipped'); // true
33
+ *
34
+ * Hosts that already have their own typed transition error can swap it
35
+ * in via `errorFactory`:
36
+ *
37
+ * @example
38
+ * import { InvalidTransitionError } from './errors.js';
39
+ * const FLOW_MACHINE = defineStateMachine<MoveStatus>({
40
+ * name: 'StockMove',
41
+ * transitions: { … },
42
+ * errorFactory: ({ entityType, entityId, from, to }) =>
43
+ * new InvalidTransitionError(entityType, entityId, from, to),
44
+ * });
45
+ */
46
+ /**
47
+ * Default error thrown by `assertTransition` when no `errorFactory` is
48
+ * supplied. Carries the structured fields (`entityType`, `entityId`,
49
+ * `from`, `to`) plus a stable `code` so hosts can pattern-match on
50
+ * either `instanceof` or `error.code === 'illegal_transition'`.
51
+ */
52
+ declare class IllegalTransitionError extends Error {
53
+ readonly code: "illegal_transition";
54
+ readonly status = 422;
55
+ readonly entityType: string;
56
+ readonly entityId: string;
57
+ readonly from: string;
58
+ readonly to: string;
59
+ constructor(entityType: string, entityId: string, from: string, to: string);
60
+ }
61
+ interface TransitionErrorContext {
62
+ readonly entityType: string;
63
+ readonly entityId: string;
64
+ readonly from: string;
65
+ readonly to: string;
66
+ }
67
+ interface StateMachineDefinition<TStatus extends string> {
68
+ /** Aggregate name surfaced in the thrown error's `entityType`. */
69
+ name: string;
70
+ /**
71
+ * Adjacency list — for each status, the set of statuses you can
72
+ * legally transition TO. Terminal statuses map to an empty array.
73
+ * `Record<TStatus, …>` enforces exhaustiveness at the call site.
74
+ */
75
+ transitions: Record<TStatus, readonly TStatus[]>;
76
+ /**
77
+ * Optional custom error factory. When omitted, `assertTransition`
78
+ * throws `IllegalTransitionError`. Hosts that own a domain error
79
+ * (e.g. flow's `InvalidTransitionError`) wire their own here so the
80
+ * thrown type stays consistent with the rest of the package.
81
+ */
82
+ errorFactory?: (ctx: TransitionErrorContext) => Error;
83
+ }
84
+ interface StateMachine<TStatus extends string> {
85
+ /** The aggregate name (`StockMove`, `ProcurementOrder`, etc.). */
86
+ readonly name: string;
87
+ /** All terminal statuses (no outgoing transitions). */
88
+ readonly terminal: ReadonlySet<TStatus>;
89
+ /** True when `from → to` is in the transition table. */
90
+ canTransition(from: TStatus, to: TStatus): boolean;
91
+ /** True when `status` is terminal. */
92
+ isTerminal(status: TStatus): boolean;
93
+ /**
94
+ * Throw a transition error when `from → to` isn't allowed.
95
+ * `entityId` is included in the error for traceability.
96
+ */
97
+ assertTransition(entityId: string, from: TStatus, to: TStatus): void;
98
+ /** The raw transition table — exposed read-only for advanced callers. */
99
+ readonly transitions: Readonly<Record<TStatus, readonly TStatus[]>>;
100
+ }
101
+ declare function defineStateMachine<TStatus extends string>(definition: StateMachineDefinition<TStatus>): StateMachine<TStatus>;
102
+ //#endregion
103
+ export { IllegalTransitionError, StateMachine, StateMachineDefinition, TransitionErrorContext, defineStateMachine };
@@ -0,0 +1,94 @@
1
+ //#region src/state-machine.ts
2
+ /**
3
+ * Declarative state-machine primitive.
4
+ *
5
+ * Replaces hand-rolled `if (status === 'X') throw` blocks scattered
6
+ * across domain services. Each aggregate declares one transition table;
7
+ * the helper provides `canTransition` / `assertTransition` / `isTerminal`
8
+ * accessors that the services share.
9
+ *
10
+ * Generic over the status string-union so calls stay type-safe:
11
+ * `defineStateMachine<ProcurementStatus>({ name: 'ProcurementOrder', transitions: { … } })`
12
+ * The TS compiler enforces exhaustiveness — every status string in the
13
+ * union MUST have an entry in `transitions` (terminal statuses map to `[]`).
14
+ *
15
+ * @example
16
+ * import { defineStateMachine } from '@classytic/primitives/state-machine';
17
+ *
18
+ * type OrderStatus = 'draft' | 'approved' | 'shipped' | 'cancelled';
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
+ * ORDER_MACHINE.assertTransition('order-1', 'draft', 'approved'); // OK
30
+ * ORDER_MACHINE.assertTransition('order-1', 'shipped', 'draft'); // throws
31
+ * ORDER_MACHINE.canTransition('draft', 'shipped'); // false
32
+ * ORDER_MACHINE.isTerminal('shipped'); // true
33
+ *
34
+ * Hosts that already have their own typed transition error can swap it
35
+ * in via `errorFactory`:
36
+ *
37
+ * @example
38
+ * import { InvalidTransitionError } from './errors.js';
39
+ * const FLOW_MACHINE = defineStateMachine<MoveStatus>({
40
+ * name: 'StockMove',
41
+ * transitions: { … },
42
+ * errorFactory: ({ entityType, entityId, from, to }) =>
43
+ * new InvalidTransitionError(entityType, entityId, from, to),
44
+ * });
45
+ */
46
+ /**
47
+ * Default error thrown by `assertTransition` when no `errorFactory` is
48
+ * supplied. Carries the structured fields (`entityType`, `entityId`,
49
+ * `from`, `to`) plus a stable `code` so hosts can pattern-match on
50
+ * either `instanceof` or `error.code === 'illegal_transition'`.
51
+ */
52
+ var IllegalTransitionError = class extends Error {
53
+ code = "illegal_transition";
54
+ status = 422;
55
+ entityType;
56
+ entityId;
57
+ from;
58
+ to;
59
+ constructor(entityType, entityId, from, to) {
60
+ super(`Invalid transition for ${entityType} ${entityId}: ${from} → ${to}`);
61
+ this.name = "IllegalTransitionError";
62
+ this.entityType = entityType;
63
+ this.entityId = entityId;
64
+ this.from = from;
65
+ this.to = to;
66
+ }
67
+ };
68
+ function defineStateMachine(definition) {
69
+ const { name, transitions, errorFactory } = definition;
70
+ const terminal = /* @__PURE__ */ new Set();
71
+ for (const [status, allowed] of Object.entries(transitions)) if (allowed.length === 0) terminal.add(status);
72
+ const buildError = errorFactory ? errorFactory : (ctx) => new IllegalTransitionError(ctx.entityType, ctx.entityId, ctx.from, ctx.to);
73
+ return {
74
+ name,
75
+ terminal,
76
+ transitions,
77
+ canTransition(from, to) {
78
+ return transitions[from]?.includes(to) ?? false;
79
+ },
80
+ isTerminal(status) {
81
+ return terminal.has(status);
82
+ },
83
+ assertTransition(entityId, from, to) {
84
+ if (!transitions[from]?.includes(to)) throw buildError({
85
+ entityType: name,
86
+ entityId,
87
+ from,
88
+ to
89
+ });
90
+ }
91
+ };
92
+ }
93
+ //#endregion
94
+ export { IllegalTransitionError, defineStateMachine };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/primitives",
3
- "version": "0.1.1",
3
+ "version": "0.3.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"
@@ -97,6 +85,10 @@
97
85
  "types": "./dist/person.d.mts",
98
86
  "default": "./dist/person.mjs"
99
87
  },
88
+ "./state-machine": {
89
+ "types": "./dist/state-machine.d.mts",
90
+ "default": "./dist/state-machine.mjs"
91
+ },
100
92
  "./package.json": "./package.json"
101
93
  },
102
94
  "files": [
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 };