@classytic/primitives 0.26.0 → 0.26.2
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 +27 -0
- package/dist/item-identity.d.mts +45 -1
- package/dist/item-identity.mjs +44 -1
- package/dist/testing/stock-key-seam.d.mts +47 -0
- package/dist/testing/stock-key-seam.mjs +109 -0
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,33 @@
|
|
|
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.26.2
|
|
7
|
+
|
|
8
|
+
### Added — `stockKeyFor` (`/item-identity`): one stock-key precedence for every package
|
|
9
|
+
|
|
10
|
+
The product-level stock key, with a single precedence:
|
|
11
|
+
|
|
12
|
+
1. the variant's stamped `skuRef` — assigned once at birth, immutable
|
|
13
|
+
2. a **sole** variant with no stamp → the product's `_id`, never the label
|
|
14
|
+
(legacy writers derived `variantSku || productId`, and for one variant that
|
|
15
|
+
key IS the `_id`)
|
|
16
|
+
3. a **sibling** with no stamp → its label, which genuinely is its legacy key
|
|
17
|
+
4. nothing addressable → the product's `_id`
|
|
18
|
+
|
|
19
|
+
The caller declares only what it HAS — `{ kind: 'sole' | 'label' | 'variant' }`
|
|
20
|
+
— so the sole-vs-sibling decision is internal and a caller cannot make it wrong.
|
|
21
|
+
That choice previously lived at every call site across three packages, in two
|
|
22
|
+
functions with different fallbacks; they disagreed on a sole, unstamped,
|
|
23
|
+
labelled variant, and the read then looked up a key no quant is stored under.
|
|
24
|
+
|
|
25
|
+
It lives here, below every domain, because `@spinekit/order` and
|
|
26
|
+
`@spinekit/catalog` must not import a peer domain — the rule sitting one layer
|
|
27
|
+
too high is exactly why each package re-derived it.
|
|
28
|
+
|
|
29
|
+
## 0.26.1
|
|
30
|
+
|
|
31
|
+
- `./testing/stock-key-seam` — new subpath; `scanStockKeySeam`, `assertStockKeySeam`, `takesStockKeyFromLabel`, `STOCK_KEY_SEAM_FIXTURES` for conformance-scanning packages that resolve stock keys
|
|
32
|
+
|
|
6
33
|
## [0.26.0] — 2026-08-27
|
|
7
34
|
|
|
8
35
|
### Added
|
package/dist/item-identity.d.mts
CHANGED
|
@@ -31,6 +31,50 @@ interface SkuFields {
|
|
|
31
31
|
declare function stockKeyOf(item: SkuFields, fallback?: string | {
|
|
32
32
|
toString(): string;
|
|
33
33
|
}): StockKey | undefined;
|
|
34
|
+
/** The minimal product shape {@link stockKeyFor} reads. */
|
|
35
|
+
interface StockKeySource {
|
|
36
|
+
_id: string | {
|
|
37
|
+
toString(): string;
|
|
38
|
+
};
|
|
39
|
+
variants?: ReadonlyArray<SkuFields> | null;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* WHAT THE CALLER HAS — never what the key should be.
|
|
43
|
+
*
|
|
44
|
+
* - `sole` nothing selected (a simple product, or a whole-product read)
|
|
45
|
+
* - `label` an operator-typed / scanned SKU label
|
|
46
|
+
* - `variant` one variant record in hand, from iterating `variants`
|
|
47
|
+
*/
|
|
48
|
+
type StockKeySelector = {
|
|
49
|
+
readonly kind: "sole";
|
|
50
|
+
} | {
|
|
51
|
+
readonly kind: "label";
|
|
52
|
+
readonly sku: string;
|
|
53
|
+
} | {
|
|
54
|
+
readonly kind: "variant";
|
|
55
|
+
readonly variant: SkuFields;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* THE product-level stock key. One precedence, for every package.
|
|
59
|
+
*
|
|
60
|
+
* 1. the variant's STAMPED `skuRef` — assigned once at birth, immutable
|
|
61
|
+
* 2. a SOLE variant with no stamp -> the product's `_id`. Never the label:
|
|
62
|
+
* legacy writers derived `variantSku || productId`, and for one variant
|
|
63
|
+
* that key IS the `_id`
|
|
64
|
+
* 3. a SIBLING with no stamp -> its label, which genuinely IS its legacy key
|
|
65
|
+
* 4. nothing addressable -> the product's `_id`
|
|
66
|
+
*
|
|
67
|
+
* The caller declares only what it HAS; the sole-vs-sibling decision is
|
|
68
|
+
* internal, so a caller cannot make it wrong. That choice previously lived at
|
|
69
|
+
* every call site across three packages, in two functions with different
|
|
70
|
+
* fallbacks — they disagreed on a sole, unstamped, labelled variant, and the
|
|
71
|
+
* read then looked up a key no quant is stored under.
|
|
72
|
+
*
|
|
73
|
+
* Lives HERE, below every domain, because `@spinekit/order` and
|
|
74
|
+
* `@spinekit/catalog` must not import a peer domain — the rule being one layer
|
|
75
|
+
* too high is exactly why each re-derived it.
|
|
76
|
+
*/
|
|
77
|
+
declare function stockKeyFor(product: StockKeySource, selector: StockKeySelector): StockKey;
|
|
34
78
|
/**
|
|
35
79
|
* {@link stockKeyOf} that THROWS. Use wherever the key feeds a stock movement:
|
|
36
80
|
* a reservation on `undefined` returns "out of stock" instead of failing.
|
|
@@ -58,4 +102,4 @@ declare function skuDisplay(item: SkuFields): SkuDisplay;
|
|
|
58
102
|
*/
|
|
59
103
|
declare function looksLikeOpaqueId(value: string | undefined): boolean;
|
|
60
104
|
//#endregion
|
|
61
|
-
export { SkuDisplay, SkuFields, StockKey, looksLikeOpaqueId, requireStockKey, skuDisplay, stockKeyOf };
|
|
105
|
+
export { SkuDisplay, SkuFields, StockKey, StockKeySelector, StockKeySource, looksLikeOpaqueId, requireStockKey, skuDisplay, stockKeyFor, stockKeyOf };
|
package/dist/item-identity.mjs
CHANGED
|
@@ -14,6 +14,49 @@ function stockKeyOf(item, fallback) {
|
|
|
14
14
|
return nonEmpty(item.skuRef) ?? nonEmpty(item.sku) ?? (fallback !== void 0 ? String(fallback) : void 0);
|
|
15
15
|
}
|
|
16
16
|
/**
|
|
17
|
+
* THE product-level stock key. One precedence, for every package.
|
|
18
|
+
*
|
|
19
|
+
* 1. the variant's STAMPED `skuRef` — assigned once at birth, immutable
|
|
20
|
+
* 2. a SOLE variant with no stamp -> the product's `_id`. Never the label:
|
|
21
|
+
* legacy writers derived `variantSku || productId`, and for one variant
|
|
22
|
+
* that key IS the `_id`
|
|
23
|
+
* 3. a SIBLING with no stamp -> its label, which genuinely IS its legacy key
|
|
24
|
+
* 4. nothing addressable -> the product's `_id`
|
|
25
|
+
*
|
|
26
|
+
* The caller declares only what it HAS; the sole-vs-sibling decision is
|
|
27
|
+
* internal, so a caller cannot make it wrong. That choice previously lived at
|
|
28
|
+
* every call site across three packages, in two functions with different
|
|
29
|
+
* fallbacks — they disagreed on a sole, unstamped, labelled variant, and the
|
|
30
|
+
* read then looked up a key no quant is stored under.
|
|
31
|
+
*
|
|
32
|
+
* Lives HERE, below every domain, because `@spinekit/order` and
|
|
33
|
+
* `@spinekit/catalog` must not import a peer domain — the rule being one layer
|
|
34
|
+
* too high is exactly why each re-derived it.
|
|
35
|
+
*/
|
|
36
|
+
function stockKeyFor(product, selector) {
|
|
37
|
+
const productId = String(product._id);
|
|
38
|
+
const variants = product.variants ?? [];
|
|
39
|
+
const sole = variants.length <= 1;
|
|
40
|
+
switch (selector.kind) {
|
|
41
|
+
case "sole":
|
|
42
|
+
if (!sole) return productId;
|
|
43
|
+
return nonEmpty(variants[0]?.skuRef) ?? productId;
|
|
44
|
+
case "label": {
|
|
45
|
+
const matched = variants.find((v) => v.sku === selector.sku);
|
|
46
|
+
const stamp = nonEmpty(matched?.skuRef);
|
|
47
|
+
if (stamp !== void 0) return stamp;
|
|
48
|
+
if (sole) return productId;
|
|
49
|
+
return selector.sku;
|
|
50
|
+
}
|
|
51
|
+
case "variant": {
|
|
52
|
+
const stamp = nonEmpty(selector.variant.skuRef);
|
|
53
|
+
if (stamp !== void 0) return stamp;
|
|
54
|
+
if (sole) return productId;
|
|
55
|
+
return stockKeyOf(selector.variant, productId);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
17
60
|
* {@link stockKeyOf} that THROWS. Use wherever the key feeds a stock movement:
|
|
18
61
|
* a reservation on `undefined` returns "out of stock" instead of failing.
|
|
19
62
|
*/
|
|
@@ -47,4 +90,4 @@ function looksLikeOpaqueId(value) {
|
|
|
47
90
|
return typeof value === "string" && /^[0-9a-f]{24}$/i.test(value);
|
|
48
91
|
}
|
|
49
92
|
//#endregion
|
|
50
|
-
export { looksLikeOpaqueId, requireStockKey, skuDisplay, stockKeyOf };
|
|
93
|
+
export { looksLikeOpaqueId, requireStockKey, skuDisplay, stockKeyFor, stockKeyOf };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//#region src/testing/stock-key-seam.d.ts
|
|
2
|
+
interface StockKeyViolation {
|
|
3
|
+
/** Path relative to the scanned root. */
|
|
4
|
+
file: string;
|
|
5
|
+
/** 1-indexed line number. */
|
|
6
|
+
line: number;
|
|
7
|
+
/** The offending source line, trimmed. */
|
|
8
|
+
text: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Does this line take a stock key from a label?
|
|
12
|
+
*
|
|
13
|
+
* The class is "assigns `skuRef` from an expression that reads `.sku` and never
|
|
14
|
+
* reads `.skuRef`". Matching `.sku` alone flags the CORRECT idiom
|
|
15
|
+
* (`line.skuRef ?? line.sku`), and a gate that cries wolf is a gate someone
|
|
16
|
+
* deletes — so the ref-read is what distinguishes a fallback from a label grab.
|
|
17
|
+
*/
|
|
18
|
+
declare function takesStockKeyFromLabel(line: string): boolean;
|
|
19
|
+
/** @deprecated Kept for callers that want the raw predicate; prefer {@link takesStockKeyFromLabel}. */
|
|
20
|
+
declare const STOCK_KEY_FROM_LABEL: {
|
|
21
|
+
test: typeof takesStockKeyFromLabel;
|
|
22
|
+
};
|
|
23
|
+
interface StockKeySeamScan {
|
|
24
|
+
/** How many source files were read — assert a floor, or the gate can pass on an empty tree. */
|
|
25
|
+
scanned: number;
|
|
26
|
+
violations: StockKeyViolation[];
|
|
27
|
+
}
|
|
28
|
+
/** Scan a package's `src` root. Pure: returns findings, throws nothing. */
|
|
29
|
+
declare function scanStockKeySeam(srcRoot: string): StockKeySeamScan;
|
|
30
|
+
/**
|
|
31
|
+
* Throwing form for a one-line test. `minFiles` is the scan-volume floor: a gate
|
|
32
|
+
* that quietly scanned nothing passes forever, so state what "ran" means.
|
|
33
|
+
*/
|
|
34
|
+
declare function assertStockKeySeam(srcRoot: string, opts: {
|
|
35
|
+
minFiles: number;
|
|
36
|
+
}): void;
|
|
37
|
+
/**
|
|
38
|
+
* The fixtures the matcher must and must not fire on — exported so every
|
|
39
|
+
* adopting package falsifies the DETECTOR, not just its own current cleanliness.
|
|
40
|
+
* Both live defects are here verbatim.
|
|
41
|
+
*/
|
|
42
|
+
declare const STOCK_KEY_SEAM_FIXTURES: {
|
|
43
|
+
readonly mustMatch: readonly [" skuRef: (snapshot.sku as string | undefined) ?? '',", " skuRef: snap?.sku ?? '',", " const skuRef = line.snapshot?.sku ?? line.offerId;", " skuRef: (line.snapshot?.sku ?? line.offerId) as string,"];
|
|
44
|
+
readonly mustNotMatch: readonly [" skuRef: stockKeyOf(snapshot) ?? '',", " skuRef: line.skuRef,", " sku: (snapshot.sku as string | undefined) ?? '',", " const label = variant.sku ?? variant.skuRef;", " const skuRef = line.skuRef ?? line.sku;", " skuRef: item.skuRef ?? item.sku,"];
|
|
45
|
+
};
|
|
46
|
+
//#endregion
|
|
47
|
+
export { STOCK_KEY_FROM_LABEL, STOCK_KEY_SEAM_FIXTURES, StockKeySeamScan, StockKeyViolation, assertStockKeySeam, scanStockKeySeam, takesStockKeyFromLabel };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
//#region src/testing/stock-key-seam.ts
|
|
4
|
+
/**
|
|
5
|
+
* Conformance scan: a STOCK KEY is never taken from a merchandising label.
|
|
6
|
+
*
|
|
7
|
+
* `skuRef: snapshot.sku` type-checks (both are `string`), reads plausibly, and
|
|
8
|
+
* ships. Downstream it decrements a quant nothing is stocked under — the row
|
|
9
|
+
* goes NEGATIVE while the real units sit under the stamped ref, and nothing
|
|
10
|
+
* throws because a move carrying a reservation may go negative. The rule and
|
|
11
|
+
* `stockKeyOf` already existed when that shipped; what was missing was a gate.
|
|
12
|
+
*
|
|
13
|
+
* Every package that resolves a stock key runs this over its own `src`, the way
|
|
14
|
+
* `@classytic/arc/testing`'s store contracts are run by their implementors. The
|
|
15
|
+
* matcher lives HERE so a fix to it reaches every consumer at once — a regex
|
|
16
|
+
* copied per package is the same drift one layer up.
|
|
17
|
+
*
|
|
18
|
+
* Node-only (`node:fs`), so it is a `/testing` subpath and never reachable from
|
|
19
|
+
* the isomorphic value-object entries.
|
|
20
|
+
*/
|
|
21
|
+
/** `skuRef` being assigned — property (`skuRef:`) or binding (`const skuRef =`). Both are the same defect. */
|
|
22
|
+
const ASSIGNS_STOCK_KEY = /\bskuRef\s*[:=]\s*([^,;{}\n]*)/;
|
|
23
|
+
/** The right-hand side reads a `.sku` label. `(?!Ref)` so `.skuRef` is not a `.sku` hit. */
|
|
24
|
+
const READS_LABEL = /\.sku\b(?!Ref)/;
|
|
25
|
+
/** The right-hand side already reads the stamped ref, i.e. it IS the `skuRef ?? sku` fallback. */
|
|
26
|
+
const READS_REF = /\.skuRef\b/;
|
|
27
|
+
/**
|
|
28
|
+
* Does this line take a stock key from a label?
|
|
29
|
+
*
|
|
30
|
+
* The class is "assigns `skuRef` from an expression that reads `.sku` and never
|
|
31
|
+
* reads `.skuRef`". Matching `.sku` alone flags the CORRECT idiom
|
|
32
|
+
* (`line.skuRef ?? line.sku`), and a gate that cries wolf is a gate someone
|
|
33
|
+
* deletes — so the ref-read is what distinguishes a fallback from a label grab.
|
|
34
|
+
*/
|
|
35
|
+
function takesStockKeyFromLabel(line) {
|
|
36
|
+
const rhs = ASSIGNS_STOCK_KEY.exec(line)?.[1];
|
|
37
|
+
if (rhs === void 0) return false;
|
|
38
|
+
return READS_LABEL.test(rhs) && !READS_REF.test(rhs);
|
|
39
|
+
}
|
|
40
|
+
/** @deprecated Kept for callers that want the raw predicate; prefer {@link takesStockKeyFromLabel}. */
|
|
41
|
+
const STOCK_KEY_FROM_LABEL = { test: takesStockKeyFromLabel };
|
|
42
|
+
/** Lines that are prose, not code. */
|
|
43
|
+
const isComment = (trimmed) => trimmed.startsWith("*") || trimmed.startsWith("//") || trimmed.startsWith("/*");
|
|
44
|
+
function tsFilesIn(dir, acc = []) {
|
|
45
|
+
for (const entry of readdirSync(dir)) {
|
|
46
|
+
if (entry === "node_modules" || entry === "dist") continue;
|
|
47
|
+
const full = join(dir, entry);
|
|
48
|
+
if (statSync(full).isDirectory()) tsFilesIn(full, acc);
|
|
49
|
+
else if (entry.endsWith(".ts") && !entry.endsWith(".d.ts")) acc.push(full);
|
|
50
|
+
}
|
|
51
|
+
return acc;
|
|
52
|
+
}
|
|
53
|
+
/** Scan a package's `src` root. Pure: returns findings, throws nothing. */
|
|
54
|
+
function scanStockKeySeam(srcRoot) {
|
|
55
|
+
const files = tsFilesIn(srcRoot);
|
|
56
|
+
const violations = [];
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const lines = readFileSync(file, "utf8").split("\n");
|
|
59
|
+
for (let i = 0; i < lines.length; i++) {
|
|
60
|
+
const raw = lines[i] ?? "";
|
|
61
|
+
const trimmed = raw.trim();
|
|
62
|
+
if (isComment(trimmed)) continue;
|
|
63
|
+
if (takesStockKeyFromLabel(raw)) violations.push({
|
|
64
|
+
file: relative(srcRoot, file),
|
|
65
|
+
line: i + 1,
|
|
66
|
+
text: trimmed.slice(0, 120)
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
scanned: files.length,
|
|
72
|
+
violations
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Throwing form for a one-line test. `minFiles` is the scan-volume floor: a gate
|
|
77
|
+
* that quietly scanned nothing passes forever, so state what "ran" means.
|
|
78
|
+
*/
|
|
79
|
+
function assertStockKeySeam(srcRoot, opts) {
|
|
80
|
+
const { scanned, violations } = scanStockKeySeam(srcRoot);
|
|
81
|
+
if (scanned < opts.minFiles) throw new Error(`stock-key seam REFUSING to pass: scanned only ${scanned} file(s) under ${srcRoot}, expected >= ${opts.minFiles}`);
|
|
82
|
+
if (violations.length > 0) {
|
|
83
|
+
const list = violations.map((v) => ` ${v.file}:${v.line} ${v.text}`).join("\n");
|
|
84
|
+
throw new Error(`A stock key must come from stockKeyOf() (skuRef ?? sku), never from the .sku label:\n${list}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The fixtures the matcher must and must not fire on — exported so every
|
|
89
|
+
* adopting package falsifies the DETECTOR, not just its own current cleanliness.
|
|
90
|
+
* Both live defects are here verbatim.
|
|
91
|
+
*/
|
|
92
|
+
const STOCK_KEY_SEAM_FIXTURES = {
|
|
93
|
+
mustMatch: [
|
|
94
|
+
" skuRef: (snapshot.sku as string | undefined) ?? '',",
|
|
95
|
+
" skuRef: snap?.sku ?? '',",
|
|
96
|
+
" const skuRef = line.snapshot?.sku ?? line.offerId;",
|
|
97
|
+
" skuRef: (line.snapshot?.sku ?? line.offerId) as string,"
|
|
98
|
+
],
|
|
99
|
+
mustNotMatch: [
|
|
100
|
+
" skuRef: stockKeyOf(snapshot) ?? '',",
|
|
101
|
+
" skuRef: line.skuRef,",
|
|
102
|
+
" sku: (snapshot.sku as string | undefined) ?? '',",
|
|
103
|
+
" const label = variant.sku ?? variant.skuRef;",
|
|
104
|
+
" const skuRef = line.skuRef ?? line.sku;",
|
|
105
|
+
" skuRef: item.skuRef ?? item.sku,"
|
|
106
|
+
]
|
|
107
|
+
};
|
|
108
|
+
//#endregion
|
|
109
|
+
export { STOCK_KEY_FROM_LABEL, STOCK_KEY_SEAM_FIXTURES, assertStockKeySeam, scanStockKeySeam, takesStockKeyFromLabel };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/primitives",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.2",
|
|
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",
|
|
@@ -225,6 +225,10 @@
|
|
|
225
225
|
"./item-facts": {
|
|
226
226
|
"types": "./dist/item-facts.d.mts",
|
|
227
227
|
"default": "./dist/item-facts.mjs"
|
|
228
|
+
},
|
|
229
|
+
"./testing/stock-key-seam": {
|
|
230
|
+
"types": "./dist/testing/stock-key-seam.d.mts",
|
|
231
|
+
"default": "./dist/testing/stock-key-seam.mjs"
|
|
228
232
|
}
|
|
229
233
|
},
|
|
230
234
|
"files": [
|