@happyvertical/smrt-inventory 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.
@@ -3,12 +3,12 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-inventory",
6
- "packageVersion": "0.55.7",
6
+ "packageVersion": "0.55.8",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "fa6c2f8a2c8fb641766bde2fc197dca46734e94f529367641de3529e8cd4b6bc",
11
- "packageJson": "5155a0dd22f82447970100cadd4aadf5b588c314152435a3c071e2a511c85e2f",
10
+ "manifest": "0b483c113fd2ef2605aed68aedf1a8239eee1d799f5a58f2b312ee5342f80d51",
11
+ "packageJson": "27ff7d0f4fc0e7c44180ababd234960407ec0056fa7195537be93d1d566857a4",
12
12
  "agents": "f1e6618e492c2726dee29c3e3fb203c03a64de184c7992407a1025b12618625d"
13
13
  },
14
14
  "exports": [
@@ -552,21 +552,24 @@
552
552
  "type": "text",
553
553
  "required": true,
554
554
  "columnType": "TEXT",
555
- "default": ""
555
+ "default": "",
556
+ "description": "A short code for the place stock is kept, for example MAIN."
556
557
  },
557
558
  {
558
559
  "name": "name",
559
560
  "type": "text",
560
561
  "required": false,
561
562
  "columnType": "TEXT",
562
- "default": ""
563
+ "default": "",
564
+ "description": "What you call the place, for example Main warehouse."
563
565
  },
564
566
  {
565
567
  "name": "kind",
566
568
  "type": "text",
567
569
  "required": false,
568
570
  "columnType": "TEXT",
569
- "default": "warehouse"
571
+ "default": "warehouse",
572
+ "description": "What sort of place it is, for example a warehouse or a shop floor."
570
573
  },
571
574
  {
572
575
  "name": "placeId",
@@ -695,27 +698,31 @@
695
698
  "type": "text",
696
699
  "required": true,
697
700
  "columnType": "TEXT",
698
- "default": ""
701
+ "default": "",
702
+ "description": "The item being counted."
699
703
  },
700
704
  {
701
705
  "name": "locationId",
702
706
  "type": "text",
703
707
  "required": true,
704
708
  "columnType": "TEXT",
705
- "default": ""
709
+ "default": "",
710
+ "description": "Where the stock is kept."
706
711
  },
707
712
  {
708
713
  "name": "state",
709
714
  "type": "text",
710
715
  "required": true,
711
716
  "columnType": "TEXT",
712
- "default": "available"
717
+ "default": "available",
718
+ "description": "The state of this stock: available, allocated, wip, qc_hold or damaged."
713
719
  },
714
720
  {
715
721
  "name": "qty",
716
722
  "type": "decimal",
717
723
  "required": false,
718
- "columnType": "REAL"
724
+ "columnType": "REAL",
725
+ "description": "How many are on hand."
719
726
  },
720
727
  {
721
728
  "name": "reorderPoint",
@@ -810,39 +817,45 @@
810
817
  "type": "text",
811
818
  "required": true,
812
819
  "columnType": "TEXT",
813
- "default": ""
820
+ "default": "",
821
+ "description": "The item that moved."
814
822
  },
815
823
  {
816
824
  "name": "locationId",
817
825
  "type": "text",
818
826
  "required": true,
819
827
  "columnType": "TEXT",
820
- "default": ""
828
+ "default": "",
829
+ "description": "The place it moved at."
821
830
  },
822
831
  {
823
832
  "name": "fromState",
824
833
  "type": "text",
825
834
  "required": false,
826
- "columnType": "TEXT"
835
+ "columnType": "TEXT",
836
+ "description": "What it was before, for example available or damaged."
827
837
  },
828
838
  {
829
839
  "name": "toState",
830
840
  "type": "text",
831
841
  "required": false,
832
- "columnType": "TEXT"
842
+ "columnType": "TEXT",
843
+ "description": "What it became."
833
844
  },
834
845
  {
835
846
  "name": "qty",
836
847
  "type": "decimal",
837
848
  "required": false,
838
- "columnType": "REAL"
849
+ "columnType": "REAL",
850
+ "description": "How many moved."
839
851
  },
