Skip to content

fix(core): event-scoped DB connection for cron, plugin contexts, and media providers - #1625

Merged
ascorbic merged 3 commits into
mainfrom
fix/event-scoped-db-1622
Jun 25, 2026
Merged

fix(core): event-scoped DB connection for cron, plugin contexts, and media providers#1625
ascorbic merged 3 commits into
mainfrom
fix/event-scoped-db-1622

Conversation

@ascorbic

@ascorbic ascorbic commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Makes scheduled publishing, plugin cron, and database-querying plugin hooks work on connection-backed adapters (Postgres over Cloudflare Hyperdrive), and removes the corresponding "request path only" limitation from the hyperdrive() adapter.

On Workers a database connection is bound to the event that opened it; workerd rejects reuse from a later event. #1614 fixed the HTTP request path (per-request Kysely in ALS; the runtime db getter prefers it). But several long-lived subsystems captured the per-isolate singleton Kysely at runtime init and never consulted ALS, so on a warm isolate they reused a socket opened by an earlier request:

  • the cron executor (Cron Trigger sweep → scheduled publishing, plugin cron, system cleanup),
  • plugin hook contexts (a hook's content / media / users / cron access),
  • media providers.

This threads an event-scoped DB resolver through those subsystems so each resolves the current connection at use-time:

  • EmDashRuntime.create() builds a resolveDb() closure (ALS-aware, singleton fallback) and passes it to the cron executor, the plugin context factory (via an additive getDb? on PluginContextFactoryOptions, carried in pipelineFactoryOptions so rebuildHookPipeline() keeps it), and the media provider context.
  • CronExecutor resolves its db per tick; PluginContextFactory resolves per createContext(); the local media provider builds its repository per operation.
  • The Cron Trigger sweep (runScheduledTasks) opens its own event-scoped connection, runs the batch under it in ALS, and closes it — gated on the adapter being connection-backed (it exposes close()), so D1 / Node SQLite keep using the singleton unchanged.

Per a second opinion, this uses explicit resolver threading (with additive, backwards-compatible getDb? on the external MediaProviderContext contract) rather than a transparent Kysely proxy.

Stateless adapters (D1, Node SQLite) are unchanged — they set no ALS db on most paths, so the resolver falls back to the singleton, and pnpm query-counts matches the SQLite snapshot.

Out of scope: sandboxed plugins remain D1-only — the sandbox bridge Durable Object talks to a D1 binding directly, independent of the configured adapter. That's a pre-existing constraint unrelated to connection scoping; filed as #1623. The docs now say so precisely.

Closes #1622

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change) — full emdash suite (4138) + new event-scoped-db tests (7) pass
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable) — new tests/integration/plugins/event-scoped-db.test.ts covering cron, plugin context, media provider resolution, and the pipeline-rebuild path
  • User-visible strings in the admin UI are wrapped for translation — n/a, no admin UI strings
  • I have added a changesetemdash patch (and updates the unreleased @emdash-cms/cloudflare adapter changeset to drop the now-fixed limitation)
  • New features link to an approved Discussion — n/a, bug fix

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.8 (via OpenCode). Reviewed with the adversarial-reviewer pass (which caught a real rebuildHookPipeline regression, now fixed and tested) and a GPT-5.5 second opinion on the resolver-vs-proxy approach.

Screenshots / test output

pnpm --filter emdash test event-scoped-db   # 7 passed
pnpm --filter emdash test                    # 4138 passed (290 files)
node scripts/query-counts.mjs --target sqlite  # counts + query text match snapshot
pnpm typecheck                               # clean
pnpm lint:json                               # 0 diagnostics

Try this PR

Open a fresh playground →

A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.

Tracks fix/event-scoped-db-1622. Updated automatically when the playground redeploys.

…s, and media providers

Long-lived subsystems built at runtime init captured the per-isolate singleton
Kysely and never consulted the request/event-scoped connection in ALS. On
connection-backed adapters (Postgres over Hyperdrive) a connection is bound to
the event that opened it, so the cron sweep, plugin hook contexts, and media
providers failed under workerd's cross-request I/O guard on warm isolates.

- Add a resolveDb closure in EmDashRuntime.create() (ALS-aware, singleton
  fallback) and thread it into the cron executor, the plugin context factory
  (via additive getDb on PluginContextFactoryOptions and pipelineFactoryOptions,
  so rebuildHookPipeline keeps it), and the media provider context.
- CronExecutor resolves its db per tick; PluginContextFactory resolves per
  createContext; the local media provider builds its repository per operation.
