Skip to content

fix(core): make snapshot generation dialect-aware so backups work on Postgres#2234

Open
esaa-egypt wants to merge 1 commit into
emdash-cms:mainfrom
esaa-egypt:fix/postgres-snapshot-dialect
Open

fix(core): make snapshot generation dialect-aware so backups work on Postgres#2234
esaa-egypt wants to merge 1 commit into
emdash-cms:mainfrom
esaa-egypt:fix/postgres-snapshot-dialect

Conversation

@esaa-egypt

@esaa-egypt esaa-egypt commented Jul 25, 2026

Copy link
Copy Markdown

What does this PR do?

generateSnapshot() reaches for three SQLite-only constructs with no dialect branch, so every snapshot-backed feature fails on PostgreSQL:

Line Construct Postgres result
snapshot.ts:250 SELECT name FROM sqlite_master ... LIKE 'ec_%' 42P01
snapshot.ts:275 PRAGMA table_info("<table>") 42601
snapshot.ts:303 strftime('%Y-%m-%dT%H:%M:%fZ', 'now') 42883

Only the first appears in a traceback, because it fails first — the other two are on the same path behind it. Together they take out Settings → Backups (Download backup, Back up now, and the daily scheduled run) and /_emdash/api/snapshot (the preview-mode export). The strftime one is in the default no-drafts/no-trash branch, which is exactly the path preview uses, so this is not only a backups bug.

The scheduled path is the one worth fixing carefully: saving the settings succeeds and GET /_emdash/api/settings/backups keeps returning {"enabled":true,"retention":30,...,"storageAvailable":true}, so the admin presents daily backups as armed while every tick throws and the archive list stays empty. An operator on Postgres gets no signal that they have no backups.

What changed. Table discovery and the status filter now go through the helpers that already exist for exactly this — listTablesLike and buildStatusCondition. Column discovery had no equivalent, so this adds listTableColumns next to them in dialect-helpers.ts.

listTableColumns returns SQLite storage classes on both dialects, not the raw information_schema.data_type. That is the one design decision here worth reviewing: snapshots are consumed by SQLite targets — EmDashPreviewDB.applySnapshot recreates every table from these strings — and that consumer allowlists TEXT/INTEGER/REAL/BLOB/JSON, silently falling back to TEXT. Passing Postgres type names straight through would "work" while being lossy: integer happens to survive, but bigint, boolean, jsonb and double precision would all land as TEXT. The Postgres branch maps them explicitly instead. If you'd rather that mapping lived in the consumer, say so and I'll move it.

One behavioural detail preserved: listTablesLike has no ORDER BY, so the call site sorts in JS to keep the previous deterministic table order.

Closes #2233

Type of change

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

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes — not run locally, see note below
  • pnpm lint passes — not run locally, see note below
  • pnpm test passes (or targeted tests for my change) — not run locally, see note below
  • pnpm format has been run — not run locally, see note below
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable) — n/a, no UI strings
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion — n/a, bug fix

On the four unchecked boxes: I could not run pnpm install / build / typecheck / lint / test locally. The machine that has the PostgreSQL deployment is disk-constrained (~2.6 GB free, and the monorepo install pulls Playwright browsers), so I verified the runtime behaviour there instead — see below — and wrote the tests to the conventions in AGENTS.md and the neighbouring list-tables-like.test.ts without executing them. I'd rather say that plainly than tick boxes I didn't earn. Please treat CI as the authority on the test files specifically; I'm happy to fix anything it flags.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 5 (Claude Code)

Screenshots / test output

End-to-end verification on a real deployment. I applied these three changes to the built 0.31.1 server on a live Node + PostgreSQL 16 instance (Astro 6.4.8, @astrojs/node, ~30 _emdash_*/ec_* tables at migration 053, R2 storage) and ran it on an isolated port against the production database:

BEFORE  GET /_emdash/api/settings/backups/export  -> 500 BACKUP_EXPORT_ERROR
AFTER   GET /_emdash/api/settings/backups/export  -> 200, 9,178,151 bytes

The resulting archive is well-formed:

format: emdash-backup v1 | emdash 0.31.1
tables exported: 18 | schema entries: 19
ec_* row counts: {'ec_events': 10, 'ec_pages': 210, 'ec_posts': 286, 'ec_publications': 108}
system tables:   {'revisions': 874, 'content_taxonomies': 614, '_emdash_menu_items': 114, ...}
ec_pages: 32 columns | type histogram: {'TEXT': 29, 'JSON': 2, 'INTEGER': 1} | version -> INTEGER
option keys: 6 | unsafe keys leaked: none

That last line is the SAFE_OPTIONS_PREFIXES allowlist still holding — no plugin:, *secret*, or passkey_pending: keys in the export.

Tests added. Two files using describeEachDialect, so they run against SQLite and against a real Postgres connection; their Postgres variants fail against the pre-fix implementation:

  • tests/integration/database/list-table-columns.test.ts — declaration ordering, the storage-class invariant, integer mapping, and the missing-table case
  • tests/integration/snapshot/snapshot-dialect.test.tsgenerateSnapshot across both dialects, covering the default (preview) path that evaluates the status filter and the includeTrashed path the backup handlers use

…Postgres

generateSnapshot() reached for three SQLite-only constructs with no dialect
branch, so every snapshot-backed feature failed on PostgreSQL with 42P01:

- table discovery via `SELECT name FROM sqlite_master ... LIKE 'ec_%'`
- column discovery via `PRAGMA table_info("<table>")`
- the published-content filter via `strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`

