refactor(core/inventory): column-scope the inventory upsert's existing-row write - #2144
Conversation
piotrswierzy
left a comment
There was a problem hiding this comment.
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'sUpdateQueryBuilderappends the auto-stamp only when the column is absent fromSET, so the exclusion is load-bearing — andInventorySyncServicederives its propagation dedupe key from that value.id/productId/productVariantId/locationId→ lookup key, never writable.isStale→ out-of-band writer ismarkStaleExceptVariants, a single bulk absolute-setUPDATE. This PR deliberately keepsisStalein the written set, and the stated precondition holds:upsert's only caller isInventoryService.setInventory, whose only caller ismaster-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", therecordStatusshape), 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 undefined → new 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
left a comment
There was a problem hiding this comment.
Delta re-review (339a6808 → 522e4269) — ❌ 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.
…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>
…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>
522e426 to
ae6c059
Compare
What
InventoryRepository.upsertwrote an existinginventory_itemsrow throughsave()aftertoOrmEntitypopulated 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:
id,productId,productVariantId,locationIdavailableQuantity,reservedQuantity,isStaleupdatedAt@UpdateDateColumnkeeps stamping itupdatedAtwas actually being written wrong — this is a fix, not only hardeningThe issue framed this as pure hardening, and so did my first draft. Both were wrong.
toOrmEntityassigneditem.updatedAt, and on thesave()path that value reached the database:SubjectChangedColumnsComputerhascolumn.isUpdateDateexplicitly commented out of its skip list, so an assigned update-date enters the change map and suppressesCURRENT_TIMESTAMP. (TheSubjectExecutoroverwrite 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
@UpdateDateColumnhas been carrying a master-supplied value. It has never bitten only because both shipped inventory-master adapters leave the field undefined andMasterInventorySyncServicefills it with?? new Date(). An adapter that starts reportingupdatedAtwould silently take over the column — andInventorySyncServicederives 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
updatedAtin 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 therepository.update(id, …)idiom used elsewhere — this file already uses both the builder and.returning()formarkStaleExceptVariants.Both branches changed, not just the update branch
The same exclusion applies to the insert path:
toOrmEntityno longer assignsupdatedAtat all, because assigning an@UpdateDateColumnon 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 whensave()returns no usable stamp, rather than handing back anInventoryItemwhoseupdatedAtis typedDatebut isundefined— into the same dedupe-key consumer. Both resolve through one helper, which additionally rejects an unparseable value: the raw key is literallyupdatedAtonly because nonamingStrategyis configured, and under a snake_case strategy the row object stays truthy while the property readsundefined, 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 raisesInventoryRowVanishedErrorinstead of returning anInventoryItemfor a row that no longer exists — whichInventorySyncServicewould turn into a marketplace propagation for absent stock. Unreachable today (the port has no delete, the staleness sweep is a soft update, both FKs areON DELETE NO ACTION), so it guards a future delete path.isStalestays owned — and why that is safe is now written downisStaleis writtenfalseon 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:MasterInventorySyncServiceruns itssetInventoryloop beforepruneStaleVariants, 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, #2101fulfillmentState, #2140syncStatus/syncAttempts), but the first to use allowlisting rather than omission.docs/lessons.mddescribed 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:
saveis not called.route-lazy.test.ts's expected-count assertion.RETURNING, an unparseable returned stamp, and both insert sub-branches (direct and regenerated-id) each assert their throw.inventory-stale-prune.int-spec.tspasses a deliberately stale master timestamp and asserts the persistedupdatedAtis 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
updatedAtback 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 — aPHP Unit Testsfailure on a PR touching zero PHP).pnpm lint0 errors,pnpm type-checkclean,pnpm testexit 0, 5 inventory suites / 74 tests green.Closes #2071
🤖 Generated with Claude Code