diff --git a/.gitignore b/.gitignore index ed35b4ec73..6bebd59117 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,4 @@ examples/wp-theme-unit-test/ .perf-query-counts query-counts-out/ +**/.dev.vars diff --git a/demos/cloudflare/.gitignore b/demos/cloudflare/.gitignore new file mode 100644 index 0000000000..babca1bb1d --- /dev/null +++ b/demos/cloudflare/.gitignore @@ -0,0 +1 @@ +.dev.vars diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index 3c7c731cce..d89d105328 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -2,15 +2,14 @@ import cloudflare from "@astrojs/cloudflare"; import react from "@astrojs/react"; import { - d1, + hyperdrive, r2, - access, sandbox, - cloudflareCache, - cloudflareImages, - cloudflareStream, + // cloudflareCache, } from "@emdash-cms/cloudflare"; import { formsPlugin } from "@emdash-cms/plugin-forms"; +import { notifyOnPublishPlugin } from "@emdash-cms/plugin-notify-on-publish"; +import { notifyPostmarkPlugin } from "@emdash-cms/plugin-notify-postmark"; import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier"; import { defineConfig, fontProviders } from "astro/config"; import emdash from "emdash/astro"; @@ -36,45 +35,36 @@ export default defineConfig({ integrations: [ react(), emdash({ - // D1 database - binding name must match wrangler.jsonc - // session: "auto" enables read replicas (nearest replica for anon, - // bookmark-based consistency for authenticated users) - database: d1({ binding: "DB", session: "auto" }), + // Hyperdrive database — binding name must match wrangler.jsonc + database: hyperdrive({ binding: "HYPERDRIVE" }), // R2 storage for media storage: r2({ binding: "MEDIA" }), // Cloudflare Access authentication // Reads CF_ACCESS_AUDIENCE from env (wrangler secret or .dev.vars) - auth: access({ - teamDomain: "cloudflare-cto.cloudflareaccess.com", - autoProvision: true, - defaultRole: 30, // Author - // Map your IdP groups to roles (optional) - // roleMapping: { - // "Admins": 50, - // "Editors": 40, - // }, - }), // Media providers - Cloudflare Images and Stream // Reads from env vars at runtime: CF_ACCOUNT_ID, CF_IMAGES_TOKEN, CF_STREAM_TOKEN // Or customize with accountIdEnvVar/apiTokenEnvVar options - mediaProviders: [ - cloudflareImages({ - accountIdEnvVar: "CF_MEDIA_ACCOUNT_ID", - apiTokenEnvVar: "CF_MEDIA_API_TOKEN", - accountHash: "5LGXGUnHU18h6ehN_xjpXQ", - }), - cloudflareStream({ - accountIdEnvVar: "CF_MEDIA_ACCOUNT_ID", - apiTokenEnvVar: "CF_MEDIA_API_TOKEN", - }), - ], // Trusted plugins (run in host worker) plugins: [ // Test plugin that exercises all v2 APIs formsPlugin(), + notifyOnPublishPlugin(), + notifyPostmarkPlugin(), + // notifyOnPublishPlugin({ + // recipients: ["ljanaideh@atypon.com"], + // collections: ["posts"], + // from: "onboarding@resend.dev", + // siteUrl: "https://emdash-laith.laithaljanaideh.workers.dev", + // }), + // notifyOnPublishPlugin({ + // recipients: (process.env.EMAIL_TO || "").split(",").map(s => s.trim()).filter(Boolean), + // collections: ["posts"], + // from: process.env.EMAIL_FROM || "onboarding@resend.dev", + // siteUrl: process.env.SITE_URL || "https://emdash-laith.laithaljanaideh.workers.dev", + // }), ], // Sandboxed plugins (run in isolated workers) - sandboxed: [webhookNotifierPlugin()], + sandboxed: [], // Sandbox runner for Cloudflare sandboxRunner: sandbox(), // Plugin marketplace @@ -82,9 +72,9 @@ export default defineConfig({ }), ], experimental: { - cache: { - provider: cloudflareCache(), - }, + // cache: { + // provider: cloudflareCache(), + // }, routeRules: { "/": { maxAge: 3_600, diff --git a/demos/cloudflare/emdash-env.d.ts b/demos/cloudflare/emdash-env.d.ts index ea5f02a61b..abb26262fc 100644 --- a/demos/cloudflare/emdash-env.d.ts +++ b/demos/cloudflare/emdash-env.d.ts @@ -10,7 +10,6 @@ export interface Page { slug: string | null; status: string; title: string; - template?: "Default" | "Full Width"; content?: PortableTextBlock[]; createdAt: Date; updatedAt: Date; diff --git a/demos/cloudflare/package.json b/demos/cloudflare/package.json index c4e53d2422..8e2270357b 100644 --- a/demos/cloudflare/package.json +++ b/demos/cloudflare/package.json @@ -8,16 +8,21 @@ "build": "astro build", "build:all": "pnpm run --filter @emdash-cms/demo-cloudflare... build", "preview": "astro preview", - "deploy": "pnpm build:all && wrangler deploy", + "deploy": "wrangler deploy", + "db:bootstrap": "node scripts/bootstrap-postgres.mjs", "db:create": "wrangler d1 create emdash-demo", "db:reset:remote": "./scripts/reset-db.sh", "typecheck": "astro check" }, "dependencies": { + "kysely": "^0.27.0", + "pg": "^8.0.0", "@astrojs/cloudflare": "catalog:", "@astrojs/react": "catalog:", "@emdash-cms/cloudflare": "workspace:*", "@emdash-cms/plugin-forms": "workspace:*", + "@emdash-cms/plugin-notify-on-publish": "workspace:*", + "@emdash-cms/plugin-notify-postmark": "workspace:*", "@emdash-cms/plugin-webhook-notifier": "workspace:*", "@tanstack/react-query": "catalog:", "@tanstack/react-router": "catalog:", @@ -34,7 +39,5 @@ }, "emdash": { "seed": "seed/seed.json" - }, - "peerDependencies": {}, - "optionalDependencies": {} + } } diff --git a/demos/cloudflare/scripts/bootstrap-postgres.mjs b/demos/cloudflare/scripts/bootstrap-postgres.mjs new file mode 100644 index 0000000000..d8fb0b1d21 --- /dev/null +++ b/demos/cloudflare/scripts/bootstrap-postgres.mjs @@ -0,0 +1,40 @@ +/** + * Bootstrap script — runs EmDash migrations against a PostgreSQL database. + * + * Run as part of the deploy command, or manually: + * + * DATABASE_URL="postgres://user:pass@host:5432/db" node scripts/bootstrap-postgres.mjs + */ + +import { runMigrations } from "emdash/db"; +import { Kysely, PostgresDialect } from "kysely"; +import pg from "pg"; + +const { Pool } = pg; + +const connectionString = process.env.DATABASE_URL; +if (!connectionString) { + console.error("Error: DATABASE_URL environment variable is required."); + process.exit(1); +} + +console.log("Connecting to PostgreSQL..."); +const ssl = process.env.DATABASE_SSL === "false" ? false : { rejectUnauthorized: false }; +const pool = new Pool({ connectionString, max: 1, ssl }); +const db = new Kysely({ dialect: new PostgresDialect({ pool }) }); + +try { + console.log("Running migrations..."); + const { applied } = await runMigrations(db); + + if (applied.length === 0) { + console.log("No new migrations — database is already up to date."); + } else { + console.log(`Applied ${applied.length} migration(s):`); + for (const m of applied) { + console.log(` ✓ ${m}`); + } + } +} finally { + await pool.end(); +} diff --git a/demos/cloudflare/terraform/outputs.tf b/demos/cloudflare/terraform/outputs.tf new file mode 100644 index 0000000000..67576d9569 --- /dev/null +++ b/demos/cloudflare/terraform/outputs.tf @@ -0,0 +1,60 @@ +output "rds_endpoint" { + description = "RDS instance hostname" + value = aws_db_instance.emdash.address +} + +output "rds_port" { + description = "RDS port" + value = aws_db_instance.emdash.port +} + +output "rds_db_name" { + description = "Database name" + value = aws_db_instance.emdash.db_name +} + +output "connection_string" { + description = "DATABASE_URL for bootstrap script (uses master user — swap to emdash_app after setup)" + value = "postgres://${var.master_username}:PASSWORD@${aws_db_instance.emdash.address}:${aws_db_instance.emdash.port}/${var.db_name}?sslmode=require" + sensitive = false +} + +output "hyperdrive_origin" { + description = "Host to use when running: wrangler hyperdrive update --origin-host " + value = aws_db_instance.emdash.address +} + +output "post_provision_steps" { + description = "Reminder of manual steps after terraform apply" + value = <<-EOT + + ── Post-provision checklist ──────────────────────────────────────────── + + 1. Connect as master user and create the app user: + + psql "postgres://${var.master_username}:PASSWORD@${aws_db_instance.emdash.address}:5432/${var.db_name}?sslmode=require" + + CREATE USER emdash_app WITH PASSWORD 'your-app-password'; + GRANT CONNECT ON DATABASE ${var.db_name} TO emdash_app; + GRANT CREATE ON SCHEMA public TO emdash_app; + GRANT USAGE ON SCHEMA public TO emdash_app; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO emdash_app; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO emdash_app; + + 2. Update Cloudflare Hyperdrive to point at the new endpoint: + + wrangler hyperdrive update 01b192bf33194ecda6ad2aa1b2f2f8d2 \ + --origin-host ${aws_db_instance.emdash.address} \ + --origin-port 5432 \ + --database ${var.db_name} \ + --origin-user emdash_app + + 3. Update DATABASE_URL in Cloudflare Pages env vars: + + postgres://emdash_app:PASSWORD@${aws_db_instance.emdash.address}:5432/${var.db_name}?sslmode=require + + 4. Push a new commit to trigger the Pages build (which runs migrations). + + ──────────────────────────────────────────────────────────────────────── + EOT +} diff --git a/demos/cloudflare/worker-configuration.d.ts b/demos/cloudflare/worker-configuration.d.ts index 629f0c7371..41d411e963 100644 --- a/demos/cloudflare/worker-configuration.d.ts +++ b/demos/cloudflare/worker-configuration.d.ts @@ -7,7 +7,7 @@ declare namespace Cloudflare { } interface Env { MEDIA: R2Bucket; - DB: D1Database; + HYPERDRIVE: Hyperdrive; LOADER: WorkerLoader; CF_ACCESS_AUDIENCE: string; CF_MEDIA_API_TOKEN: string; diff --git a/demos/cloudflare/wrangler.jsonc b/demos/cloudflare/wrangler.jsonc index 2dc5de41f2..6c65b758d3 100644 --- a/demos/cloudflare/wrangler.jsonc +++ b/demos/cloudflare/wrangler.jsonc @@ -1,37 +1,38 @@ { "$schema": "node_modules/wrangler/config-schema.json", - "name": "emdash-demo", + "name": "emdash-laith", "main": "./src/worker.ts", "compatibility_date": "2026-01-14", - // disable_nodejs_process_v2 needed until unenv fix lands in Pages - // See: https://github.com/withastro/astro/issues/14511 "compatibility_flags": ["nodejs_compat", "disable_nodejs_process_v2"], - // Static assets served from dist/ - "routes": [ + + // Hyperdrive binding — emdash-pg config pointing to emdash-demo RDS + "hyperdrive": [ { - "pattern": "demo.emdashcms.com", - "zone_name": "demo.emdashcms.com", - "custom_domain": true, + "binding": "HYPERDRIVE", + "id": "01b192bf33194ecda6ad2aa1b2f2f8d2", }, ], - // D1 Database binding - "d1_databases": [ - { - "binding": "DB", - "database_name": "emdash_db", - }, - ], - // R2 bucket for media storage + + // R2 bucket — points to existing my-emdash-media bucket "r2_buckets": [ { "binding": "MEDIA", - "bucket_name": "emdash-media", + "bucket_name": "my-emdash-media", }, ], - // Observability + "observability": { "enabled": true, }, + + // KV namespace for Astro session storage + "kv_namespaces": [ + { + "binding": "SESSION", + "id": "0516c5af42c24460b6a9eba751ffc0e3", + }, + ], + // Worker Loader for plugin sandboxing "worker_loaders": [ { diff --git a/docs/src/content/docs/deployment/hyperdrive-postgresql.mdx b/docs/src/content/docs/deployment/hyperdrive-postgresql.mdx new file mode 100644 index 0000000000..791f575fca --- /dev/null +++ b/docs/src/content/docs/deployment/hyperdrive-postgresql.mdx @@ -0,0 +1,316 @@ +--- +title: PostgreSQL on Cloudflare Workers (Hyperdrive) +description: Deploy EmDash on Cloudflare Workers with a PostgreSQL database via Cloudflare Hyperdrive, including pool configuration, caching gotchas, and production safeguards. +--- + +import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components"; + +This guide covers deploying EmDash on Cloudflare Workers using [Cloudflare Hyperdrive](https://developers.cloudflare.com/hyperdrive/) to connect to a PostgreSQL database (AWS RDS, Supabase, Neon, or any PostgreSQL provider). + +## How It Works + +Hyperdrive sits between your Worker and PostgreSQL. It maintains a warm connection pool at the Cloudflare edge, so each Worker request opens a fast local connection to Hyperdrive rather than a slow cross-region TCP handshake to your database. + +``` +Browser → Cloudflare Worker → Hyperdrive (edge pool) → PostgreSQL (AWS RDS / Supabase / Neon) +``` + +EmDash's `@emdash-cms/cloudflare` package provides a Hyperdrive-aware Kysely dialect that creates a fresh `pg.Client` per query — re-reading `env.HYPERDRIVE.connectionString` each time — so the Worker never holds stale connections. + +## Setup + + + +1. **Create a PostgreSQL database** + + Any provider works. For AWS RDS: + - Create a PostgreSQL 15+ instance + - Set **Publicly Accessible = yes** (or configure VPC peering) + - Open port `5432` to Cloudflare's IP ranges in your security group + - Note: endpoint, port, database name, username, password + +2. **Create a Hyperdrive config** + + ```bash + npx wrangler hyperdrive create emdash-prod \ + --connection-string "postgresql://user:pass@your-db.example.com:5432/dbname" + ``` + + Save the returned Hyperdrive config ID. + +3. **Add the binding to `wrangler.jsonc`** + + ```jsonc + { + "hyperdrive": [ + { + "binding": "HYPERDRIVE", + "id": "your-hyperdrive-config-id" + } + ] + } + ``` + +4. **Configure EmDash in `astro.config.mjs`** + + ```js + import { defineConfig } from "astro/config"; + import cloudflare from "@astrojs/cloudflare"; + import emdash from "emdash"; + import { hyperdrive } from "@emdash-cms/cloudflare"; + + export default defineConfig({ + adapter: cloudflare({ platformProxy: { enabled: true } }), + integrations: [ + emdash({ + database: hyperdrive({ + binding: "HYPERDRIVE", + pool: { + min: 2, + max: 5, + idleTimeoutMillis: 10_000, + connectionTimeoutMillis: 5_000, + }, + }), + }), + ], + }); + ``` + +5. **Set local dev connection string** + + Create `.dev.vars` in your project root: + + ```env + HYPERDRIVE_LOCAL_CONNECTION_STRING=postgresql://user:pass@your-db.example.com:5432/dbname + ``` + +6. **Generate TypeScript types** + + ```bash + npx wrangler types + ``` + + This generates `worker-configuration.d.ts` with the correct `HYPERDRIVE` binding type. + +7. **Deploy** + + ```bash + npx wrangler deploy + ``` + + Then visit `/_emdash/admin/setup` in a regular browser window (not incognito — passkeys require credential storage) to run the setup wizard. + + + +## Pool Configuration + +The pool settings passed to `hyperdrive()` control how EmDash manages connections **per Worker isolate**. Size them using this formula: + +``` +(pods × processes × pool.max) + background_connections + admin_connections < max_connections × 0.7 +``` + +| Setting | Recommended | Why | +|---|---|---| +| `min` | `2` | Keeps 2 connections warm, absorbs cold-start bursts without TCP/TLS overhead | +| `max` | `5` | Tight ceiling per pod — leave room to scale horizontally | +| `idleTimeoutMillis` | `10000` | Release idle connections after 10s | +| `connectionTimeoutMillis` | `5000` | Fail fast when pool is exhausted — never hang | + + + +### Worked example + +10 Worker instances, 1 process each, `pool.max = 5`: + +``` +(10 × 1 × 5) + 5 (background) + 10 (admin) = 65 +max_connections = 200 → 70% cap = 140 +65 < 140 ✅ safe +``` + +At `max: 5` you can safely run ~18 pods against a `max_connections = 200` PostgreSQL instance before needing PgBouncer. + +## PostgreSQL Server Configuration + +Set these in `postgresql.conf` or your provider's configuration panel: + +```ini +max_connections = 200 +statement_timeout = 30000 # kill queries running > 30s +idle_in_transaction_session_timeout = 10000 # kill idle-in-transaction sessions after 10s +shared_buffers = 256MB # ~25% of RAM +work_mem = 8MB # per query, per sort +``` + +`idle_in_transaction_session_timeout` is critical: without it, a client that opens a transaction and crashes before closing it holds its locks and connection slot indefinitely. + +## SSL Configuration + + + + ```env + DATABASE_URL=postgresql://user:pass@host:5432/db?sslmode=require + ``` + + Hyperdrive handles TLS between the Worker and Hyperdrive's proxy. The `pg.Client` inside the Worker connects to Hyperdrive's local endpoint without TLS (`ssl: false`). This is correct — do not override it. + + + Some managed providers use self-signed certificates. Pass this when creating the Hyperdrive config: + + ```bash + npx wrangler hyperdrive create emdash-prod \ + --connection-string "postgresql://..." \ + --caching-disabled # optional, if you want full cache control + ``` + + And in the pool config: + ```js + pool: { + ssl: { rejectUnauthorized: false }, + } + ``` + + + + + +## The Hyperdrive Caching Problem + + + +Hyperdrive caches **non-transactional reads** for up to ~60 seconds at the nearest edge replica. This is invisible in development (SQLite has no cache layer) and only manifests in production. + +**The pattern it breaks:** any code that reads state immediately after writing it. + +``` +Request A: writes setup_complete = true +Request B: reads setup_complete → gets cached null → wrong branch taken +``` + +**The fix:** wrap every read that follows a recent write in `withTransaction`. Kysely transactions force Hyperdrive to route to the primary, bypassing the cache. + +```ts +// WRONG — may return stale cached value +const row = await db + .selectFrom("options") + .where("name", "=", "emdash:setup_complete") + .executeTakeFirst(); + +// RIGHT — bypasses Hyperdrive cache +const row = await withTransaction(db, (trx) => + trx + .selectFrom("options") + .where("name", "=", "emdash:setup_complete") + .executeTakeFirst() +); +``` + +### INSERT-then-catch instead of SELECT-then-INSERT + +Pre-check `SELECT` queries are cached. If you check for existence before inserting, the cached null will cause duplicate inserts. Let the database enforce uniqueness atomically: + +```ts +// WRONG — SELECT is cached, INSERT duplicates +const existing = await db + .selectFrom("collections") + .where("slug", "=", slug) + .executeTakeFirst(); +if (!existing) await db.insertInto("collections").values({...}).execute(); + +// RIGHT — atomic, cache-safe +try { + await db.insertInto("collections").values({...}).execute(); +} catch (err) { + if (isDuplicateKeyError(err)) return; // already exists, fine + throw err; +} +``` + +Use this helper to detect duplicates across all three drivers: + +```ts +function isDuplicateKeyError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const e = err as Error & { code?: string }; + return ( + e.code === "23505" || // PostgreSQL + e.code === "SQLITE_CONSTRAINT_UNIQUE" || // better-sqlite3 + e.message.includes("UNIQUE constraint failed") // libSQL + ); +} +``` + +## PostgreSQL vs SQLite Differences + +| Topic | SQLite (dev) | PostgreSQL (prod via Hyperdrive) | +|---|---|---| +| JSON extraction | `json_extract(data, '$.field')` | `data->>'field'` | +| Reserved keywords | Lenient | Strict — quote `"window"`, `"order"`, etc. in raw SQL | +| Read-after-write | Immediate | Must use transaction to bypass Hyperdrive cache | +| Duplicate detection | `SQLITE_CONSTRAINT_UNIQUE` | Error code `23505` | +| Booleans | `0` / `1` | `true` / `false` or `0` / `1` | + +### Reserved keywords in raw SQL + +PostgreSQL enforces reserved keywords strictly. `window` is a common one that works in SQLite but breaks in PostgreSQL: + +```sql +-- WRONG — works in SQLite, syntax error in PostgreSQL +INSERT INTO _emdash_rate_limits (key, window, count) VALUES (...) + +-- RIGHT — always quote column names that are keywords +INSERT INTO _emdash_rate_limits (key, "window", count) VALUES (...) +``` + +## PgBouncer (When You Need It) + +Add PgBouncer when `pods × pool.max > 100` (roughly 20+ pods at `max: 5`). + +```ini +pool_mode = transaction # correct for EmDash's stateless SSR +max_client_conn = 1000 +default_pool_size = 20 +``` + +**Transaction pooling limitations** — do not use these features in EmDash routes when behind PgBouncer in transaction mode: + +- `SET` session variables +- Named prepared statements +- `LISTEN` / `NOTIFY` +- Advisory locks held across queries +- Cursors held open across statements + +EmDash's Kysely-based handlers use anonymous queries and are compatible with transaction pooling out of the box. + +## Observability + +Track these metrics in production: + +| Metric | What it tells you | +|---|---| +| `pg_stat_activity` count | Live connection usage vs `max_connections` | +| Pool waiting clients | Queue depth — early warning of pool exhaustion | +| Query duration p99 | Slow queries holding connections | +| Connection error rate | Pool exhaustion or database instability | + +## Failure Mode Strategy + +| Failure | Without safeguards | With safeguards | +|---|---|---| +| Pool exhausted | Request hangs until OS timeout | Returns 503 via `connectionTimeoutMillis` | +| Query timeout | Request hangs | Returns 503 via `statement_timeout` | +| DB unreachable | 500 with stack trace | Returns 503 with `Retry-After` | + +Always return **503** (Service Unavailable), not 500. 503 signals to load balancers that the request is safe to retry. 500 signals a bug. + +## Passkey Registration + +Passkey registration does not work in incognito/private browsing mode — Chrome and Edge do not save credentials to the OS credential store in incognito. Use a regular browser window when completing the setup wizard. diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 1d4683777f..d46975fcac 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -17,6 +17,10 @@ "types": "./dist/db/d1.d.mts", "default": "./dist/db/d1.mjs" }, + "./db/hyperdrive": { + "types": "./dist/db/hyperdrive.d.mts", + "default": "./dist/db/hyperdrive.mjs" + }, "./db/do": { "types": "./dist/db/do.d.mts", "default": "./dist/db/do.mjs" @@ -74,6 +78,7 @@ "emdash": "workspace:*", "jose": "^6.1.3", "kysely-d1": "^0.4.0", + "pg": "^8.0.0", "ulidx": "^2.4.1" }, "peerDependencies": { @@ -84,6 +89,7 @@ "devDependencies": { "@arethetypeswrong/cli": "catalog:", "@cloudflare/workers-types": "catalog:", + "@types/pg": "^8.16.0", "publint": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", diff --git a/packages/cloudflare/src/db/hyperdrive.ts b/packages/cloudflare/src/db/hyperdrive.ts new file mode 100644 index 0000000000..577ffecffb --- /dev/null +++ b/packages/cloudflare/src/db/hyperdrive.ts @@ -0,0 +1,102 @@ +/** + * Cloudflare Hyperdrive runtime adapter - RUNTIME ENTRY + * + * Creates a Kysely PostgresDialect that establishes a fresh pg.Client for + * every query. Hyperdrive handles connection pooling to the real database on + * the Cloudflare network; the Worker only needs an ephemeral connection to + * Hyperdrive's local proxy per query. + * + * WHY NOT a module-scoped Pool? + * Hyperdrive may provide a different connectionString per Worker request + * (or per isolate startup). A cached Pool bakes in the first request's CS; + * subsequent connect() calls on the stale Pool hang indefinitely in the + * Workers Node.js compat layer when the endpoint has changed or the idle + * connection was closed server-side. Creating a fresh Client per query + * re-reads env.HYPERDRIVE.connectionString every time, which is always + * current regardless of when the isolate started. + * + * Do NOT import this at config time — use { hyperdrive } from "@emdash-cms/cloudflare" instead. + */ + +import { env } from "cloudflare:workers"; +import { PostgresDialect } from "kysely"; +import { Client, type Pool } from "pg"; + +interface HyperdriveConfig { + binding: string; + pool?: { max?: number }; +} + +interface HyperdriveBinding { + connectionString: string; +} + +// How long to wait for a Hyperdrive TCP connect before giving up. +const CONNECT_TIMEOUT_MS = 8_000; + +function getBinding(bindingName: string): HyperdriveBinding { + const binding = (env as Record)[bindingName] as HyperdriveBinding | undefined; + if (!binding) { + throw new Error( + `Hyperdrive binding "${bindingName}" not found in environment. ` + + `Add it to your wrangler.jsonc:\n\n` + + ` "hyperdrive": [{ "binding": "${bindingName}", "id": "your-hyperdrive-config-id" }]`, + ); + } + return binding; +} + +export function createDialect(config: HyperdriveConfig): PostgresDialect { + // Validate the binding exists at dialect creation time. + getBinding(config.binding); + + // Fake pool: Kysely only needs connect() + end(). + // We re-read env.HYPERDRIVE.connectionString on every connect() so we + // always use the current CS, even if it changes between requests. + const fakePool = { + connect: async (): Promise Promise }> => { + const binding = getBinding(config.binding); + const cs = binding.connectionString; + + const connectPromise = (async () => { + const client = new Client({ + connectionString: cs, + // Hyperdrive handles TLS to the database; the Worker connects + // to Hyperdrive's local proxy without TLS. + ssl: false, + }); + await client.connect(); + // Kysely calls release() when it's done with the connection. + // We close the Client rather than returning it to a pool. + (client as Client & { release: (destroy?: boolean) => Promise }).release = async ( + _destroy?: boolean, + ) => { + await client.end().catch(() => {}); + }; + return client as Client & { release: (destroy?: boolean) => Promise }; + })(); + + return Promise.race([ + connectPromise, + new Promise((_, reject) => + setTimeout( + () => + reject( + new Error( + `[hyperdrive] connect timeout after ${CONNECT_TIMEOUT_MS}ms — ` + + `check Hyperdrive binding "${config.binding}" and RDS reachability`, + ), + ), + CONNECT_TIMEOUT_MS, + ), + ), + ]); + }, + // Called by Kysely.destroy() — nothing to clean up. + end: async (): Promise => {}, + }; + + // Cast: Kysely only uses connect() + end() at runtime; the full Pool type + // is not required. + return new PostgresDialect({ pool: fakePool as unknown as Pool }); +} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 5009ae379c..9e854b29b2 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -3,6 +3,7 @@ * * Cloudflare adapters for EmDash: * - D1 database adapter + * - Hyperdrive database adapter (PostgreSQL via Hyperdrive) * - R2 storage adapter * - Cloudflare Access authentication * - Worker Loader sandbox for plugins @@ -169,6 +170,46 @@ export function d1(config: D1Config): DatabaseDescriptor { export type { PreviewDOConfig } from "./db/do-types.js"; +/** + * Hyperdrive configuration + */ +export interface HyperdriveConfig { + /** + * Name of the Hyperdrive binding in wrangler.jsonc + */ + binding: string; + + /** + * pg.Pool size. Hyperdrive handles connection pooling externally; + * keep this small (default: 5). + */ + pool?: { max?: number }; +} + +/** + * Cloudflare Hyperdrive database adapter + * + * For Cloudflare Workers connecting to PostgreSQL via Hyperdrive. + * Uses a module-scoped pg.Pool backed by env[binding].connectionString. + * + * Requires a Hyperdrive binding in wrangler.jsonc: + * ```jsonc + * "hyperdrive": [{ "binding": "HYPERDRIVE", "id": "your-config-id" }] + * ``` + * + * @example + * ```ts + * database: hyperdrive({ binding: "HYPERDRIVE" }) + * ``` + */ +export function hyperdrive(config: HyperdriveConfig): DatabaseDescriptor { + return { + entrypoint: "@emdash-cms/cloudflare/db/hyperdrive", + config, + type: "postgres", + }; +} + /** * Durable Object preview database adapter * diff --git a/packages/cloudflare/src/sandbox/runner.ts b/packages/cloudflare/src/sandbox/runner.ts index b26de38223..5aad8d584d 100644 --- a/packages/cloudflare/src/sandbox/runner.ts +++ b/packages/cloudflare/src/sandbox/runner.ts @@ -267,6 +267,11 @@ class CloudflareSandboxedPlugin implements SandboxedPlugin { PLUGIN_VERSION: this.manifest.version || "0.0.0", // Bridge binding for all host operations BRIDGE: bridgeBinding, + // Forward selected host bindings so sandbox plugins can read Worker secrets (wrangler secret put …) + RESEND_API_KEY: (env as Record).RESEND_API_KEY, + EMAIL_FROM: (env as Record).EMAIL_FROM, + POSTMARK_SERVER_TOKEN: (env as Record).POSTMARK_SERVER_TOKEN, + POSTMARK_FROM: (env as Record).POSTMARK_FROM, }, })); } diff --git a/packages/cloudflare/src/sandbox/wrapper.ts b/packages/cloudflare/src/sandbox/wrapper.ts index 3f74104539..5e910176ea 100644 --- a/packages/cloudflare/src/sandbox/wrapper.ts +++ b/packages/cloudflare/src/sandbox/wrapper.ts @@ -173,7 +173,13 @@ function createContext(env) { site, url, users, - email + email, + env: { + RESEND_API_KEY: env.RESEND_API_KEY, + EMAIL_FROM: env.EMAIL_FROM, + POSTMARK_SERVER_TOKEN: env.POSTMARK_SERVER_TOKEN, + POSTMARK_FROM: env.POSTMARK_FROM + } }; } diff --git a/packages/cloudflare/tsdown.config.ts b/packages/cloudflare/tsdown.config.ts index 2e524c7b44..00b3c59635 100644 --- a/packages/cloudflare/tsdown.config.ts +++ b/packages/cloudflare/tsdown.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: [ "src/index.ts", "src/db/d1.ts", + "src/db/hyperdrive.ts", "src/db/do.ts", "src/db/playground.ts", "src/db/playground-middleware.ts", diff --git a/packages/core/src/astro/middleware/setup.ts b/packages/core/src/astro/middleware/setup.ts index 704afc9f9b..0d4dda2287 100644 --- a/packages/core/src/astro/middleware/setup.ts +++ b/packages/core/src/astro/middleware/setup.ts @@ -15,6 +15,7 @@ import { defineMiddleware } from "astro:middleware"; import { getAuthMode } from "../../auth/mode.js"; +import { withTransaction } from "../../database/transaction.js"; export const onRequest = defineMiddleware(async (context, next) => { // Only check setup on admin routes (but not the setup page itself) @@ -31,52 +32,51 @@ export const onRequest = defineMiddleware(async (context, next) => { } try { - // Check setup_complete flag - const setupComplete = await emdash.db - .selectFrom("options") - .select("value") - .where("name", "=", "emdash:setup_complete") - .executeTakeFirst(); + // Read setup_complete and user count in a single transaction so + // Hyperdrive bypasses its query cache and we always see the values + // written by the preceding setup/admin-verify request. + const { isComplete, userCount } = await withTransaction(emdash.db, async (trx) => { + const completeRow = await trx + .selectFrom("options") + .select("value") + .where("name", "=", "emdash:setup_complete") + .executeTakeFirst(); - // Value is JSON-encoded, parse it. Accepts both boolean true and string "true" - const isComplete = - setupComplete && - (() => { - try { - const parsed = JSON.parse(setupComplete.value); - return parsed === true || parsed === "true"; - } catch { - return false; - } - })(); + const complete = + completeRow && + (() => { + try { + const parsed = JSON.parse(completeRow.value); + return parsed === true || parsed === "true"; + } catch { + return false; + } + })(); + + const countResult = await trx + .selectFrom("users") + .select((eb) => eb.fn.countAll().as("count")) + .executeTakeFirst(); + + return { isComplete: complete, userCount: Number(countResult?.count ?? 0) }; + }); if (!isComplete) { - // Redirect to setup wizard return context.redirect("/_emdash/admin/setup"); } - // Check auth mode - user verification differs by mode const authMode = getAuthMode(emdash.config); - // In passkey mode, verify users exist - // In Access mode, skip this check - first user is created on first Access login - if (authMode.type === "passkey") { - // Setup is marked complete, but verify users exist - // This catches edge case where setup_complete is true but no users - const userCount = await emdash.db - .selectFrom("users") - .select((eb) => eb.fn.countAll().as("count")) - .executeTakeFirstOrThrow(); - - if (userCount.count === 0) { - // No users - need to complete admin creation - return context.redirect("/_emdash/admin/setup"); - } + if (authMode.type === "passkey" && userCount === 0) { + return context.redirect("/_emdash/admin/setup"); } } catch (error) { // If the options table doesn't exist yet, redirect to setup // This handles fresh installations where migrations haven't run - if (error instanceof Error && error.message.includes("no such table")) { + if ( + error instanceof Error && + (error.message.includes("no such table") || error.message.includes("does not exist")) + ) { return context.redirect("/_emdash/admin/setup"); } diff --git a/packages/core/src/astro/routes/api/setup/admin-verify.ts b/packages/core/src/astro/routes/api/setup/admin-verify.ts index b8197ffad7..7299657720 100644 --- a/packages/core/src/astro/routes/api/setup/admin-verify.ts +++ b/packages/core/src/astro/routes/api/setup/admin-verify.ts @@ -19,6 +19,7 @@ import { setupAdminVerifyBody } from "#api/schemas.js"; import { createChallengeStore } from "#auth/challenge-store.js"; import { getPasskeyConfig } from "#auth/passkey-config.js"; import { OptionsRepository } from "#db/repositories/options.js"; +import { withTransaction } from "#db/transaction.js"; export const POST: APIRoute = async ({ request, locals }) => { const { emdash } = locals; @@ -28,29 +29,67 @@ export const POST: APIRoute = async ({ request, locals }) => { } try { - // Check if setup is already complete - const options = new OptionsRepository(emdash.db); - const setupComplete = await options.get("emdash:setup_complete"); + // Read all setup-state values in a single transaction so Hyperdrive + // bypasses its query cache and we always see the values written by the + // preceding admin-options request in the same setup flow. + const { setupComplete, userCount, setupState } = await withTransaction( + emdash.db, + async (trx) => { + const completeRow = await trx + .selectFrom("options") + .select("value") + .where("name", "=", "emdash:setup_complete") + .executeTakeFirst(); + const sc = completeRow + ? (() => { + try { + return JSON.parse(completeRow.value); + } catch { + return null; + } + })() + : null; + + const countResult = await trx + .selectFrom("users") + .select((eb) => eb.fn.countAll().as("count")) + .executeTakeFirst(); + const uc = countResult?.count ?? 0; + + const stateRow = await trx + .selectFrom("options") + .select("value") + .where("name", "=", "emdash:setup_state") + .executeTakeFirst(); + const ss = stateRow + ? (() => { + try { + return JSON.parse(stateRow.value); + } catch { + return null; + } + })() + : null; + + return { setupComplete: sc, userCount: uc, setupState: ss }; + }, + ); if (setupComplete === true || setupComplete === "true") { return apiError("SETUP_COMPLETE", "Setup already complete", 400); } - // Check if any users exist - const adapter = createKyselyAdapter(emdash.db); - const userCount = await adapter.countUsers(); - if (userCount > 0) { return apiError("ADMIN_EXISTS", "Admin user already exists", 400); } - // Get setup state - const setupState = await options.get("emdash:setup_state"); - if (!setupState || setupState.step !== "admin") { return apiError("INVALID_STATE", "Invalid setup state. Please restart setup.", 400); } + const adapter = createKyselyAdapter(emdash.db); + const options = new OptionsRepository(emdash.db); + // Parse request body const body = await parseBody(request, setupAdminVerifyBody); if (isParseError(body)) return body; diff --git a/packages/core/src/astro/routes/api/setup/index.ts b/packages/core/src/astro/routes/api/setup/index.ts index c4b246ebaf..8161ea6ee1 100644 --- a/packages/core/src/astro/routes/api/setup/index.ts +++ b/packages/core/src/astro/routes/api/setup/index.ts @@ -15,6 +15,7 @@ import { setupBody } from "#api/schemas.js"; import { getAuthMode } from "#auth/mode.js"; import { runMigrations } from "#db/migrations/runner.js"; import { OptionsRepository } from "#db/repositories/options.js"; +import { withTransaction } from "#db/transaction.js"; import { applySeed } from "#seed/apply.js"; import { loadSeed } from "#seed/load.js"; import { validateSeed } from "#seed/validate.js"; @@ -28,11 +29,27 @@ export const POST: APIRoute = async ({ request, url, locals }) => { try { // Guard: reject if setup has already been completed. + // Use a transaction so Hyperdrive bypasses its query cache and we see + // the true value rather than a stale null from a recent write. // The options table may not exist on first-ever setup (pre-migration), // so a query failure means setup hasn't run yet — allow it to proceed. try { - const options = new OptionsRepository(emdash.db); - const setupComplete = await options.get("emdash:setup_complete"); + const setupCompleteRow = await withTransaction(emdash.db, async (trx) => + trx + .selectFrom("options") + .select("value") + .where("name", "=", "emdash:setup_complete") + .executeTakeFirst(), + ); + const setupComplete = setupCompleteRow + ? (() => { + try { + return JSON.parse(setupCompleteRow.value); + } catch { + return null; + } + })() + : null; if (setupComplete === true || setupComplete === "true") { return apiError("ALREADY_CONFIGURED", "Setup has already been completed", 409); diff --git a/packages/core/src/astro/routes/api/setup/status.ts b/packages/core/src/astro/routes/api/setup/status.ts index 4f9c068b89..c75c0cf5bb 100644 --- a/packages/core/src/astro/routes/api/setup/status.ts +++ b/packages/core/src/astro/routes/api/setup/status.ts @@ -10,6 +10,7 @@ export const prerender = false; import { apiError, apiSuccess, handleError } from "#api/error.js"; import { getAuthMode } from "#auth/mode.js"; +import { withTransaction } from "#db/transaction.js"; import { loadUserSeed } from "#seed/load.js"; export const GET: APIRoute = async ({ locals }) => { @@ -20,36 +21,49 @@ export const GET: APIRoute = async ({ locals }) => { } try { - // Check if setup is complete - const setupComplete = await emdash.db - .selectFrom("options") - .select("value") - .where("name", "=", "emdash:setup_complete") - .executeTakeFirst(); - - // Value is JSON-encoded, parse it. Accepts both boolean true and string "true" - const isComplete = - setupComplete && - (() => { - try { - const parsed = JSON.parse(setupComplete.value); - return parsed === true || parsed === "true"; - } catch { - return false; - } - })(); - - // Also check if users exist - let hasUsers = false; - try { - const userCount = await emdash.db + // Read all setup-state values in a single transaction so Hyperdrive + // bypasses its query cache and we always see the latest written values. + const { isComplete, hasUsers, setupState } = await withTransaction(emdash.db, async (trx) => { + const completeRow = await trx + .selectFrom("options") + .select("value") + .where("name", "=", "emdash:setup_complete") + .executeTakeFirst(); + + const complete = + completeRow && + (() => { + try { + const parsed = JSON.parse(completeRow.value); + return parsed === true || parsed === "true"; + } catch { + return false; + } + })(); + + const countResult = await trx .selectFrom("users") .select((eb) => eb.fn.countAll().as("count")) - .executeTakeFirstOrThrow(); - hasUsers = userCount.count > 0; - } catch { - // Users table might not exist yet - } + .executeTakeFirst(); + const foundUsers = Number(countResult?.count ?? 0) > 0; + + const stateRow = await trx + .selectFrom("options") + .select("value") + .where("name", "=", "emdash:setup_state") + .executeTakeFirst(); + const state = stateRow + ? (() => { + try { + return JSON.parse(stateRow.value); + } catch { + return null; + } + })() + : null; + + return { isComplete: complete, hasUsers: foundUsers, setupState: state }; + }); // Setup is complete only if flag is set AND users exist if (isComplete && hasUsers) { @@ -62,23 +76,11 @@ export const GET: APIRoute = async ({ locals }) => { // step: "start" | "site" | "admin" | "complete" let step: "start" | "site" | "admin" = "start"; - // Get setup state if it exists - const setupState = await emdash.db - .selectFrom("options") - .select("value") - .where("name", "=", "emdash:setup_state") - .executeTakeFirst(); - if (setupState) { - try { - const state = JSON.parse(setupState.value); - if (state.step === "admin") { - step = "admin"; - } else if (state.step === "site") { - step = "site"; - } - } catch { - // Invalid state, stay at start + if (setupState.step === "admin") { + step = "admin"; + } else if (setupState.step === "site") { + step = "site"; } } diff --git a/packages/core/src/auth/rate-limit.ts b/packages/core/src/auth/rate-limit.ts index 2710be0e30..a6833cefab 100644 --- a/packages/core/src/auth/rate-limit.ts +++ b/packages/core/src/auth/rate-limit.ts @@ -61,11 +61,12 @@ export async function checkRateLimit( ).toISOString(); const key = `${ip}:${endpoint}`; - // Atomic upsert: insert or increment, return current count + // Atomic upsert: insert or increment, return current count. + // "window" must be quoted — it is a reserved keyword in PostgreSQL. const result = await sql<{ count: number }>` - INSERT INTO _emdash_rate_limits (key, window, count) + INSERT INTO _emdash_rate_limits (key, "window", count) VALUES (${key}, ${windowStart}, 1) - ON CONFLICT (key, window) + ON CONFLICT (key, "window") DO UPDATE SET count = _emdash_rate_limits.count + 1 RETURNING count `.execute(db); @@ -151,7 +152,7 @@ export async function cleanupExpiredRateLimits( const cutoff = new Date(Date.now() - maxAgeSeconds * 1000).toISOString(); const result = await sql` - DELETE FROM _emdash_rate_limits WHERE window < ${cutoff} + DELETE FROM _emdash_rate_limits WHERE "window" < ${cutoff} `.execute(db); return Number(result.numAffectedRows ?? 0); diff --git a/packages/core/src/db/adapters.ts b/packages/core/src/db/adapters.ts index bf2d7859be..c23a4d1159 100644 --- a/packages/core/src/db/adapters.ts +++ b/packages/core/src/db/adapters.ts @@ -118,7 +118,17 @@ export interface PostgresConfig { user?: string; password?: string; ssl?: boolean; - pool?: { min?: number; max?: number }; + pool?: { + min?: number; + max?: number; + /** Milliseconds before an idle connection is closed. Default: 10000. */ + idleTimeoutMillis?: number; + /** + * Milliseconds to wait for a connection before failing. Default: 5000. + * Set this to avoid indefinite hangs when the pool is exhausted. + */ + connectionTimeoutMillis?: number; + }; } /** diff --git a/packages/core/src/db/postgres.ts b/packages/core/src/db/postgres.ts index 421b4b07b0..043df1ef14 100644 --- a/packages/core/src/db/postgres.ts +++ b/packages/core/src/db/postgres.ts @@ -24,6 +24,8 @@ export function createDialect(config: PostgresConfig): PostgresDialect { ssl: config.ssl, min: config.pool?.min ?? 0, max: config.pool?.max ?? 10, + idleTimeoutMillis: config.pool?.idleTimeoutMillis ?? 10_000, + connectionTimeoutMillis: config.pool?.connectionTimeoutMillis ?? 5_000, }); return new PostgresDialect({ pool }); diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index dd9192aa62..03289504af 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -1654,9 +1654,9 @@ export class EmDashRuntime { bylines: body.bylines, }); - // Run afterSave hooks (fire-and-forget) + // Run afterSave hooks (awaited — required for CF Workers sandbox fetch lifetime) if (result.success && result.data) { - this.runAfterSaveHooks(contentItemToRecord(result.data.item), collection, true); + await this.runAfterSaveHooks(contentItemToRecord(result.data.item), collection, true); } return result; @@ -1794,9 +1794,9 @@ export class EmDashRuntime { bylines: bodyWithoutRev.bylines, }); - // Run afterSave hooks (fire-and-forget) + // Run afterSave hooks (awaited — required for CF Workers sandbox fetch lifetime) if (result.success && result.data) { - this.runAfterSaveHooks(contentItemToRecord(result.data.item), collection, false); + await this.runAfterSaveHooks(contentItemToRecord(result.data.item), collection, false); } return result; @@ -1832,9 +1832,9 @@ export class EmDashRuntime { // Delete the content const result = await handleContentDelete(this.db, collection, id); - // Run afterDelete hooks (fire-and-forget) + // Run afterDelete hooks (awaited — required for CF Workers sandbox fetch lifetime) if (result.success) { - this.runAfterDeleteHooks(id, collection, false); + await this.runAfterDeleteHooks(id, collection, false); } return result; @@ -1860,7 +1860,7 @@ export class EmDashRuntime { // Run afterDelete hooks so plugins (e.g. AI Search) can clean up if (result.success) { - this.runAfterDeleteHooks(id, collection, true); + await this.runAfterDeleteHooks(id, collection, true); } return result; @@ -1881,9 +1881,9 @@ export class EmDashRuntime { async handleContentPublish(collection: string, id: string) { const result = await handleContentPublish(this.db, collection, id); - // Run afterPublish hooks (fire-and-forget) + // Run afterPublish hooks (awaited — required for CF Workers sandbox fetch lifetime) if (result.success && result.data) { - this.runAfterPublishHooks(contentItemToRecord(result.data.item), collection); + await this.runAfterPublishHooks(contentItemToRecord(result.data.item), collection); } return result; @@ -1892,9 +1892,9 @@ export class EmDashRuntime { async handleContentUnpublish(collection: string, id: string) { const result = await handleContentUnpublish(this.db, collection, id); - // Run afterUnpublish hooks (fire-and-forget) + // Run afterUnpublish hooks (awaited — required for CF Workers sandbox fetch lifetime) if (result.success && result.data) { - this.runAfterUnpublishHooks(contentItemToRecord(result.data.item), collection); + await this.runAfterUnpublishHooks(contentItemToRecord(result.data.item), collection); } return result; @@ -1966,7 +1966,7 @@ export class EmDashRuntime { // Create the media record const result = await handleMediaCreate(this.db, processedInput); - // Run afterUpload hooks (fire-and-forget) + // Run afterUpload hooks (awaited — required for CF Workers sandbox fetch lifetime) if (result.success && this.hooks.hasHooks("media:afterUpload")) { const item = result.data.item; const mediaItem: MediaItem = { @@ -1977,9 +1977,11 @@ export class EmDashRuntime { url: `/media/${item.id}/${item.filename}`, createdAt: item.createdAt, }; - this.hooks - .runMediaAfterUpload(mediaItem) - .catch((err) => console.error("EmDash afterUpload hook error:", err)); + try { + await this.hooks.runMediaAfterUpload(mediaItem); + } catch (err) { + console.error("EmDash afterUpload hook error:", err); + } } return result; @@ -2215,16 +2217,18 @@ export class EmDashRuntime { return true; } - private runAfterSaveHooks( + private async runAfterSaveHooks( content: Record, collection: string, isNew: boolean, - ): void { + ): Promise { // Trusted plugins if (this.hooks.hasHooks("content:afterSave")) { - this.hooks - .runContentAfterSave(content, collection, isNew) - .catch((err) => console.error("EmDash afterSave hook error:", err)); + try { + await this.hooks.runContentAfterSave(content, collection, isNew); + } catch (err) { + console.error("EmDash afterSave hook error:", err); + } } // Sandboxed plugins @@ -2232,39 +2236,52 @@ export class EmDashRuntime { const [id] = pluginKey.split(":"); if (!id || !this.isPluginEnabled(id)) continue; - plugin - .invokeHook("content:afterSave", { content, collection, isNew }) - .catch((err) => console.error(`EmDash: Sandboxed plugin ${id} afterSave error:`, err)); + try { + await plugin.invokeHook("content:afterSave", { content, collection, isNew }); + } catch (err) { + console.error(`EmDash: Sandboxed plugin ${id} afterSave error:`, err); + } } } - private runAfterDeleteHooks(id: string, collection: string, permanent: boolean): void { + private async runAfterDeleteHooks( + id: string, + collection: string, + permanent: boolean, + ): Promise { // Trusted plugins if (this.hooks.hasHooks("content:afterDelete")) { - this.hooks - .runContentAfterDelete(id, collection, permanent) - .catch((err) => console.error("EmDash afterDelete hook error:", err)); + try { + await this.hooks.runContentAfterDelete(id, collection, permanent); + } catch (err) { + console.error("EmDash afterDelete hook error:", err); + } } - // Sandboxed plugins + // Sandboxed plugins (awaited — required for CF Workers sandbox fetch lifetime) for (const [pluginKey, plugin] of this.sandboxedPlugins) { const [pluginId] = pluginKey.split(":"); if (!pluginId || !this.isPluginEnabled(pluginId)) continue; - plugin - .invokeHook("content:afterDelete", { id, collection, permanent }) - .catch((err) => - console.error(`EmDash: Sandboxed plugin ${pluginId} afterDelete error:`, err), - ); + try { + await plugin.invokeHook("content:afterDelete", { id, collection, permanent }); + } catch (err) { + console.error(`EmDash: Sandboxed plugin ${pluginId} afterDelete error:`, err); + } } } - private runAfterPublishHooks(content: Record, collection: string): void { + private async runAfterPublishHooks( + content: Record, + collection: string, + ): Promise { // Trusted plugins if (this.hooks.hasHooks("content:afterPublish")) { - this.hooks - .runContentAfterPublish(content, collection) - .catch((err) => console.error("EmDash afterPublish hook error:", err)); + try { + await this.hooks.runContentAfterPublish(content, collection); + } catch (err) { + console.error("EmDash afterPublish hook error:", err); + } } // Sandboxed plugins @@ -2272,32 +2289,37 @@ export class EmDashRuntime { const [pluginId] = pluginKey.split(":"); if (!pluginId || !this.isPluginEnabled(pluginId)) continue; - plugin - .invokeHook("content:afterPublish", { content, collection }) - .catch((err) => - console.error(`EmDash: Sandboxed plugin ${pluginId} afterPublish error:`, err), - ); + try { + await plugin.invokeHook("content:afterPublish", { content, collection }); + } catch (err) { + console.error(`EmDash: Sandboxed plugin ${pluginId} afterPublish error:`, err); + } } } - private runAfterUnpublishHooks(content: Record, collection: string): void { + private async runAfterUnpublishHooks( + content: Record, + collection: string, + ): Promise { // Trusted plugins if (this.hooks.hasHooks("content:afterUnpublish")) { - this.hooks - .runContentAfterUnpublish(content, collection) - .catch((err) => console.error("EmDash afterUnpublish hook error:", err)); + try { + await this.hooks.runContentAfterUnpublish(content, collection); + } catch (err) { + console.error("EmDash afterUnpublish hook error:", err); + } } - // Sandboxed plugins + // Sandboxed plugins (awaited — required for CF Workers sandbox fetch lifetime) for (const [pluginKey, plugin] of this.sandboxedPlugins) { const [pluginId] = pluginKey.split(":"); if (!pluginId || !this.isPluginEnabled(pluginId)) continue; - plugin - .invokeHook("content:afterUnpublish", { content, collection }) - .catch((err) => - console.error(`EmDash: Sandboxed plugin ${pluginId} afterUnpublish error:`, err), - ); + try { + await plugin.invokeHook("content:afterUnpublish", { content, collection }); + } catch (err) { + console.error(`EmDash: Sandboxed plugin ${pluginId} afterUnpublish error:`, err); + } } } diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index 5d2e74bb3a..183baa76f3 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -124,12 +124,6 @@ export class SchemaRegistry { throw new SchemaError(`Collection slug "${input.slug}" is reserved`, "RESERVED_SLUG"); } - // Check if collection already exists - const existing = await this.getCollection(input.slug); - if (existing) { - throw new SchemaError(`Collection "${input.slug}" already exists`, "COLLECTION_EXISTS"); - } - const id = ulid(); // Insert collection record and create content table in a transaction @@ -138,7 +132,20 @@ export class SchemaRegistry { // Derive hasSeo from supports array if not explicitly set const hasSeo = input.hasSeo ?? input.supports?.includes("seo") ?? false; + let collection: Collection | null = null; + await withTransaction(this.db, async (trx) => { + // Check existence inside the transaction so transactional reads bypass + // the Hyperdrive query cache and see the current DB state. + const existing = await trx + .selectFrom("_emdash_collections") + .where("slug", "=", input.slug) + .select("id") + .executeTakeFirst(); + if (existing) { + throw new SchemaError(`Collection "${input.slug}" already exists`, "COLLECTION_EXISTS"); + } + await trx .insertInto("_emdash_collections") .values({ @@ -158,9 +165,21 @@ export class SchemaRegistry { // Create the content table for this collection await this.createContentTable(input.slug, trx); + + // Read via trx (not this.db) to avoid connection mutex deadlock on + // PostgreSQL/Hyperdrive where reading from this.db after a transaction + // may not see the just-committed row. Matches the pattern in createField. + const row = await trx + .selectFrom("_emdash_collections") + .where("slug", "=", input.slug) + .selectAll() + .executeTakeFirst(); + + if (row) { + collection = this.mapCollectionRow(row); + } }); - const collection = await this.getCollection(input.slug); if (!collection) { throw new SchemaError("Failed to create collection", "CREATE_FAILED"); } @@ -327,39 +346,53 @@ export class SchemaRegistry { * Create a new field */ async createField(collectionSlug: string, input: CreateFieldInput): Promise { - const collection = await this.getCollection(collectionSlug); - if (!collection) { - throw new SchemaError(`Collection "${collectionSlug}" not found`, "COLLECTION_NOT_FOUND"); - } - - // Validate slug + // Validate slug before any DB work this.validateSlug(input.slug, "field"); if (RESERVED_FIELD_SLUGS.includes(input.slug)) { throw new SchemaError(`Field slug "${input.slug}" is reserved`, "RESERVED_SLUG"); } - // Check if field already exists - const existing = await this.getField(collectionSlug, input.slug); - if (existing) { - throw new SchemaError( - `Field "${input.slug}" already exists in collection "${collectionSlug}"`, - "FIELD_EXISTS", - ); - } - const id = ulid(); const columnType = FIELD_TYPE_TO_COLUMN[input.type]; - // Get max sort order - const maxSort = await this.db - .selectFrom("_emdash_fields") - .where("collection_id", "=", collection.id) - .select((eb) => eb.fn.max("sort_order").as("max")) - .executeTakeFirst(); + return withTransaction(this.db, async (trx) => { + // Read collection via trx to avoid Hyperdrive query cache returning a + // stale empty result when the collection was just created in a prior + // transaction. Transactional reads bypass the Hyperdrive cache. + const collection = await trx + .selectFrom("_emdash_collections") + .where("slug", "=", collectionSlug) + .selectAll() + .executeTakeFirst(); - const sortOrder = input.sortOrder ?? (maxSort?.max ?? -1) + 1; + if (!collection) { + throw new SchemaError(`Collection "${collectionSlug}" not found`, "COLLECTION_NOT_FOUND"); + } + + // Check if field already exists (via trx for the same cache-bypass reason) + const existingField = await trx + .selectFrom("_emdash_fields") + .where("collection_id", "=", collection.id) + .where("slug", "=", input.slug) + .selectAll() + .executeTakeFirst(); + + if (existingField) { + throw new SchemaError( + `Field "${input.slug}" already exists in collection "${collectionSlug}"`, + "FIELD_EXISTS", + ); + } + + // Get max sort order (via trx) + const maxSort = await trx + .selectFrom("_emdash_fields") + .where("collection_id", "=", collection.id) + .select((eb) => eb.fn.max("sort_order").as("max")) + .executeTakeFirst(); + + const sortOrder = input.sortOrder ?? (maxSort?.max ?? -1) + 1; - return withTransaction(this.db, async (trx) => { // Insert field record await trx .insertInto("_emdash_fields") diff --git a/packages/core/src/seed/apply.ts b/packages/core/src/seed/apply.ts index 3fb42030a4..2779b3f6d3 100644 --- a/packages/core/src/seed/apply.ts +++ b/packages/core/src/seed/apply.ts @@ -19,7 +19,7 @@ import { withTransaction } from "../database/transaction.js"; import type { Database } from "../database/types.js"; import type { MediaValue } from "../fields/types.js"; import { ssrfSafeFetch, validateExternalUrl } from "../import/ssrf.js"; -import { SchemaRegistry } from "../schema/registry.js"; +import { SchemaError, SchemaRegistry } from "../schema/registry.js"; import { FTSManager } from "../search/fts-manager.js"; import { setSiteSettings } from "../settings/index.js"; import type { Storage } from "../storage/types.js"; @@ -36,6 +36,21 @@ import type { const FILE_EXTENSION_PATTERN = /\.([a-z0-9]+)(?:\?|$)/i; import { validateSeed } from "./validate.js"; +/** + * Returns true if the error is a unique-constraint / duplicate-key violation. + * Detects across PostgreSQL (code 23505), better-sqlite3 (SQLITE_CONSTRAINT_UNIQUE), + * and libSQL/D1 (message substring). + */ +function isDuplicateKeyError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const e = err as Error & { code?: string }; + return ( + e.code === "23505" || + e.code === "SQLITE_CONSTRAINT_UNIQUE" || + (typeof e.message === "string" && e.message.includes("UNIQUE constraint failed")) + ); +} + /** Pattern to remove file extensions */ const EXTENSION_PATTERN = /\.[^.]+$/; @@ -123,14 +138,38 @@ export async function applySeed( const registry = new SchemaRegistry(db); for (const collection of seed.collections) { - // Check if collection exists - const existing = await registry.getCollection(collection.slug); - - if (existing) { + // Attempt to create the collection directly. createCollection does its + // own existence check inside a transaction, so we avoid a non-transactional + // pre-check here that Hyperdrive could cache as empty immediately after a + // prior collection was created in the same request. + let collectionExisted = false; + + try { + await registry.createCollection({ + slug: collection.slug, + label: collection.label, + labelSingular: collection.labelSingular, + description: collection.description, + icon: collection.icon, + supports: collection.supports || [], + source: "seed", + urlPattern: collection.urlPattern, + commentsEnabled: collection.commentsEnabled, + }); + result.collections.created++; + } catch (err) { + if (!(err instanceof SchemaError) || err.code !== "COLLECTION_EXISTS") { + throw err; + } + collectionExisted = true; if (onConflict === "error") { - throw new Error(`Conflict: collection "${collection.slug}" already exists`); + throw new Error(`Conflict: collection "${collection.slug}" already exists`, { + cause: err, + }); } + } + if (collectionExisted) { if (onConflict === "update") { await registry.updateCollection(collection.slug, { label: collection.label, @@ -183,21 +222,7 @@ export async function applySeed( continue; } - // Create collection - await registry.createCollection({ - slug: collection.slug, - label: collection.label, - labelSingular: collection.labelSingular, - description: collection.description, - icon: collection.icon, - supports: collection.supports || [], - source: "seed", - urlPattern: collection.urlPattern, - commentsEnabled: collection.commentsEnabled, - }); - result.collections.created++; - - // Create fields + // Create fields (collection was just created above) for (const field of collection.fields) { await registry.createField(collection.slug, { slug: field.slug, @@ -219,37 +244,17 @@ export async function applySeed( // 4-5. Taxonomies if (seed.taxonomies) { for (const taxonomy of seed.taxonomies) { - // Check if taxonomy definition exists - const existingDef = await db - .selectFrom("_emdash_taxonomy_defs") - .selectAll() - .where("name", "=", taxonomy.name) - .executeTakeFirst(); - - if (existingDef) { - if (onConflict === "error") { - throw new Error(`Conflict: taxonomy "${taxonomy.name}" already exists`); - } - if (onConflict === "update") { - await db - .updateTable("_emdash_taxonomy_defs") - .set({ - label: taxonomy.label, - label_singular: taxonomy.labelSingular ?? null, - hierarchical: taxonomy.hierarchical ? 1 : 0, - collections: JSON.stringify(taxonomy.collections), - }) - .where("id", "=", existingDef.id) - .execute(); - // Taxonomy defs don't track an "updated" counter -- just the definition is updated - } - // skip: do nothing for the definition - } else { - // Create taxonomy definition + // Attempt INSERT first rather than SELECT then INSERT to avoid + // Hyperdrive caching the pre-check as empty right before the INSERT. + // Transactional reads bypass the Hyperdrive cache; standalone SELECTs + // do not, so a pre-check executed moments after a prior collection + // INSERT can return a stale null and then re-insert a duplicate row. + const defId = ulid(); + try { await db .insertInto("_emdash_taxonomy_defs") .values({ - id: ulid(), + id: defId, name: taxonomy.name, label: taxonomy.label, label_singular: taxonomy.labelSingular ?? null, @@ -258,6 +263,34 @@ export async function applySeed( }) .execute(); result.taxonomies.created++; + } catch (insertErr) { + if (!isDuplicateKeyError(insertErr)) throw insertErr; + // Row already exists — handle per onConflict + if (onConflict === "error") { + throw new Error(`Conflict: taxonomy "${taxonomy.name}" already exists`, { + cause: insertErr, + }); + } + if (onConflict === "update") { + const existingDef = await db + .selectFrom("_emdash_taxonomy_defs") + .select("id") + .where("name", "=", taxonomy.name) + .executeTakeFirst(); + if (existingDef) { + await db + .updateTable("_emdash_taxonomy_defs") + .set({ + label: taxonomy.label, + label_singular: taxonomy.labelSingular ?? null, + hierarchical: taxonomy.hierarchical ? 1 : 0, + collections: JSON.stringify(taxonomy.collections), + }) + .where("id", "=", existingDef.id) + .execute(); + } + } + // skip: do nothing for the definition } // Create terms (if provided) @@ -270,29 +303,33 @@ export async function applySeed( } else { // Flat taxonomy - create all terms for (const term of taxonomy.terms) { - const existing = await termRepo.findBySlug(taxonomy.name, term.slug); - if (existing) { + try { + await termRepo.create({ + name: taxonomy.name, + slug: term.slug, + label: term.label, + data: term.description ? { description: term.description } : undefined, + }); + result.taxonomies.terms++; + } catch (createErr) { + if (!isDuplicateKeyError(createErr)) throw createErr; if (onConflict === "error") { throw new Error( `Conflict: taxonomy term "${term.slug}" in "${taxonomy.name}" already exists`, + { cause: createErr }, ); } if (onConflict === "update") { - await termRepo.update(existing.id, { - label: term.label, - data: term.description ? { description: term.description } : {}, - }); - result.taxonomies.terms++; + const existing = await termRepo.findBySlug(taxonomy.name, term.slug); + if (existing) { + await termRepo.update(existing.id, { + label: term.label, + data: term.description ? { description: term.description } : {}, + }); + result.taxonomies.terms++; + } } // skip: do nothing - } else { - await termRepo.create({ - name: taxonomy.name, - slug: term.slug, - label: term.label, - data: term.description ? { description: term.description } : undefined, - }); - result.taxonomies.terms++; } } } @@ -685,23 +722,7 @@ async function applyHierarchicalTerms( if (!term.parent || slugToId.has(term.parent)) { const parentId = term.parent ? slugToId.get(term.parent) : undefined; - const existing = await termRepo.findBySlug(taxonomyName, term.slug); - if (existing) { - if (onConflict === "error") { - throw new Error( - `Conflict: taxonomy term "${term.slug}" in "${taxonomyName}" already exists`, - ); - } - if (onConflict === "update") { - await termRepo.update(existing.id, { - label: term.label, - parentId, - data: term.description ? { description: term.description } : {}, - }); - result.taxonomies.terms++; - } - slugToId.set(term.slug, existing.id); - } else { + try { const created = await termRepo.create({ name: taxonomyName, slug: term.slug, @@ -711,6 +732,27 @@ async function applyHierarchicalTerms( }); slugToId.set(term.slug, created.id); result.taxonomies.terms++; + } catch (createErr) { + if (!isDuplicateKeyError(createErr)) throw createErr; + if (onConflict === "error") { + throw new Error( + `Conflict: taxonomy term "${term.slug}" in "${taxonomyName}" already exists`, + { cause: createErr }, + ); + } + // Resolve ID for parent-child chain regardless of skip/update + const existing = await termRepo.findBySlug(taxonomyName, term.slug); + if (existing) { + if (onConflict === "update") { + await termRepo.update(existing.id, { + label: term.label, + parentId, + data: term.description ? { description: term.description } : {}, + }); + result.taxonomies.terms++; + } + slugToId.set(term.slug, existing.id); + } } processedThisPass.push(term.slug); diff --git a/packages/plugins/notify-on-publish/package.json b/packages/plugins/notify-on-publish/package.json new file mode 100644 index 0000000000..115d228c75 --- /dev/null +++ b/packages/plugins/notify-on-publish/package.json @@ -0,0 +1,28 @@ +{ + "name": "@emdash-cms/plugin-notify-on-publish", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + }, + "./sandbox": { + "types": "./dist/sandbox-entry.d.mts", + "import": "./dist/sandbox-entry.mjs" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean" + }, + "peerDependencies": { + "emdash": "workspace:*" + }, + "devDependencies": { + "tsdown": "catalog:" + } +} diff --git a/packages/plugins/notify-on-publish/src/index.ts b/packages/plugins/notify-on-publish/src/index.ts new file mode 100644 index 0000000000..f2faf387d9 --- /dev/null +++ b/packages/plugins/notify-on-publish/src/index.ts @@ -0,0 +1,13 @@ +import type { PluginDescriptor } from "emdash"; + +export function notifyOnPublishPlugin(): PluginDescriptor { + return { + id: "notify-on-publish", + version: "1.0.0", + format: "standard", + entrypoint: "@emdash-cms/plugin-notify-on-publish/sandbox", + capabilities: ["read:content", "network:fetch"], + allowedHosts: ["api.resend.com", "webhook.site"], + options: {}, + }; +} diff --git a/packages/plugins/notify-on-publish/src/sandbox-entry.ts b/packages/plugins/notify-on-publish/src/sandbox-entry.ts new file mode 100644 index 0000000000..7d1d8d369f --- /dev/null +++ b/packages/plugins/notify-on-publish/src/sandbox-entry.ts @@ -0,0 +1,210 @@ +import { definePlugin } from "emdash"; +import type { ContentPublishStateChangeEvent, PluginContext } from "emdash"; + +const RESEND_ENDPOINT = "https://api.resend.com/emails"; +const DEFAULT_FROM = "onboarding@resend.dev"; + +export default definePlugin({ + hooks: { + "content:afterPublish": { + handler: async (event: ContentPublishStateChangeEvent, ctx: PluginContext) => { + const content = event.content as { + id?: string; + title?: string; + slug?: string; + publishedAt?: string; + email?: string | string[]; + data?: Record; + fields?: { email?: string }; + [key: string]: unknown; + }; + + try { + ctx.log.info( + `[notify-on-publish] fired collection=${event.collection} id=${content.id ?? "(no-id)"}`, + ); + + const rawRecipient = + content.email ?? content.data?.email ?? content.fields?.email ?? findEmailDeep(content); + + const recipients = normalizeRecipients(rawRecipient); + if (recipients.length === 0) { + ctx.log.info( + `[notify-on-publish] skip: ${event.collection}/${content.id ?? "(no-id)"} has no email field (opt-in)`, + ); + return; + } + + const apiKey = resolveEnv(ctx, "RESEND_API_KEY"); + if (!apiKey) { + ctx.log.error(`[notify-on-publish] RESEND_API_KEY not in ctx.env`); + return; + } + + const http = (ctx as { http?: { fetch: typeof fetch } }).http; + if (!http?.fetch) { + ctx.log.error(`[notify-on-publish] ctx.http.fetch unavailable`); + return; + } + + const title = String(content.title ?? content.id ?? "(untitled)"); + const slug = String(content.slug ?? content.id ?? ""); + const publishedAt = + typeof content.publishedAt === "string" + ? content.publishedAt + : new Date().toISOString(); + const from = resolveEnv(ctx, "EMAIL_FROM") ?? DEFAULT_FROM; + const collectionLabel = capitalize(event.collection); + + ctx.log.info( + `[notify-on-publish] sending: collection=${event.collection} to=[${recipients.join(", ")}] from=${from}`, + ); + + const text = `"${title}" was just published. + +Collection: ${event.collection} +Slug: ${slug} +Published: ${publishedAt}`; + const html = `
+

${escapeHtml(collectionLabel)} published: ${escapeHtml(title)}

+

+ Collection: ${escapeHtml(event.collection)}
+ Slug: ${escapeHtml(slug)}
+ Published: ${escapeHtml(publishedAt)} +

`; + + const t0 = Date.now(); + let res: Response; + try { + res = await http.fetch(RESEND_ENDPOINT, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from, + to: recipients, + subject: `${collectionLabel} published: ${title}`, + text, + html, + }), + }); + ctx.log.info( + `[notify-on-publish] Resend status=${res.status} elapsed_ms=${Date.now() - t0}`, + ); + } catch (fetchErr) { + ctx.log.error( + `[notify-on-publish] fetch threw: ${fetchErr instanceof Error ? `${fetchErr.name}: ${fetchErr.message}` : String(fetchErr)}`, + ); + return; + } + + if (!res.ok) { + const errText = await res.text().catch(() => "(body unreadable)"); + ctx.log.error(`[notify-on-publish] Resend ${res.status}: ${errText.slice(0, 500)}`); + return; + } + + let respJson: { id?: string } = {}; + try { + respJson = (await res.json()) as { id?: string }; + } catch { + /* ignore */ + } + ctx.log.info( + `[notify-on-publish] SENT to=[${recipients.join(", ")}] resend_id=${respJson?.id ?? "unknown"}`, + ); + } catch (topErr) { + ctx.log.error( + `[notify-on-publish] top error: ${topErr instanceof Error ? `${topErr.name}: ${topErr.message}\n${topErr.stack?.slice(0, 400)}` : String(topErr)}`, + ); + } + }, + }, + }, +}); + +const EMAIL_REGEX = /^[^@\s,]+@[^@\s,]+\.[^@\s,]+$/; + +/** + * Accepts: + * - undefined / null / empty → [] + * - "alice@x.com" → ["alice@x.com"] + * - "alice@x.com, bob@y.com; carol@z.com" → 3 addresses + * - ["alice@x.com", "bob@y.com"] → as-is (validated) + * Deduplicates and validates each. + */ +function normalizeRecipients(raw: unknown): string[] { + if (!raw) return []; + const candidates: string[] = []; + if (Array.isArray(raw)) { + for (const item of raw) { + if (typeof item === "string") candidates.push(...splitList(item)); + } + } else if (typeof raw === "string") { + candidates.push(...splitList(raw)); + } + const seen = new Set(); + const out: string[] = []; + for (const c of candidates) { + const trimmed = c.trim(); + if (!trimmed || !EMAIL_REGEX.test(trimmed)) continue; + const key = trimmed.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(trimmed); + } + return out; +} + +function splitList(s: string): string[] { + return s.split(/[,;\s]+/).filter(Boolean); +} + +function findEmailDeep(obj: unknown, depth = 0): string | string[] | undefined { + if (!obj || typeof obj !== "object" || depth > 4) return undefined; + const record = obj as Record; + for (const [key, value] of Object.entries(record)) { + const k = key.toLowerCase(); + if (k === "email" || k === "emails") { + if (typeof value === "string" && normalizeRecipients(value).length > 0) { + return value; + } + if (Array.isArray(value) && normalizeRecipients(value).length > 0) { + return value as string[]; + } + } + } + for (const value of Object.values(record)) { + if (value && typeof value === "object") { + const nested = findEmailDeep(value, depth + 1); + if (nested) return nested; + } + } + return undefined; +} + +function resolveEnv(ctx: PluginContext, name: string): string | undefined { + const env = (ctx as { env?: Record }).env; + if (env && typeof env[name] === "string") return env[name] as string; + const g = globalThis as unknown as Record; + if (typeof g[name] === "string") return g[name] as string; + const proc = g.process as { env?: Record } | undefined; + if (proc?.env?.[name]) return proc.env[name]; + return undefined; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function capitalize(s: string): string { + if (!s) return s; + return s.charAt(0).toUpperCase() + s.slice(1); +} diff --git a/packages/plugins/notify-on-publish/tsconfig.json b/packages/plugins/notify-on-publish/tsconfig.json new file mode 100644 index 0000000000..f7304871d7 --- /dev/null +++ b/packages/plugins/notify-on-publish/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/plugins/notify-postmark/package.json b/packages/plugins/notify-postmark/package.json new file mode 100644 index 0000000000..7a1ef302f6 --- /dev/null +++ b/packages/plugins/notify-postmark/package.json @@ -0,0 +1,28 @@ +{ + "name": "@emdash-cms/plugin-notify-postmark", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + }, + "./sandbox": { + "types": "./dist/sandbox-entry.d.mts", + "import": "./dist/sandbox-entry.mjs" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean" + }, + "peerDependencies": { + "emdash": "workspace:*" + }, + "devDependencies": { + "tsdown": "catalog:" + } +} diff --git a/packages/plugins/notify-postmark/src/index.ts b/packages/plugins/notify-postmark/src/index.ts new file mode 100644 index 0000000000..e88462f31a --- /dev/null +++ b/packages/plugins/notify-postmark/src/index.ts @@ -0,0 +1,13 @@ +import type { PluginDescriptor } from "emdash"; + +export function notifyPostmarkPlugin(): PluginDescriptor { + return { + id: "notify-postmark", + version: "1.0.0", + format: "standard", + entrypoint: "@emdash-cms/plugin-notify-postmark/sandbox", + capabilities: ["read:content", "network:fetch"], + allowedHosts: ["api.postmarkapp.com"], + options: {}, + }; +} diff --git a/packages/plugins/notify-postmark/src/sandbox-entry.ts b/packages/plugins/notify-postmark/src/sandbox-entry.ts new file mode 100644 index 0000000000..4b413881f7 --- /dev/null +++ b/packages/plugins/notify-postmark/src/sandbox-entry.ts @@ -0,0 +1,205 @@ +import { definePlugin } from "emdash"; +import type { ContentPublishStateChangeEvent, PluginContext } from "emdash"; + +const POSTMARK_ENDPOINT = "https://api.postmarkapp.com/email"; +/** Postmark requires a verified sender signature or domain */ +const DEFAULT_FROM = "notifications@example.com"; + +export default definePlugin({ + hooks: { + "content:afterPublish": { + handler: async (event: ContentPublishStateChangeEvent, ctx: PluginContext) => { + const content = event.content as { + id?: string; + title?: string; + slug?: string; + publishedAt?: string; + email?: string | string[]; + data?: Record; + fields?: { email?: string }; + [key: string]: unknown; + }; + + try { + ctx.log.info( + `[notify-postmark] fired collection=${event.collection} id=${content.id ?? "(no-id)"}`, + ); + + const rawRecipient = + content.email ?? content.data?.email ?? content.fields?.email ?? findEmailDeep(content); + + const recipients = normalizeRecipients(rawRecipient); + if (recipients.length === 0) { + ctx.log.info( + `[notify-postmark] skip: ${event.collection}/${content.id ?? "(no-id)"} has no email field (opt-in)`, + ); + return; + } + + const apiKey = resolveEnv(ctx, "POSTMARK_SERVER_TOKEN"); + if (!apiKey) { + ctx.log.error(`[notify-postmark] POSTMARK_SERVER_TOKEN not in ctx.env`); + return; + } + + const http = (ctx as { http?: { fetch: typeof fetch } }).http; + if (!http?.fetch) { + ctx.log.error(`[notify-postmark] ctx.http.fetch unavailable`); + return; + } + + const title = String(content.title ?? content.id ?? "(untitled)"); + const slug = String(content.slug ?? content.id ?? ""); + const publishedAt = + typeof content.publishedAt === "string" + ? content.publishedAt + : new Date().toISOString(); + const from = resolveEnv(ctx, "POSTMARK_FROM") ?? DEFAULT_FROM; + const collectionLabel = capitalize(event.collection); + + ctx.log.info( + `[notify-postmark] sending: collection=${event.collection} to=[${recipients.join(", ")}] from=${from}`, + ); + + const text = `"${title}" was just published. + +Collection: ${event.collection} +Slug: ${slug} +Published: ${publishedAt}`; + const html = `
+

${escapeHtml(collectionLabel)} published: ${escapeHtml(title)}

+

+ Collection: ${escapeHtml(event.collection)}
+ Slug: ${escapeHtml(slug)}
+ Published: ${escapeHtml(publishedAt)} +

`; + + const t0 = Date.now(); + let res: Response; + try { + res = await http.fetch(POSTMARK_ENDPOINT, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-Postmark-Server-Token": apiKey, + }, + body: JSON.stringify({ + From: from, + To: recipients.join(", "), + Subject: `${collectionLabel} published: ${title}`, + TextBody: text, + HtmlBody: html, + MessageStream: "outbound", + }), + }); + ctx.log.info( + `[notify-postmark] Postmark status=${res.status} elapsed_ms=${Date.now() - t0}`, + ); + } catch (fetchErr) { + ctx.log.error( + `[notify-postmark] fetch threw: ${fetchErr instanceof Error ? `${fetchErr.name}: ${fetchErr.message}` : String(fetchErr)}`, + ); + return; + } + + if (!res.ok) { + const errText = await res.text().catch(() => "(body unreadable)"); + ctx.log.error(`[notify-postmark] Postmark ${res.status}: ${errText.slice(0, 500)}`); + return; + } + + let respJson: { MessageID?: string } = {}; + try { + respJson = (await res.json()) as { MessageID?: string }; + } catch { + /* ignore */ + } + ctx.log.info( + `[notify-postmark] SENT to=[${recipients.join(", ")}] MessageID=${respJson?.MessageID ?? "unknown"}`, + ); + } catch (topErr) { + ctx.log.error( + `[notify-postmark] top error: ${topErr instanceof Error ? `${topErr.name}: ${topErr.message}\n${topErr.stack?.slice(0, 400)}` : String(topErr)}`, + ); + } + }, + }, + }, +}); + +const EMAIL_REGEX = /^[^@\s,]+@[^@\s,]+\.[^@\s,]+$/; + +function normalizeRecipients(raw: unknown): string[] { + if (!raw) return []; + const candidates: string[] = []; + if (Array.isArray(raw)) { + for (const item of raw) { + if (typeof item === "string") candidates.push(...splitList(item)); + } + } else if (typeof raw === "string") { + candidates.push(...splitList(raw)); + } + const seen = new Set(); + const out: string[] = []; + for (const c of candidates) { + const trimmed = c.trim(); + if (!trimmed || !EMAIL_REGEX.test(trimmed)) continue; + const key = trimmed.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(trimmed); + } + return out; +} + +function splitList(s: string): string[] { + return s.split(/[,;\s]+/).filter(Boolean); +} + +function findEmailDeep(obj: unknown, depth = 0): string | string[] | undefined { + if (!obj || typeof obj !== "object" || depth > 4) return undefined; + const record = obj as Record; + for (const [key, value] of Object.entries(record)) { + const k = key.toLowerCase(); + if (k === "email" || k === "emails") { + if (typeof value === "string" && normalizeRecipients(value).length > 0) { + return value; + } + if (Array.isArray(value) && normalizeRecipients(value).length > 0) { + return value as string[]; + } + } + } + for (const value of Object.values(record)) { + if (value && typeof value === "object") { + const nested = findEmailDeep(value, depth + 1); + if (nested) return nested; + } + } + return undefined; +} + +function resolveEnv(ctx: PluginContext, name: string): string | undefined { + const env = (ctx as { env?: Record }).env; + if (env && typeof env[name] === "string") return env[name] as string; + const g = globalThis as unknown as Record; + if (typeof g[name] === "string") return g[name] as string; + const proc = g.process as { env?: Record } | undefined; + if (proc?.env?.[name]) return proc.env[name]; + return undefined; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function capitalize(s: string): string { + if (!s) return s; + return s.charAt(0).toUpperCase() + s.slice(1); +} diff --git a/packages/plugins/notify-postmark/tsconfig.json b/packages/plugins/notify-postmark/tsconfig.json new file mode 100644 index 0000000000..f7304871d7 --- /dev/null +++ b/packages/plugins/notify-postmark/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/plugins/plugin-email-on-publish/package.json b/packages/plugins/plugin-email-on-publish/package.json new file mode 100644 index 0000000000..f9b2e02f95 --- /dev/null +++ b/packages/plugins/plugin-email-on-publish/package.json @@ -0,0 +1,9 @@ +{ + "name": "@emdash-cms/plugin-email-on-publish", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./sandbox": "./src/sandbox-entry.ts" + } +} diff --git a/packages/plugins/plugin-email-on-publish/src/index.ts b/packages/plugins/plugin-email-on-publish/src/index.ts new file mode 100644 index 0000000000..b87f9322af --- /dev/null +++ b/packages/plugins/plugin-email-on-publish/src/index.ts @@ -0,0 +1,13 @@ +// src/index.ts — descriptor factory, runs in Vite at build time +// Imported in astro.config.mjs — must be side-effect-free. +import type { PluginDescriptor } from "emdash"; + +export function emailOnPublishPlugin(): PluginDescriptor { + return { + id: "email-on-publish", + version: "1.0.0", + format: "standard", + entrypoint: "@emdash-cms/plugin-email-on-publish/sandbox", + options: {}, + }; +} diff --git a/packages/plugins/plugin-email-on-publish/src/sandbox-entry.ts b/packages/plugins/plugin-email-on-publish/src/sandbox-entry.ts new file mode 100644 index 0000000000..c674735c4a --- /dev/null +++ b/packages/plugins/plugin-email-on-publish/src/sandbox-entry.ts @@ -0,0 +1,182 @@ +// src/sandbox-entry.ts — plugin definition, runs at request time +// This is the actual plugin logic. Works in both trusted and sandboxed modes. +// Uses only Web APIs (fetch) — no Node.js built-ins. +// +// Configure via CF Dashboard → Workers & Pages → Settings → Variables & Secrets: +// +// EMAIL_PROVIDER mailchannels | resend | sendgrid (default: mailchannels) +// EMAIL_FROM sender address e.g. cms@yourdomain.com +// EMAIL_TO recipient address e.g. you@yourdomain.com +// RESEND_API_KEY required only when EMAIL_PROVIDER=resend +// SENDGRID_API_KEY required only when EMAIL_PROVIDER=sendgrid + +import { definePlugin } from "emdash"; +import type { PluginContext } from "emdash"; + +// --------------------------------------------------------------------------- +// Provider implementations (Web API fetch only — sandbox compatible) +// --------------------------------------------------------------------------- + +async function sendViaMailChannels( + from: string, + to: string, + subject: string, + html: string, +): Promise { + const response = await fetch("https://api.mailchannels.net/tx/v1/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: to }] }], + from: { email: from }, + subject, + content: [{ type: "text/html", value: html }], + }), + }); + if (!response.ok) { + throw new Error(`MailChannels ${response.status}: ${await response.text()}`); + } +} + +async function sendViaResend( + apiKey: string, + from: string, + to: string, + subject: string, + html: string, +): Promise { + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ from, to, subject, html }), + }); + if (!response.ok) { + throw new Error(`Resend ${response.status}: ${await response.text()}`); + } +} + +async function sendViaSendGrid( + apiKey: string, + from: string, + to: string, + subject: string, + html: string, +): Promise { + const response = await fetch("https://api.sendgrid.com/v3/mail/send", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: to }] }], + from: { email: from }, + subject, + content: [{ type: "text/html", value: html }], + }), + }); + // SendGrid returns 202 Accepted on success + if (response.status !== 202) { + throw new Error(`SendGrid ${response.status}: ${await response.text()}`); + } +} + +// --------------------------------------------------------------------------- +// Email HTML builder +// --------------------------------------------------------------------------- + +function buildHtml(title: string, collection: string, id: string): string { + return ` +
+

📢 New content published

+ + + + + + + + + + + + + +
Title${title}
Collection${collection}
ID${id}
+

+ Sent by EmDash · plugin-email-on-publish +

+
`; +} + +// --------------------------------------------------------------------------- +// Plugin definition (default export required) +// --------------------------------------------------------------------------- + +export default definePlugin({ + hooks: { + "content:afterSave": { + handler: async (event: any, ctx: PluginContext) => { + // Only fire when content transitions to published + if (event.content.status !== "published") return; + + const env = (ctx as any).env ?? {}; + const provider: string = env.EMAIL_PROVIDER ?? "mailchannels"; + const from: string = env.EMAIL_FROM ?? ""; + const to: string = env.EMAIL_TO ?? ""; + + if (!from || !to) { + ctx.log.error("[email-on-publish] EMAIL_FROM and EMAIL_TO must be set"); + return; + } + + const title = event.content.title ?? "Untitled"; + const collection = event.collection ?? "unknown"; + const id = event.content.id ?? ""; + const subject = `Published: ${title}`; + const html = buildHtml(title, collection, id); + + try { + switch (provider) { + case "mailchannels": + await sendViaMailChannels(from, to, subject, html); + break; + + case "resend": { + const key = env.RESEND_API_KEY; + if (!key) { + ctx.log.error("[email-on-publish] RESEND_API_KEY not set"); + return; + } + await sendViaResend(key, from, to, subject, html); + break; + } + + case "sendgrid": { + const key = env.SENDGRID_API_KEY; + if (!key) { + ctx.log.error("[email-on-publish] SENDGRID_API_KEY not set"); + return; + } + await sendViaSendGrid(key, from, to, subject, html); + break; + } + + default: + ctx.log.error( + `[email-on-publish] Unknown provider "${provider}". Use: mailchannels | resend | sendgrid`, + ); + return; + } + + ctx.log.info(`[email-on-publish] ✓ Sent via ${provider} — "${title}"`); + } catch (err: any) { + ctx.log.error(`[email-on-publish] Send failed: ${err.message}`); + } + }, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6a943d552..88d96666f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,6 +203,12 @@ importers: '@emdash-cms/plugin-forms': specifier: workspace:* version: link:../../packages/plugins/forms + '@emdash-cms/plugin-notify-on-publish': + specifier: workspace:* + version: link:../../packages/plugins/notify-on-publish + '@emdash-cms/plugin-notify-postmark': + specifier: workspace:* + version: link:../../packages/plugins/notify-postmark '@emdash-cms/plugin-webhook-notifier': specifier: workspace:* version: link:../../packages/plugins/webhook-notifier @@ -218,6 +224,12 @@ importers: emdash: specifier: workspace:* version: link:../../packages/core + kysely: + specifier: ^0.27.0 + version: 0.27.6 + pg: + specifier: ^8.0.0 + version: 8.18.0 react: specifier: 'catalog:' version: 19.2.4 @@ -963,6 +975,9 @@ importers: kysely-d1: specifier: ^0.4.0 version: 0.4.0(kysely@0.27.6) + pg: + specifier: ^8.0.0 + version: 8.18.0 ulidx: specifier: ^2.4.1 version: 2.4.1 @@ -973,6 +988,9 @@ importers: '@cloudflare/workers-types': specifier: 'catalog:' version: 4.20260305.1 + '@types/pg': + specifier: ^8.16.0 + version: 8.16.0 publint: specifier: 'catalog:' version: 0.3.17 @@ -1377,6 +1395,28 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/plugins/notify-on-publish: + dependencies: + emdash: + specifier: workspace:* + version: link:../../core + devDependencies: + tsdown: + specifier: 'catalog:' + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + + packages/plugins/notify-postmark: + dependencies: + emdash: + specifier: workspace:* + version: link:../../core + devDependencies: + tsdown: + specifier: 'catalog:' + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + + packages/plugins/plugin-email-on-publish: {} + packages/plugins/sandboxed-test: dependencies: emdash: @@ -1801,7 +1841,7 @@ packages: wrangler: ^4.61.1 '@astrojs/cloudflare@https://pkg.pr.new/@astrojs/cloudflare@94d342d': - resolution: {tarball: https://pkg.pr.new/@astrojs/cloudflare@94d342d} + resolution: {integrity: sha512-Bt+G512Dr1SqYdsza6HOLP2azfHg0m5UE0s6SBGX77g+ThFV95Nai5boyM8HO3jVpqwVPPh+5ycMptjrtzv7Yg==, tarball: https://pkg.pr.new/@astrojs/cloudflare@94d342d} version: 13.1.10 peerDependencies: astro: ^6.0.0 @@ -3071,7 +3111,7 @@ packages: resolution: {integrity: sha512-yTCCjuQapvRz6S30B8DyqHu1WYsbYRCww6uNsmbQU4GQVf5gJzJSB60qUHj+qBSxReLtRL/mhmhYhrIc9jVFTw==} '@lunariajs/core@https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@83617cc': - resolution: {tarball: https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@83617cc} + resolution: {integrity: sha512-k8sHBM7S10HBa39fxsJcOGYMGrbru5UZ9vMS4kmCa9o6dJTUP6rt3zKVEs7uEsHAYasoXyiC6wre2Jiqs3X+zQ==, tarball: https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@83617cc} version: 0.1.1 engines: {node: '>=18.17.0'} @@ -5360,7 +5400,7 @@ packages: hasBin: true astro@https://pkg.pr.new/astro@94d342d: - resolution: {tarball: https://pkg.pr.new/astro@94d342d} + resolution: {integrity: sha512-1XlhRGRCQP4L5KPZUgSRCKOD28aKiGYQ8TBAxBIJvFV/HUuct3eHvc7sY/krhhCAju81JMlvbWU+1XVzltgZTQ==, tarball: https://pkg.pr.new/astro@94d342d} version: 6.1.7 engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} hasBin: true diff --git a/skills/hyperdrive-postgresql/SKILL.md b/skills/hyperdrive-postgresql/SKILL.md new file mode 100644 index 0000000000..97473fda66 --- /dev/null +++ b/skills/hyperdrive-postgresql/SKILL.md @@ -0,0 +1,199 @@ +--- +name: hyperdrive-postgresql +description: Debug and fix EmDash deployments on Cloudflare Workers with Hyperdrive + PostgreSQL. Use when hitting connection hangs, stale read bugs, duplicate key errors during seed, setup wizard failures, or passkey auth errors on Cloudflare Workers. +--- + +# Hyperdrive + PostgreSQL Deployment Skill + +You are helping debug or set up EmDash running on Cloudflare Workers with Cloudflare Hyperdrive connecting to a PostgreSQL database (AWS RDS, Supabase, Neon, etc.). + +## Architecture + +``` +Browser → Cloudflare Worker → Hyperdrive (edge pool) → PostgreSQL +``` + +Hyperdrive maintains warm connections at the edge. The Worker creates a fresh `pg.Client` per query (not a Pool) — re-reading `env.HYPERDRIVE.connectionString` each time to handle CS rotation. + +## The One Rule That Explains Most Bugs + +**Hyperdrive caches non-transactional reads for ~60 seconds.** + +Any `SELECT` that is not inside `BEGIN...COMMIT` is served from a read replica cache. This is invisible in development (SQLite has no cache) and only shows up in production. + +**Pattern that breaks:** + +1. Request A writes a value +2. Request B reads it → gets cached stale value → takes wrong branch + +**Fix:** wrap every read that follows a recent write in `withTransaction`: + +```ts +// WRONG +const row = await db.selectFrom("options").where("name", "=", "key").executeTakeFirst(); + +// RIGHT — transaction bypasses Hyperdrive cache +const row = await withTransaction(db, (trx) => + trx.selectFrom("options").where("name", "=", "key").executeTakeFirst(), +); +``` + +## Diagnosing Common Errors + +### Setup wizard fails mid-flow / collection not found after creation + +**Cause:** `createField` or `applySeed` does a non-transactional existence check after a recent write. + +**Fix:** All reads inside `createCollection`, `createField`, and `applySeed` must use `withTransaction`. Pre-check SELECTs must be replaced with INSERT-then-catch-duplicate. + +```ts +// WRONG — SELECT served from cache, INSERT duplicates +const existing = await db.selectFrom("t").where("slug", "=", slug).executeTakeFirst(); +if (!existing) await db.insertInto("t").values({...}).execute(); + +// RIGHT +try { + await db.insertInto("t").values({...}).execute(); +} catch (err) { + if (isDuplicateKeyError(err)) return; + throw err; +} + +function isDuplicateKeyError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const e = err as Error & { code?: string }; + return ( + e.code === "23505" || + e.code === "SQLITE_CONSTRAINT_UNIQUE" || + e.message.includes("UNIQUE constraint failed") + ); +} +``` + +### Passkey verify returns 400 on first attempt ("needs to create the key twice") + +**Cause:** `admin-verify.ts` reads `emdash:setup_state` non-transactionally. Hyperdrive serves stale null → step check fails. + +**Fix:** Wrap all three guard reads (`setup_complete`, `userCount`, `setup_state`) in a single `withTransaction` at the top of the POST handler. + +### Setup redirect loop after successful verify + +**Cause:** `middleware/setup.ts` reads `emdash:setup_complete` non-transactionally. After verify sets it to true, the middleware still sees cached null and redirects back to setup. + +**Fix:** Wrap `setup_complete` and `userCount` reads in `withTransaction` in the setup middleware. + +### SQL syntax error: `syntax error at or near "window"` + +**Cause:** `window` is a PostgreSQL reserved keyword. Works unquoted in SQLite, breaks in PostgreSQL. + +**Fix:** Quote it in all raw SQL: + +```sql +-- WRONG +INSERT INTO _emdash_rate_limits (key, window, count) ... +ON CONFLICT (key, window) DO UPDATE ... + +-- RIGHT +INSERT INTO _emdash_rate_limits (key, "window", count) ... +ON CONFLICT (key, "window") DO UPDATE ... +``` + +Check every raw SQL string for other reserved keywords: `order`, `group`, `user`, `table`, `index`, `select`, `where`, etc. + +### Worker hangs on DB connect (no timeout, no error) + +**Cause:** A cached `pg.Pool` baked in a stale Hyperdrive `connectionString`. Subsequent `connect()` calls hang indefinitely in Workers Node.js compat layer. + +**Fix:** Use a fresh `pg.Client` per query, not a module-scoped Pool. The `hyperdrive.ts` dialect does this — never revert to a Pool singleton. + +### Duplicate key errors during seed (`_emdash_collections`, `_emdash_taxonomy_defs`, `taxonomies`) + +**Cause:** Seed ran partially before, or Hyperdrive cached the pre-check SELECT as null, causing a second INSERT. + +**Fix:** + +1. Clean up the partial DB state +2. Switch from SELECT-then-INSERT to INSERT-then-catch-duplicate (see above) +3. All seed reads must use `withTransaction` + +## Files to Check for Hyperdrive Cache Bugs + +When a read returns stale data after a write, check these files in order: + +| File | Reads that need `withTransaction` | +| ---------------------------------------------------------- | ---------------------------------------------------------------------- | +| `packages/core/src/schema/registry.ts` | Collection existence in `createCollection`; all reads in `createField` | +| `packages/core/src/seed/apply.ts` | Collection, taxonomy def, and term existence checks | +| `packages/core/src/astro/routes/api/setup/admin-verify.ts` | `setup_complete`, `userCount`, `setup_state` | +| `packages/core/src/astro/middleware/setup.ts` | `setup_complete`, `userCount` | +| `packages/core/src/astro/routes/api/setup/status.ts` | `setup_complete`, `userCount`, `setup_state` | +| `packages/core/src/astro/routes/api/setup/index.ts` | `setup_complete` guard | + +## Setup Checklist (Fresh Deployment) + +``` +1. AWS RDS: PostgreSQL 15+, port 5432 open, publicly accessible +2. Hyperdrive: `npx wrangler hyperdrive create` → save ID +3. wrangler.jsonc: add hyperdrive binding +4. astro.config.mjs: use hyperdrive() with pool.min=2, pool.max=5, timeouts +5. .dev.vars: HYPERDRIVE_LOCAL_CONNECTION_STRING +6. npx wrangler types → worker-configuration.d.ts +7. npx wrangler deploy +8. Visit /_emdash/admin/setup in a REGULAR browser window (not incognito) +``` + +## Pool Sizing Formula + +``` +(pods × processes × pool.max) + background + admin < max_connections × 0.7 + +Safe defaults: pool.min=2, pool.max=5 +Max pods at max_connections=200: ~18 pods before PgBouncer needed +``` + +## PostgreSQL Server Settings + +```ini +max_connections = 200 +statement_timeout = 30000 +idle_in_transaction_session_timeout = 10000 +shared_buffers = 256MB +work_mem = 8MB +``` + +## Import Path Gotcha + +In `packages/core/src/astro/middleware/`, the `#db/*` alias is NOT available. Use relative paths: + +```ts +// WRONG (alias not resolved in middleware build) +import { withTransaction } from "#db/transaction.js"; + +// RIGHT +import { withTransaction } from "../../database/transaction.js"; +``` + +`#db/*` maps to `src/database/*` — the directory is `database`, not `db`. + +## Passkey Notes + +- Passkey registration **does not work in incognito/private browsing**. Chrome/Edge do not save credentials to the OS store in incognito. +- Always use a regular browser window for setup wizard and initial login. +- After DB cleanup between test runs, also clear saved passkeys from the browser's password manager. + +## DB Cleanup SQL (Between Test Runs) + +```sql +DELETE FROM _emdash_fields; +DELETE FROM _emdash_collections WHERE slug IN ('posts', 'pages'); +DROP TABLE IF EXISTS ec_posts; +DROP TABLE IF EXISTS ec_pages; +DELETE FROM _emdash_taxonomy_defs; +DELETE FROM taxonomies; +DELETE FROM options WHERE name IN ('emdash:setup_complete', 'emdash:setup_state', 'emdash:site_title', 'emdash:site_url'); +DELETE FROM users; +DELETE FROM credentials; +DELETE FROM auth_tokens; +DELETE FROM auth_challenges; +DELETE FROM _emdash_rate_limits; +```