That took down Settings -> Backups (Download backup, Back up now, and the
daily scheduled run) and the /_emdash/api/snapshot preview endpoint. The
scheduled path is the worst of the three: saving the settings succeeds and
the API keeps reporting `{"enabled":true,...,"storageAvailable":true}`, so
the admin shows daily backups as armed while every tick throws and the
archive list stays empty.

Table discovery and the status filter now go through the existing
`listTablesLike` and `buildStatusCondition` helpers. Column discovery needed
a new `listTableColumns` helper alongside them.

`listTableColumns` returns SQLite storage classes on BOTH dialects rather
than the raw `information_schema` `data_type`. Snapshots are consumed by
SQLite targets — the Durable Object preview database recreates every table
from these strings — and that consumer allowlists TEXT/INTEGER/REAL/BLOB/JSON
and falls back to TEXT. Passing Postgres type names through would therefore
be silently lossy (`integer` survives, but `bigint`, `boolean`, `jsonb` and
`double precision` would all collapse to TEXT), so the Postgres branch maps
them explicitly instead.

Table discovery keeps its deterministic ordering: `listTablesLike` has no
ORDER BY, so the result is sorted in JS to preserve the previous output.

Tests use the repo's describeEachDialect harness, so both new files run
against SQLite and against a real Postgres connection; their Postgres
variants fail against the pre-fix implementation.

Fixes emdash-cms#2233
Copilot AI review requested due to automatic review settings July 25, 2026 15:49
@changeset-bot

changeset-bot Bot commented Jul 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f2f38c3

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

This PR includes changesets to release 17 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

Copy link
Copy Markdown
Contributor


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA


You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond area/core size/L cla: needed labels Jul 25, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The approach is sound and addresses the right problem. Backups/snapshots failing on Postgres because of SQLite-only catalog queries is a real bug, and routing table/column/status discovery through the existing dialect-helpers.ts pattern is the idiomatic fix in this codebase. The core change is minimal and preserves existing SQLite behavior.

I read the diff, the full changed files, the existing snapshot tests, the DO preview consumer (packages/cloudflare/src/db/do-class.ts), and the schema registry's column-type declarations. The source fix looks correct: listTablesLike and buildStatusCondition already existed for exactly this, and the new listTableColumns uses parameterized queries and scopes Postgres catalog reads to current_schema(). The snapshot consumer already uppercases and allowlists the type strings, so the type-mapping strategy is safe.

Headline conclusion: the production fix is good, but two issues in the new tests/helper contract need fixing before this can merge — otherwise CI will fail on SQLite and one dialect test assertion is structurally wrong.

  1. listTableColumns returns raw, lowercase SQLite declared types, contradicting its interface and breaking the new tests on SQLite. Its TableColumnInfo.type docstring promises "SQLite storage class — TEXT, INTEGER, REAL, BLOB, or JSON", and the Postgres branch normalizes accordingly, but the SQLite branch returns whatever PRAGMA table_info reports (e.g. "text", "integer"). The new list-table-columns.test.ts and snapshot-dialect.test.ts assertions expect uppercase, so they will fail on SQLite. The consumer uppercases on its own, so snapshots still work, but the helper's contract is inconsistent.

  2. The new snapshot-dialect.test.ts asserts snapshot.tables.ec_post).toEqual([]), but generateSnapshot only assigns tables[tableName] when rows.length > 0. With the default published filter and no rows, ec_post is omitted entirely (undefined), not an empty array. This mirrors the existing snapshot.test.ts behavior ("Schema should include ec_post ... tables._emdash_collections should have 2 rows"), so the test expectation should match it.

Both are small, mechanical fixes.

const result = await sql<{ name: string; type: string }>`
SELECT name, type FROM pragma_table_info(${tableName})
`.execute(db);
return result.rows.map((r) => ({ name: r.name, type: r.type || "TEXT" }));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[needs fixing] The SQLite branch returns the raw r.type string, which for EmDash-created tables is lowercase ("text", "integer"). That contradicts the TableColumnInfo docstring above, which promises an uppercase SQLite storage class (TEXT, INTEGER, REAL, BLOB, JSON), and it differs from the Postgres branch which normalizes to those same uppercase strings. The preview consumer happens to uppercase before checking, so production snapshots survive, but the helper's own contract is dialect-dependent in case and the new list-table-columns.test.ts assertions (INTEGER, TEXT, etc.) will fail on SQLite.

Normalize the SQLite branch to return uppercase storage classes, matching Postgres and the tests:

return result.rows.map((r) => ({ name: r.name, type: (r.type || "TEXT").toUpperCase() }));

// endpoint uses, and the only one that evaluates the status filter.
const snapshot = await generateSnapshot(ctx.db);

expect(snapshot.tables.ec_post).toEqual([]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[needs fixing] generateSnapshot only assigns tables[tableName] when rows.length > 0, so an empty content table is omitted from snapshot.tables entirely (undefined), not represented as []. This matches the existing snapshot.test.ts behavior, but this new assertion expects an empty array and will fail.

Fix the expectation to match the actual (and pre-existing) behavior:

expect(snapshot.tables.ec_post).toBeUndefined();

Or, if you want to assert the filter behavior more directly, insert a draft/scheduled row and verify it is excluded.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core cla: needed review/awaiting-author Reviewed; waiting on the author to respond size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Backups and snapshot export fail on PostgreSQL — generateSnapshot() queries sqlite_master / PRAGMA table_info with no dialect branch

2 participants