@happyvertical/smrt-products 0.55.7 → 0.55.8

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.
Files changed (32) hide show
  1. package/dist/lib/chunks/{ProductAssetCollection-BZBV2HDk.js → ProductAssetCollection-pTm67_Vn.js} +3 -3
  2. package/dist/lib/chunks/{ProductAssetCollection-BZBV2HDk.js.map → ProductAssetCollection-pTm67_Vn.js.map} +1 -1
  3. package/dist/lib/chunks/{ProductCollection-BvRm14q5.js → ProductCollection-BJXVIE6A.js} +2 -2
  4. package/dist/lib/chunks/{ProductCollection-BvRm14q5.js.map → ProductCollection-BJXVIE6A.js.map} +1 -1
  5. package/dist/lib/chunks/{Sku-BN7ydk5h.js → Sku-CdhhTtjd.js} +23 -6
  6. package/dist/lib/chunks/Sku-CdhhTtjd.js.map +1 -0
  7. package/dist/lib/chunks/__smrt-register__-gVFEye5S.js +6 -0
  8. package/dist/lib/chunks/__smrt-register__-gVFEye5S.js.map +1 -0
  9. package/dist/lib/chunks/{collections-CqifjBOG.js → collections-jXx-DX7-.js} +5 -5
  10. package/dist/lib/chunks/{collections-CqifjBOG.js.map → collections-jXx-DX7-.js.map} +1 -1
  11. package/dist/lib/chunks/{generated-CUnONwMT.js → generated-DubZj9rG.js} +2 -2
  12. package/dist/lib/chunks/{generated-CUnONwMT.js.map → generated-DubZj9rG.js.map} +1 -1
  13. package/dist/lib/chunks/models-B3mKXkTB.js +2 -0
  14. package/dist/lib/collections.js +3 -3
  15. package/dist/lib/generated.js +1 -1
  16. package/dist/lib/index.js +163 -9
  17. package/dist/lib/index.js.map +1 -1
  18. package/dist/lib/lib/index.d.ts +1 -0
  19. package/dist/lib/lib/index.d.ts.map +1 -1
  20. package/dist/lib/lib/models/Product.d.ts.map +1 -1
  21. package/dist/lib/lib/models/ProductVariant.d.ts.map +1 -1
  22. package/dist/lib/lib/models/Sku.d.ts.map +1 -1
  23. package/dist/lib/lib/recipes.d.ts +211 -0
  24. package/dist/lib/lib/recipes.d.ts.map +1 -0
  25. package/dist/lib/manifest.json +335 -13
  26. package/dist/lib/models.js +2 -2
  27. package/dist/lib/smrt-knowledge.json +310 -16
  28. package/package.json +14 -14
  29. package/dist/lib/chunks/Sku-BN7ydk5h.js.map +0 -1
  30. package/dist/lib/chunks/__smrt-register__-DTpJWzEc.js +0 -6
  31. package/dist/lib/chunks/__smrt-register__-DTpJWzEc.js.map +0 -1
  32. package/dist/lib/chunks/models-A3KLrfkP.js +0 -2
@@ -3,12 +3,12 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-products",
6
- "packageVersion": "0.55.7",
6
+ "packageVersion": "0.55.8",
7
7
  "sourceManifestPath": "dist/lib/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "e7a367f8a8276747d5a45e14b3ee6201d0f780811ea1cd978bc557d18755fc92",
11
- "packageJson": "d7902fcc010fc95586bbbf7c40c672bc9406d278c842077180cc8dd3ac390dbe",
10
+ "manifest": "f2015f14325221f1b17082f25eb0516bed193791a02dd496f8dca254dfaedc06",
11
+ "packageJson": "29652c6cbc31db32bff53c092c6c66a8656ffb386b6627eaeaf76a4579fd394a",
12
12
  "agents": "b5aa86a9c02bb81be5621ae3397ff2dedcef972a520c2ba2f56ee7abf5613b8c"
13
13
  },
14
14
  "exports": [
@@ -1120,13 +1120,15 @@
1120
1120
  "name": "name",
1121
1121
  "type": "text",
1122
1122
  "required": false,
1123
- "columnType": "TEXT"
1123
+ "columnType": "TEXT",
1124
+ "description": "The product name, as customers see it."
1124
1125
  },
1125
1126
  {
1126
1127
  "name": "description",
1127
1128
  "type": "text",
1128
1129
  "required": false,
1129
- "columnType": "TEXT"
1130
+ "columnType": "TEXT",
1131
+ "description": "A few words on what the product is and who it is for."
1130
1132
  },
1131
1133
  {
1132
1134
  "name": "category",
@@ -1151,7 +1153,8 @@
1151
1153
  "type": "integer",
1152
1154
  "required": true,
1153
1155
  "columnType": "INTEGER",
1154
- "default": 0
1156
+ "default": 0,
1157
+ "description": "The price in minor units (cents), for example 1999 for 19.99."
1155
1158
  },
1156
1159
  {
1157
1160
  "name": "inStock",
@@ -1440,13 +1443,15 @@
1440
1443
  "name": "name",
1441
1444
  "type": "text",
1442
1445
  "required": false,
1443
- "columnType": "TEXT"
1446
+ "columnType": "TEXT",
1447
+ "description": "The product name, as customers see it."
1444
1448
  },
1445
1449
  {
1446
1450
  "name": "description",
1447
1451
  "type": "text",
1448
1452
  "required": false,
1449
- "columnType": "TEXT"
1453
+ "columnType": "TEXT",
1454
+ "description": "A few words on what the product is and who it is for."
1450
1455
  },
1451
1456
  {
1452
1457
  "name": "category",
@@ -1471,7 +1476,8 @@
1471
1476
  "type": "integer",
1472
1477
  "required": true,
1473
1478
  "columnType": "INTEGER",
1474
- "default": 0
1479
+ "default": 0,
1480
+ "description": "The price in minor units (cents), for example 1999 for 19.99."
1475
1481
  },
1476
1482
  {
1477
1483
  "name": "inStock",
@@ -1809,14 +1815,16 @@
1809
1815
  "type": "text",
1810
1816
  "required": true,
1811
1817
  "columnType": "TEXT",
1812
- "default": ""
1818
+ "default": "",
1819
+ "description": "The product this choice belongs to."
1813
1820
  },
1814
1821
  {
1815
1822
  "name": "axisName",
1816
1823
  "type": "text",
1817
1824
  "required": true,
1818
1825
  "columnType": "TEXT",
1819
- "default": ""
1826
+ "default": "",
1827
+ "description": "What the product varies by, for example size or color."
1820
1828
  },
1821
1829
  {
1822
1830
  "name": "label",
@@ -1830,7 +1838,8 @@
1830
1838
  "type": "text",
1831
1839
  "required": false,
1832
1840
  "columnType": "TEXT",
1833
- "default": "[]"
1841
+ "default": "[]",
1842
+ "description": "The values on offer, for example S, M and L, stored as a JSON list."
1834
1843
  },
1835
1844
  {
1836
1845
  "name": "sortOrder",
@@ -1998,14 +2007,16 @@
1998
2007
  "type": "text",
1999
2008
  "required": true,
2000
2009
  "columnType": "TEXT",
2001
- "default": ""
2010
+ "default": "",
2011
+ "description": "The product this item is a version of."
2002
2012
  },
2003
2013
  {
2004
2014
  "name": "code",
2005
2015
  "type": "text",
2006
2016
  "required": true,
2007
2017
  "columnType": "TEXT",
2008
- "default": ""
2018
+ "default": "",
2019
+ "description": "The short code that identifies this exact item, such as a UPC or part number."
2009
2020
  },
2010
2021
  {
2011
2022
  "name": "barcode",
@@ -2026,7 +2037,8 @@
2026
2037
  "type": "text",
2027
2038
  "required": false,
2028
2039
  "columnType": "TEXT",
2029
- "default": "{}"
2040
+ "default": "{}",
2041
+ "description": "Which attribute values, such as size and color, this item has, stored as JSON."
2030
2042
  },
2031
2043
  {
2032
2044
  "name": "weightGrams",
@@ -3032,5 +3044,287 @@
3032
3044
  "polymorphicAssociations": 0,
3033
3045
  "uuidColumns": 30
3034
3046
  },