- Give the Cron Trigger sweep its own event-scoped connection: runScheduledTasks
  opens a request-scoped db, runs the batch under it in ALS, and closes it,
  gated on the adapter being connection-backed (D1/SQLite keep the singleton).

Stateless adapters (D1, Node SQLite) are unchanged. Sandboxed plugins remain
D1-only (the bridge DO talks to a D1 binding directly; tracked in #1623).

Closes #1622
@changeset-bot

changeset-bot Bot commented Jun 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0fe252e

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

This PR includes changesets to release 16 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto 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

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 540 lines across 10 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Jun 25, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 25, 2026

Copy link
Copy Markdown

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-demo-do 0fe252e Jun 25 2026, 06:21 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 25, 2026

Copy link
Copy Markdown

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 0fe252e Jun 25 2026, 06:21 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 25, 2026

Copy link
Copy Markdown

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-demo-cache 0fe252e Jun 25 2026, 06:20 PM

EmDashRuntime captured a SchemaRegistry over the per-isolate singleton at
construction, so request handlers (content update, revision restore, media
normalization) queried it through the singleton. On a connection-backed adapter
(Postgres over Hyperdrive) that socket belongs to an earlier event, so on a warm
isolate the schema lookup throws and handleContentUpdate's catch treats a
revision-enabled collection as non-revisioned, writing draft edits to live
columns.

Make schemaRegistry a getter that builds against this.db (the ALS-aware getter),
matching the per-call registry already used in _buildManifest. Add a regression
test asserting the registry resolves the event-scoped db, not the singleton.
@pkg-pr-new

pkg-pr-new Bot commented Jun 25, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1625

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1625

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1625

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1625

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1625

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1625

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 0fe252e

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the right fix for #1622. The approach is additive and backwards-compatible: instead of capturing the per-isolate singleton Kysely in long-lived subsystems, the runtime threads an ALS-aware resolver through the cron executor, the plugin context factory (via pipelineFactoryOptions so rebuildHookPipeline() preserves it), and the local media provider. Stateless adapters fall back to the singleton unchanged, and the Cron Trigger sweep opens its own event-scoped connection on connection-backed adapters and closes it when done.

I traced the changed code and the relevant call sites. The component-level tests are reasonable; they prove each subsystem resolves the connection at use-time by pointing a resolver at two isolated SQLite databases. The Hyperdrive documentation updates correctly narrow the limitation to sandboxed plugins.

One real edge case in the new cron sweep path: the finally block guards scoped.commit() but not scoped.close(). If close() throws, it will mask the original return value or exception. This is inconsistent with finishScoped / closeSafely in middleware/scoped-db.ts, which guards close on every path. It should be guarded too.

Separate pre-existing note (not a blocker for this PR): EmDashRuntime.schemaRegistry still captures the singleton db, so the request-path content handlers use the request-scoped Kysely for repository queries but the per-isolate singleton for collection metadata reads. On Hyperdrive that is the same class of cross-event risk this PR fixes elsewhere, so it may need a follow-up.

Comment thread packages/core/src/astro/middleware.ts Outdated
} catch (error) {
console.error("[scheduled] request-scoped db commit failed:", error);
}
scoped.close();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[needs fixing] The finally block carefully guards scoped.commit() so a failure there cannot skip close(), but scoped.close() itself is unguarded. If a connection-backed adapter's close() ever throws synchronously, it will replace the sweep's return value or the original exception.

This contradicts the lifecycle handling in middleware/scoped-db.ts (finishScoped/closeSafely), which defends close() on both the success and error paths. Mirror that here.