840
852
  {
841
853
  "name": "reasonCode",
842
854
  "type": "text",
843
855
  "required": true,
844
856
  "columnType": "TEXT",
845
- "default": "adjustment"
857
+ "default": "adjustment",
858
+ "description": "Why it moved, for example adjustment, sale or return."
846
859
  },
847
860
  {
848
861
  "name": "sourceType",
@@ -870,13 +883,15 @@
870
883
  "type": "text",
871
884
  "required": false,
872
885
  "columnType": "TEXT",
873
- "default": ""
886
+ "default": "",
887
+ "description": "Anything worth remembering about this movement."
874
888
  },
875
889
  {
876
890
  "name": "occurredAt",
877
891
  "type": "datetime",
878
892
  "required": false,
879
- "columnType": "TIMESTAMP"
893
+ "columnType": "TIMESTAMP",
894
+ "description": "When it happened."
880
895
  }
881
896
  ],
882
897
  "relationships": [
@@ -1392,5 +1407,117 @@
1392
1407
  "polymorphicAssociations": 0,
1393
1408
  "uuidColumns": 14
1394
1409
  },
1395
- "agentDoc": "# @happyvertical/smrt-inventory\n\nMulti-location stock tracking. Strictly industry-neutral — the same primitives serve apparel, furniture, automotive, CPG, electronics, and any other vertical that counts discrete units across locations.\n\n## Models\n\n| Model | Purpose |\n|---|---|\n| _(catalog shapes — `Product`, `Material`, `ProductVariant`, `Sku` — live in `@happyvertical/smrt-products`)_ | Sku is the smallest sellable / countable unit; its `attributes` JSON carries axis-value pins. ProductVariant is the per-product axis declaration. Import either from `@happyvertical/smrt-products` directly. |\n| `InventoryLocation` | Warehouse / factory / retail / in-transit / virtual. Open-ended `kind` string. Optional `placeId` references `@happyvertical/smrt-places`. `conflictColumns: ['code', 'tenant_id']`. |\n| `StockLevel` | Materialized `qty` for a `(skuId, locationId, state)` tuple. Upserted in place. **Mutated only by `StockService`.** States: `available`, `allocated`, `wip`, `qc_hold`, `damaged`. |\n| `StockMovement` | Append-only audit log. Every `StockService` mutation writes one (or two for `transfer`). `sourceType` + `sourceId` carry cross-package attribution. |\n\nAll three inventory models (`InventoryLocation`, `StockLevel`, `StockMovement`) use `@TenantScoped({ mode: 'optional' })` + nullable `tenantId` so they can be used either tenant-scoped or globally. The catalog shapes (`Product`, `Material`, `ProductVariant`, `Sku`) live in `@happyvertical/smrt-products` and carry their own tenant decoration there.\n\n## StockService — the only sanctioned way to mutate stock\n\n```typescript\nimport { createStockService } from '@happyvertical/smrt-inventory';\n\nconst service = await createStockService({ db });\nawait service.receive(skuId, locationId, qty, { sourceType, sourceId });\nawait service.reserve(skuId, locationId, qty, { sourceType, sourceId });\nawait service.release(skuId, locationId, qty, { sourceType, sourceId });\nawait service.fulfill(skuId, locationId, qty, { sourceType, sourceId });\nawait service.transfer(skuId, fromLocId, toLocId, qty, { sourceType, sourceId });\nawait service.adjust(skuId, locationId, delta, { sourceType, sourceId });\n```\n\n| Method | Behavior | Movement reason |\n|---|---|---|\n| `receive` | +qty available — purchase receipt, return, production produce | `receipt` |\n| `reserve` | available → allocated. Throws `InsufficientStockError` on overdraw | `reservation` |\n| `release` | allocated → available (order cancel) | `release` |\n| `fulfill` | -qty allocated. Stock leaves the building | `fulfillment` |\n| `transfer` | move stock between locations (writes two movements) | `transfer_out`, `transfer_in` |\n| `adjust` | signed delta — cycle counts and one-off corrections | `adjustment` |\n\nAll methods reject zero / negative / non-finite quantities except `adjust`, which accepts non-zero signed deltas. Negative deltas that would drive a state below zero throw `InsufficientStockError`.\n\n## Opt-in DispatchBus hooks\n\nOff by default. Wire them up explicitly in the application's `smrt.ts`:\n\n```typescript\nimport { installInventoryDispatchHandlers } from '@happyvertical/smrt-inventory';\n\nconst handlers = await installInventoryDispatchHandlers({\n dispatchBus: bus,\n db, // or stockService: existingService\n});\n```\n\nThis subscribes to:\n- `contract:created` → calls `service.reserve()` for every line inside one `stockService.withTransaction(...)`, attributed to `('Contract', payload.contractId)`\n- `fulfillment:shipped` → calls `service.fulfill()` for every line inside one `stockService.withTransaction(...)`, attributed to `('Fulfillment', payload.fulfillmentId)`\n\nBoth handlers are atomic across lines: a shortfall on line N rolls back lines 1..N-1 so the event is all-or-nothing. Without this guarantee, `DispatchBus` would only log the async handler error and a partially-reserved (or partially-fulfilled) contract would have no compensating event to fix it.\n\nPer-handler toggles: `installContractReserved`, `installFulfillmentShipped`. The `production_order:posted` hook is deliberately not installed here — it depends on the BOM model and ships in `@happyvertical/smrt-manufacturing` (issue #1250).\n\n## Schema migration (Phase 1 release)\n\nThe `Sku` model moved out of this package and into `@happyvertical/smrt-products`. Previously it lived in this package's `inventory_skus` table; it now lives in `product_skus` over there.\n\n**Upgrade procedure** for deployed consumers:\n\n1. **Let the framework create the destination table first.** Boot the new version once with `@happyvertical/smrt-products` registered in your `SmrtClassOptions`; the lazy `syncSchema` path will create `product_skus` with the right primary key, NOT NULL, UNIQUE (`code`, `tenant_id`), and indexes — they're derived from the `Sku` model decorators and would be stripped by a raw `CREATE TABLE AS SELECT`.\n\n2. **Idempotently copy rows across.** SQLite + Postgres:\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 you've verified row counts match:\n\n ```sql\n DROP TABLE IF EXISTS inventory_skus;\n ```\n\n`StockLevel.skuId` and `StockMovement.skuId` carry plain string ids that still resolve to the same rows after the rename, so no inventory data has to move.\n\n## Gotchas\n\n- **Movements are append-only.** The materialized `StockLevel` row is derived state; the `StockMovement` ledger is the source of truth. Never let CRUD UIs expose update/delete on the movement table. If you find yourself wanting to \"fix\" a movement row, write a compensating `adjust()` call instead.\n- **Never call `StockLevel.create()` or `save()` directly.** Going around `StockService` silently desyncs the audit log and breaks cycle counts.\n- **Cross-industry constraint.** This package's vocabulary stays generic: `InventoryLocation`, `StockLevel`, `StockMovement`. Apparel-specific concepts (`Style`, `Makeup`, `Colorway`, fashion `Season`, tech-pack metadata) and their analogues in furniture / automotive / CPG live in the relevant template package, never here. PRs that introduce industry vocabulary should be rejected.\n- **Cross-package references are plain strings.** `skuId`, `placeId`, `sourceId` — all plain string ids, never `@foreignKey()`. Inventory's stock-motion logic only ever reads StockLevel/StockMovement; it doesn't follow `skuId` back to the catalog's Sku table, so the layering stays loose.\n- **`conflictColumns` include `tenant_id`** on `InventoryLocation` and `StockLevel`. NULL-matching semantics (`(code, NULL) IS NOT DISTINCT FROM (code, NULL)`) are handled by `@happyvertical/sql >= 0.74.0` natively, so two saves with the same `(code, NULL)` tuple correctly merge in place. Pass `nullsDistinct: true` at the sql layer to opt back into NULL-distinct semantics.\n- **Atomic per-method, transactional across composition.** Each `StockService` mutation (`receive` / `reserve` / `release` / `fulfill` / `transfer` / `adjust`) wraps every write in a single `db.transaction(...)` (via `@happyvertical/sql >= 0.74.0`). Partial failure rolls the whole call back — level writes and the matching `StockMovement` audit row commit together or not at all. `transfer` writes both legs (source decrement, destination increment, and both audit rows) inside one tx, so a failure mid-`transfer` is fully reverted.\n Callers composing multiple `StockService` calls into one logical unit (e.g. consuming materials line-by-line for a production order) should wrap them in `await stockService.withTransaction(async (tx) => { ... })` — `tx` is a tx-bound `StockService` with the same public API; mutation calls inside the callback share one transaction and either all commit or all roll back.\n If the underlying SQL adapter does not expose `transaction()` (extremely rare — all four built-in `@happyvertical/sql >= 0.74.0` adapters implement it), the service degrades to non-atomic serial writes and emits a one-time `console.warn`.\n- **Concurrent reservations: tightened, not bulletproof.** `@happyvertical/sql >= 0.74.0` serialises null-aware *upserts* via a per-key in-process lock (SQLite) or advisory lock (Postgres). That lock fixed the row-creation race (issue #1246) — two concurrent `create()` calls on the same conflict-column tuple no longer race the underlying `INSERT ... ON CONFLICT`. It does NOT serialise the read-modify-write compound that `reserve` / `fulfill` / `transfer` / `adjust` perform on an existing row: each method reads the current level (`SELECT`), computes the new value in JS, then writes it back (`UPDATE` via `save()` on the loaded row). The whole compound is inside one `db.transaction(...)` from round-3, so the level write and audit row commit together — but two concurrent transactions on the same `(skuId, locationId)` can each see the same starting balance, compute the same decrement, and double-spend. Awaited (serial) calls always behave correctly. Use a job queue (e.g. `@happyvertical/smrt-jobs`) or your own mutex when you need hard atomicity across concurrent callers. The full fix is `SELECT ... FOR UPDATE` inside the same transaction (Postgres) or upgrading the journal mode (SQLite); deferred until we see a real high-contention workload that warrants it.\n\n## Source attribution\n\nEvery `StockMovement` carries `sourceType` + `sourceId` so downstream queries can reconstruct \"what caused this movement\". Conventions used by the built-in dispatch handlers:\n\n| sourceType | Emitter | Note |\n|---|---|---|\n| `Contract` | `smrt-commerce` `contract:created` | All reservation/release/fulfilment legs caused by a contract |\n| `Fulfillment` | `smrt-commerce` `fulfillment:shipped` | Outbound shipment |\n| `PurchaseOrder` | (your code) | Inbound receipt |\n| `CycleCount` | (your code) | Adjustments from physical counts |\n| `TransferOrder` | (your code) | Both legs of a transfer |\n| `ProductionOrder` | `smrt-manufacturing` (issue #1250) | Materials consumed + finished goods produced |\n\n## Replenishment and browser components\n\n`StockService.setReorderPolicy()` is the sanctioned metadata writer for nullable\n`StockLevel.reorderPoint` / `reorderQuantity` on the available SKU/location row.\nIt does not change quantity or emit a movement. `findBelowReorderPoint()` uses\npublic tenant-filtered collection reads and strict less-than semantics.\n`StockMutationOptions.actorProfileId` propagates to every movement, including\nboth transfer legs; null retains compatibility for automation. It is a qualified\ncross-package profile reference, not an authorization decision.\n\n`src/svelte/` exports data-driven components through `./svelte`. Server callers\nown authentication and persistence callbacks; never import backend models into\nthis entry or expose ledger mutations as CRUD. Package `typecheck` includes\nSvelte checks and `test` includes DOM component tests. See README for the public\nprops, service contracts, and additive schema upgrade requirements.\n"
1410
+ "agentDoc": "# @happyvertical/smrt-inventory\n\nMulti-location stock tracking. Strictly industry-neutral — the same primitives serve apparel, furniture, automotive, CPG, electronics, and any other vertical that counts discrete units across locations.\n\n## Models\n\n| Model | Purpose |\n|---|---|\n| _(catalog shapes — `Product`, `Material`, `ProductVariant`, `Sku` — live in `@happyvertical/smrt-products`)_ | Sku is the smallest sellable / countable unit; its `attributes` JSON carries axis-value pins. ProductVariant is the per-product axis declaration. Import either from `@happyvertical/smrt-products` directly. |\n| `InventoryLocation` | Warehouse / factory / retail / in-transit / virtual. Open-ended `kind` string. Optional `placeId` references `@happyvertical/smrt-places`. `conflictColumns: ['code', 'tenant_id']`. |\n| `StockLevel` | Materialized `qty` for a `(skuId, locationId, state)` tuple. Upserted in place. **Mutated only by `StockService`.** States: `available`, `allocated`, `wip`, `qc_hold`, `damaged`. |\n| `StockMovement` | Append-only audit log. Every `StockService` mutation writes one (or two for `transfer`). `sourceType` + `sourceId` carry cross-package attribution. |\n\nAll three inventory models (`InventoryLocation`, `StockLevel`, `StockMovement`) use `@TenantScoped({ mode: 'optional' })` + nullable `tenantId` so they can be used either tenant-scoped or globally. The catalog shapes (`Product`, `Material`, `ProductVariant`, `Sku`) live in `@happyvertical/smrt-products` and carry their own tenant decoration there.\n\n## StockService — the only sanctioned way to mutate stock\n\n```typescript\nimport { createStockService } from '@happyvertical/smrt-inventory';\n\nconst service = await createStockService({ db });\nawait service.receive(skuId, locationId, qty, { sourceType, sourceId });\nawait service.reserve(skuId, locationId, qty, { sourceType, sourceId });\nawait service.release(skuId, locationId, qty, { sourceType, sourceId });\nawait service.fulfill(skuId, locationId, qty, { sourceType, sourceId });\nawait service.transfer(skuId, fromLocId, toLocId, qty, { sourceType, sourceId });\nawait service.adjust(skuId, locationId, delta, { sourceType, sourceId });\n```\n\n| Method | Behavior | Movement reason |\n|---|---|---|\n| `receive` | +qty available — purchase receipt, return, production produce | `receipt` |\n| `reserve` | available → allocated. Throws `InsufficientStockError` on overdraw | `reservation` |\n| `release` | allocated → available (order cancel) | `release` |\n| `fulfill` | -qty allocated. Stock leaves the building | `fulfillment` |\n| `transfer` | move stock between locations (writes two movements) | `transfer_out`, `transfer_in` |\n| `adjust` | signed delta — cycle counts and one-off corrections | `adjustment` |\n\nAll methods reject zero / negative / non-finite quantities except `adjust`, which accepts non-zero signed deltas. Negative deltas that would drive a state below zero throw `InsufficientStockError`.\n\n## Opt-in DispatchBus hooks\n\nOff by default. Wire them up explicitly in the application's `smrt.ts`:\n\n```typescript\nimport { installInventoryDispatchHandlers } from '@happyvertical/smrt-inventory';\n\nconst handlers = await installInventoryDispatchHandlers({\n dispatchBus: bus,\n db, // or stockService: existingService\n});\n```\n\nThis subscribes to:\n- `contract:created` → calls `service.reserve()` for every line inside one `stockService.withTransaction(...)`, attributed to `('Contract', payload.contractId)`\n- `fulfillment:shipped` → calls `service.fulfill()` for every line inside one `stockService.withTransaction(...)`, attributed to `('Fulfillment', payload.fulfillmentId)`\n\nBoth handlers are atomic across lines: a shortfall on line N rolls back lines 1..N-1 so the event is all-or-nothing. Without this guarantee, `DispatchBus` would only log the async handler error and a partially-reserved (or partially-fulfilled) contract would have no compensating event to fix it.\n\nPer-handler toggles: `installContractReserved`, `installFulfillmentShipped`. The `production_order:posted` hook is deliberately not installed here — it depends on the BOM model and ships in `@happyvertical/smrt-manufacturing` (issue #1250).\n\n## Schema migration (Phase 1 release)\n\nThe `Sku` model moved out of this package and into `@happyvertical/smrt-products`. Previously it lived in this package's `inventory_skus` table; it now lives in `product_skus` over there.\n\n**Upgrade procedure** for deployed consumers:\n\n1. **Let the framework create the destination table first.** Boot the new version once with `@happyvertical/smrt-products` registered in your `SmrtClassOptions`; the lazy `syncSchema` path will create `product_skus` with the right primary key, NOT NULL, UNIQUE (`code`, `tenant_id`), and indexes — they're derived from the `Sku` model decorators and would be stripped by a raw `CREATE TABLE AS SELECT`.\n\n2. **Idempotently copy rows across.** SQLite + Postgres:\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 you've verified row counts match:\n\n ```sql\n DROP TABLE IF EXISTS inventory_skus;\n ```\n\n`StockLevel.skuId` and `StockMovement.skuId` carry plain string ids that still resolve to the same rows after the rename, so no inventory data has to move.\n\n## Gotchas\n\n- **Movements are append-only.** The materialized `StockLevel` row is derived state; the `StockMovement` ledger is the source of truth. Never let CRUD UIs expose update/delete on the movement table. If you find yourself wanting to \"fix\" a movement row, write a compensating `adjust()` call instead.\n- **Never call `StockLevel.create()` or `save()` directly.** Going around `StockService` silently desyncs the audit log and breaks cycle counts.\n- **Cross-industry constraint.** This package's vocabulary stays generic: `InventoryLocation`, `StockLevel`, `StockMovement`. Apparel-specific concepts (`Style`, `Makeup`, `Colorway`, fashion `Season`, tech-pack metadata) and their analogues in furniture / automotive / CPG live in the relevant template package, never here. PRs that introduce industry vocabulary should be rejected.\n- **Cross-package references are plain strings.** `skuId`, `placeId`, `sourceId` — all plain string ids, never `@foreignKey()`. Inventory's stock-motion logic only ever reads StockLevel/StockMovement; it doesn't follow `skuId` back to the catalog's Sku table, so the layering stays loose.\n- **`conflictColumns` include `tenant_id`** on `InventoryLocation` and `StockLevel`. NULL-matching semantics (`(code, NULL) IS NOT DISTINCT FROM (code, NULL)`) are handled by `@happyvertical/sql >= 0.74.0` natively, so two saves with the same `(code, NULL)` tuple correctly merge in place. Pass `nullsDistinct: true` at the sql layer to opt back into NULL-distinct semantics.\n- **Atomic per-method, transactional across composition.** Each `StockService` mutation (`receive` / `reserve` / `release` / `fulfill` / `transfer` / `adjust`) wraps every write in a single `db.transaction(...)` (via `@happyvertical/sql >= 0.74.0`). Partial failure rolls the whole call back — level writes and the matching `StockMovement` audit row commit together or not at all. `transfer` writes both legs (source decrement, destination increment, and both audit rows) inside one tx, so a failure mid-`transfer` is fully reverted.\n Callers composing multiple `StockService` calls into one logical unit (e.g. consuming materials line-by-line for a production order) should wrap them in `await stockService.withTransaction(async (tx) => { ... })` — `tx` is a tx-bound `StockService` with the same public API; mutation calls inside the callback share one transaction and either all commit or all roll back.\n If the underlying SQL adapter does not expose `transaction()` (extremely rare — all four built-in `@happyvertical/sql >= 0.74.0` adapters implement it), the service degrades to non-atomic serial writes and emits a one-time `console.warn`.\n- **Concurrent reservations: tightened, not bulletproof.** `@happyvertical/sql >= 0.74.0` serialises null-aware *upserts* via a per-key in-process lock (SQLite) or advisory lock (Postgres). That lock fixed the row-creation race (issue #1246) — two concurrent `create()` calls on the same conflict-column tuple no longer race the underlying `INSERT ... ON CONFLICT`. It does NOT serialise the read-modify-write compound that `reserve` / `fulfill` / `transfer` / `adjust` perform on an existing row: each method reads the current level (`SELECT`), computes the new value in JS, then writes it back (`UPDATE` via `save()` on the loaded row). The whole compound is inside one `db.transaction(...)` from round-3, so the level write and audit row commit together — but two concurrent transactions on the same `(skuId, locationId)` can each see the same starting balance, compute the same decrement, and double-spend. Awaited (serial) calls always behave correctly. Use a job queue (e.g. `@happyvertical/smrt-jobs`) or your own mutex when you need hard atomicity across concurrent callers. The full fix is `SELECT ... FOR UPDATE` inside the same transaction (Postgres) or upgrading the journal mode (SQLite); deferred until we see a real high-contention workload that warrants it.\n\n## Source attribution\n\nEvery `StockMovement` carries `sourceType` + `sourceId` so downstream queries can reconstruct \"what caused this movement\". Conventions used by the built-in dispatch handlers:\n\n| sourceType | Emitter | Note |\n|---|---|---|\n| `Contract` | `smrt-commerce` `contract:created` | All reservation/release/fulfilment legs caused by a contract |\n| `Fulfillment` | `smrt-commerce` `fulfillment:shipped` | Outbound shipment |\n| `PurchaseOrder` | (your code) | Inbound receipt |\n| `CycleCount` | (your code) | Adjustments from physical counts |\n| `TransferOrder` | (your code) | Both legs of a transfer |\n| `ProductionOrder` | `smrt-manufacturing` (issue #1250) | Materials consumed + finished goods produced |\n\n## Replenishment and browser components\n\n`StockService.setReorderPolicy()` is the sanctioned metadata writer for nullable\n`StockLevel.reorderPoint` / `reorderQuantity` on the available SKU/location row.\nIt does not change quantity or emit a movement. `findBelowReorderPoint()` uses\npublic tenant-filtered collection reads and strict less-than semantics.\n`StockMutationOptions.actorProfileId` propagates to every movement, including\nboth transfer legs; null retains compatibility for automation. It is a qualified\ncross-package profile reference, not an authorization decision.\n\n`src/svelte/` exports data-driven components through `./svelte`. Server callers\nown authentication and persistence callbacks; never import backend models into\nthis entry or expose ledger mutations as CRUD. Package `typecheck` includes\nSvelte checks and `test` includes DOM component tests. See README for the public\nprops, service contracts, and additive schema upgrade requirements.\n",
1411
+ "recipes": [
1412
+ {
1413
+ "id": "inventory.stock",
1414
+ "className": "StockRecipe",
1415
+ "label": "Inventory",
1416
+ "summary": "Keep count of how many of each product you have.",
1417
+ "synonyms": [
1418
+ "stock",
1419
+ "quantity",
1420
+ "on hand"
1421
+ ],
1422
+ "models": [
1423
+ "@happyvertical/smrt-inventory:StockLevel",
1424
+ "@happyvertical/smrt-inventory:InventoryLocation",
1425
+ "@happyvertical/smrt-inventory:StockMovement"
1426
+ ],
1427
+ "nav": [
1428
+ {
1429
+ "label": "Stock levels",
1430
+ "model": "@happyvertical/smrt-inventory:StockLevel",
1431
+ "icon": "archive",
1432
+ "description": "How much of each item you have on hand right now.",
1433
+ "noun": "stock entry"
1434
+ },
1435
+ {
1436
+ "label": "Locations",
1437
+ "model": "@happyvertical/smrt-inventory:InventoryLocation",
1438
+ "icon": "home",
1439
+ "description": "The places you keep stock, like shelves, rooms or vans."
1440
+ },
1441
+ {
1442
+ "label": "Stock movements",
1443
+ "model": "@happyvertical/smrt-inventory:StockMovement",
1444
+ "icon": "truck",
1445
+ "description": "Every time stock came in, went out or moved."
1446
+ }
1447
+ ],
1448
+ "requires": [],
1449
+ "requiresAny": [
1450
+ [
1451
+ "products.simple",
1452
+ "products.clothing"
1453
+ ]
1454
+ ],
1455
+ "section": {
1456
+ "id": "catalog",
1457
+ "label": "Catalog",
1458
+ "icon": "package",
1459
+ "description": "Products, ingredients and the stock you hold."
1460
+ },
1461
+ "options": {
1462
+ "@happyvertical/smrt-inventory:StockLevel": {
1463
+ "fields": {
1464
+ "reorderPoint": {
1465
+ "visibility": "hidden"
1466
+ },
1467
+ "reorderQuantity": {
1468
+ "visibility": "hidden"
1469
+ }
1470
+ }
1471
+ },
1472
+ "@happyvertical/smrt-inventory:InventoryLocation": {
1473
+ "fields": {
1474
+ "placeId": {
1475
+ "visibility": "hidden"
1476
+ },
1477
+ "active": {
1478
+ "visibility": "hidden"
1479
+ }
1480
+ }
1481
+ },
1482
+ "@happyvertical/smrt-inventory:StockMovement": {
1483
+ "fields": {
1484
+ "sourceType": {
1485
+ "visibility": "hidden"
1486
+ },
1487
+ "sourceId": {
1488
+ "visibility": "hidden"
1489
+ },
1490
+ "actorProfileId": {
1491
+ "visibility": "hidden"
1492
+ }
1493
+ }
1494
+ }
1495
+ },
1496
+ "help": {
1497
+ "markdown": "## Overview\n\nInventory keeps count of how many of each product you have. Each count is a\nstock level: an item, the place it is kept and how many are on hand. Stock can\nsit in more than one place, and every change is recorded as a stock movement so\nthere is a trail.\n\n## Tasks\n\n### Count what you have\n\n1. Create a stock level for the item and the place it is kept.\n2. Set **{field:qty}** to how many you have on hand.\n3. Set **{field:state}** if the stock is not available to sell, for example\n allocated or on quality hold.\n\n### Correct a count\n\n1. Find the stock level for the item.\n2. Set **{field:qty}** to what you really have. Record why as a stock movement.\n\n### Keep stock in more than one place\n\n1. Add a location with a **{field:code}** and a **{field:name}**, and say what\n sort it is in **{field:kind}**.\n2. Create stock levels against that location.\n",
1498
+ "fieldRefs": [
1499
+ "code",
1500
+ "kind",
1501
+ "name",
1502
+ "qty",
1503
+ "state"
1504
+ ]
1505
+ },
1506
+ "demo": {
1507
+ "mode": "server",
1508
+ "reasons": [
1509
+ "Needs a server because its package depends on @happyvertical/smrt-tenancy, which do not build for a browser (#3624)."
1510
+ ]
1511
+ }
1512
+ }
1513
+ ],
1514
+ "browser": {
1515
+ "status": "server-only",
1516
+ "issues": [
1517
+ "#3624"
1518
+ ],
1519
+ "via": [
1520
+ "@happyvertical/smrt-tenancy"
1521
+ ]
1522
+ }
1396
1523
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-inventory",
3
- "version": "0.55.7",
3
+ "version": "0.55.8",
4
4
  "description": "Multi-location stock tracking for the SMRT framework — Sku, InventoryLocation, StockLevel, StockMovement, and a stock-mutation service",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -24,14 +24,14 @@
24
24
  }
25
25
  },
26
26
  "dependencies": {
27
- "@happyvertical/logger": "^0.102.5",
28
- "@happyvertical/smrt-core": "0.55.7",
29
- "@happyvertical/smrt-tenancy": "0.55.7",
30
- "@happyvertical/sql": "^0.102.5",
31
- "@happyvertical/smrt-ui": "0.55.7"
27
+ "@happyvertical/logger": "^0.103.1",
28
+ "@happyvertical/smrt-core": "0.55.8",
29
+ "@happyvertical/smrt-tenancy": "0.55.8",
30
+ "@happyvertical/sql": "^0.103.1",
31
+ "@happyvertical/smrt-ui": "0.55.8"
32
32
  },
33
33
  "devDependencies": {
34
- "@happyvertical/smrt-vitest": "0.55.7",
34
+ "@happyvertical/smrt-vitest": "0.55.8",
35
35
  "@types/node": "24.13.6",
36
36
  "typescript": "6.0.3",
37
37
  "vite": "8.1.4",