@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 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
@@ -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 };
@@ -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.0",
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": [