3035
- "agentDoc": "# @happyvertical/smrt-products\n\nProduct catalog — reference template for triple-consumption: npm package library, module federation, and standalone REST API server.\n\n## Models\n\n- **Product**: STI base. Tenant-scoped (`@TenantScoped({ mode: 'optional' })`, nullable `tenantId`). Knowledge base product with specs, tags, and the `productType` discriminator. Consumers subclass this with vertical-specific subtypes (apparel `Style`, automotive `Model`, furniture `Design`, etc.).\n- **Material**: STI subtype. Raw input consumed by manufacturing (fabric, trim, thread, packaging, component). Meta fields: `materialKind`, `uom`, `costPerUnit`. Materials are first-class products in the catalog — the SAP/NetSuite pattern. Bills of materials in `@happyvertical/smrt-manufacturing` reference Materials by id.\n- **Assembly** (`ProductType.ASSEMBLY`, `'assembly'`): the made counterpart to `Material`, defined in `@happyvertical/smrt-manufacturing` as a cross-package STI subtype. Its own fields are columns on `products`; the enum value lives here so catalog code can filter by type without importing manufacturing.\n- **ProductVariant**: standalone (NOT a Product STI subtype). Declarative axis definition: `productId`, `axisName` (e.g. `'size'`, `'color'`, `'finish'`), `allowedValues` (JSON-stored array), optional `label` and `sortOrder`. Per-SKU value pins live on `Sku.attributes`. Lives in its own `product_variants` table; `conflictColumns: ['product_id', 'axis_name', 'tenant_id']`.\n- **Sku**: standalone. The smallest sellable / countable unit. `productId` points at a `Product` or any of its STI subtypes; `code` is the human-meaningful identifier; `attributes` JSON pins each axis value (`{ size: 'M', color: 'navy' }`). Lives in its own `product_skus` table; `conflictColumns: ['code', 'tenant_id']`. Stock balance and movement history for a Sku live in `@happyvertical/smrt-inventory`.\n- **ProductAsset**: dedicated owned-asset join in `product_assets` with `relationship` and `sortOrder`. Tenant-scoped to match Product.\n- **Category**: hierarchical (parentId, level, productCount). STI base, tenant-scoped.\n\n## Vertical-specific Product subtypes\n\nThis package deliberately ships ONLY the generic primitives. Domain-specific top-level item types live in the consumer's template:\n\n- Apparel: `Style extends Product`, `Makeup extends Product`\n- Furniture: `Design extends Product`, `Configuration extends Product`\n- Automotive: `Model extends Product`, `Trim extends Product`\n- CPG: `Brand extends Product`, `Recipe extends Product`\n\nEach is a small subclass: `@smrt()`, override `productType`, add `@meta()` fields. See `packages/template-apparel-erp` for a worked example.\n\n## Variants — the two concepts\n\nThe framework's variant story uses two distinct primitives that look similar but do different jobs:\n\n- **`ProductVariant`** — *axis declaration*. \"For product X, axis `size` accepts the values `[XS, S, M, L, XL]`.\" Drives form/UI choices.\n- **`Sku.attributes`** — *per-unit value pins*. `{ color: 'navy', size: 'M' }` on each concrete sellable SKU.\n\nBoth live in this package — all catalog shapes are here. Stock motion (where the SKU is, how many, history) lives in `@happyvertical/smrt-inventory`.\n\nThere is deliberately no separate \"catalog grouping above SKU\" row (a la a \"Navy colorway\" row sitting between the Product and its Skus). Group SKUs by axis value via `attributes.color = 'navy'` queries; attach per-axis-value assets via `ProductAsset` rows with relationship metadata. Matches how Shopify, Stripe Products, and most e-commerce platforms model the same shape.\n\n## Collections\n\n- **ProductCollection** — base. Polymorphic queries return the correct subclass instance per row.\n- **MaterialCollection** — STI-filtered subclass. Override `_itemClass`; framework auto-filters by `_meta_type`.\n- **ProductVariantCollection** — standalone collection over `product_variants`. Helpers: `findForProduct(productId)`, `findAxis(productId, axisName)`.\n- **SkuCollection** — standalone collection over `product_skus`. Helpers: `findByCode`, `findByBarcode`, `findByProduct`, `findByParent`, `findActive`.\n- **CategoryCollection** — standard Category CRUD with `getRootCategories()`.\n- **ProductAssetCollection** — direct access to `product_assets` rows and product asset helper wrappers.\n\n## Multi-tenancy\n\nOptional. With `withTenant(id, fn)` (from `@happyvertical/smrt-tenancy`), all queries auto-filter and inserts auto-stamp `tenantId`. Without it, models behave globally (`tenantId = null`). This lets the same package serve both shared reference catalogs and per-merchant SaaS catalogs.\n\n## Triple-Consumption Pattern\n\nSame codebase consumed three ways:\n1. **NPM library**: import classes directly\n2. **Module federation**: runtime component sharing (experimental)\n3. **Standalone API**: `startRestServer([Product, Category])`\n\n## Virtual Modules (Vite)\n\nAuto-generated via Vite plugins: `@happyvertical/smrt-client` (TypeScript client), `@happyvertical/smrt-types`, `@happyvertical/smrt-routes` (Express), `@happyvertical/smrt-mcp`, `@happyvertical/smrt-manifest`.\n\nSvelte 5 stores use runes (`$state`, `$derived`, `$effect`). `product-store.svelte.ts` is the main store (backed by the SMRT client); `product-store.client.svelte.ts` is a virtual-module-free variant for federation builds.\n\n## Client-data runtime — reference store (#1761)\n\nProducts is the reference consumer of the `@happyvertical/smrt-web` browser client-data runtime.\n\n- **Store** — `src/lib/stores/product-collection.ts` (subpath `@happyvertical/smrt-products/stores/product-collection`). `createProductCollection(definition, options)` builds a cached, reactive `SmrtWebCollection<ProductData>` over the generated `/api/v1/products` REST surface via `createSmrtCollection`. It is plugin-agnostic on purpose: the `products` definition is a PARAMETER (not a top-level import), so importing the store never eagerly resolves `@happyvertical/smrt-virt-web` (which exists only under the SMRT Vite plugin) — a unit test / npm consumer supplies its own definition. `productsCollectionDefinition()` lazily resolves the plugin definition (dynamic `import()`; resolved for `tsc` via the `@smrt/web` ambient alias). Deliberately NOT re-exported from the always-loaded `./stores` barrel — importing it is an explicit opt-in to the engine.\n- **Live surface** — `src/lib/components/LiveProductList.svelte` binds the store with `liveCollection` from `@happyvertical/smrt-svelte/web` (live rows + optimistic insert); it takes the definition as a prop. `src/app/pages/LiveProductsPage.svelte` (route `#live`) resolves the component AND the definition via `import()` and passes the definition down.\n- **Code-split (ratified condition ①)** — the ~76 kB TanStack engine must NEVER load on public / smrt-sites pages. `LiveProductsPage` reaches `LiveProductList` and the store ONLY through dynamic `import()`, so the engine lands in a lazily-loaded chunk; the app entry stays engine-free. NEVER import the store or live component statically from an app-root / public entry. `scripts/check-web-engine-code-split.mjs` (wrapped by `src/web-engine-code-split.spec.ts`, `pnpm test:code-split`) proves this against the REAL app graph: it runs the `--mode standalone` vite build and asserts, over the emitted rollup chunk graph, that the entry's static-import closure has zero `@tanstack/*` and the engine is reached only via `dynamicImports`. If the build hits the pre-existing nested-worktree `Tsconfig not found` failure locally, it falls back to a static-import-graph walk of the real `src/app/main.ts` (CI runs the real build).\n\n## Schema migrations (Phase 1 release)\n\nThis package's schema changed shape between the previous release and the Phase 1 apparel-ERP release. Consumers upgrading need to migrate two tables.\n\n### `Sku` rows moved tables\n\nPreviously `Sku` shipped in `@happyvertical/smrt-inventory` under table `inventory_skus`. It now lives here under `product_skus`. Cross-package refs (`StockLevel.skuId`, `BomLine.componentSkuId`) carry plain string ids that still resolve.\n\n**Upgrade procedure** (works on SQLite + Postgres; does NOT rely on `CREATE TABLE AS` which strips constraints):\n\n1. **Boot the new version once** so the framework's lazy `syncSchema` creates `product_skus` with the right PRIMARY KEY, NOT NULL, UNIQUE (`code`, `tenant_id`), and indexes derived from the `Sku` model.\n\n2. **Idempotently copy rows**:\n\n ```sql\n BEGIN;\n INSERT INTO product_skus (\n id, slug, context, created_at, updated_at,\n tenant_id, product_id, code, barcode, name,\n attributes, weight_grams, parent_sku_id, active\n )\n SELECT\n id, slug, context, created_at, updated_at,\n tenant_id, product_id, code, barcode, name,\n attributes, weight_grams, parent_sku_id, active\n FROM inventory_skus\n WHERE NOT EXISTS (\n SELECT 1 FROM product_skus p WHERE p.id = inventory_skus.id\n );\n COMMIT;\n ```\n\n3. **Drop the legacy table** once row counts match:\n\n ```sql\n DROP TABLE IF EXISTS inventory_skus;\n ```\n\n### `ProductVariant` changed shape entirely\n\nPreviously a Product STI subtype carrying `parentProductId` + `axisValues` JSON inside `_meta_data` on the shared `products` table (`_meta_type='@happyvertical/smrt-products:ProductVariant'`). It is now a **standalone model** on its own `product_variants` table, with columns `productId`, `axisName`, `allowedValues`, `label`, `sortOrder`.\n\n**There is no automatic data conversion.** The old \"catalog grouping above SKU\" concept doesn't map 1:1 to the new \"per-axis declaration\" concept. Recommended procedure:\n\n1. Inspect the old rows: `SELECT * FROM products WHERE _meta_type = '@happyvertical/smrt-products:ProductVariant';`. Treat them as historical reference.\n2. Re-author axis declarations against the new shape (one `ProductVariant` row per `(productId, axisName)` pair, with `allowedValues` listing the values).\n3. Once the new declarations are populated and verified, remove the legacy rows: `DELETE FROM products WHERE _meta_type = '@happyvertical/smrt-products:ProductVariant';`.\n\nIf you had per-colorway / per-variant images attached via `ProductAsset` rows pointing at old ProductVariant ids, repoint those to the parent Product id; group-by-axis-value queries on `Sku.attributes` cover the same use case at the SKU level.\n\n## Gotchas\n\n- **`ProductVariant` and `Sku` are NOT Product STI subtypes** — they each have their own table (`product_variants`, `product_skus`) because their shapes don't fit the Product schema. Don't try to query them via `ProductCollection`.\n- **`npm run build` emits the published library surface directly**: package consumers read model, collection, and helper exports from `dist/lib`. Cross-package imports from this package should target `/models` or `/collections` subpaths (not the main entry) when the consumer isn't a vite app — the main entry pulls in vite virtual modules.\n- **Use `npm run build:all` only when you need standalone or federation bundles** in addition to the library output\n- **Constructor must explicitly assign all properties**: `Object.assign` doesn't work reliably with decorators\n- **STI subtype-specific fields use `Meta<T>`** — declare them as `fieldName: Meta<FieldType> = defaultValue`. The AST scanner detects the `Meta<T>` type wrapper at build time and routes the field through `_meta_data` JSON storage instead of materializing it as a column on the parent's table. Do **NOT** use the runtime `@meta()` decorator on STI children — it never reaches the manifest, so the schema generator treats the field as an ordinary column on the parent table and the framework's hydration path can't tell it's meta. Override `productType` on each subclass.\n- **Tenant-scoped STI children must repeat `@TenantScoped`** — `@TenantScoped` registers per concrete className, so `Material extends Product` inheriting from a tenant-scoped `Product` is NOT automatically tenant-scoped itself. `MaterialCollection.list/save` passes `'Material'` to the tenant interceptor, which looks up `'Material'` (not `'Product'`) in the per-class registry. Without an explicit `@TenantScoped({ mode: 'optional' })` on `Material`, material rows skip the tenant auto-filter and auto-populate.\n- **STI children must repeat `@smrt({ api, mcp, cli })` generation config (S5 #1406)** — like `@TenantScoped`, the generation config is registered per concrete className and is NOT inherited from the STI parent. An empty `@smrt()` on a child resolves `getConfig(child).api/.mcp` to `undefined`, which the REST and MCP generators treat as \"expose EVERYTHING\" — including `delete` and write-capable MCP tools — even when the parent deliberately restricts its surface. The package's `mcp.ts` generator enumerates the whole registry, so an under-configured child ships a wide-open surface silently. Re-declare the parent's `api`/`mcp`/`cli` posture on every subtype (see `Material`). Consumer subtypes (apparel `Style`/`Makeup`, automotive `Model`/`Trim`, …) must do the same.\n- **Two tenants can share a slug on `Product`/`Category` (smrt#2360)** — a tenant-scoped STI base keys on `(tenant_id, slug, context, _meta_type)` by default, on the schema index and the runtime upsert alike, so no per-tenant slug namespacing and no `conflictColumns` override is needed. Global rows (`tenantId = null`) still dedup among themselves through the SDK's null-aware upsert. Existing databases pick the new key up through `smrt db:migrate` (same-name index swap) — deploy the code and the migration together, since neither version's create binds against the other's index.\n- **`price` is integer minor units** (cents, satoshis) — `$19.99` is `1999`. Money is exact, so `price` initializes `= 0`; writing `19.99` is the bug, and PostgreSQL rejects it with `22P02` while SQLite's affinity stores it and hides it from this package's SQLite suites. `pnpm --filter @happyvertical/smrt-products test:postgres` is the lane that holds the line (#2361). Because `Product` is the STI base, the column is shared by every consumer subtype. `Material.costPerUnit` is a `Meta<number>` in `_meta_data` rather than a column, so it is outside this rule.\n- **Module Federation marked experimental**: may change\n"
3047
+ "agentDoc": "# @happyvertical/smrt-products\n\nProduct catalog — reference template for triple-consumption: npm package library, module federation, and standalone REST API server.\n\n## Models\n\n- **Product**: STI base. Tenant-scoped (`@TenantScoped({ mode: 'optional' })`, nullable `tenantId`). Knowledge base product with specs, tags, and the `productType` discriminator. Consumers subclass this with vertical-specific subtypes (apparel `Style`, automotive `Model`, furniture `Design`, etc.).\n- **Material**: STI subtype. Raw input consumed by manufacturing (fabric, trim, thread, packaging, component). Meta fields: `materialKind`, `uom`, `costPerUnit`. Materials are first-class products in the catalog — the SAP/NetSuite pattern. Bills of materials in `@happyvertical/smrt-manufacturing` reference Materials by id.\n- **Assembly** (`ProductType.ASSEMBLY`, `'assembly'`): the made counterpart to `Material`, defined in `@happyvertical/smrt-manufacturing` as a cross-package STI subtype. Its own fields are columns on `products`; the enum value lives here so catalog code can filter by type without importing manufacturing.\n- **ProductVariant**: standalone (NOT a Product STI subtype). Declarative axis definition: `productId`, `axisName` (e.g. `'size'`, `'color'`, `'finish'`), `allowedValues` (JSON-stored array), optional `label` and `sortOrder`. Per-SKU value pins live on `Sku.attributes`. Lives in its own `product_variants` table; `conflictColumns: ['product_id', 'axis_name', 'tenant_id']`.\n- **Sku**: standalone. The smallest sellable / countable unit. `productId` points at a `Product` or any of its STI subtypes; `code` is the human-meaningful identifier; `attributes` JSON pins each axis value (`{ size: 'M', color: 'navy' }`). Lives in its own `product_skus` table; `conflictColumns: ['code', 'tenant_id']`. Stock balance and movement history for a Sku live in `@happyvertical/smrt-inventory`.\n- **ProductAsset**: dedicated owned-asset join in `product_assets` with `relationship` and `sortOrder`. Tenant-scoped to match Product.\n- **Category**: hierarchical (parentId, level, productCount). STI base, tenant-scoped.\n\n## Vertical-specific Product subtypes\n\nThis package deliberately ships ONLY the generic primitives. Domain-specific top-level item types live in the consumer's template:\n\n- Apparel: `Style extends Product`, `Makeup extends Product`\n- Furniture: `Design extends Product`, `Configuration extends Product`\n- Automotive: `Model extends Product`, `Trim extends Product`\n- CPG: `Brand extends Product`, `Recipe extends Product`\n\nEach is a small subclass: `@smrt()`, override `productType`, add `@meta()` fields. See `packages/template-apparel-erp` for a worked example.\n\n## Variants — the two concepts\n\nThe framework's variant story uses two distinct primitives that look similar but do different jobs:\n\n- **`ProductVariant`** — *axis declaration*. \"For product X, axis `size` accepts the values `[XS, S, M, L, XL]`.\" Drives form/UI choices.\n- **`Sku.attributes`** — *per-unit value pins*. `{ color: 'navy', size: 'M' }` on each concrete sellable SKU.\n\nBoth live in this package — all catalog shapes are here. Stock motion (where the SKU is, how many, history) lives in `@happyvertical/smrt-inventory`.\n\nThere is deliberately no separate \"catalog grouping above SKU\" row (a la a \"Navy colorway\" row sitting between the Product and its Skus). Group SKUs by axis value via `attributes.color = 'navy'` queries; attach per-axis-value assets via `ProductAsset` rows with relationship metadata. Matches how Shopify, Stripe Products, and most e-commerce platforms model the same shape.\n\n## Collections\n\n- **ProductCollection** — base. Polymorphic queries return the correct subclass instance per row.\n- **MaterialCollection** — STI-filtered subclass. Override `_itemClass`; framework auto-filters by `_meta_type`.\n- **ProductVariantCollection** — standalone collection over `product_variants`. Helpers: `findForProduct(productId)`, `findAxis(productId, axisName)`.\n- **SkuCollection** — standalone collection over `product_skus`. Helpers: `findByCode`, `findByBarcode`, `findByProduct`, `findByParent`, `findActive`.\n- **CategoryCollection** — standard Category CRUD with `getRootCategories()`.\n- **ProductAssetCollection** — direct access to `product_assets` rows and product asset helper wrappers.\n\n## Multi-tenancy\n\nOptional. With `withTenant(id, fn)` (from `@happyvertical/smrt-tenancy`), all queries auto-filter and inserts auto-stamp `tenantId`. Without it, models behave globally (`tenantId = null`). This lets the same package serve both shared reference catalogs and per-merchant SaaS catalogs.\n\n## Triple-Consumption Pattern\n\nSame codebase consumed three ways:\n1. **NPM library**: import classes directly\n2. **Module federation**: runtime component sharing (experimental)\n3. **Standalone API**: `startRestServer([Product, Category])`\n\n## Virtual Modules (Vite)\n\nAuto-generated via Vite plugins: `@happyvertical/smrt-client` (TypeScript client), `@happyvertical/smrt-types`, `@happyvertical/smrt-routes` (Express), `@happyvertical/smrt-mcp`, `@happyvertical/smrt-manifest`.\n\nSvelte 5 stores use runes (`$state`, `$derived`, `$effect`). `product-store.svelte.ts` is the main store (backed by the SMRT client); `product-store.client.svelte.ts` is a virtual-module-free variant for federation builds.\n\n## Client-data runtime — reference store (#1761)\n\nProducts is the reference consumer of the `@happyvertical/smrt-web` browser client-data runtime.\n\n- **Store** — `src/lib/stores/product-collection.ts` (subpath `@happyvertical/smrt-products/stores/product-collection`). `createProductCollection(definition, options)` builds a cached, reactive `SmrtWebCollection<ProductData>` over the generated `/api/v1/products` REST surface via `createSmrtCollection`. It is plugin-agnostic on purpose: the `products` definition is a PARAMETER (not a top-level import), so importing the store never eagerly resolves `@happyvertical/smrt-virt-web` (which exists only under the SMRT Vite plugin) — a unit test / npm consumer supplies its own definition. `productsCollectionDefinition()` lazily resolves the plugin definition (dynamic `import()`; resolved for `tsc` via the `@smrt/web` ambient alias). Deliberately NOT re-exported from the always-loaded `./stores` barrel — importing it is an explicit opt-in to the engine.\n- **Live surface** — `src/lib/components/LiveProductList.svelte` binds the store with `liveCollection` from `@happyvertical/smrt-svelte/web` (live rows + optimistic insert); it takes the definition as a prop. `src/app/pages/LiveProductsPage.svelte` (route `#live`) resolves the component AND the definition via `import()` and passes the definition down.\n- **Code-split (ratified condition ①)** — the ~76 kB TanStack engine must NEVER load on public / smrt-sites pages. `LiveProductsPage` reaches `LiveProductList` and the store ONLY through dynamic `import()`, so the engine lands in a lazily-loaded chunk; the app entry stays engine-free. NEVER import the store or live component statically from an app-root / public entry. `scripts/check-web-engine-code-split.mjs` (wrapped by `src/web-engine-code-split.spec.ts`, `pnpm test:code-split`) proves this against the REAL app graph: it runs the `--mode standalone` vite build and asserts, over the emitted rollup chunk graph, that the entry's static-import closure has zero `@tanstack/*` and the engine is reached only via `dynamicImports`. If the build hits the pre-existing nested-worktree `Tsconfig not found` failure locally, it falls back to a static-import-graph walk of the real `src/app/main.ts` (CI runs the real build).\n\n## Schema migrations (Phase 1 release)\n\nThis package's schema changed shape between the previous release and the Phase 1 apparel-ERP release. Consumers upgrading need to migrate two tables.\n\n### `Sku` rows moved tables\n\nPreviously `Sku` shipped in `@happyvertical/smrt-inventory` under table `inventory_skus`. It now lives here under `product_skus`. Cross-package refs (`StockLevel.skuId`, `BomLine.componentSkuId`) carry plain string ids that still resolve.\n\n**Upgrade procedure** (works on SQLite + Postgres; does NOT rely on `CREATE TABLE AS` which strips constraints):\n\n1. **Boot the new version once** so the framework's lazy `syncSchema` creates `product_skus` with the right PRIMARY KEY, NOT NULL, UNIQUE (`code`, `tenant_id`), and indexes derived from the `Sku` model.\n\n2. **Idempotently copy rows**:\n\n ```sql\n BEGIN;\n INSERT INTO product_skus (\n id, slug, context, created_at, updated_at,\n tenant_id, product_id, code, barcode, name,\n attributes, weight_grams, parent_sku_id, active\n )\n SELECT\n id, slug, context, created_at, updated_at,\n tenant_id, product_id, code, barcode, name,\n attributes, weight_grams, parent_sku_id, active\n FROM inventory_skus\n WHERE NOT EXISTS (\n SELECT 1 FROM product_skus p WHERE p.id = inventory_skus.id\n );\n COMMIT;\n ```\n\n3. **Drop the legacy table** once row counts match:\n\n ```sql\n DROP TABLE IF EXISTS inventory_skus;\n ```\n\n### `ProductVariant` changed shape entirely\n\nPreviously a Product STI subtype carrying `parentProductId` + `axisValues` JSON inside `_meta_data` on the shared `products` table (`_meta_type='@happyvertical/smrt-products:ProductVariant'`). It is now a **standalone model** on its own `product_variants` table, with columns `productId`, `axisName`, `allowedValues`, `label`, `sortOrder`.\n\n**There is no automatic data conversion.** The old \"catalog grouping above SKU\" concept doesn't map 1:1 to the new \"per-axis declaration\" concept. Recommended procedure:\n\n1. Inspect the old rows: `SELECT * FROM products WHERE _meta_type = '@happyvertical/smrt-products:ProductVariant';`. Treat them as historical reference.\n2. Re-author axis declarations against the new shape (one `ProductVariant` row per `(productId, axisName)` pair, with `allowedValues` listing the values).\n3. Once the new declarations are populated and verified, remove the legacy rows: `DELETE FROM products WHERE _meta_type = '@happyvertical/smrt-products:ProductVariant';`.\n\nIf you had per-colorway / per-variant images attached via `ProductAsset` rows pointing at old ProductVariant ids, repoint those to the parent Product id; group-by-axis-value queries on `Sku.attributes` cover the same use case at the SKU level.\n\n## Gotchas\n\n- **`ProductVariant` and `Sku` are NOT Product STI subtypes** — they each have their own table (`product_variants`, `product_skus`) because their shapes don't fit the Product schema. Don't try to query them via `ProductCollection`.\n- **`npm run build` emits the published library surface directly**: package consumers read model, collection, and helper exports from `dist/lib`. Cross-package imports from this package should target `/models` or `/collections` subpaths (not the main entry) when the consumer isn't a vite app — the main entry pulls in vite virtual modules.\n- **Use `npm run build:all` only when you need standalone or federation bundles** in addition to the library output\n- **Constructor must explicitly assign all properties**: `Object.assign` doesn't work reliably with decorators\n- **STI subtype-specific fields use `Meta<T>`** — declare them as `fieldName: Meta<FieldType> = defaultValue`. The AST scanner detects the `Meta<T>` type wrapper at build time and routes the field through `_meta_data` JSON storage instead of materializing it as a column on the parent's table. Do **NOT** use the runtime `@meta()` decorator on STI children — it never reaches the manifest, so the schema generator treats the field as an ordinary column on the parent table and the framework's hydration path can't tell it's meta. Override `productType` on each subclass.\n- **Tenant-scoped STI children must repeat `@TenantScoped`** — `@TenantScoped` registers per concrete className, so `Material extends Product` inheriting from a tenant-scoped `Product` is NOT automatically tenant-scoped itself. `MaterialCollection.list/save` passes `'Material'` to the tenant interceptor, which looks up `'Material'` (not `'Product'`) in the per-class registry. Without an explicit `@TenantScoped({ mode: 'optional' })` on `Material`, material rows skip the tenant auto-filter and auto-populate.\n- **STI children must repeat `@smrt({ api, mcp, cli })` generation config (S5 #1406)** — like `@TenantScoped`, the generation config is registered per concrete className and is NOT inherited from the STI parent. An empty `@smrt()` on a child resolves `getConfig(child).api/.mcp` to `undefined`, which the REST and MCP generators treat as \"expose EVERYTHING\" — including `delete` and write-capable MCP tools — even when the parent deliberately restricts its surface. The package's `mcp.ts` generator enumerates the whole registry, so an under-configured child ships a wide-open surface silently. Re-declare the parent's `api`/`mcp`/`cli` posture on every subtype (see `Material`). Consumer subtypes (apparel `Style`/`Makeup`, automotive `Model`/`Trim`, …) must do the same.\n- **Two tenants can share a slug on `Product`/`Category` (smrt#2360)** — a tenant-scoped STI base keys on `(tenant_id, slug, context, _meta_type)` by default, on the schema index and the runtime upsert alike, so no per-tenant slug namespacing and no `conflictColumns` override is needed. Global rows (`tenantId = null`) still dedup among themselves through the SDK's null-aware upsert. Existing databases pick the new key up through `smrt db:migrate` (same-name index swap) — deploy the code and the migration together, since neither version's create binds against the other's index.\n- **`price` is integer minor units** (cents, satoshis) — `$19.99` is `1999`. Money is exact, so `price` initializes `= 0`; writing `19.99` is the bug, and PostgreSQL rejects it with `22P02` while SQLite's affinity stores it and hides it from this package's SQLite suites. `pnpm --filter @happyvertical/smrt-products test:postgres` is the lane that holds the line (#2361). Because `Product` is the STI base, the column is shared by every consumer subtype. `Material.costPerUnit` is a `Meta<number>` in `_meta_data` rather than a column, so it is outside this rule.\n- **Module Federation marked experimental**: may change\n",
3048
+ "recipes": [
3049
+ {
3050
+ "id": "products.clothing",
3051
+ "className": "ClothingProductsRecipe",
3052
+ "label": "Clothing",
3053
+ "summary": "Products that come in sizes and colors, with a stock-keeping unit for each combination.",
3054
+ "synonyms": [
3055
+ "apparel",
3056
+ "garments",
3057
+ "sizes",
3058
+ "variants"
3059
+ ],
3060
+ "models": [
3061
+ "@happyvertical/smrt-products:Product",
3062
+ "@happyvertical/smrt-products:ProductVariant",
3063
+ "@happyvertical/smrt-products:Sku"
3064
+ ],
3065
+ "nav": [
3066
+ {
3067
+ "label": "Products",
3068
+ "model": "@happyvertical/smrt-products:Product",
3069
+ "icon": "package",
3070
+ "description": "What you sell, with prices and details."
3071
+ }
3072
+ ],
3073
+ "requires": [],
3074
+ "group": {
3075
+ "id": "products",
3076
+ "label": "Products",
3077
+ "summary": "Sell goods: simple items, or clothing that comes in sizes and colors."
3078
+ },
3079
+ "section": {
3080
+ "id": "catalog",
3081
+ "label": "Catalog",
3082
+ "icon": "package",
3083
+ "description": "Products, ingredients and the stock you hold."
3084
+ },
3085
+ "options": {
3086
+ "@happyvertical/smrt-products:Product": {
3087
+ "fields": {
3088
+ "productType": {
3089
+ "visibility": "hidden"
3090
+ },
3091
+ "category": {
3092
+ "visibility": "hidden"
3093
+ },
3094
+ "manufacturer": {
3095
+ "visibility": "hidden"
3096
+ },
3097
+ "model": {
3098
+ "visibility": "hidden"
3099
+ },
3100
+ "inStock": {
3101
+ "visibility": "hidden"
3102
+ },
3103
+ "specifications": {
3104
+ "visibility": "hidden"
3105
+ },
3106
+ "tags": {
3107
+ "visibility": "hidden"
3108
+ },
3109
+ "description": {
3110
+ "visibility": "hidden"
3111
+ }
3112
+ }
3113
+ },
3114
+ "@happyvertical/smrt-products:ProductVariant": {
3115
+ "fields": {
3116
+ "label": {
3117
+ "visibility": "hidden"
3118
+ },
3119
+ "sortOrder": {
3120
+ "visibility": "hidden"
3121
+ }
3122
+ }
3123
+ },
3124
+ "@happyvertical/smrt-products:Sku": {
3125
+ "fields": {
3126
+ "barcode": {
3127
+ "visibility": "hidden"
3128
+ },
3129
+ "name": {
3130
+ "visibility": "hidden"
3131
+ },
3132
+ "weightGrams": {
3133
+ "visibility": "hidden"
3134
+ },
3135
+ "parentSkuId": {
3136
+ "visibility": "hidden"
3137
+ },
3138
+ "active": {
3139
+ "visibility": "hidden"
3140
+ }
3141
+ }
3142
+ }
3143
+ },
3144
+ "help": {
3145
+ "markdown": "## Overview\n\nClothing is for products that come in sizes and colors. You describe the product\nonce, say which sizes and colors you offer, and each combination becomes its own\nitem you can sell and count separately: a shirt in three sizes and two colors is\nsix items.\n\n## Tasks\n\n### Add a clothing item\n\n1. Create a product with a **{field:name}** and a **{field:price}**.\n2. Add a variant axis for sizes and list the values you offer, for example XS to\n XL. Leave out the ones you do not make.\n3. Add a variant axis for colors the same way.\n4. Create an item (SKU) for each size and color combination, with its own code\n and its size and color attributes.\n\n### Add a size or a color later\n\n1. Add the new value to the size or color axis of the product.\n2. Create items for the new combinations. Items you already had keep their\n codes.\n\n### Stop selling a size\n\n1. Remove the size from the product's size axis.\n2. Remove the items for that size.\n",
3146
+ "fieldRefs": [
3147
+ "name",
3148
+ "price"
3149
+ ]
3150
+ },
3151
+ "demo": {
3152
+ "mode": "server",
3153
+ "reasons": [
3154
+ "Needs a server because its package does not build for a browser: server bootstrap (startRestServer, express/cors), core generators/mcp; and its package depends on @happyvertical/smrt-tenancy, which do not build for a browser (#3616, #3624, #3626)."
3155
+ ]
3156
+ }
3157
+ },
3158
+ {
3159
+ "id": "products.ingredients",
3160
+ "className": "IngredientsRecipe",
3161
+ "label": "Ingredients",
3162
+ "summary": "Things you buy in to make what you sell, kept as products of the material kind with their own list.",
3163
+ "synonyms": [
3164
+ "materials",
3165
+ "raw materials",
3166
+ "supplies",
3167
+ "stock items"
3168
+ ],
3169
+ "models": [
3170
+ "@happyvertical/smrt-products:Product"
3171
+ ],
3172
+ "nav": [
3173
+ {
3174
+ "label": "Ingredients",
3175
+ "model": "@happyvertical/smrt-products:Product",
3176
+ "icon": "package",
3177
+ "description": "What you buy to make what you sell, with what each costs.",
3178
+ "key": "ingredients",
3179
+ "noun": "ingredient",
3180
+ "filter": {
3181
+ "field": "productType",
3182
+ "value": "material"
3183
+ }
3184
+ }
3185
+ ],
3186
+ "requires": [
3187
+ "products.simple"
3188
+ ],
3189
+ "group": {
3190
+ "id": "products",
3191
+ "label": "Products",
3192
+ "summary": "Sell goods: simple items, or clothing that comes in sizes and colors."
3193
+ },
3194
+ "section": {
3195
+ "id": "catalog",
3196
+ "label": "Catalog",
3197
+ "icon": "package",
3198
+ "description": "Products, ingredients and the stock you hold."
3199
+ },
3200
+ "options": {
3201
+ "@happyvertical/smrt-products:Product": {
3202
+ "fields": {
3203
+ "productType": {
3204
+ "visibility": "hidden"
3205
+ },
3206
+ "category": {
3207
+ "visibility": "hidden"
3208
+ },
3209
+ "manufacturer": {
3210
+ "visibility": "hidden"
3211
+ },
3212
+ "model": {
3213
+ "visibility": "hidden"
3214
+ },
3215
+ "inStock": {
3216
+ "visibility": "hidden"
3217
+ },
3218
+ "specifications": {
3219
+ "visibility": "hidden"
3220
+ },
3221
+ "tags": {
3222
+ "visibility": "hidden"
3223
+ }
3224
+ }
3225
+ }
3226
+ },
3227
+ "help": {
3228
+ "markdown": "## Overview\n\nIngredients are the things you buy in to make what you sell: flour, butter,\nsteel, fabric. They are products of the material kind, so they have the same\nname, price and description as anything you sell, and they are listed apart from\nthe products you sell. Count them with inventory like any other item.\n\n## Tasks\n\n### Add an ingredient\n\n1. Create a new ingredient.\n2. Give it a **{field:name}** that includes the pack size, for example \"Bread flour, 25 kg\".\n3. Enter what you pay in the **{field:price}**, in minor units (cents).\n4. Add a **{field:description}** such as the brand or grade.\n5. Save. It appears in the ingredients list, not in the products you sell.\n\n### Update what you pay\n\n1. Find the ingredient.\n2. Change the **{field:price}** and save.\n",
3229
+ "fieldRefs": [
3230
+ "description",
3231
+ "name",
3232
+ "price"
3233
+ ]
3234
+ },
3235
+ "demo": {
3236
+ "mode": "server",
3237
+ "reasons": [
3238
+ "Needs a server because its package does not build for a browser: server bootstrap (startRestServer, express/cors), core generators/mcp; and its package depends on @happyvertical/smrt-tenancy, which do not build for a browser (#3616, #3624, #3626)."
3239
+ ]
3240
+ }
3241
+ },
3242
+ {
3243
+ "id": "products.simple",
3244
+ "className": "SimpleProductsRecipe",
3245
+ "label": "Simple products",
3246
+ "summary": "Items with a name, a price and a description.",
3247
+ "synonyms": [
3248
+ "items",
3249
+ "goods",
3250
+ "catalog"
3251
+ ],
3252
+ "models": [
3253
+ "@happyvertical/smrt-products:Product"
3254
+ ],
3255
+ "nav": [
3256
+ {
3257
+ "label": "Products",
3258
+ "model": "@happyvertical/smrt-products:Product",
3259
+ "icon": "package",
3260
+ "description": "What you sell, with prices and details."
3261
+ }
3262
+ ],
3263
+ "requires": [],
3264
+ "group": {
3265
+ "id": "products",
3266
+ "label": "Products",
3267
+ "summary": "Sell goods: simple items, or clothing that comes in sizes and colors."
3268
+ },
3269
+ "section": {
3270
+ "id": "catalog",
3271
+ "label": "Catalog",
3272
+ "icon": "package",
3273
+ "description": "Products, ingredients and the stock you hold."
3274
+ },
3275
+ "options": {
3276
+ "@happyvertical/smrt-products:Product": {
3277
+ "fields": {
3278
+ "productType": {
3279
+ "visibility": "hidden"
3280
+ },
3281
+ "category": {
3282
+ "visibility": "hidden"
3283
+ },
3284
+ "manufacturer": {
3285
+ "visibility": "hidden"
3286
+ },
3287
+ "model": {
3288
+ "visibility": "hidden"
3289
+ },
3290
+ "inStock": {
3291
+ "visibility": "hidden"
3292
+ },
3293
+ "specifications": {
3294
+ "visibility": "hidden"
3295
+ },
3296
+ "tags": {
3297
+ "visibility": "hidden"
3298
+ }
3299
+ }
3300
+ }
3301
+ },
3302
+ "help": {
3303
+ "markdown": "## Overview\n\nSimple products are things you sell that come in one form: one name, one price,\none description. Keep a record for each so you can see everything you offer in\none list, with the price next to it, and change it whenever it moves.\n\n## Tasks\n\n### Add a simple product\n\n1. Create a new product.\n2. Give it a **{field:name}**, for example \"Ceramic mug\".\n3. Enter the **{field:price}** in minor units (cents).\n4. Add a **{field:description}** so anyone reading the list knows what it is.\n5. Save. The product is now on your list.\n\n### Change a price\n\n1. Find the product.\n2. Change the **{field:price}** and save.\n",
3304
+ "fieldRefs": [
3305
+ "description",
3306
+ "name",
3307
+ "price"
3308
+ ]
3309
+ },
3310
+ "demo": {
3311
+ "mode": "server",
3312
+ "reasons": [
3313
+ "Needs a server because its package does not build for a browser: server bootstrap (startRestServer, express/cors), core generators/mcp; and its package depends on @happyvertical/smrt-tenancy, which do not build for a browser (#3616, #3624, #3626)."
3314
+ ]
3315
+ }
3316
+ }
3317
+ ],
3318
+ "browser": {
3319
+ "status": "server-only",
3320
+ "issues": [
3321
+ "#3616",
3322
+ "#3624",
3323
+ "#3626"
3324
+ ],
3325
+ "reason": "server bootstrap (startRestServer, express/cors), core generators/mcp",
3326
+ "via": [
3327
+ "@happyvertical/smrt-tenancy"
3328
+ ]
3329
+ }
3036
3330
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-products",
3
- "version": "0.55.7",
3
+ "version": "0.55.8",
4
4
  "smrtJsdoc": "strict",
