fix(core/orders): stop the order upsert resetting fulfillmentState to NULL - #2107
Conversation
… NULL `OrderRecordRepository.toOrm` mapped `fulfillmentState` unconditionally while `persistOrder` never populated it, so every re-ingestion of an order - a poll re-read, a webhook-triggered sync, a manual re-sync - wrote the ingestion path's in-memory `null` over the rollup the shipping context had committed via `updateFulfillmentState`. A dispatched order reappeared as not-shipped: it re-entered its ship-by SLA bucket and matched the not-shipped list filter again. Exclude the column from the upsert's write set, exactly as `cancelledAt` already is (#1984), leaving `updateFulfillmentState` as its sole writer. Reading the row first and carrying the value forward was the alternative, but it still loses a rollup that commits between that read and the unlocked save; omitting the column is race-free and costs no extra read. Adds unit coverage that the property never reaches `save()` (including when a domain record carries a value) and an integration test proving the committed `dispatched` state survives a second `persistOrder` and is not reclassified by the SLA bucket or the fulfillment filter. Closes #2101 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
Tech Lead review (PR #2107)Reviewed against Findings[BLOCKING] -
[IMPORTANT] - acceptance criterion 4 of #2101 (
[IMPORTANT] -
[SUGGESTION] -
[SUGGESTION] -
Positive observations
SummaryA correct, minimal, well-argued fix for a real operator-visible defect: Merge ReadinessRequest changes Priority fixes
|
|
Addressed the blocking finding in What changed. The spec no longer imports
That is the only change; the fix itself is untouched. No coverage lost. Verification.
Still outstanding. The acceptance-criterion-4 follow-up for the |
#2101 int-spec The new int-spec resolved `ORDER_RECORD_REPOSITORY_TOKEN` and imported `OrderRecordRepositoryPort` from `@openlinker/core/orders`. A `*RepositoryPort` is an intra-context contract, so importing one from `apps/api` is a deny shape in `scripts/check-cross-context-imports.mjs` and fails root `pnpm lint` and CI (the walked scope covers `apps/api/test/integration/**`, and the `type`-only import prefix is stripped before matching). Route the three filter reads and the single record read through `IOrderRecordService.findMany` / `getOrderRecord`, the published cross-context surface for exactly this. Both service methods are pass-through delegations to `findMany` / `findById`, so the same SQL runs against the same shapes and no coverage is lost: the spec still asserts that after persistOrder -> updateFulfillmentState -> persistOrder the committed `dispatched` rollup survives, is not reclassified as not-shipped, and does not re-enter the overdue SLA bucket. Refs #2101 Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
a222cba to
00a31fb
Compare
piotrswierzy
left a comment
There was a problem hiding this comment.
Tech-lead review — ✅ Approve (merge once CI actually runs)
Correct fix, and it follows the established precedent in this exact file rather than inventing a mechanism.
Confirmed: toOrm mapped entity.fulfillmentState = orderRecord.fulfillmentState while OrderRecordService.persistOrder passes only 11 constructor args — the rollup is the 12th — so every re-ingestion wrote the in-memory null over a committed 'dispatched'. Removing the assignment makes TypeORM omit the column, which is the identical mechanism as the cancelledAt exclusion three lines below (#1984) and race-safe under the same webhook-vs-poll two-writer scenario. updateFulfillmentState remains an absolute-set single-statement UPDATE, so there's no read-modify-write to lose.
Rejecting the read-before-write alternative in the code comment is the right call, and the consequence of the exclusion — upsert()'s returned record reporting the column as null regardless of the row — is documented on both the port and the impl, matching the cancelledAt treatment.
IMPORTANT — same defect class survives two columns over
order-record.repository.ts:790-796 — syncStatus / syncAttempts have the same shape, and this PR's own new lessons.md rule names it. Both are mapped in toOrm; persistOrder always passes [] for both (order-record.service.ts:125, :135); and updateSyncStatus is exactly the narrow out-of-band single-statement writer the rule describes. So a reconciliation re-poll of an already-dispatched order wipes its per-destination sync status and attempt history.
I haven't traced whether a caller repopulates it before an operator sees the list, so severity is unconfirmed — but the shape is. Worth a follow-up issue referencing #2101 rather than blocking here.
By contrast recordStatus / mappingFailureReason (also written narrowly, by updateItemResolutionFailure) are legitimately re-derived by ingestion — 'ready' on successful resolution — so they're not instances of this bug. Worth one sentence in lessons.md, or someone will eventually "fix" them and reintroduce a real regression.
Worth calling out
- The test pair is exactly right. The mocked-repo spec (
expect(callArg.fulfillmentState).toBeUndefined()) proves intent, but only the new int-spec proves TypeORM actually omits the column from the generated statement — a mock can't establish that. And the second int-spec case asserts the operator-visible consequence (not-shipped filter +overdueSLA bucket +deriveSlaStateagreeing with the SQL), which is what actually regressed rather than the mechanism. - The "even when the domain record carries one" spec guards a future caller reintroducing the clobber.
- No schema change, so no migration and no backfill question:
null ≡ not-shipped, and wrongly-nulled rows self-heal on the nextupdateFulfillmentStatepush.
CI: no check runs exist for 00a31fb at all (total_count: 0) — the workflow never fired for this head. Must be triggered and green before merge.
Merge readiness: ✅ Approve on the code; blocked only on CI actually running. Please file the syncStatus/syncAttempts follow-up.
Signed-off-by: Peter Swierzy <123735851+piotrswierzy@users.noreply.github.com>
Problem
order_records.fulfillmentStateis a denormalized rollup over the order's shipments, pushed in from the shipping context by the narrowupdateFulfillmentStateUPDATE. Nothing on the ingestion path produces it:OrderRecordService.persistOrderbuildsnew OrderRecord(...)with 11 positional arguments andfulfillmentStateis the 12th, so it always defaulted tonull.OrderRecordRepository.upsertis a full-object TypeORMsave()andtoOrmmapped the column unconditionally, so every re-ingestion of an order - a poll re-read, a webhook-triggered sync, a manual re-sync - wrote thatnullover a'dispatched'value already committed by the shipping context.Operator-visible effect: a dispatched order reappeared as not-shipped.
deriveSlaStatetreats anullrollup as not-shipped by design, so the order re-entered its ship-by SLA bucket, and the orders-listfulfillmentStatefilter matched it asnot-shippedagain.Change
toOrmno longer mapsfulfillmentState. Leaving the ORM entity property unset makes TypeORM omit the column from the generated statement, so the row's existing value survives andupdateFulfillmentStateis the column's sole writer. This is the shapecancelledAtalready uses in the same method (#1984), three lines below the offending assignment.The alternative was to read the existing record in
persistOrderand carry the value onto the new instance. Rejected:upsertruns with no per-order lock and the shipping-side rollup write is unsynchronised with it, so a value committing between that read and the save would still be lost. Omitting the column is race-free and costs no extra read.Documented consequence: the record returned by
upsertreportsfulfillmentState(likecancelledAt) asnullwhatever the row holds, because the column was never part of the statement. No caller reads it off the return value today; the port doc, the repository doc and the entity doc all state it and point atfindById.dispatchByAtis unaffected - it is re-derived from the source payload on every persist, so writing it is correct.Tests
order-record.repository.spec.ts: the property never reachessave(), including when the domain record carries'dispatched'(guards a future caller reintroducing the clobber), plus thetoDomainread-back.order-record.service.spec.ts:persistOrdernever hands the upsert a non-null rollup.apps/api/test/integration/orders/order-record-fulfillment-state.int-spec.ts:persistOrder->updateFulfillmentState('dispatched')->persistOrderagain still readsdispatched, and the re-polled order is excluded from thenot-shippedandoverduefilters whilederiveSlaStatereportsnone. Only a realsave()proves TypeORM actually omits the column, which a mocked spec cannot.docs/lessons.mdgains the regression rule, since this is the second time the same class of defect landed on this table.Known related finding (not fixed here)
syncStatusandsyncAttemptshave the same shape of problem and are reported separately rather than widened into this change:persistOrder/persistIncomingSnapshotpass[]for both andtoOrmwrites both unconditionally, so every re-ingestion truncates them. On the happy pathsyncStatusself-heals seconds later (OrderIngestionServicecallsupdateSyncStatusper destination aftersyncOrder), butsyncAttemptsis genuine data loss on every re-poll - the per-destinationfailed -> retried -> syncedtimeline is replaced by a single fresh entry.syncOrderthrows or the process dies between the upsert and the writeback,syncStatusstays empty: the operator retry action 404s (OrderDestinationRetryServicelooks the destination row up insyncStatus) andFulfillmentStatusSyncService.findExternalOrderIdcan no longer resolve the destination order id, so fulfillment tracking for that order stops.order_records.syncStatusisjsonb NOT NULLwith no DB default, so omitting it fromtoOrmbreaks the INSERT path. It needs either a migration adding a'[]'default (assyncAttemptsalready has) or an insert-vs-update split in the repository.Verification
pnpm --filter @openlinker/core type-check- cleanpnpm --filter @openlinker/core lint- 0 errors (only pre-existing warnings in untouched files)pnpm --filter @openlinker/core test -- order-record- 89 passed;-- order-sla order-ingestion- 55 passedCloses #2101