Skip to content

fix: wrap concurrent operations in transactions for atomicity#495

Open
ascorbic wants to merge 5 commits intomainfrom
fix/transaction-correctness
Open

fix: wrap concurrent operations in transactions for atomicity#495
ascorbic wants to merge 5 commits intomainfrom
fix/transaction-correctness

Conversation

@ascorbic
Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes four atomicity gaps identified in the code quality review (findings H1, M4, M7, M17).

  • Content update (H1): Moved the _rev optimistic concurrency check inside the transaction so the read-then-write is atomic. A concurrent write between the rev check and update can no longer succeed. Also deduplicated a redundant findById call when both _rev and slug are provided.
  • Menu reorder (M4): Wrapped sequential menu item position updates in withTransaction() to prevent partial reorder if a crash occurs mid-loop.
  • Byline delete (M7): Replaced db.transaction().execute() with withTransaction() for D1 compatibility (D1 doesn't support native transactions; withTransaction handles the fallback).
  • Seed content (M17): Wrapped each content entry creation/update + bylines + taxonomies in a per-entry transaction. A crash mid-seed no longer leaves orphaned content without its associated bylines/taxonomies.

Type of change

  • Bug fix

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • I have added a changeset

AI-generated code disclosure

  • This PR includes AI-generated code

- Content update: move _rev optimistic concurrency check inside
  transaction so read-then-write is atomic (H1)
- Menu reorder: wrap sequential item updates in withTransaction
  to prevent partial reorder on crash (M4)
- Byline delete: replace db.transaction() with withTransaction()
  for D1 compatibility (M7)
- Seed content: wrap each entry creation/update + bylines +
  taxonomies in a per-entry transaction (M17)
Addresses adversarial review feedback:
- Hoist findById to serve both _rev check and old slug capture (was called twice)
- Remove unused HTTP status from thrown apiError objects (status is derived from error code downstream)
Copilot AI review requested due to automatic review settings April 12, 2026 18:49
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 12, 2026

🦋 Changeset detected

Latest commit: 4cc3cb7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Apr 12, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground 4cc3cb7 Apr 12 2026, 06:53 PM

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 12, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@495

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@495

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@495

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@495

emdash

npm i https://pkg.pr.new/emdash@495

create-emdash

npm i https://pkg.pr.new/create-emdash@495

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@495

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@495

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@495

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@495

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@495

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@495

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@495

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@495

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@495

commit: 4cc3cb7

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Improves write-atomicity across core operations by standardizing on withTransaction() for multi-step DB updates, aiming to prevent partially-applied changes during concurrent writes or mid-operation crashes.

Changes:

  • Wrap content update flow (including _rev optimistic concurrency check) in a transaction and dedupe redundant reads.
  • Make menu item reorder updates atomic by performing the full reorder + re-read inside withTransaction().
  • Use withTransaction() for byline deletion (and seed content writes) to improve D1 compatibility and reduce partial-write risk.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/core/src/seed/apply.ts Wrap per-entry content create/update + bylines + taxonomies in withTransaction() for atomic seed application.
packages/core/src/database/repositories/byline.ts Use withTransaction() for byline delete to improve atomicity and D1 compatibility.
packages/core/src/api/handlers/menus.ts Wrap reorder loop + fetch in withTransaction() to avoid partial reorders.
packages/core/src/api/handlers/content.ts Move _rev check into the transactional update path and avoid duplicate findById calls.
.changeset/poor-feet-watch.md Adds a patch changeset documenting the atomicity fixes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +507 to +515
// The _rev check is inside the transaction so the read-then-write
// is atomic -- no concurrent write can slip between the check and update.
const item = await withTransaction(db, async (trx) => {
const trxRepo = new ContentRepository(trx);
const bylineRepo = new BylineRepository(trx);

// Read existing item once for both _rev check and old slug capture
const existing =
body._rev || body.slug ? await trxRepo.findById(collection, resolvedId) : null;
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

Placing the _rev check inside a transaction doesn’t fully prevent lost updates on databases with READ COMMITTED / snapshot isolation unless the row is locked (e.g. SELECT ... FOR UPDATE) or the subsequent UPDATE is conditional on the expected version/updated_at. As written, another transaction can still commit an update after findById and before update, and this handler will overwrite it while still passing the earlier _rev check. Consider either locking the row when reading for the _rev check or extending ContentRepository.update to accept an expected rev and include it in the WHERE clause, returning a CONFLICT when zero rows are affected.

Suggested change
// The _rev check is inside the transaction so the read-then-write
// is atomic -- no concurrent write can slip between the check and update.
const item = await withTransaction(db, async (trx) => {
const trxRepo = new ContentRepository(trx);
const bylineRepo = new BylineRepository(trx);
// Read existing item once for both _rev check and old slug capture
const existing =
body._rev || body.slug ? await trxRepo.findById(collection, resolvedId) : null;
// Lock the current row when reading it for _rev validation / slug capture
// so no concurrent writer can update it between the check and the write.
const item = await withTransaction(db, async (trx) => {
const trxRepo = new ContentRepository(trx);
const bylineRepo = new BylineRepository(trx);
// Read and lock the existing item once for both _rev check and old slug capture.
// This prevents lost updates under READ COMMITTED / snapshot isolation.
const existing =
body._rev || body.slug
? (((await sql<ContentItem>`
select *
from ${sql.table(collection)}
where id = ${resolvedId}
for update
`.execute(trx)).rows[0] as ContentItem | undefined) ?? null
: null;

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants