Skip to content

refactor(core/inventory): column-scope the inventory upsert's existing-row write - #2144

Merged
piotrswierzy merged 4 commits into
mainfrom
2071-inventory-upsert-column-scope
Aug 19, 2026
Merged

refactor(core/inventory): column-scope the inventory upsert's existing-row write#2144
piotrswierzy merged 4 commits into
mainfrom
2071-inventory-upsert-column-scope

Conversation

@piotrswierzy

@piotrswierzy piotrswierzy commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

What

InventoryRepository.upsert wrote an existing inventory_items row through save() after toOrmEntity populated every column, so which columns the master sync may touch was an emergent property of TypeORM's diffing — nothing declared it, nothing failed when it changed. This is the row every published quantity derives from, so the failure mode is an oversell or a mass unpublish, produced with zero compile errors.

Three column groups are now declared and the update writes exactly one of them:

Group Columns Why
Identity id, productId, productVariantId, locationId These are the lookup key — writing them back is a no-op at best, a row-identity change at worst
Master-owned availableQuantity, reservedQuantity, isStale What the master sync is entitled to write
DB-managed updatedAt Excluded from both the SET clause and the insert mapping, so @UpdateDateColumn keeps stamping it

updatedAt was actually being written wrong — this is a fix, not only hardening

The issue framed this as pure hardening, and so did my first draft. Both were wrong.

toOrmEntity assigned item.updatedAt, and on the save() path that value reached the database: SubjectChangedColumnsComputer has column.isUpdateDate explicitly commented out of its skip list, so an assigned update-date enters the change map and suppresses CURRENT_TIMESTAMP. (The SubjectExecutor overwrite that appears to prevent this sits in the MongoDB branch and never runs on Postgres — a trap, because it reads like the general case. I cited it wrongly at first.)

So an @UpdateDateColumn has been carrying a master-supplied value. It has never bitten only because both shipped inventory-master adapters leave the field undefined and MasterInventorySyncService fills it with ?? new Date(). An adapter that starts reporting updatedAt would silently take over the column — and InventorySyncService derives the propagation job's dedupe key from it, so a master reporting a stable timestamp while quantity moved would collide the key and drop the propagation.

Verified empirically rather than by reading: reinstating updatedAt in the SET clause makes the integration test persist a 2020 master timestamp.

Excluding it means the returned entity can no longer reconstruct the value, hence the query-builder form with .returning(['updatedAt']) rather than the repository.update(id, …) idiom used elsewhere — this file already uses both the builder and .returning() for markStaleExceptVariants.

Both branches changed, not just the update branch

The same exclusion applies to the insert path: toOrmEntity no longer assigns updatedAt at all, because assigning an @UpdateDateColumn on an INSERT persists the master's timestamp exactly as it used to on UPDATE.

That makes the stamp DB-only on both paths, so both must verify they got one. The update branch throws when the driver ignores RETURNING; the insert branch now throws when save() returns no usable stamp, rather than handing back an InventoryItem whose updatedAt is typed Date but is undefined — into the same dedupe-key consumer. Both resolve through one helper, which additionally rejects an unparseable value: the raw key is literally updatedAt only because no namingStrategy is configured, and under a snake_case strategy the row object stays truthy while the property reads undefined, so guarding the row was never sufficient.

Failing loudly instead of returning a phantom row

A scoped UPDATE cannot resurrect a deleted row the way save() could (it fell back to an INSERT). Zero affected rows now raises InventoryRowVanishedError instead of returning an InventoryItem for a row that no longer exists — which InventorySyncService would turn into a marketplace propagation for absent stock. Unreachable today (the port has no delete, the staleness sweep is a soft update, both FKs are ON DELETE NO ACTION), so it guards a future delete path.

isStale stays owned — and why that is safe is now written down

isStale is written false on every upsert (the sole domain construction outside this file omits the argument, so the constructor default applies). The existing comment cites #1478 "clears the flag when a variant reappears", but that states intent. The actual guarantee is ordering: MasterInventorySyncService runs its setInventory loop before pruneStaleVariants, and the prune stales exactly the variants the loop did not report — so the sets are disjoint and an upsert cannot un-stale a row the same run just flagged. That precondition is now recorded next to the set it constrains, and restated on the port where a new caller will actually read it.

