@classytic/primitives 0.24.0 → 0.25.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,12 @@
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.25.0] - 2026-08-24
7
+
8
+ ### Added
9
+
10
+ - **`@classytic/primitives/item-identity` subpath** — `SkuFields`, `stockKeyOf`, `skuDisplay`. `sku` is a label; `skuRef` is the stock identity — the two fall back in opposite directions and getting one backwards type-checks while returning a plausible wrong string. `stockKeyOf` resolves `skuRef ?? sku ?? fallback` (the key to reserve, decrement and post against); `skuDisplay` resolves `skuLabel ?? sku ?? skuRef` (what a human reads). Canonicalises the per-package `variantSku || String(productId)` derivations into one place.
11
+
6
12
  ## [0.24.0] - 2026-08-24
7
13
 
8
14
  ### Added — `@classytic/primitives/address`: capture-format system
package/README.md CHANGED
@@ -1,160 +1,160 @@
1
- # @classytic/primitives
2
-
3
- [![Sponsor](https://img.shields.io/github/sponsors/classytic?style=flat-square&label=Sponsor&logo=GitHub&color=EA4AAA)](https://github.com/sponsors/classytic)
4
-
5
- Framework-agnostic TypeScript primitives shared across Classytic packages.
6
- Zero runtime dependencies. ESM only. Node 22+.
7
-
8
- These are the building blocks every Classytic package speaks in common:
9
- `Money`, `DomainEvent`, `OperationContext`, polymorphic references. When a
10
- package duplicates one of these shapes, it drifts — this package exists so it
11
- doesn't.
12
-
13
- > **Canonical-contract relocations in 0.3.0.** Three modules moved out to
14
- > `@classytic/repo-core` so kits and arc see one source of truth instead of
15
- > two:
16
- > - **Pagination** (`OffsetPaginationResult`, `KeysetPaginationResult`,
17
- > `AggregatePaginationResult`, `PaginationResult`, `toCanonicalList()`) →
18
- > `@classytic/repo-core/pagination`.
19
- > - **Tenant config** (`TenantConfig`, `TenantStrategy`, `TenantFieldType`,
20
- > `resolveTenantConfig`, `DEFAULT_TENANT_CONFIG`, `ResolvedTenantConfig`) →
21
- > `@classytic/repo-core/tenant`. Mongokit and sqlitekit's
22
- > `MultiTenantOptions extends Pick<TenantConfig, ...>`.
23
- > - **Error contracts** (`HttpError` throwable, `ErrorContract` wire shape,
24
- > `ErrorDetail`, `ErrorCode`, `ERROR_CODES`, `toErrorContract()`,
25
- > `statusToErrorCode()`) → `@classytic/repo-core/errors`.
26
- > `ArcError implements HttpError`.
27
- >
28
- > Events stay here. `@classytic/primitives/events` remains the canonical
29
- > source for `EventMeta`, `DomainEvent`, `EventHandler`, `EventLogger`,
30
- > `EventTransport`, `DeadLetteredEvent`, `PublishManyResult`, `createEvent`,
31
- > `createChildEvent`, `matchEventPattern`. Arc 2.12 re-exports only the
32
- > runtime `MemoryEventTransport`.
33
-
34
- ## Install
35
-
36
- ```bash
37
- npm install @classytic/primitives
38
- ```
39
-
40
- ## Use
41
-
42
- Prefer **subpath imports** — each concern is its own entry point, types erase
43
- at compile time, runtime helpers are tiny.
44
-
45
- ```ts
46
- import { addMoney, fromMajor, type Money } from '@classytic/primitives/money';
47
- import { createEvent, type DomainEvent, type EventTransport } from '@classytic/primitives/events';
48
- import type { OperationContext } from '@classytic/primitives/context';
49
- import { allocate, type SplitResult } from '@classytic/primitives/split-allocation';
50
- // Tenant config, error contracts, and pagination now live in repo-core:
51
- import { resolveTenantConfig, type TenantConfig } from '@classytic/repo-core/tenant';
52
- import type { HttpError, ErrorContract } from '@classytic/repo-core/errors';
53
- import type {
54
- OffsetPaginationResult, KeysetPaginationResult, AggregatePaginationResult,
55
- } from '@classytic/repo-core/pagination';
56
- ```
57
-
58
- There is intentionally **no root barrel** — a barrel forces the Node ESM
59
- loader and TypeScript compiler to walk every module on a single convenience
60
- import, which slows CI and pulls unused code into the consumer's graph. Use
61
- subpath imports only.
62
-
63
- ## What's inside
64
-
65
- | Subpath | Ships |
66
- |---|---|
67
- | `/money` | `Money`, `fromMajor`, `toMajor`, `addMoney`, `subtractMoney`, `multiplyMoney`, `sumMoney`, `equalsMoney`, `compareMoney`, `negateMoney`, `absMoney`, `isMoney`, `CurrencyMismatchError` |
68
- | `/currency` | `CurrencyCode` brand, `CURRENCIES`, `MINOR_UNIT_FACTOR`, `minorUnitFactor`, `isKnownCurrency`, `toCurrencyCode`, `currencyCode`, `isCurrencyCode`, `InvalidCurrencyCodeError`, `CurrencyMismatchError`, `FxSnapshot`, `convertWithSnapshot`, `reverseWithSnapshot`, `FxRounding` |
69
- | `/address` | `Address`, `ContactAddress`, `GeoPoint`, `GeoJsonPoint`, `toGeoJsonPoint`, `fromGeoJsonPoint` |
70
- | `/period` | `DateRange`, `Period`, `isDateRange`, `isWithin`, `rangeDurationMs`, `rangesOverlap`, `DateRangeError` — **plus timezone-aware resolution**: `resolvePeriod`, `resolveDay`, `resolveMonth`, `resolveQuarter`, `resolveYear`, `resolveDateSpan`, `dayStart`, `periodOf`, `parsePeriod`, `formatPeriod`, `granularityOf`, `periodTimeZone`, `inclusiveEnd`, `PeriodError` |
71
- | `/reference` | `ExternalRef`, `ObjectIdLike`, `IdLike`, `DocumentRef`, `idToString`, `isExternalRef` |
72
- | `/context` | `OperationContext`, `ActorRef` |
73
- | `/events` | `DomainEvent`, `EventMeta`, `EventHandler`, `EventLogger`, `EventTransport`, `DeadLetteredEvent`, `PublishManyResult`, `createEvent`, `createChildEvent`, `matchEventPattern` |
74
- | `/result` | `Result<T, E>`, `ok`, `err`, `isOk`, `isErr`, `mapResult`, `mapError`, `unwrap` |
75
- | `/brand` | `Brand<T, B>`, `Prettify`, `DeepPartial`, `DeepReadonly`, `RequireKeys`, `OptionalKeys`, `Nullable`, `KeysMatching`, `NonEmptyArray` |
76
- | `/split-allocation` | `allocate`, `isBalanced`, `SplitAllocationError`, `SplitMethod`, `SplitSubject`, `SplitPart`, `SplitResult` |
77
- | `/idempotency` | `decideClaim`, `completeClaim`, `renewLease`, `identityKey`, `sameIdentity`, `fingerprintRequest`, `newLeaseToken`, `isLeaseExpired`, `holdsLease`, `isInFlight`, `DEFAULT_LEASE_MS`, `IdempotencyClaim`, `ClaimOutcome`, `ClaimResult`, `ClaimState`, `IdempotencyIdentity`, `IdempotencyError` |
78
- | `/approval` | `createChain`, `applyDecision`, `skipStep`, `nextPendingStep`, `pendingSteps`, `isApproved`, `assertApproved`, `assertApprovedIfPresent`, `isRejected`, `isPending`, `decisionCount`, `ApprovalError`, `ApprovalChain`, `ApprovalStep`, `ApprovalDecision`, `Approver`, `ApprovalThreshold` |
79
- | `/cadence` | `nextOccurrence`, `occurrencesBetween`, `validateCadence`, `CadenceError`, `Cadence`, `DailyCadence`, `WeeklyCadence`, `MonthlyCadence`, `YearlyCadence`, `CronCadence`, `IsoWeekday` |
80
- | `/hold` | `addHold`, `resolveHold`, `activeHolds`, `resolvedHolds`, `isOnHold`, `hasActiveHoldOfCode`, `HoldError`, `HoldReason`, `HoldActor` |
81
- | `/sla` | `breachedAt`, `remainingMs`, `elapsedMs`, `isBreached`, `consumedFraction`, `validateSLA`, `SLAError`, `SLA`, `BreachPolicy` |
82
-
83
- ## Design
84
-
85
- ### Money — integer minor units, always
86
-
87
- ```ts
88
- import { fromMajor, addMoney, toMajor } from '@classytic/primitives/money';
89
-
90
- const subtotal = fromMajor(19.99, 'USD'); // { amount: 1999, currency: 'USD' }
91
- const shipping = fromMajor(5.00, 'USD'); // { amount: 500, currency: 'USD' }
92
- const total = addMoney(subtotal, shipping); // { amount: 2499, currency: 'USD' }
93
- toMajor(total); // 24.99
94
- ```
95
-
96
- `Money.amount` is always an integer in the currency's minor unit — `1` = one
97
- cent for USD, one yen for JPY, one fils for KWD. Mixing currencies throws
98
- `CurrencyMismatchError`. This eliminates the float-rounding drift that happens
99
- when every package rolls its own `{ amount: number, currency: string }`.
100
-
101
- ### DomainEvent — structurally identical to Arc
102
-
103
- ```ts
104
- import { createEvent, type EventTransport } from '@classytic/primitives/events';
105
-
106
- const event = createEvent('order:placed', { orderId: 'ord_123' }, {
107
- organizationId: ctx.organizationId,
108
- userId: ctx.actorId,
109
- correlationId: ctx.correlationId,
110
- });
111
-
112
- await transport.publish(event);
113
- ```
114
-
115
- Shape matches `@classytic/arc`'s `DomainEvent` exactly — any Arc transport
116
- (memory, Redis pub/sub, Redis Streams, Kafka) plugs into a package expecting
117
- this interface without adapters. See PACKAGE_RULES.md §11.
118
-
119
- ### TenantConfig — moved to `@classytic/repo-core/tenant`
120
-
121
- ```ts
122
- import { resolveTenantConfig } from '@classytic/repo-core/tenant';
123
-
124
- const tenant = resolveTenantConfig(config.tenant);
125
- // { enabled: true, tenantField: 'organizationId', fieldType: 'objectId',
126
- // ref: 'organization', contextKey: 'organizationId', required: true }
127
- ```
128
-
129
- Mongokit's `MultiTenantOptions` and sqlitekit's equivalent both
130
- `extends Pick<TenantConfig, ...>` from repo-core — pass the resolved config
131
- straight into `multiTenantPlugin(resolved)`.
132
-
133
- ### ExternalRef — polymorphic, framework-free
134
-
135
- ```ts
136
- interface ExternalRef {
137
- sourceId: string;
138
- sourceModel: string;
139
- }
140
- ```
141
-
142
- For references that cross trust boundaries (another package, another service,
143
- Stripe, Postgres) — paired with a host-implemented `SourceBridge`. Works for
144
- ObjectId, UUID, Stripe ID, anything. See PACKAGE_RULES.md §7.
145
-
146
- ## What's not here (on purpose)
147
-
148
- - **Mongoose types** — use `ObjectIdLike` from `/reference`. Any Mongoose
149
- `Types.ObjectId` satisfies it structurally.
150
- - **Transaction / Order / Cart shapes** — those are domain types, not
151
- primitives. They live in their respective packages.
152
- - **Error subclasses** — throw plain `Error`s; hosts serialize them into
153
- `ErrorContract` (from `@classytic/repo-core/errors`) at the HTTP boundary
154
- via `toErrorContract(err)`.
155
- - **Runtime currency database** — ship a whitelist at the app layer if you
156
- need one. `MINOR_UNIT_FACTOR` covers common cases.
157
-
158
- ## License
159
-
160
- MIT © Classytic
1
+ # @classytic/primitives
2
+
3
+ [![Sponsor](https://img.shields.io/github/sponsors/classytic?style=flat-square&label=Sponsor&logo=GitHub&color=EA4AAA)](https://github.com/sponsors/classytic)
4
+
5
+ Framework-agnostic TypeScript primitives shared across Classytic packages.
6
+ Zero runtime dependencies. ESM only. Node 22+.
7
+
8
+ These are the building blocks every Classytic package speaks in common:
9
+ `Money`, `DomainEvent`, `OperationContext`, polymorphic references. When a
10
+ package duplicates one of these shapes, it drifts — this package exists so it
11
+ doesn't.
12
+
13
+ > **Canonical-contract relocations in 0.3.0.** Three modules moved out to
14
+ > `@classytic/repo-core` so kits and arc see one source of truth instead of
15
+ > two:
16
+ > - **Pagination** (`OffsetPaginationResult`, `KeysetPaginationResult`,
17
+ > `AggregatePaginationResult`, `PaginationResult`, `toCanonicalList()`) →
18
+ > `@classytic/repo-core/pagination`.
19
+ > - **Tenant config** (`TenantConfig`, `TenantStrategy`, `TenantFieldType`,
20
+ > `resolveTenantConfig`, `DEFAULT_TENANT_CONFIG`, `ResolvedTenantConfig`) →
21
+ > `@classytic/repo-core/tenant`. Mongokit and sqlitekit's
22
+ > `MultiTenantOptions extends Pick<TenantConfig, ...>`.
23
+ > - **Error contracts** (`HttpError` throwable, `ErrorContract` wire shape,
24
+ > `ErrorDetail`, `ErrorCode`, `ERROR_CODES`, `toErrorContract()`,
25
+ > `statusToErrorCode()`) → `@classytic/repo-core/errors`.
26
+ > `ArcError implements HttpError`.
27
+ >
28
+ > Events stay here. `@classytic/primitives/events` remains the canonical
29
+ > source for `EventMeta`, `DomainEvent`, `EventHandler`, `EventLogger`,
30
+ > `EventTransport`, `DeadLetteredEvent`, `PublishManyResult`, `createEvent`,
31
+ > `createChildEvent`, `matchEventPattern`. Arc 2.12 re-exports only the
32
+ > runtime `MemoryEventTransport`.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ npm install @classytic/primitives
38
+ ```
39
+
40
+ ## Use
41
+
42
+ Prefer **subpath imports** — each concern is its own entry point, types erase
43
+ at compile time, runtime helpers are tiny.
44
+
45
+ ```ts
46
+ import { addMoney, fromMajor, type Money } from '@classytic/primitives/money';
47
+ import { createEvent, type DomainEvent, type EventTransport } from '@classytic/primitives/events';
48
+ import type { OperationContext } from '@classytic/primitives/context';
49
+ import { allocate, type SplitResult } from '@classytic/primitives/split-allocation';
50
+ // Tenant config, error contracts, and pagination now live in repo-core:
51
+ import { resolveTenantConfig, type TenantConfig } from '@classytic/repo-core/tenant';
52
+ import type { HttpError, ErrorContract } from '@classytic/repo-core/errors';
53
+ import type {
54
+ OffsetPaginationResult, KeysetPaginationResult, AggregatePaginationResult,
55
+ } from '@classytic/repo-core/pagination';
56
+ ```
57
+
58
+ There is intentionally **no root barrel** — a barrel forces the Node ESM
59
+ loader and TypeScript compiler to walk every module on a single convenience
60
+ import, which slows CI and pulls unused code into the consumer's graph. Use
61
+ subpath imports only.
62
+
63
+ ## What's inside
64
+
65
+ | Subpath | Ships |
66
+ |---|---|
67
+ | `/money` | `Money`, `fromMajor`, `toMajor`, `addMoney`, `subtractMoney`, `multiplyMoney`, `sumMoney`, `equalsMoney`, `compareMoney`, `negateMoney`, `absMoney`, `isMoney`, `CurrencyMismatchError` |
68
+ | `/currency` | `CurrencyCode` brand, `CURRENCIES`, `MINOR_UNIT_FACTOR`, `minorUnitFactor`, `isKnownCurrency`, `toCurrencyCode`, `currencyCode`, `isCurrencyCode`, `InvalidCurrencyCodeError`, `CurrencyMismatchError`, `FxSnapshot`, `convertWithSnapshot`, `reverseWithSnapshot`, `FxRounding` |
69
+ | `/address` | `Address`, `ContactAddress`, `GeoPoint`, `GeoJsonPoint`, `toGeoJsonPoint`, `fromGeoJsonPoint` |
70
+ | `/period` | `DateRange`, `Period`, `isDateRange`, `isWithin`, `rangeDurationMs`, `rangesOverlap`, `DateRangeError` — **plus timezone-aware resolution**: `resolvePeriod`, `resolveDay`, `resolveMonth`, `resolveQuarter`, `resolveYear`, `resolveDateSpan`, `dayStart`, `periodOf`, `parsePeriod`, `formatPeriod`, `granularityOf`, `periodTimeZone`, `inclusiveEnd`, `PeriodError` |
71
+ | `/reference` | `ExternalRef`, `ObjectIdLike`, `IdLike`, `DocumentRef`, `idToString`, `isExternalRef` |
72
+ | `/context` | `OperationContext`, `ActorRef` |
73
+ | `/events` | `DomainEvent`, `EventMeta`, `EventHandler`, `EventLogger`, `EventTransport`, `DeadLetteredEvent`, `PublishManyResult`, `createEvent`, `createChildEvent`, `matchEventPattern` |
74
+ | `/result` | `Result<T, E>`, `ok`, `err`, `isOk`, `isErr`, `mapResult`, `mapError`, `unwrap` |
75
+ | `/brand` | `Brand<T, B>`, `Prettify`, `DeepPartial`, `DeepReadonly`, `RequireKeys`, `OptionalKeys`, `Nullable`, `KeysMatching`, `NonEmptyArray` |
76
+ | `/split-allocation` | `allocate`, `isBalanced`, `SplitAllocationError`, `SplitMethod`, `SplitSubject`, `SplitPart`, `SplitResult` |
77
+ | `/idempotency` | `decideClaim`, `completeClaim`, `renewLease`, `identityKey`, `sameIdentity`, `fingerprintRequest`, `newLeaseToken`, `isLeaseExpired`, `holdsLease`, `isInFlight`, `DEFAULT_LEASE_MS`, `IdempotencyClaim`, `ClaimOutcome`, `ClaimResult`, `ClaimState`, `IdempotencyIdentity`, `IdempotencyError` |
78
+ | `/approval` | `createChain`, `applyDecision`, `skipStep`, `nextPendingStep`, `pendingSteps`, `isApproved`, `assertApproved`, `assertApprovedIfPresent`, `isRejected`, `isPending`, `decisionCount`, `ApprovalError`, `ApprovalChain`, `ApprovalStep`, `ApprovalDecision`, `Approver`, `ApprovalThreshold` |
79
+ | `/cadence` | `nextOccurrence`, `occurrencesBetween`, `validateCadence`, `CadenceError`, `Cadence`, `DailyCadence`, `WeeklyCadence`, `MonthlyCadence`, `YearlyCadence`, `CronCadence`, `IsoWeekday` |
80
+ | `/hold` | `addHold`, `resolveHold`, `activeHolds`, `resolvedHolds`, `isOnHold`, `hasActiveHoldOfCode`, `HoldError`, `HoldReason`, `HoldActor` |
81
+ | `/sla` | `breachedAt`, `remainingMs`, `elapsedMs`, `isBreached`, `consumedFraction`, `validateSLA`, `SLAError`, `SLA`, `BreachPolicy` |
82
+
83
+ ## Design
84
+
85
+ ### Money — integer minor units, always
86
+
87
+ ```ts
88
+ import { fromMajor, addMoney, toMajor } from '@classytic/primitives/money';
89
+
90
+ const subtotal = fromMajor(19.99, 'USD'); // { amount: 1999, currency: 'USD' }
91
+ const shipping = fromMajor(5.00, 'USD'); // { amount: 500, currency: 'USD' }
92
+ const total = addMoney(subtotal, shipping); // { amount: 2499, currency: 'USD' }
93
+ toMajor(total); // 24.99
94
+ ```
95
+
96
+ `Money.amount` is always an integer in the currency's minor unit — `1` = one
97
+ cent for USD, one yen for JPY, one fils for KWD. Mixing currencies throws
98
+ `CurrencyMismatchError`. This eliminates the float-rounding drift that happens
99
+ when every package rolls its own `{ amount: number, currency: string }`.
100
+
101
+ ### DomainEvent — structurally identical to Arc
102
+
103
+ ```ts
104
+ import { createEvent, type EventTransport } from '@classytic/primitives/events';
105
+
106
+ const event = createEvent('order:placed', { orderId: 'ord_123' }, {
107
+ organizationId: ctx.organizationId,
108
+ userId: ctx.actorId,
109
+ correlationId: ctx.correlationId,
110
+ });
111
+
112
+ await transport.publish(event);
113
+ ```
114
+
115
+ Shape matches `@classytic/arc`'s `DomainEvent` exactly — any Arc transport
116
+ (memory, Redis pub/sub, Redis Streams, Kafka) plugs into a package expecting
117
+ this interface without adapters. See PACKAGE_RULES.md §11.
118
+
119
+ ### TenantConfig — moved to `@classytic/repo-core/tenant`
120
+
121
+ ```ts
122
+ import { resolveTenantConfig } from '@classytic/repo-core/tenant';
123
+
124
+ const tenant = resolveTenantConfig(config.tenant);
125
+ // { enabled: true, tenantField: 'organizationId', fieldType: 'objectId',
126
+ // ref: 'organization', contextKey: 'organizationId', required: true }
127
+ ```
128
+
129
+ Mongokit's `MultiTenantOptions` and sqlitekit's equivalent both
130
+ `extends Pick<TenantConfig, ...>` from repo-core — pass the resolved config
131
+ straight into `multiTenantPlugin(resolved)`.
132
+
133
+ ### ExternalRef — polymorphic, framework-free
134
+
135
+ ```ts
136
+ interface ExternalRef {
137
+ sourceId: string;
138
+ sourceModel: string;
139
+ }
140
+ ```
141
+
142
+ For references that cross trust boundaries (another package, another service,
143
+ Stripe, Postgres) — paired with a host-implemented `SourceBridge`. Works for
144
+ ObjectId, UUID, Stripe ID, anything. See PACKAGE_RULES.md §7.
145
+
146
+ ## What's not here (on purpose)
147
+
148
+ - **Mongoose types** — use `ObjectIdLike` from `/reference`. Any Mongoose
149
+ `Types.ObjectId` satisfies it structurally.
150
+ - **Transaction / Order / Cart shapes** — those are domain types, not
151
+ primitives. They live in their respective packages.
152
+ - **Error subclasses** — throw plain `Error`s; hosts serialize them into
153
+ `ErrorContract` (from `@classytic/repo-core/errors`) at the HTTP boundary
154
+ via `toErrorContract(err)`.
155
+ - **Runtime currency database** — ship a whitelist at the app layer if you
156
+ need one. `MINOR_UNIT_FACTOR` covers common cases.
157
+
158
+ ## License
159
+
160
+ MIT © Classytic
@@ -0,0 +1,71 @@
1
+ //#region src/identity/item-identity.d.ts
2
+ /**
3
+ * `sku` is a LABEL; `skuRef` is the stock IDENTITY. Never one field.
4
+ *
5
+ * | field | mutable? | job |
6
+ * |---|---|---|
7
+ * | `skuRef` | never | keys quants, moves and ledger rows |
8
+ * | `sku` | operator edits it | what a human reads, types, searches |
9
+ *
10
+ * Use `stockKeyOf` for anything that reserves or moves stock, `skuDisplay` for
11
+ * anything a human reads. Never hand-roll either: both fall back, in OPPOSITE
12
+ * directions, and getting one backwards type-checks and returns a plausible
13
+ * string.
14
+ *
15
+ * - stock key = `skuRef ?? sku` — absent `skuRef` means a pre-split producer
16
+ * whose `sku` still IS the key.
17
+ * - display = `skuLabel ?? sku ?? skuRef` — the ref last, and flagged, because
18
+ * it can be an opaque id with no merchandising meaning.
19
+ *
20
+ * Pinned by `tests/unit/item-identity.test.ts`.
21
+ */
22
+ /** Identifier fields a record may carry — each shape populates a different subset. */
23
+ interface SkuFields {
24
+ /** Merchandising label — mutable, human-facing. */
25
+ sku?: string | undefined;
26
+ /** Stock/ledger identity — immutable, keys quants and moves. */
27
+ skuRef?: string | undefined;
28
+ /** Label resolved server-side for a read model keyed by `skuRef`. */
29
+ skuLabel?: string | undefined;
30
+ }
31
+ /**
32
+ * The STOCK key to reserve, decrement and post against: `skuRef ?? sku`,
33
+ * falling back to `fallback` (typically the owning entity's `_id`) when the
34
+ * record carries neither — the shape every "variant, else the product"
35
+ * ledger key already re-derives by hand across three packages.
36
+ *
37
+ * `undefined` = the record carries neither AND no `fallback` was given —
38
+ * malformed, not untracked. Use {@link requireStockKey} where that must stop
39
+ * the write.
40
+ */
41
+ declare function stockKeyOf(item: SkuFields, fallback?: string | {
42
+ toString(): string;
43
+ }): string | undefined;
44
+ /**
45
+ * {@link stockKeyOf} that THROWS. Use wherever the key feeds a stock movement:
46
+ * a reservation on `undefined` returns "out of stock" instead of failing.
47
+ */
48
+ declare function requireStockKey(item: SkuFields, context?: string): string;
49
+ /** What a surface should render for an item, and whether it had a real label. */
50
+ interface SkuDisplay {
51
+ /** Best human-facing value. `undefined` = no identifier at all; render a dash. */
52
+ label: string | undefined;
53
+ /** The stock identity, for subtext or copy-to-clipboard. */
54
+ ref: string | undefined;
55
+ /**
56
+ * TRUE when `label` is really the `ref` (nothing resolved). Style it as an id
57
+ * — monospace, muted — never as a SKU.
58
+ */
59
+ isRefFallback: boolean;
60
+ }
61
+ /** What to SHOW: `skuLabel ?? sku ?? skuRef`. The ref is returned alongside,
62
+ * never dropped — it is the join key an operator quotes. */
63
+ declare function skuDisplay(item: SkuFields): SkuDisplay;
64
+ /**
65
+ * Looks like an opaque id (24-char hex) rather than a merchandising code.
66
+ * PRESENTATION HINT ONLY — never branch stock logic on it; a deployment may key
67
+ * quants by any stable string.
68
+ */
69
+ declare function looksLikeOpaqueId(value: string | undefined): boolean;
70
+ //#endregion
71
+ export { SkuDisplay, SkuFields, looksLikeOpaqueId, requireStockKey, skuDisplay, stockKeyOf };
@@ -0,0 +1,50 @@
1
+ //#region src/identity/item-identity.ts
2
+ const nonEmpty = (value) => typeof value === "string" && value.trim().length > 0 ? value : void 0;
3
+ /**
4
+ * The STOCK key to reserve, decrement and post against: `skuRef ?? sku`,
5
+ * falling back to `fallback` (typically the owning entity's `_id`) when the
6
+ * record carries neither — the shape every "variant, else the product"
7
+ * ledger key already re-derives by hand across three packages.
8
+ *
9
+ * `undefined` = the record carries neither AND no `fallback` was given —
10
+ * malformed, not untracked. Use {@link requireStockKey} where that must stop
11
+ * the write.
12
+ */
13
+ function stockKeyOf(item, fallback) {
14
+ return nonEmpty(item.skuRef) ?? nonEmpty(item.sku) ?? (fallback !== void 0 ? String(fallback) : void 0);
15
+ }
16
+ /**
17
+ * {@link stockKeyOf} that THROWS. Use wherever the key feeds a stock movement:
18
+ * a reservation on `undefined` returns "out of stock" instead of failing.
19
+ */
20
+ function requireStockKey(item, context) {
21
+ const key = stockKeyOf(item);
22
+ if (key === void 0) throw new Error(`stock key missing: neither skuRef nor sku is set${context ? ` (${context})` : ""}`);
23
+ return key;
24
+ }
25
+ /** What to SHOW: `skuLabel ?? sku ?? skuRef`. The ref is returned alongside,
26
+ * never dropped — it is the join key an operator quotes. */
27
+ function skuDisplay(item) {
28
+ const ref = nonEmpty(item.skuRef);
29
+ const label = nonEmpty(item.skuLabel) ?? nonEmpty(item.sku);
30
+ if (label !== void 0) return {
31
+ label,
32
+ ref,
33
+ isRefFallback: false
34
+ };
35
+ return {
36
+ label: ref,
37
+ ref,
38
+ isRefFallback: ref !== void 0
39
+ };
40
+ }
41
+ /**
42
+ * Looks like an opaque id (24-char hex) rather than a merchandising code.
43
+ * PRESENTATION HINT ONLY — never branch stock logic on it; a deployment may key
44
+ * quants by any stable string.
45
+ */
46
+ function looksLikeOpaqueId(value) {
47
+ return typeof value === "string" && /^[0-9a-f]{24}$/i.test(value);
48
+ }
49
+ //#endregion
50
+ export { looksLikeOpaqueId, requireStockKey, skuDisplay, stockKeyOf };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/primitives",
3
- "version": "0.24.0",
3
+ "version": "0.25.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",
@@ -209,6 +209,10 @@
209
209
  "./subject": {
210
210
  "types": "./dist/subject.d.mts",
211
211
  "default": "./dist/subject.mjs"
212
+ },
213
+ "./item-identity": {
214
+ "types": "./dist/item-identity.d.mts",
215
+ "default": "./dist/item-identity.mjs"
212
216
  }
213
217
  },
214
218
  "files": [