5
5
  "description": "SMRT products module: triple-purpose microservice template for standalone apps, federated modules, and NPM libraries",
6
6
  "author": "HappyVertical",
@@ -54,18 +54,18 @@
54
54
  "./manifest.json": "./dist/lib/manifest.json"
55
55
  },
56
56
  "dependencies": {
57
- "@happyvertical/ai": "^0.102.5",
58
- "@happyvertical/files": "^0.102.5",
59
- "@happyvertical/logger": "^0.102.5",
60
- "@happyvertical/smrt-assets": "0.55.7",
61
- "@happyvertical/smrt-core": "0.55.7",
62
- "@happyvertical/smrt-scanner": "0.55.7",
63
- "@happyvertical/smrt-svelte": "0.55.7",
64
- "@happyvertical/smrt-tenancy": "0.55.7",
65
- "@happyvertical/smrt-ui": "0.55.7",
66
- "@happyvertical/smrt-web": "0.55.7",
67
- "@happyvertical/sql": "^0.102.5",
68
- "@happyvertical/utils": "^0.102.5",
57
+ "@happyvertical/ai": "^0.103.1",
58
+ "@happyvertical/files": "^0.103.1",
59
+ "@happyvertical/logger": "^0.103.1",
60
+ "@happyvertical/smrt-assets": "0.55.8",
61
+ "@happyvertical/smrt-core": "0.55.8",
62
+ "@happyvertical/smrt-scanner": "0.55.8",
63
+ "@happyvertical/smrt-svelte": "0.55.8",
64
+ "@happyvertical/smrt-tenancy": "0.55.8",
65
+ "@happyvertical/smrt-ui": "0.55.8",
66
+ "@happyvertical/smrt-web": "0.55.8",
67
+ "@happyvertical/sql": "^0.103.1",
68
+ "@happyvertical/utils": "^0.103.1",
69
69
  "cors": "^2.8.6",
70
70
  "express": "^5.2.1"
71
71
  },