Two mechanisms for one defect class, now recorded

This is the fourth fix in the "a narrow out-of-band writer gets clobbered by a broad upsert" family (#1984 cancelledAt, #2101 fulfillmentState, #2140 syncStatus/syncAttempts), but the first to use allowlisting rather than omission.

docs/lessons.md described only omission. It now names both, says when each earns its weight — allowlisting buys a build failure on a newly-added column, which omission structurally cannot, since omission is the absence of a line and nothing fails when the next author adds one — and lists this repository under Applies to. Without that, the next author picks between the two mechanisms by coin flip.

Tests

The AC asks for a test that "a column outside the owned set is not modified". No such column exists — every column is identity, owned, or DB-managed — so that test would have to invent one and prove nothing. Instead:

  • Behavioural: the SET payload has exactly the owned keys (both directions), each value sourced from the item, keyed on the matched row's id; no identity or DB-managed column appears; save is not called.
  • Contract: every column declared on the ORM entity is classified into exactly one group — so adding a column fails the build until someone decides where it belongs. Same "must be updated deliberately" shape as route-lazy.test.ts's expected-count assertion.
  • Failure paths: zero-affected, ignored-RETURNING, an unparseable returned stamp, and both insert sub-branches (direct and regenerated-id) each assert their throw.
  • Integration: inventory-stale-prune.int-spec.ts passes a deliberately stale master timestamp and asserts the persisted updatedAt is strictly greater than both it and the pre-update value — the only assertion shape that proves TypeORM really omitted the column, which a mocked spec cannot.

All three guards are mutation-verified, not merely green: adding a column to the entity fails the contract spec by name, slipping updatedAt back into the SET clause fails the behavioural specs, and the same mutation fails the integration assertion.

Gate

Rebased onto current main (the previous red CI was a stale base — a PHP Unit Tests failure on a PR touching zero PHP). pnpm lint 0 errors, pnpm type-check clean, pnpm test exit 0, 5 inventory suites / 74 tests green.

Closes #2071

🤖 Generated with Claude Code

@piotrswierzy piotrswierzy left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tech Lead review — 🔄 Approve with changes

(Posted as a comment because GitHub rejects APPROVE on one's own PR. No blocking defects.)

Third instance of the "narrow out-of-band writer gets clobbered by a broad upsert" family, after #2107 and #2141, this time on inventory_items. Notably it uses a different mechanism from its two predecessors: instead of dropping the assignment in toOrm so TypeORM omits the column, it replaces the existing-row save() with an explicit column-scoped UPDATE … SET availableQuantity, reservedQuantity, isStale WHERE id = :id RETURNING updatedAt. I checked the mechanics against the files and found no correctness defect. CI 9/9.

Race-safety — confirmed. findByProductAndVariant is used only to resolve identity for the WHERE id; every written column is an absolute set from the inbound item, so this is not the read-modify-write shape the lessons rule forbids. Owners of the scoped-out columns:

  • updatedAt → the DB (@UpdateDateColumn, inventory-item.orm-entity.ts:70). TypeORM's UpdateQueryBuilder appends the auto-stamp only when the column is absent from SET, so the exclusion is load-bearing — and InventorySyncService derives its propagation dedupe key from that value.
  • id / productId / productVariantId / locationId → lookup key, never writable.
  • isStale → out-of-band writer is markStaleExceptVariants, a single bulk absolute-set UPDATE. This PR deliberately keeps isStale in the written set, and the stated precondition holds: upsert's only caller is InventoryService.setInventory, whose only caller is master-inventory-sync.service.ts:101, which runs before the prune at :131/:219 — so the two write sets are disjoint. That is also squarely inside the lessons rule's carve-out (ingestion legitimately re-derives "the master reported this variant ⇒ live", the recordStatus shape), so I agree it isn't an instance of the bug.

#2141's three refinements. Whole method swept — yes, and the insert branch is explicitly reasoned about and left un-scoped, which is right (an INSERT has no competing writer). ?? [] guards — correctly N/A: no array/jsonb column on this entity, and the update branch never routes through toDomain. DB-default provenance — no NOT NULL column newly depends on a default, so the absence of a migration is a conclusion rather than an omission.

Int-spec — present and the right kind. inventory-stale-prune.int-spec.ts pushes a deliberately ancient master timestamp (2020-01-01) through a real save path and asserts the committed updatedAt is newer than both the prior row and the master value. That is the only assertion shape that proves the column was actually omitted; a mocked spec cannot.

IMPORTANT

1. libs/core/src/inventory/domain/ports/inventory-repository.port.ts:52 — the upsert doc comment is unchanged. Both predecessors documented the exclusion on the port and the impl. Here the port hides three caller-observable changes: the returned item's updatedAt is now the DB-stamped value rather than the inbound one, and upsert can now throw (InventoryRowVanishedError, plus a bare Error on a driver that ignores RETURNING) where it previously always resolved. Please state on the port that the master-owned write set is scoped, that updatedAt is DB-authoritative, and that the method throws.

2. docs/lessons.md:29-32 — not updated, and this is the gap I'd most want closed before merge. The existing rule prescribes the toOrm-omission mechanism and its Applies to names only order-record.repository.ts. This PR introduces a second, different mechanism for the same defect class on a second table. Whichever is now preferred, the ledger should say so and list inventory.repository.ts — otherwise instance #4 picks between save()-omission and a scoped UPDATE arbitrarily, and we re-litigate the mechanism instead of applying it.

SUGGESTION

3. The missing-RETURNING branch throws a bare Error('inventory_upsert_missing_returning …') from a repository, where standards call for a domain exception — and the sibling path in the same block correctly uses InventoryRowVanishedError. Either a second named error or a discriminating reason on the existing one.

4. raw[0].updatedAt is key-name-coupled. It works today only because the project sets no namingStrategy, so the column is literally "updatedAt". Adopt snake_case later and returnedRow stays truthy while returnedRow.updatedAt becomes undefinednew Date(undefined) → a silent Invalid Date past both guards. A Number.isNaN(persistedUpdatedAt.getTime()) check makes that fail loudly.

5. The INVENTORY_*_COLUMNS groups are exported purely for the spec. Acceptable, but they're infrastructure detail — a one-line "exported for the classification spec, not for callers" note keeps them from drifting into the public surface. (Correctly absent from the barrel; only the error is exported there.)

Positive observations

  • The classification spec is the strongest artefact here. Requiring every declared entity column to fall into exactly one group means adding a column now fails the build until someone classifies it — a materially better guarantee than either predecessor got, and the thing that makes this mechanism worth its extra weight.
  • The comment forbidding "DRYing" the literal set({...}) into an object built from the constant — because the literal is what gives TypeORM's key type-checking something to check — is exactly right, and pre-empts a plausible future regression.
  • Both failure branches are justified by what the caller would do with a wrong value (enqueue propagation for absent stock / poison the dedupe key), not by defensive habit. InventoryRowVanishedError's header states why it should be unreachable and cites the evidence.

CI: all 9 checks green on 339a680.

Merge readiness: 🔄 ready once the port doc and the docs/lessons.md update land. Both small; the second is what stops instance #4 from starting over.

@piotrswierzy piotrswierzy left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delta re-review (339a6808522e4269) — ❌ Not ready

(Posted as a comment because GitHub rejects REQUEST_CHANGES on one's own PR. Treat this as blocking: two prior findings are untouched, there's a new asymmetry, and CI is red.)

One new commit: fix(core/inventory): stop the insert path persisting the master's updatedAt.

Prior findings

Finding Status Evidence
IMPORTANT 1 — inventory-repository.port.ts:52 upsert doc Not resolved The port file isn't in the diff at all. The doc still reads @returns Upserted inventory item domain entity, with no mention of the column-scoped write set, the DB-authoritative updatedAt, or the two new throws
IMPORTANT 2 — docs/lessons.md Not resolved Untouched. Applies to still names only order-record.repository.ts, and the ledger still describes one mechanism while this PR ships a second
SUGGESTION a — bare Error ✅ Resolved New inventory-returning-unsupported.error.ts, thrown at :318, exported from the barrel, spec asserts the type rather than a message regex
SUGGESTION b — raw[0].updatedAt key coupling ⬜ Not resolved :320-323 still lacks a Number.isNaN(...getTime()) guard
SUGGESTION c — INVENTORY_*_COLUMNS export note ⬜ Not resolved Mitigated by their absence from the barrel, so reach is intra-context

Invariant re-established. MasterInventorySyncService and InventoryService are untouched, so the setInventory-before-prune ordering that makes isStale safe in the owned set still holds, and the precondition is still recorded at :61-68. The classification spec still forces classification — inventory.repository.spec.ts:289-302 reads getMetadataArgsStorage().columns and toEquals the union of the three groups, so a new entity column fails by name. Both good.

NEW — IMPORTANT

1. inventory.repository.ts:397-402 — the insert path now has no counterpart to the update path's RETURNING guard. Dropping entity.updatedAt = item.updatedAt from toOrmEntity is correct and proven on Postgres by the new int-spec. But toDomain(saved) at :389/:394 now reads entity.updatedAt straight back, and on any driver where TypeORM doesn't return the inserted row that property is undefined while InventoryItem.updatedAt is typed Date. The update path throws loudly for exactly this condition (InventoryReturningUnsupportedError); the insert path hands a malformed entity to the same propagation-dedupe-key consumer.

That is precisely trap (1) in the existing docs/lessons.md rule — "an excluded column needs a guard where toDomain reads it" — which is a second, independent reason finding 2 above needs closing rather than a coincidence. Either guard symmetrically or state why the insert path is exempt.

2. The PR body is now stale. This commit changes the insert path, not just the update path, but the summary table still describes updatedAt as excluded only from "the SET clause". Worth fixing so a reader of the merge commit sees both.

SUGGESTION

inventory-row-vanished.error.ts:24-28 dropped the cause?: unknown parameter. Correct — no caller passes one — and safe, since the class was introduced by this same PR so nothing external depends on the old shape. Noting it only so the signature change is on the record.

CI — red on 522e4269

Lint, Type Check, Build, Test, PHP Unit Tests all failing; Integration Tests, Docker Smoke, Scaffolded Adapter Builds pass. Plausibly a stale base rather than a branch defect: mergeable_state: "behind", base bc59f43e, and a PHP failure on a PR touching zero PHP is otherwise inexplicable. Sibling #2150, based one commit later, is fully green. Rebase onto c9231c9b and re-run. If Lint stays red, the if (Error.captureStackTrace) guard added in both new exception files is the candidate — no-unnecessary-condition, since the property is non-optional in the TS lib types.

One cross-PR note

#2150 currently deletes the docs/lessons.md rule you'd be extending here (verified: present on main, absent on its branch). I've asked that PR to restore it. Worth coordinating the merge order — if #2150 lands first unfixed, finding 2 has nothing to attach to.

Merge readiness: ❌ Not ready. Both untouched findings are single-file doc edits, and the second is arguably the more valuable half of this PR: the point of introducing a second mechanism for a known defect class is that the next author can tell which one to reach for. Add the insert-path guard or its exemption comment, rebase, confirm green.

norbert-kulus-blockydevs added a commit that referenced this pull request Aug 18, 2026
…orders barrel, link #2152

Piotr's #2150 re-review found a doc regression: the merge that added three
new lessons.md entries silently dropped the pre-existing "column written by
a narrow out-of-band UPDATE" entry instead of landing above it. That rule is
actively maintained across #1984/#2101/#2140 and #2144 is extending it right
now, so losing it here would drop the fourth instance of a recurring defect
from the ledger.

Also closes the IMPORTANT finding: frontend-architecture.md's cross-feature
guard comment says to update that doc whenever a new slug is added to the
no-restricted-imports groups, and `orders` (added in the prior round for
this same PR) never got its paragraph even though it's now the most
cross-imported feature barrel in the app.

And the SUGGESTION: the three per-page divergences #2091 exposed, plus the
DataTableSkeleton row-height mismatch, now have a tracking issue (#2152)
instead of living only in the PR body.

Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
piotrswierzy and others added 4 commits August 18, 2026 11:27
…g-row write

`InventoryRepository.upsert` wrote an existing `inventory_items` row through
`save()` after `toOrmEntity` populated every column, so which columns the master
sync may touch was an emergent property of TypeORM's diffing — nothing declared
it and nothing failed when it changed. `inventory_items` is the row every
published quantity derives from, so the failure mode is an oversell or a mass
unpublish, produced with zero compile errors.

Declare three column groups and write exactly the owned one:

- identity (`id`, `productId`, `productVariantId`, `locationId`) — these ARE the
  lookup key, so writing them back is a no-op at best;
- master-owned (`availableQuantity`, `reservedQuantity`, `isStale`);
- DB-managed (`updatedAt`).

`updatedAt` is the subtle one and is deliberately excluded. On the `save()` path
TypeORM discards the assigned value and stamps its own, so the column has always
meant OL-write time. But `UpdateQueryBuilder` appends the `@UpdateDateColumn`
timestamp only when the column is absent from the SET clause — naming it would
have suppressed the stamp and persisted whatever the master reported. That
matters because `InventorySyncService` derives the propagation job's dedupe key
from this field: a master reporting a stable timestamp while quantity moved
would collide the key and the propagation would be dropped silently.

Excluding it means the caller can no longer reconstruct the returned entity's
`updatedAt`, hence the query-builder form with `.returning(['updatedAt'])` rather
than the `repository.update(id, …)` idiom used elsewhere — this file already uses
both the builder and `.returning()` for `markStaleExceptVariants`.

`isStale` stays in the owned set, and the comment now records why that is safe
rather than citing intent: it is written `false` on every upsert (the sole domain
construction outside this file omits the argument), and only the ordering in
`MasterInventorySyncService` — the `setInventory` loop runs before
`pruneStaleVariants`, which stales exactly the variants the loop did not report —
keeps the upserted and staled sets disjoint. That precondition is now written
down next to the set it constrains.

The insert branch stays deliberately un-scoped: an INSERT necessarily writes
every column, so there is no other writer's column to avoid clobbering.

Tests: the behavioural specs assert the SET payload has exactly the owned keys
with values sourced from the item, that no identity or DB-managed column appears,
and that the returned `updatedAt` is the persisted one. A contract spec asserts
every column declared on the ORM entity is classified into exactly one group, so
adding a column fails the build until someone decides which group it belongs to.
Both guards were mutation-checked: adding a column to the entity fails the
contract spec by name, and slipping `updatedAt` back into the SET clause fails
the two behavioural specs.

No behaviour change, proven by the existing integration coverage this touches —
`inventory-stale-prune.int-spec.ts` drives `setInventory` → the existing-row
branch and asserts row reuse, cleared `isStale` and quantity.

Closes #2071

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
Review follow-ups on #2071, and a correction that changes what this change is.

`updatedAt` was NOT merely at risk of being written from the master — the old
`save()` path already persisted the master's value. `SubjectChangedColumnsComputer`
comments `column.isUpdateDate` out of its skip list, so an assigned update-date
entered the change map and suppressed CURRENT_TIMESTAMP. The
`SubjectExecutor` overwrite that appears to prevent this sits in the MongoDB
branch and never runs on Postgres. Verified empirically: reinstating
`updatedAt` in the SET clause makes the integration test persist a 2020 master
timestamp. So excluding it is a fix, not just hardening, and the plan is
corrected to say so.

Three defects in the first cut:

- A scoped UPDATE cannot resurrect a deleted row the way `save()` could (it fell
  back to an INSERT). Zero affected rows silently returned an `InventoryItem` for
  a row that no longer exists, which `InventorySyncService` would turn into a
  marketplace propagation for absent stock. Now raises the domain error
  `InventoryRowVanishedError`. Unreachable today — the port has no delete, the
  staleness sweep is a soft update, and both FKs are `ON DELETE NO ACTION` — so
  it guards a future delete path rather than a live case.
- The empty-`raw` fallback substituted `item.updatedAt`, the master-supplied
  value, which is precisely the dedupe-key poison the exclusion exists to
  prevent. TypeORM makes `.returning()` a silent no-op on drivers lacking
  support, so that branch would have fired on every successful update there.
  It now throws.
- The int-spec asserted nothing about `updatedAt`, leaving the CURRENT_TIMESTAMP
  claim evidenced only by a mock returning a hardcoded date. It now passes a
  deliberately stale master timestamp and asserts the persisted value is strictly
  greater than both it and the pre-update value.

Also records the app-clock-to-DB-clock shift and its one tripwire (a future
multi-row transaction would give every row an identical `updatedAt`; safe today
because the dedupe key also includes product and variant), notes the read-then-
update race as unchanged last-write-wins, and marks the SET literal as
deliberately not derived from the constant so TypeORM's key typing still applies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
…atedAt

PR-review follow-ups on #2071. The first pass fixed only half the problem.

`toOrmEntity` still assigned `entity.updatedAt = item.updatedAt`, and that
mapping is what the INSERT branch saves. By the same mechanism this PR already
proved for the UPDATE branch, assigning an @UpdateDateColumn puts it in the
change map and suppresses CURRENT_TIMESTAMP — so a first insert persisted the
master's timestamp while an update no longer did. The invariant this change
states in three places therefore held on one branch and was violated on the
other.

That is operator-visible: `findStockAggregatesByProductIds` surfaces
`MAX(updatedAt)` as `stockUpdatedAt`, so a freshly created row could read "last
updated 2020"; and `InventorySyncService` always enqueues on a first write
(`previous === null`), keying the dedupe token off that master value.

Drop the assignment and let the database stamp both branches. Verified by
mutation: restoring the line makes the new insert-path integration test persist
the 2020 timestamp and fail.

Also from the review:

- Replace the bare `Error` on the missing-RETURNING path with
  `InventoryReturningUnsupportedError`, so both guards on that write are typed
  domain errors per `engineering-standards.md § Error Handling` rather than one
  typed and one matched by message regex in the spec.
- Relax the int-spec's before/after `updatedAt` comparison to `>=`:
  `getTime()` truncates Postgres microseconds to milliseconds, so two writes in
  the same millisecond would flake. The now-vs-2020 assertion on the next line is
  the one that actually proves the property.
- Drop the unused `cause` parameter from `InventoryRowVanishedError`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
… path

Addresses review findings on #2144.

The port doc hid three caller-observable changes: the existing-row write is
column-scoped, the returned `updatedAt` is DB-authoritative rather than the
inbound value, and `upsert` can now throw where it previously always resolved.
Both predecessors (#2101, #2140) documented the exclusion on the port as well
as the impl; this brings the port up to that bar and records the `isStale`
ordering precondition where a new caller will actually read it.

`docs/lessons.md` described exactly one mechanism for this defect class
(omission in `toOrm`) while this PR ships a second (an explicit allowlisted
UPDATE plus a column-classification spec). The ledger now names both, says
when each is worth its weight — allowlisting buys a build failure on a
newly-added column, which omission cannot — and lists `inventory.repository.ts`
under "Applies to" so the next author picks deliberately instead of by coin
flip.

The insert branch had no counterpart to the update branch's RETURNING guard.
Since `toOrmEntity` stopped assigning `updatedAt`, an insert whose driver does
not return the row handed back an `InventoryItem` whose `updatedAt` is typed
`Date` but is `undefined` — into the same propagation-dedupe-key consumer the
update branch fails loudly to protect. Both branches now resolve the stamp
through one helper, which also rejects an unparseable value: the raw key is
literally `updatedAt` only because no `namingStrategy` is configured, and under
a snake_case strategy the row object stays truthy while the property reads
`undefined`, so guarding the row was never sufficient.

Four specs added: the unparseable stamp, and both insert sub-branches.

Signed-off-by: Piotr Swierzy <p.j.swierzy@gmail.com>
Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
@piotrswierzy
piotrswierzy force-pushed the 2071-inventory-upsert-column-scope branch from 522e426 to ae6c059 Compare August 18, 2026 13:39
@piotrswierzy
piotrswierzy merged commit 116598a into main Aug 19, 2026
9 checks passed
@piotrswierzy
piotrswierzy deleted the 2071-inventory-upsert-column-scope branch August 19, 2026 09:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[TECH-DEBT] Inventory — make InventoryRepository.upsert's existing-row write explicitly column-scoped

1 participant