@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 +124 -0
- package/README.md +38 -17
- package/dist/person.mjs +1 -1
- package/dist/state-machine.d.mts +81 -1
- package/dist/state-machine.mjs +69 -2
- package/package.json +1 -13
- package/dist/errors.d.mts +0 -50
- package/dist/errors.mjs +0 -20
- package/dist/pagination.d.mts +0 -57
- package/dist/pagination.mjs +0 -22
- package/dist/tenant.d.mts +0 -129
- package/dist/tenant.mjs +0 -58
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`, `
|
|
8
|
-
|
|
9
|
-
|
|
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 —
|
|
116
|
+
### TenantConfig — moved to `@classytic/repo-core/tenant`
|
|
95
117
|
|
|
96
118
|
```ts
|
|
97
|
-
import { resolveTenantConfig } from '@classytic/
|
|
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
|
-
|
|
105
|
-
|
|
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
|
|
13
|
+
return [name.given, name.family].filter((p) => Boolean(p?.trim())).join(" ");
|
|
14
14
|
}
|
|
15
15
|
//#endregion
|
|
16
16
|
export { formatDisplayName, formatFullName };
|
package/dist/state-machine.d.mts
CHANGED
|
@@ -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 };
|
package/dist/state-machine.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
"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 };
|
package/dist/pagination.d.mts
DELETED
|
@@ -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 };
|
package/dist/pagination.mjs
DELETED
|
@@ -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 };
|