Spike: run Ghost on Postgres - #30506
Draft
jonatansberg wants to merge 3 commits into
Draft
Conversation
no ref Spike towards running Ghost on Postgres only. This wires the `pg` driver through the config sanitizer and knex connection, adds Postgres-aware table/index/column introspection to the schema commands, and subclasses @tryghost/database-info locally with `isPostgres` until that can go upstream. knex-migrator only knows MySQL and SQLite, so it is patched via pnpm to create and drop Postgres databases and to recognise Postgres error codes. @tryghost/bookshelf-pagination is patched to drop ORDER BY from its count query, which Postgres rejects under count(*). pg-query-stream is required for knex's `.stream()` on Postgres (members CSV export). Datetime columns are created as `timestamp` rather than knex's default `timestamptz`, and the connection installs pg type parsers so bigint and numeric come back as numbers and naive timestamps parse as UTC, matching what mysql2 returned with `timezone: 'Z'` and `decimalNumbers`.
no ref Running Ghost against Postgres surfaced a set of MySQL/SQLite assumptions in raw SQL and error handling. This adds a small `sql-helpers` module (unique/foreign-key/unknown-column error detection across the three dialects, raw result normalisation, identifier quoting, group_concat) and uses it at every site that previously checked `ER_DUP_ENTRY` or `SQLITE_CONSTRAINT` directly. Behavioural differences handled explicitly on Postgres: - `FOR UPDATE` is rejected with `DISTINCT`, which bookshelf emits for eager-loaded relations, so the crud plugin locks the target row itself and the fetching hook skips the lock on distinct relation queries. - NULLs sort first on DESC, so the fetching hook pins knex's nulls ordering to MySQL's behaviour (drafts stay last under published_at desc) and the pinned-comment ordering is made NULL-aware. - int/int division truncates, so rate calculations multiply by 1.0 and the ARR query divides by 100.0. `CAST(... AS CHAR)` is char(1), so the MRR date cast uses CHAR(10). - Boolean columns are real booleans, so comparisons use TRUE. - Batched `CASE id WHEN ... END` updates gain an `ELSE <column>` branch so untyped bound values resolve to the column type. - A failed statement aborts the transaction, so bulk inserts run each chunk/row attempt inside a savepoint. - Unquoted identifiers fold to lowercase (CTE names, raw aliases), and ORDER BY cannot live inside a WHERE fragment reused by the count query. - LIKE is case-sensitive, so member search uses ILIKE. - Backtick-quoted identifiers are replaced with portable quoting.
no ref `pnpm dev:postgres` starts the development stack with a Postgres 16 container in place of MySQL, mirroring the existing `dev:sqlite` override, so the Postgres spike can be run without hand-setting environment variables.
Contributor
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueComment |
Contributor
|
It looks like this PR contains a migration 👀 General requirements
Schema changes
Data changes
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Spike exploring what a Postgres-only Ghost would take, prompted by Ghost-CLI being deprecated in favour of a Docker-based setup (which removes the "self-hosters need the zero-dependency SQLite install" constraint).
Not for merging. This branch keeps MySQL and SQLite working and adds
pgas a third client so the two can be compared. The point is to enumerate what actually breaks and how much of it is structural.Running it
That swaps the MySQL container for Postgres 16 via
compose.dev.postgres.yaml. Or, against any Postgres, fromghost/core:knex-migrator initruns on first boot: 94 tables, the view, and fixtures, in a few seconds.What's in here
Driver and plumbing (commit 1)
pgandpg-query-stream(needed for knex.stream()) added to Ghost corepg/postgres/postgresqland no longer strips connection keys for non-MySQL clientsconnection.jsgets apgbranch:SET TIME ZONE 'UTC'per connection, and type parsers sobigint/numericcome back as numbers and naive timestamps parse as UTC (mirrors mysql2'stimezone: 'Z'+decimalNumbers)schema/commands.jsgains Postgres introspection for tables/indexes/columns, creates datetime columns astimestamp(not knex's defaulttimestamptz), and accepts Postgres duplicate/undefined-object codes in the index guardsDatabaseInfosubclass withisPostgres(needs to go upstream to@tryghost/database-info)knex-migrator: create/drop database for pg, pg error codes for "database/relation does not exist", and the internal lock-table migration only swallowed MySQL's multiple-primary-key error@tryghost/bookshelf-pagination:clear('order')on the count query, which Postgres rejects undercount(*)Dialect fixes (commit 2)
A new
data/db/sql-helpers.jswithisDuplicateEntryError,isForeignKeyError,isUnknownColumnError,rawRows,quoteIdentifier, andgroupConcat, used at the 14 sites that previously checkedER_DUP_ENTRY/SQLITE_CONSTRAINTby hand. Then the behavioural differences, each handled only on Postgres unless the portable form is identical:FOR UPDATErejected withDISTINCT(bookshelf emits DISTINCT for eager-loaded relations, and passesoptions.lockdown to them)crud.jslocks the target row explicitly; thefetchinghook skipsforUpdate()on distinct queriespublished_at desc; pinned commentsevents.jssets knexnulls: first/laston non-raworderBy; comment ordering made NULL-awareint / inttruncates* 1.0,/ 100.0CAST(x AS CHAR)ischar(1)CHAR(10)subscribed = 1,track_opens = 1,email_disabled = 1= TRUECASE id WHEN ? THEN ? ENDresolve totextELSE <column>branch gives the CASE the column typePostClicksCTE,maxTimestampalias??ORDER BYinside aWHEREfragment reused by the count queryautoOrderLIKEis case-sensitiveILIKEon pg (NQL~was already case-insensitive vialower())??/wrapIdentifierGROUP_CONCATstring_aggon pgCONVERT_TZ/ two-argDATE()Note
DATE(col)itself works on Postgres, so the 15 bareDATE()sites are fine.Verified against Postgres
Seeded members, a paid subscription with MRR events, an email with recipients, link clicks, comments, and a draft post, then checked by hand:
PAIDmatchesPaid.Member@Example.compublished_at desc, first underasc; pinned comment firstTest status
mainadmin/members.test.jsandmembers/webhooks.test.jsare flaky locally on cleanmaintoo (36 failures clean vs 19 on this branch, overlapping sets), so not regressionstest/utils/db-utils.jsanddb-template.jsare MySQL-specific (SET FOREIGN_KEY_CHECKS,SHOW CREATE TABLE,GET_LOCK)Not done / open questions
knex-migrator,@tryghost/database-info,@tryghost/bookshelf-pagination) need upstream PRs; the pnpm patches and local subclass are stand-ins@tryghost/mongo-knexshould get a Postgres test suite; it appears to work but is only exercised via the API hereinit/fromschema.js, so the 340 versioned migrations don't need to be dual-dialect if MySQL → Postgres is a data-level export/importDockerfile.production, e2emysql-manager.ts, the Admin "unsupported database" warning, and the dev seeder (LOAD DATA INFILE,SET FOREIGN_KEY_CHECKS) are untouchednewsletter-email-event-storage.jsstill string-interpolates ids/timestamps into its CASE statement (pre-existing)