@@ -84,7 +84,7 @@
84
84
  "@types/cors": "2.8.19",
85
85
  "@types/express": "^5.0.6",
86
86
  "@types/node": "24.13.6",
87
- "@happyvertical/smrt-vitest": "0.55.7",
87
+ "@happyvertical/smrt-vitest": "0.55.8",
88
88
  "concurrently": "^10.0.5",
89
89
  "svelte": "^5.57.1",
90
90
  "svelte-check": "^4.7.6",
@@ -1 +0,0 @@
1
- {"version":3,"file":"Sku-BN7ydk5h.js","names":[],"sources":["../../../src/lib/models/Category.ts","../../../src/lib/models/types.ts","../../../src/lib/models/Product.ts","../../../src/lib/models/Material.ts","../../../src/lib/models/ProductAsset.ts","../../../src/lib/models/ProductVariant.ts","../../../src/lib/models/Sku.ts"],"sourcesContent":["/**\n * Product knowledge base category model\n *\n * SMRT auto-generates REST APIs, MCP tools, and TypeScript clients from this class.\n */\n\nimport {\n foreignKey,\n SmrtObject,\n type SmrtObjectOptions,\n smrt,\n} from '@happyvertical/smrt-core';\nimport { TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';\n\n/**\n * Options for Category initialization\n */\nexport interface CategoryOptions extends SmrtObjectOptions {\n tenantId?: string | null;\n name?: string;\n description?: string;\n parentId?: string;\n level?: number;\n productCount?: number;\n active?: boolean;\n}\n\n/**\n * Product knowledge base category for organizing product information.\n *\n * Optional tenancy — categories may be shared globally (tenantId=null) or\n * scoped to a tenant. Hierarchical via parentId.\n */\n@TenantScoped({ mode: 'optional' })\n@smrt({\n tableStrategy: 'sti',\n // See the matching note on `Product`: the tenant-aware default key\n // (smrt#2360) lets two tenants share a category slug.\n api: {\n include: ['list', 'get', 'create', 'update'], // Standard CRUD except delete\n },\n mcp: {\n include: ['list', 'get'], // AI tools for category discovery\n },\n cli: true, // Enable CLI commands for admin\n})\nexport class Category extends SmrtObject { static __smrtPackage__ = \"@happyvertical/smrt-products\";\n /**\n * Tenant ID for multi-tenant isolation.\n * Nullable to support both tenant-scoped and global categories.\n */\n @tenantId({ nullable: true })\n tenantId: string | null = null;\n\n name = '';\n description = '';\n @foreignKey('Category')\n parentId?: string; // For hierarchical categories\n level = 0; // Category depth in hierarchy\n productCount = 0; // Number of products in this category\n active = true;\n\n constructor(options: CategoryOptions = {}) {\n super(options);\n if (options.tenantId !== undefined) this.tenantId = options.tenantId;\n this.name = options.name || '';\n this.description = options.description || '';\n this.parentId = options.parentId;\n this.level = options.level || 0;\n this.productCount = options.productCount || 0;\n this.active = options.active !== undefined ? options.active : true;\n }\n}\n","/**\n * Shared types for the products package.\n *\n * @packageDocumentation\n */\n\n/**\n * Discriminator for Product STI subtypes.\n *\n * Industry-vertical templates extend `Product` with their own STI subtypes\n * (apparel uses `Style`/`Makeup`; furniture might use `Design`/`Finish`;\n * automotive `Model`/`Trim`) — the upstream package only ships the truly\n * generic primitives:\n *\n * - {@link ProductType.PRODUCT} — plain catalog item (base)\n * - {@link ProductType.MATERIAL} — raw input consumed by manufacturing\n * - {@link ProductType.ASSEMBLY} — an item that is made from a bill of\n * materials; the `Assembly` subtype itself lives in\n * `@happyvertical/smrt-manufacturing`\n *\n * The axis-declaration concept (\"this product varies along `size` with\n * values `[XS, S, M, L, XL]`\") is NOT a Product STI subtype — it lives in\n * its own model, {@link ProductVariant}, with a separate table. Per-SKU\n * value pins live on `Sku.attributes` (also in this package — `Sku` was\n * relocated from `@happyvertical/smrt-inventory` to here in Phase 1 so\n * every catalog primitive lives under one roof; inventory only holds\n * stock levels and movements now).\n */\nexport enum ProductType {\n /** Generic catalog item (default). */\n PRODUCT = 'product',\n /** A raw input — fabric, trim, packaging, etc. — consumed by manufacturing. */\n MATERIAL = 'material',\n /**\n * An item that is made: the `Assembly` subtype in\n * `@happyvertical/smrt-manufacturing`, built from its bill of materials.\n */\n ASSEMBLY = 'assembly',\n}\n\n/**\n * High-level classification of a {@link ProductType.MATERIAL} item.\n *\n * Open string so consumers can extend with vertical-specific kinds without\n * an enum change. The `(string & {})` branch preserves the literal-string\n * autocomplete suggestions in IDEs — a bare `| string` would collapse the\n * whole union to `string` and erase the named hints (same pattern as\n * `StockMovementReason` in `@happyvertical/smrt-inventory`).\n */\nexport type MaterialKind =\n | 'fabric'\n | 'trim'\n | 'thread'\n | 'label'\n | 'packaging'\n | 'component'\n | (string & {});\n","/**\n * Product knowledge base model\n *\n * SMRT auto-generates REST APIs, MCP tools, and TypeScript clients from this class.\n */\n\nimport type { Asset } from '@happyvertical/smrt-assets';\nimport {\n assertValidOwnedAssetRelationship,\n assertValidOwnedAssetSortOrder,\n resolveOwnedAssetsById,\n} from '@happyvertical/smrt-assets';\nimport {\n SmrtObject,\n type SmrtObjectOptions,\n smrt,\n} from '@happyvertical/smrt-core';\nimport {\n getCurrentTenant,\n TenantScoped,\n tenantId,\n} from '@happyvertical/smrt-tenancy';\nimport { ProductType } from './types';\n\n/**\n * Options for Product initialization\n */\nexport interface ProductOptions extends SmrtObjectOptions {\n tenantId?: string | null;\n productType?: ProductType;\n name?: string;\n description?: string;\n category?: string;\n manufacturer?: string;\n model?: string;\n price?: number;\n inStock?: boolean;\n specifications?: Record<string, unknown>;\n tags?: string[];\n}\n\n/**\n * Product information for knowledge base queries.\n *\n * STI base — subclasses (e.g. `Material` upstream, `Style`/`Makeup` in the\n * apparel template) share this table via the `_meta_type` discriminator\n * and `productType` field. Optional tenancy lets the same package serve\n * shared global catalogs OR per-merchant catalogs.\n */\n@TenantScoped({ mode: 'optional' })\n@smrt({\n tableStrategy: 'sti',\n // No `conflictColumns` override: since smrt#2360 a tenant-scoped STI base\n // keys on `(tenant_id, slug, context, _meta_type)` by default — schema\n // index and runtime upsert alike — so two tenants can each own the same\n // slug + type, and a re-save within one tenant still updates in place.\n api: {\n include: ['list', 'get', 'create', 'update'], // Standard CRUD except delete\n },\n mcp: {\n include: ['list', 'get'], // AI tools for product discovery\n },\n cli: { skipApiCheck: true }, // Enable CLI commands for admin\n})\nexport class Product extends SmrtObject { static __smrtPackage__ = \"@happyvertical/smrt-products\";\n /**\n * Tenant ID for multi-tenant isolation.\n * Nullable to support both tenant-scoped and global catalogs.\n */\n @tenantId({ nullable: true })\n tenantId: string | null = null;\n\n /**\n * STI discriminator. Subclasses override this with their type.\n */\n productType: ProductType = ProductType.PRODUCT;\n\n name = '';\n description = '';\n category = ''; // Reference to category\n manufacturer = '';\n model = '';\n /**\n * Catalog price in **integer minor units** (cents, satoshis).\n *\n * Money is exact, so it is stored as minor units and never as a float —\n * `$19.99` is `1999`. An integer literal is what maps this to an INTEGER\n * column; writing `19.99` here is the bug, and PostgreSQL rejects it with\n * `22P02` while SQLite's affinity silently stores it (#2361).\n *\n * `Material.costPerUnit` is a `Meta<number>` decimal in `_meta_data` rather\n * than a column, so it is unaffected by this rule.\n */\n price = 0;\n inStock = true;\n specifications: Record<string, unknown> = {};\n tags: string[] = [];\n\n constructor(options: ProductOptions = {}) {\n super(options);\n if (options.tenantId !== undefined) this.tenantId = options.tenantId;\n // Treat `null` like `undefined`: existing product rows written\n // before the `product_type` column existed hydrate with the field\n // as SQL `NULL`, and without this guard the SmrtObject loader would\n // overwrite the `ProductType.PRODUCT` initializer with `null`. The\n // STI discriminator `_meta_type` still identifies the row's\n // subtype; `productType` is the secondary human-readable label and\n // should fall back to the base default for legacy rows.\n if (options.productType !== undefined && options.productType !== null) {\n this.productType = options.productType;\n }\n this.name = options.name || '';\n this.description = options.description || '';\n this.category = options.category || '';\n this.manufacturer = options.manufacturer || '';\n this.model = options.model || '';\n this.price = options.price || 0;\n this.inStock = options.inStock !== undefined ? options.inStock : true;\n this.specifications = options.specifications || {};\n this.tags = options.tags || [];\n }\n\n async getSpecification(key: string): Promise<unknown> {\n return this.specifications[key];\n }\n\n async updateSpecification(key: string, value: unknown): Promise<void> {\n this.specifications[key] = value;\n }\n\n private async getProductAssetCollection() {\n const { ProductAssetCollection } = await import(\n '../collections/ProductAssetCollection'\n );\n return ProductAssetCollection.create({ db: this.db });\n }\n async getAssets(relationship?: string): Promise<Asset[]> {\n if (!this.id) {\n return [];\n }\n\n const productAssets = await this.getProductAssetCollection();\n // Don't pass `this.tenantId` here. ProductAsset is @TenantScoped, so the\n // tenancy interceptor auto-filters the underlying list() by the active\n // context. Passing `this.tenantId` would apply a second explicit filter on\n // TOP of the interceptor's — and when this Product is a global/shared row\n // (`tenantId === null`), that second filter would drop every per-tenant\n // link to it. The interceptor is the right boundary: in a tenant scope it\n // returns that tenant's links; outside any scope it returns everything. See\n // the `ProductAsset` model comment about cross-tenant linking.\n const linkedAssets = await productAssets.byLeft(\n this.id,\n relationship ? { relationship } : {},\n );\n\n // Use the ACTIVE tenant context's tenantId, not `this.tenantId`,\n // when resolving the underlying assets. `resolveOwnedAssetsById`\n // treats its `tenantId` arg as the caller's identity: when set, it\n // enters `withSystemContext` to bypass the interceptor and then\n // includes both that-tenant-owned AND global (tenantId === null)\n // assets in the visible set. Passing `this.tenantId` would force\n // a global Product (`tenantId === null`) running inside a tenant\n // request into the \"no caller tenant\" branch — the interceptor\n // would then filter to only the tenant's own assets and drop\n // every global asset linked to the shared product. Reading the\n // active context fixes both directions: tenant requests see\n // tenant + global assets; system requests see everything.\n const ctxTenantId = getCurrentTenant()?.tenantId ?? null;\n return resolveOwnedAssetsById(\n this.db,\n linkedAssets.map((link) => link.assetId),\n ctxTenantId,\n );\n }\n\n async addAsset(\n asset: Asset,\n relationship = 'attachment',\n sortOrder = 0,\n ): Promise<void> {\n if (!this.id || !asset.id) {\n throw new Error('Cannot associate unsaved product or asset');\n }\n\n assertValidOwnedAssetRelationship(relationship);\n assertValidOwnedAssetSortOrder(sortOrder);\n\n const productAssets = await this.getProductAssetCollection();\n // Same rationale as `getAssets` — the interceptor's auto-populate sets the\n // new row's `tenantId` from the active context, so we don't hard-wire\n // `this.tenantId`. When this Product is global (`tenantId === null`) but a\n // tenant is linking it, the link should land under that tenant, not under\n // the global namespace.\n await productAssets.attach(this.id, asset.id, {\n relationship,\n sortOrder,\n });\n }\n\n async removeAsset(assetId: string, relationship?: string): Promise<void> {\n if (!this.id) {\n return;\n }\n\n const productAssets = await this.getProductAssetCollection();\n // Tenant-scoped delete: pass only the relationship filter and let the\n // interceptor scope the underlying list() to the active tenant, rather\n // than constraining to `this.tenantId` (which on a global product would\n // never match a tenant-owned link). System-style callers without a tenant\n // context sweep every matching row — the existing `detach` semantics.\n await productAssets.detach(\n this.id,\n assetId,\n relationship ? { relationship } : {},\n );\n }\n}\n","/**\n * Material — STI subtype of Product representing a raw input consumed by\n * manufacturing: fabric, thread, trim, labels, packaging, components.\n *\n * Materials are first-class products in the catalog (the SAP/NetSuite pattern):\n * they have a name, description, vendor, unit cost, and live in inventory just\n * like finished goods. Bills of materials in `@happyvertical/smrt-manufacturing`\n * reference Materials by id.\n *\n * @packageDocumentation\n */\n\nimport { type Meta, smrt } from '@happyvertical/smrt-core';\nimport { TenantScoped } from '@happyvertical/smrt-tenancy';\nimport { Product, type ProductOptions } from './Product';\nimport { type MaterialKind, ProductType } from './types';\n\nexport interface MaterialOptions extends ProductOptions {\n materialKind?: MaterialKind;\n uom?: string;\n costPerUnit?: number;\n}\n\n// @TenantScoped is registered per concrete class, so inheriting from\n// Product is NOT enough — `MaterialCollection.list()` passes 'Material'\n// (not 'Product') to the interceptor, which then fails its\n// `isTenantScopedClass(className)` lookup and skips tenant auto-filter\n// + auto-populate. Repeat the decorator here so material rows\n// participate in the same tenant isolation as their Product parent.\n@TenantScoped({ mode: 'optional' })\n// SECURITY (S5 #1406): @smrt() generation config is ALSO registered per\n// concrete class and is NOT inherited from the STI parent. An empty\n// `@smrt()` here would resolve `getConfig('Material').api/.mcp` to\n// `undefined`, which the REST + MCP generators treat as \"expose\n// EVERYTHING\" — including `delete`, `create`, and `update` MCP tools —\n// even though the `Product` base deliberately restricts itself to CRUD\n// (no delete) over REST and read-only (`list`/`get`) over MCP. The\n// products `mcp.ts` generator enumerates the whole registry, so a\n// silently-wide-open Material surface ships the moment the package's own\n// server is generated. Re-declare the parent's restricted posture so the\n// STI child matches its base — and so consumers copying this canonical\n// subtype example (apparel `Style`/`Makeup`, automotive `Model`/`Trim`,\n// …) inherit the secure pattern instead of an accidental open surface.\n@smrt({\n api: {\n include: ['list', 'get', 'create', 'update'], // no delete, matches Product\n },\n mcp: {\n include: ['list', 'get'], // read-only AI tools, matches Product\n },\n cli: { skipApiCheck: true },\n})\nexport class Material extends Product { static __smrtPackage__ = \"@happyvertical/smrt-products\";\n override productType: ProductType = ProductType.MATERIAL;\n\n // STI child-specific fields use the `Meta<T>` type wrapper rather than\n // the runtime `@meta()` decorator. The scanner detects the wrapper at\n // build time (via the type annotation) and emits the field with\n // `type: 'meta'` in the manifest, which routes it through `_meta_data`\n // JSON storage at schema-generation time. The runtime decorator path\n // doesn't reach the manifest, so STI children that only use `@meta()`\n // get materialized as ordinary columns on the parent's table.\n\n /** Classification — fabric, trim, thread, label, packaging, component, ... */\n materialKind: Meta<MaterialKind> = 'component';\n\n /** Unit of measure — \"yards\", \"meters\", \"each\", \"grams\", \"lbs\". */\n uom: Meta<string> = 'each';\n\n /**\n * Latest known cost per UOM. For sophisticated cost rollup, manufacturing\n * may pull from purchase-order history instead of this denormalised value.\n */\n costPerUnit: Meta<number> = 0.0;\n\n constructor(options: MaterialOptions = {}) {\n super(options);\n if (options.materialKind !== undefined)\n this.materialKind = options.materialKind;\n if (options.uom !== undefined) this.uom = options.uom;\n if (options.costPerUnit !== undefined)\n this.costPerUnit = options.costPerUnit;\n }\n}\n","import type { SmrtObjectOptions } from '@happyvertical/smrt-core';\nimport {\n crossPackageRef,\n field,\n foreignKey,\n SmrtObject,\n smrt,\n} from '@happyvertical/smrt-core';\nimport { TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';\n\nexport interface ProductAssetOptions extends SmrtObjectOptions {\n tenantId?: string | null;\n productId?: string;\n assetId?: string;\n relationship?: string;\n sortOrder?: number;\n}\n\n@TenantScoped({ mode: 'optional' })\n@smrt({\n tableName: 'product_assets',\n // tenant_id is included so two tenants can independently link the same\n // shared product/asset under the same relationship without their rows\n // colliding (or worse, one tenant silently overwriting another's link\n // via UPSERT).\n conflictColumns: ['product_id', 'asset_id', 'relationship', 'tenant_id'],\n api: false,\n mcp: false,\n cli: false,\n})\nexport class ProductAsset extends SmrtObject { static __smrtPackage__ = \"@happyvertical/smrt-products\";\n @tenantId({ nullable: true })\n tenantId: string | null = null;\n\n @foreignKey('Product', { required: true })\n productId = '';\n\n @crossPackageRef('@happyvertical/smrt-assets:Asset', { required: true })\n assetId = '';\n\n @field({ required: true })\n relationship = 'attachment';\n\n @field()\n sortOrder = 0;\n\n constructor(options: ProductAssetOptions = {}) {\n super(options);\n if (options.tenantId !== undefined) this.tenantId = options.tenantId;\n if (options.productId) this.productId = options.productId;\n if (options.assetId) this.assetId = options.assetId;\n if (options.relationship) this.relationship = options.relationship;\n if (options.sortOrder !== undefined) this.sortOrder = options.sortOrder;\n }\n}\n","/**\n * ProductVariant — declarative description of one axis along which a\n * product's SKUs differ.\n *\n * A `ProductVariant` row says \"for product X, axis Y has the following\n * allowed values\". The actual per-SKU value lives on `Sku.attributes`\n * (also in this package — `Sku` was relocated to `@happyvertical/smrt-products`\n * in Phase 1; inventory only holds stock motion now), keyed by `axisName`.\n * This split keeps the axis catalog (form choices, UI grouping, ordering)\n * separate from the per-unit instances.\n *\n * Deliberately generic. `axisName` is free-form — apparel teams might\n * use `'size'` and `'color'`, automotive teams `'trim'` and `'engine'`,\n * CPG teams `'packSize'` and `'flavor'`. The framework never inspects\n * the axis name.\n *\n * NOT a Product STI subtype — it's its own model with its own table,\n * because the shape (axis declaration) doesn't fit the Product schema\n * (no name, no price, no category — it's metadata about a Product, not\n * a Product itself).\n *\n * @packageDocumentation\n */\n\nimport {\n field,\n SmrtObject,\n type SmrtObjectOptions,\n smrt,\n} from '@happyvertical/smrt-core';\nimport { TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';\n\n/**\n * Options accepted by the {@link ProductVariant} constructor.\n */\nexport interface ProductVariantOptions extends SmrtObjectOptions {\n tenantId?: string | null;\n productId?: string;\n axisName?: string;\n label?: string;\n allowedValues?: string[] | string;\n sortOrder?: number;\n}\n\n@TenantScoped({ mode: 'optional' })\n@smrt({\n tableName: 'product_variants',\n conflictColumns: ['product_id', 'axis_name', 'tenant_id'],\n api: { include: ['list', 'get', 'create', 'update'] },\n mcp: { include: ['list', 'get'] },\n cli: { skipApiCheck: true },\n})\nexport class ProductVariant extends SmrtObject { static __smrtPackage__ = \"@happyvertical/smrt-products\";\n /** Tenant scope. `null` means the variant axis is global. */\n @tenantId({ nullable: true })\n tenantId: string | null = null;\n\n /**\n * Plain string reference to the {@link Product} this axis belongs to\n * (or any Product STI subtype — `Material`, or vertical subtypes\n * defined in templates such as the apparel `Style` / `Makeup`).\n */\n @field({ required: true })\n productId: string = '';\n\n /**\n * The name of the axis (`'size'`, `'color'`, `'finish'`, `'voltage'`,\n * `'packSize'`, …). Free-form — the framework treats this as opaque.\n */\n @field({ required: true })\n axisName: string = '';\n\n /** Optional human-friendly label for forms / UIs (defaults to `axisName`). */\n label: string = '';\n\n /**\n * Allowed values for this axis, stored as a JSON string. Named\n * `allowedValues` (rather than `values`) because the unprefixed name\n * collides with the SQL `VALUES` keyword on several engines. Use\n * {@link getValues} / {@link setValues} to round-trip the array form.\n */\n allowedValues: string = '[]';\n\n /** Sort order for displaying multiple axes in a consistent column order. */\n sortOrder: number = 0;\n\n constructor(options: ProductVariantOptions = {}) {\n super(options);\n if (options.tenantId !== undefined) this.tenantId = options.tenantId;\n if (options.productId !== undefined) this.productId = options.productId;\n if (options.axisName !== undefined) this.axisName = options.axisName;\n if (options.label !== undefined) this.label = options.label;\n if (options.allowedValues !== undefined)\n this.setValues(options.allowedValues);\n if (options.sortOrder !== undefined) this.sortOrder = options.sortOrder;\n\n // Default the label to axisName when no explicit label was supplied.\n // The field docstring promises this behavior and admin UIs render\n // `variant.label` directly; without this fallback an axis declared\n // as `{ axisName: 'size' }` would render with a blank label.\n if (!this.label && this.axisName) {\n this.label = this.axisName;\n }\n }\n\n /**\n * Parse and return the {@link allowedValues} JSON array. Returns an\n * empty array if the stored value cannot be parsed as an array.\n */\n getValues(): string[] {\n if (!this.allowedValues) return [];\n try {\n const parsed = JSON.parse(this.allowedValues);\n return Array.isArray(parsed) ? parsed.map((value) => String(value)) : [];\n } catch {\n return [];\n }\n }\n\n /**\n * Serialize the allowed values back into the stored\n * {@link allowedValues} string. Accepts either an array or a\n * pre-serialized JSON string.\n */\n setValues(value: string[] | string): void {\n if (typeof value === 'string') {\n this.allowedValues = value;\n return;\n }\n this.allowedValues = JSON.stringify(Array.isArray(value) ? value : []);\n }\n}\n","/**\n * Sku — smallest sellable and countable unit.\n *\n * A SKU is the concrete catalog row that downstream inventory counts.\n * It references a higher-level concept in this same package (a\n * {@link Product} or any of its STI subtypes) via the plain string\n * `productId` field. The same pattern serves apparel (one SKU per\n * size/color), furniture (one SKU per finish), automotive parts,\n * grocery (one SKU per pack size), or any other vertical that needs\n * to count discrete units.\n *\n * Axis values such as `{ size: 'M', color: 'navy' }`, `{ finish: 'walnut' }`,\n * or `{ packSize: '12oz' }` are stored as opaque JSON in `attributes`;\n * the declarative axis catalog lives in {@link ProductVariant} in this\n * same package.\n *\n * Stock balance and movement history for a SKU live in\n * `@happyvertical/smrt-inventory` as `StockLevel` and `StockMovement`.\n *\n * @packageDocumentation\n */\n\nimport {\n field,\n SmrtObject,\n type SmrtObjectOptions,\n smrt,\n} from '@happyvertical/smrt-core';\nimport { TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';\n\n/**\n * Options accepted by the {@link Sku} constructor.\n */\nexport interface SkuOptions extends SmrtObjectOptions {\n tenantId?: string | null;\n productId?: string;\n code?: string;\n barcode?: string;\n name?: string;\n attributes?: Record<string, unknown> | string;\n weightGrams?: number;\n parentSkuId?: string;\n active?: boolean;\n}\n\n@TenantScoped({ mode: 'optional' })\n@smrt({\n tableName: 'product_skus',\n conflictColumns: ['code', 'tenant_id'],\n api: { include: ['list', 'get', 'create', 'update'] },\n mcp: { include: ['list', 'get'] },\n cli: { skipApiCheck: true },\n})\nexport class Sku extends SmrtObject { static __smrtPackage__ = \"@happyvertical/smrt-products\";\n /** Tenant scope. `null` means the SKU is a global record. */\n @tenantId({ nullable: true })\n tenantId: string | null = null;\n\n /**\n * Plain string reference to a row in this package — typically a\n * `Product` id (or any of its STI subtypes, such as `Material`\n * upstream or `Style` / `Makeup` in the apparel template).\n *\n * Marked required so the framework rejects a save with `productId`\n * empty or unset. Without this, downstream consumers (inventory\n * stock motion, manufacturing BOM resolution) would have to filter\n * out orphan SKU rows that point at no product — a sharp footgun\n * the moment one slips through. The `@field({ required: true })`\n * decorator also surfaces the constraint in generated REST/MCP\n * schemas.\n */\n @field({ required: true })\n productId: string = '';\n\n /**\n * Stable, human-meaningful identifier such as a UPC, internal SKU\n * code, or part number. Together with `tenantId` this is the natural\n * key (`conflictColumns: ['code', 'tenant_id']`), so re-saving an\n * identical row is an upsert rather than a UNIQUE violation.\n */\n @field({ required: true })\n code: string = '';\n\n /** Optional scannable barcode (EAN/UPC/Code-128/etc.). */\n barcode: string = '';\n\n /** Display name for UIs. Optional — `code` is enough for machines. */\n name: string = '';\n\n /**\n * Axis values for this SKU expressed as JSON, e.g. `{ size: 'M' }`,\n * `{ finish: 'walnut' }`, or `{ voltage: '230V' }`.\n *\n * The persisted column type is text; use {@link getAttributes} and\n * {@link setAttributes} to round-trip parsed values without worrying\n * about parse errors.\n */\n attributes: string = '{}';\n\n /** Optional physical weight in grams for shipping / cost calculations. */\n @field({ type: 'decimal' })\n weightGrams: number = 0.0;\n\n /**\n * Optional self-reference for bundles / kits — set to the id of the\n * \"parent\" SKU that this SKU is a component of.\n */\n parentSkuId: string = '';\n\n /**\n * Soft-active flag. Inactive SKUs stay queryable for history but\n * should not be offered for sale or production planning.\n */\n active: boolean = true;\n\n constructor(options: SkuOptions = {}) {\n super(options);\n if (options.tenantId !== undefined) this.tenantId = options.tenantId;\n if (options.productId !== undefined) this.productId = options.productId;\n if (options.code !== undefined) this.code = options.code;\n if (options.barcode !== undefined) this.barcode = options.barcode;\n if (options.name !== undefined) this.name = options.name;\n if (options.attributes !== undefined)\n this.setAttributes(options.attributes);\n if (options.weightGrams !== undefined)\n this.weightGrams = options.weightGrams;\n if (options.parentSkuId !== undefined)\n this.parentSkuId = options.parentSkuId;\n if (options.active !== undefined) this.active = options.active;\n }\n\n /**\n * Parse and return the {@link attributes} JSON. Returns an empty\n * object if the stored value is invalid JSON so callers don't have\n * to wrap every read in a try/catch.\n */\n getAttributes(): Record<string, unknown> {\n if (!this.attributes) return {};\n try {\n const parsed = JSON.parse(this.attributes);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed)\n ? (parsed as Record<string, unknown>)\n : {};\n } catch {\n return {};\n }\n }\n\n /**\n * Serialize axis values back into the stored {@link attributes}\n * string. Accepts either an object (will be `JSON.stringify`'d) or a\n * raw JSON string that the caller has already serialized.\n */\n setAttributes(value: Record<string, unknown> | string): void {\n if (typeof value === 'string') {\n this.attributes = value;\n return;\n }\n this.attributes = JSON.stringify(value ?? {});\n }\n}\n"],"mappings":"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"}