Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions .github/workflows/deploy-pr-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,18 @@ jobs:
# they SKIP cleanly (the check stays green) rather than fail every PR.
# Adding the token activates per-PR preview deploys automatically.

# Create/refresh the ephemeral PlanetScale Postgres branch `pr-<n>`
# (created empty — parity with the old per-PR empty D1), mint a
# credential, and export MAPLE_PG_URL to GITHUB_ENV so the migrate +
# Alchemy steps below bind to the branch.
# Ensure the ephemeral PlanetScale Postgres branch `pr-<n>` exists
# and is empty (created once per PR — ~9 min provision — then reset
# in SQL on later deploys), mint a credential, and export
# MAPLE_PG_URL to GITHUB_ENV so the migrate + Alchemy steps below
# bind to the branch.
- name: Create/refresh PlanetScale PR branch
if: ${{ github.event.action != 'closed' && env.PLANETSCALE_SERVICE_TOKEN != '' }}
# GITHUB_TOKEN lets the script refuse to provision when the PR is
# already closed — a delayed/re-run deploy after teardown would
# otherwise recreate the branch with nothing left to delete it.
env:
GITHUB_TOKEN: ${{ github.token }}
run: bun scripts/planetscale-pr-branch.ts up "$PR_NUMBER"

- name: Run database migrations
Expand All @@ -110,8 +116,19 @@ jobs:
if: ${{ github.event.action != 'closed' && env.PLANETSCALE_SERVICE_TOKEN != '' }}
run: DATABASE_URL="$MAPLE_PG_URL" bun run --cwd packages/db db:grant

# Reassign everything this run's ephemeral role created to `postgres`
# so the NEXT deploy's role (which inherits postgres) can drop it —
# pscale_api_* roles are not grantable to each other, so without this
# the in-place reset always fails on the prior run's objects and
# falls back to the ~9-minute delete → recreate path. See
# packages/db/scripts/normalize-preview-ownership.ts.
- name: Normalize preview branch ownership
if: ${{ github.event.action != 'closed' && env.PLANETSCALE_SERVICE_TOKEN != '' }}
run: DATABASE_URL="$MAPLE_PG_URL" bun run --cwd packages/db db:normalize-preview

# Create/refresh an Electric Cloud environment `pr-<n>` + a Postgres
# source pointed at this PR's PlanetScale branch (MAPLE_PG_URL), and
# source pointed at this PR's PlanetScale branch (via the dedicated
# replication-role credential MAPLE_PG_ELECTRIC_URL), and
# export ELECTRIC_URL/ELECTRIC_SOURCE_ID/ELECTRIC_SECRET to GITHUB_ENV so
# the Alchemy deploy below binds the electric-sync worker to it. Runs
# after migrations so `0009_electric_publication` exists. Gated on
Expand Down
25 changes: 16 additions & 9 deletions docs/electric-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,14 @@ PR previews provision an ephemeral Electric Cloud **environment** `pr-<n>` + a
Postgres **source** per PR, mirroring the PlanetScale/Tinybird branch lifecycle.
`scripts/electric-pr-branch.ts` (`up`/`down <pr-number>`, driven from
`.github/workflows/deploy-pr-preview.yml`) uses `@electric-sql/cli`
(`ELECTRIC_API_TOKEN` auth) to, on open/synchronize: reset any existing `pr-<n>`
environment, create a fresh one under `ELECTRIC_PROJECT_ID`, create a `postgres`
source pointed at the PR branch's `MAPLE_PG_URL` (direct 5432), and export
(`ELECTRIC_API_TOKEN` auth) to, on open/synchronize: reuse (or create under
`ELECTRIC_PROJECT_ID`) the `pr-<n>` environment, reset its services, create a
fresh `postgres` source pointed at the PR branch's `MAPLE_PG_ELECTRIC_URL`
(direct 5432 through a dedicated `--with-replication` role — Electric requires
the REPLICATION role *attribute*, which is never inherited; the main CI role
stays non-replication because PlanetScale replication roles aren't grantable,
which would break the in-place reset's role assumption), polled until active,
and export
`ELECTRIC_URL`/`ELECTRIC_SOURCE_ID`/`ELECTRIC_SECRET` to `$GITHUB_ENV` (bound to
the electric-sync worker by alchemy). On close it deletes the environment
(cascades the source). Steps are gated on `ELECTRIC_API_TOKEN`, so previews stay
Expand All @@ -171,12 +176,14 @@ green (and the worker 503s) until the token lands in Infisical.
- The web build always reads through the sync path — provisioning the source is
what makes it work in previews.
- **Publication:** the migrate step runs `0009` (creates
`electric_publication_default`) before the source is created. Prod uses manual
publishing against that publication; confirm the `electric services create
postgres` flag (`electric services create postgres --help`) and set
`ELECTRIC_PUBLICATION` (and/or `ELECTRIC_SERVICE_EXTRA_ARGS`) accordingly. The CI
role inherits `postgres`, so auto-managed publishing may also work — validate on
one PR branch before relying on it.
`electric_publication_default`) before the source is created. The script passes
`--manual-table-publishing` by default (prod parity; Electric reads that
migration-owned publication — its default name — instead of owning the tables).
Set `ELECTRIC_MANUAL_TABLE_PUBLISHING=false` to let Electric auto-manage
publishing instead; `ELECTRIC_SERVICE_EXTRA_ARGS` remains the flag escape hatch.
The script pins `@electric-sql/cli@0.0.10` (interface verified — `--json` is a
global flag, `environments create` returns `environmentId`, the postgres service
id is the shape-API `source_id`); re-verify before bumping the pin.
- **Caps:** each source counts against the Electric plan's max-databases limit and
holds a PlanetScale replication slot; teardown on close is mandatory.

