OUT-3815 | OUT-3819: collapse qb_product_sync to one row per product, drop price columns#259
Conversation
… price columns OUT-3815: add partial unique index uq_qb_product_sync_product_active on (portal_id, product_id) WHERE deleted_at IS NULL. OUT-3819: drop the vestigial price_id, unit_price, copilot_unit_price columns and remove the legacy unitPrice read in webhookProductUpdated. Both schema changes are folded into one migration (20260603102427). The one-time dedup SQL is hand-run against prod before deploy (gitignored snippet). Hardening in product.service.ts: - guard the initial-save batch insert in handleProductMap with onConflictDoNothing scoped to the partial index, so a re-fired/concurrent save no-ops instead of 500-ing. - wrap both db.transaction callbacks in try/finally so unsetTransaction() always restores the db singleton, even on a throw or early return. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR enforces one active row per product in
Confidence Score: 5/5Safe to merge — all schema changes are additive or purely drop vestigial columns, service logic is straightforward, and the new integration test directly exercises the conflict path. Both transaction callbacks are correctly hardened with try/finally, the onConflictDoNothing + getAll() approach properly handles re-fired saves without leaking an empty response, and the migration cleanly removes three unused columns alongside the new partial unique index. The new test locks in the fixed behavior end-to-end. No files require special attention. The deploy-ordering note (run cleanup.sql before migrating) is documented in the PR description and is the only operational gate. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[handleProductMap] --> B[setTransaction tx]
B --> C{initialProductSettingMap?}
C -->|false - initial save| D[insert mappingItems + portalId\nonConflictDoNothing on partial index]
D --> E[return getAll]
C -->|true - subsequent save| F{changedItemReference length > 0?}
F -->|yes| G[Promise.all: updateOrCreateQBProduct per item]
G --> H[return getAll]
F -->|no| H
E --> FIN[finally: unsetTransaction]
H --> FIN
FIN --> I[transaction committed]
Reviews (2): Last reviewed commit: "fix(OUT-3815): return live mapping from ..." | Re-trigger Greptile |
| const query = this.db | ||
| .insert(QBProductSync) | ||
| .values(formattedPayload) | ||
| .onConflictDoNothing({ | ||
| target: [QBProductSync.portalId, QBProductSync.productId], | ||
| where: isNull(QBProductSync.deletedAt), | ||
| }) | ||
| const products = returningFields?.length | ||
| ? await query.returning( | ||
| buildReturningFields(QBProductSync, returningFields), | ||
| ) | ||
| : await query.returning() | ||
| return products |
There was a problem hiding this comment.
onConflictDoNothing + RETURNING returns [] on a conflict, which the API sends to the client
PostgreSQL's RETURNING clause only yields rows that were actually inserted; rows skipped by ON CONFLICT DO NOTHING are silently omitted. On a re-fired or concurrent initial save every row will conflict, so products will be []. The controller wraps this in NextResponse.json(products), meaning the frontend receives an empty array and may treat it as the authoritative, empty product list — overwriting whatever state it already had. Consider using onConflictDoUpdate (no-op set) so that RETURNING still yields the surviving rows, or fall through to this.getAll() when the returned array is empty.
| @@ -0,0 +1,4 @@ | |||
| CREATE UNIQUE INDEX "uq_qb_product_sync_product_active" ON "qb_product_sync" USING btree ("portal_id","product_id") WHERE "qb_product_sync"."deleted_at" is null;--> statement-breakpoint | |||
There was a problem hiding this comment.
Non-concurrent unique index creation takes an
ACCESS EXCLUSIVE lock
CREATE UNIQUE INDEX (without CONCURRENTLY) acquires an ACCESS EXCLUSIVE lock on qb_product_sync for the entire duration of the index build, blocking all reads and writes. On a busy production table this will cause visible downtime. CREATE UNIQUE INDEX CONCURRENTLY builds the index without the table-level lock, but it cannot run inside a transaction block — if drizzle-kit migrate wraps statements in a transaction, the CONCURRENTLY keyword must be handled via a raw migration outside that transaction.
There was a problem hiding this comment.
We're managing all migrations via drizzle-kit. Besides, the records in the table is quiet low (2k) so this might not have severe impact.
The initial-save insert uses onConflictDoNothing, so RETURNING omitted rows skipped on a re-fired/concurrent save and the client could receive []. Always return getAll() instead; drop the now-unused returningFields param. Add a regression test covering a repeated initial save. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Stacked on OUT-3816. Combines two tickets plus a transaction-safety fix.
uq_qb_product_sync_product_activeon(portal_id, product_id) WHERE deleted_at IS NULL.price_id,unit_price,copilot_unit_pricecolumns and remove the legacyunitPriceread inwebhookProductUpdated.20260603102427).product.service.ts):onConflictDoNothing(scoped to the partial index viawhere) on the initial-save batch insert, so a re-fired/concurrent save no-ops instead of 500-ing on the new unique constraint.db.transactioncallbacks intry/finallysounsetTransaction()always restores thethis.dbsingleton, even on a throw or earlyreturn.The one-time dedup SQL lives in the gitignored
supabase/snippets/cleanup.sqland must be hand-run against prod before this deploys.build.shauto-runsdrizzle-kit migrate, and the non-concurrentCREATE UNIQUE INDEXaborts if duplicate live rows still exist. The script has a dry-run SELECT followed by the soft-delete UPDATE (keeps the earliest live row per(portal_id, product_id)).Test plan
tsc --noEmitcleanyarn lint:check0 errorsyarn test— 286/286 pass (new migration applies cleanly in the testcontainers DB; unique index doesn't collide with seeds)🤖 Generated with Claude Code