Suggested change
scoped.close();
try {
scoped.commit();
} catch (error) {
console.error("[scheduled] request-scoped db commit failed:", error);
}
try {
scoped.close();
} catch (error) {
console.error("[scheduled] request-scoped db close failed:", error);
}

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Jun 25, 2026
A throw from scoped.close() in the finally block would mask the sweep's
result or exception, and a throwing commit() could skip close() and leak the
connection. Guard both, matching closeSafely() in middleware/scoped-db.ts.
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jun 25, 2026
@ascorbic
ascorbic merged commit d74269d into main Jun 25, 2026
46 checks passed
@ascorbic
ascorbic deleted the fix/event-scoped-db-1622 branch June 25, 2026 18:38
@emdashbot emdashbot Bot mentioned this pull request Jun 25, 2026
marcusbellamyshaw-cell pushed a commit to Emdash-Bug-Testing/emdash that referenced this pull request Jul 22, 2026
…media providers (emdash-cms#1625)

* fix(core): resolve DB connection at use-time for cron, plugin contexts, and media providers

Long-lived subsystems built at runtime init captured the per-isolate singleton
Kysely and never consulted the request/event-scoped connection in ALS. On
connection-backed adapters (Postgres over Hyperdrive) a connection is bound to
the event that opened it, so the cron sweep, plugin hook contexts, and media
providers failed under workerd's cross-request I/O guard on warm isolates.

- Add a resolveDb closure in EmDashRuntime.create() (ALS-aware, singleton
  fallback) and thread it into the cron executor, the plugin context factory
  (via additive getDb on PluginContextFactoryOptions and pipelineFactoryOptions,
  so rebuildHookPipeline keeps it), and the media provider context.
- CronExecutor resolves its db per tick; PluginContextFactory resolves per
  createContext; the local media provider builds its repository per operation.
- Give the Cron Trigger sweep its own event-scoped connection: runScheduledTasks
  opens a request-scoped db, runs the batch under it in ALS, and closes it,
  gated on the adapter being connection-backed (D1/SQLite keep the singleton).

Stateless adapters (D1, Node SQLite) are unchanged. Sandboxed plugins remain
D1-only (the bridge DO talks to a D1 binding directly; tracked in emdash-cms#1623).

Closes emdash-cms#1622

* fix(core): resolve schemaRegistry against the event-scoped db

EmDashRuntime captured a SchemaRegistry over the per-isolate singleton at
construction, so request handlers (content update, revision restore, media
normalization) queried it through the singleton. On a connection-backed adapter
(Postgres over Hyperdrive) that socket belongs to an earlier event, so on a warm
isolate the schema lookup throws and handleContentUpdate's catch treats a
revision-enabled collection as non-revisioned, writing draft edits to live
columns.

Make schemaRegistry a getter that builds against this.db (the ALS-aware getter),
matching the per-call registry already used in _buildManifest. Add a regression
test asserting the registry resolves the event-scoped db, not the singleton.

* fix(core): guard close() in the cron sweep teardown

A throw from scoped.close() in the finally block would mask the sweep's
result or exception, and a throwing commit() could skip close() and leak the
connection. Guard both, matching closeSafely() in middleware/scoped-db.ts.
marcusbellamyshaw-cell pushed a commit to Emdash-Bug-Testing/emdash that referenced this pull request Jul 22, 2026
…media providers (emdash-cms#1625)

* fix(core): resolve DB connection at use-time for cron, plugin contexts, and media providers

Long-lived subsystems built at runtime init captured the per-isolate singleton
Kysely and never consulted the request/event-scoped connection in ALS. On
connection-backed adapters (Postgres over Hyperdrive) a connection is bound to
the event that opened it, so the cron sweep, plugin hook contexts, and media
providers failed under workerd's cross-request I/O guard on warm isolates.

- Add a resolveDb closure in EmDashRuntime.create() (ALS-aware, singleton
  fallback) and thread it into the cron executor, the plugin context factory
  (via additive getDb on PluginContextFactoryOptions and pipelineFactoryOptions,
  so rebuildHookPipeline keeps it), and the media provider context.
- CronExecutor resolves its db per tick; PluginContextFactory resolves per
  createContext; the local media provider builds its repository per operation.
- Give the Cron Trigger sweep its own event-scoped connection: runScheduledTasks
  opens a request-scoped db, runs the batch under it in ALS, and closes it,
  gated on the adapter being connection-backed (D1/SQLite keep the singleton).

Stateless adapters (D1, Node SQLite) are unchanged. Sandboxed plugins remain
D1-only (the bridge DO talks to a D1 binding directly; tracked in emdash-cms#1623).

Closes emdash-cms#1622

* fix(core): resolve schemaRegistry against the event-scoped db

EmDashRuntime captured a SchemaRegistry over the per-isolate singleton at
construction, so request handlers (content update, revision restore, media
normalization) queried it through the singleton. On a connection-backed adapter
(Postgres over Hyperdrive) that socket belongs to an earlier event, so on a warm
isolate the schema lookup throws and handleContentUpdate's catch treats a
revision-enabled collection as non-revisioned, writing draft edits to live
columns.

Make schemaRegistry a getter that builds against this.db (the ALS-aware getter),
matching the per-call registry already used in _buildManifest. Add a regression
test asserting the registry resolves the event-scoped db, not the singleton.

* fix(core): guard close() in the cron sweep teardown

A throw from scoped.close() in the finally block would mask the sweep's
result or exception, and a throwing commit() could skip close() and leak the
connection. Guard both, matching closeSafely() in middleware/scoped-db.ts.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/cloudflare area/core review/needs-rereview Author pushed changes since the last review size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Thread an event-scoped DB connection through cron, plugin contexts, media providers, and sandbox runner

1 participant