Expand Down
2 changes: 2 additions & 0 deletions packages/db/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"db:push": "drizzle-kit push --config ./drizzle.config.ts",
"db:studio": "drizzle-kit studio --config ./drizzle.config.ts",
"db:grant": "bun scripts/grant-runtime-role.ts",
"db:reset-preview": "bun scripts/reset-preview-branch.ts",
"db:normalize-preview": "bun scripts/normalize-preview-ownership.ts",
"ps:apply-schema": "bun scripts/planetscale-apply-schema.ts"
},
"dependencies": {
Expand Down
68 changes: 68 additions & 0 deletions packages/db/scripts/normalize-preview-ownership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env bun
/**
* Hand every object this deploy created on the PR-preview branch to `postgres`
* — the step that makes the NEXT deploy's in-place reset possible.
*
* Each preview deploy authenticates as an ephemeral `pscale role create` role
* (24h TTL, unique per run). PlanetScale does not allow granting `pscale_api_*`
* roles to one another — `GRANT <old role> TO CURRENT_USER` fails with
* "permission denied to grant role" for BOTH the replication and the plain
* role (run 30051727304) — so a later run can never assume a prior run's role
* to drop its objects, and reset-preview-branch.ts would always fail on the
* prior run's publications/schemas and fall back to the ~9-minute
* delete → recreate path.
*
* Every pscale role DOES inherit `postgres`, though. So after `drizzle-kit
* migrate` + the runtime grant pass, this script (running as the run's main
* role, a member of `postgres`) reassigns everything it owns to `postgres`:
*
* REASSIGN OWNED BY CURRENT_USER TO postgres
*
* covering the drizzle migrations schema, every object in `public`, and
* `electric_publication_default` in one statement. The next run's role then
* owns all of it through its own `postgres` membership and the in-place reset
* drops it without any role assumption. (Electric Cloud's per-service
* publication is owned by the separate replication role — scripts/
* electric-pr-branch.ts reassigns that one the same way after the source
* activates.)
*
* Failure here is deliberately NON-FATAL: an un-normalized branch only means
* the next deploy falls back to delete → recreate — it costs time, never
* correctness — and failing the deploy over an optimization would be worse.
*
* Usage: DATABASE_URL="$MAPLE_PG_URL" bun run --cwd packages/db db:normalize-preview
*/
import postgres from "postgres"

const main = async (): Promise<void> => {
const url = process.env.DATABASE_URL?.trim()
if (!url) {
console.error("✗ DATABASE_URL is not set — pass the PR branch connection string (MAPLE_PG_URL)")
process.exit(1)
}
const sql = postgres(url, { max: 1, fetch_types: false })
try {
const [who] = await sql`
SELECT current_user AS role, pg_has_role(current_user, 'postgres', 'MEMBER') AS is_member`
if (who?.role === "postgres") {
console.log("✓ Already connected as postgres — nothing to reassign")
return
}
if (who?.is_member !== true) {
console.log(
`⚠ ${String(who?.role)} is not a member of postgres — skipping ownership normalization (next deploy will use the delete → recreate fallback)`,
)
return
}
await sql.unsafe(`REASSIGN OWNED BY CURRENT_USER TO postgres`)
console.log(`✓ Reassigned all objects owned by "${String(who?.role)}" to postgres`)
} catch (error) {
console.log(
`⚠ Ownership normalization failed (${error instanceof Error ? error.message : String(error)}) — next deploy will use the delete → recreate fallback`,
)
} finally {
await sql.end()
}
}

await main()
Loading
Loading