Skip to content

@payloadcms/db-d1-sqlite: DELETE operations silently fail on Cloudflare Workers (stale D1 binding) #15070

@heatloss

Description

@heatloss

Describe the Bug

Description:

When using @payloadcms/db-d1-sqlite deployed to Cloudflare Workers via OpenNext, DELETE operations return success but do not actually delete records from the D1 database. The issue only occurs in production Workers—local development with wrangler works correctly.

Hypothesis:

The D1 database binding is captured during module initialization and becomes stale by the time HTTP requests are processed. However, the issue is more nuanced than simply needing a fresh binding reference:

  1. Getting a fresh binding via getCloudflareContext() at property access time (e.g., when Drizzle accesses .prepare) is not sufficient
  2. The fresh binding must be obtained at method call time — the exact moment .prepare(), .batch(), etc. are actually invoked
  3. Using JavaScript's .bind() on D1 native methods does not resolve the issue

Suggestion:

The sqliteD1Adapter should accept a function or getter for the binding that is invoked at query execution time, rather than capturing the binding at initialization.

Temporary Workaround:

A Proxy wrapper that returns a function which calls getCloudflareContext() inside the function body at call time:

  function createLazyD1Binding(initialBinding: any) {
    return new Proxy({}, {
      get(target, prop) {
        if (typeof prop === 'symbol' || prop === 'then' || prop === 'catch') return undefined

        const isWorkers = typeof globalThis.navigator !== 'undefined’ &&
          globalThis.navigator.userAgent === 'Cloudflare-Workers’

        if (!isWorkers) {
          const value = initialBinding[prop]
          return typeof value === 'function' ? value.bind(initialBinding) : value
        }

        // Key: get fresh binding at CALL TIME, not property access time
        return function(...args: any[]) {
          const ctx = getCloudflareContext()
          const binding = (ctx as any)?.env?.D1 || initialBinding
          return binding[prop](...args)
        }
      }
    })
  }

Link to the code that reproduces this issue

https://github.com/heatloss/chimera-d1/tree/payload-d1-bug-repro

Reproduction Steps

Reproduction:

  1. Configure Payload with sqliteD1Adapter using a D1 binding obtained at module initialization
  2. Deploy to Cloudflare Workers
  3. Delete a record via the Payload API or Admin UI
  4. Response indicates success, but record still exists in D1

Expectation:

Records should be deleted from D1 when Payload returns a successful delete response.

Which area(s) are affected?

db: d1-sqlite

Environment Info

Payload Info:

Binaries:
  Node: 20.9.0
  npm: 10.1.0
  Yarn: N/A
  pnpm: 10.23.0
Relevant Packages:
  payload: 3.69.0
  next: 16.0.3
  @payloadcms/db-d1-sqlite: 3.69.0
  @payloadcms/drizzle: 3.69.0
  @payloadcms/graphql: 3.69.0
  @payloadcms/next/utilities: 3.69.0
  @payloadcms/plugin-cloud-storage: 3.69.0
  @payloadcms/richtext-lexical: 3.69.0
  @payloadcms/storage-r2: 3.69.0
  @payloadcms/translations: 3.69.0
  @payloadcms/ui/shared: 3.69.0
  react: 19.2.3
  react-dom: 19.2.3

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions