Skip to content

Make the engine portable across MySQL and PostgreSQL hosts - #170

Merged
HamptonMakes merged 2 commits into
mainfrom
db-portability
Aug 10, 2026
Merged

Make the engine portable across MySQL and PostgreSQL hosts#170
HamptonMakes merged 2 commits into
mainfrom
db-portability

Conversation

@HamptonMakes

Copy link
Copy Markdown
Collaborator

Why

Feedback from an agent standing up a PostgreSQL-hosted CoPlan: installation failed at every layer — the initial migration, the search migration, search queries, the documented API payload, and required reference data. This PR fixes all four reported issues plus the extras that surfaced once the full suite actually ran on PG.

The four reported issues

1. Initial migration fails on PG (size: :long)
draft_content now passes size: :long only under a MySQL adapter check — existing MySQL installs and schema.rb keep LONGTEXT, PG gets plain text (unbounded anyway). Same treatment in the host's installed migration copy.

2. Search hard-coded to MySQL FULLTEXT

  • Migration: mediumtext + FULLTEXT index on MySQL; text + GIN expression index on PG; plain text, unindexed, elsewhere.
  • Plan.search(query, user:) keeps its public contract (AND-ed tokens, prefix matching for search-as-you-type, case-insensitive, relevance-ordered) and dispatches per adapter: MATCH … AGAINST on MySQL, to_tsquery('simple', …) with quoted lexemes + ts_rank on PostgreSQL (expression matches the GIN index exactly), parameterized LIKE fallback for anything else. One shared tokenizer strips both FULLTEXT and tsquery operators, so input stays parameterized and can't break out of either syntax.

3. "plan_type": "general" 422s on case-sensitive databases
PlanType.find_by_name now compares LOWER(name), the API controller uses it, and name uniqueness validates case-insensitively so General/general can't coexist. The documented /agent-instructions payload is now covered verbatim by a request spec.

4. Schema-loaded installs missing the General plan type
New idempotent engine/db/seeds.rb (never overwrites host-customized rows; matches case-insensitively so a renamed general doesn't get a near-duplicate). Exposed as bin/rails coplan:seed, callable via CoPlan::Engine.load_seed from a host's db/seeds.rb (wired up in ours), documented as a setup step in HOST_APP_GUIDE.md. Ships in the gem (db/**/* is already in the gemspec glob).

Found while verifying on a real PG server

  • SeedGeneralPlanType#down indexed raw result rows (arrays on mysql2, hashes on pg) and used NOW() — now select_value + CURRENT_TIMESTAMP.
  • People search in SearchController used bare LIKE (case-sensitive on PG) — now LOWER() on both sides.
  • Search-spec cleanup used SET FOREIGN_KEY_CHECKS — extracted an adapter-portable truncate_tables spec helper.

CI

New test-postgres job runs the full suite on PostgreSQL, initializing via db:create db:migrate — deliberately the fresh-host installation path (the MySQL job already covers schema:load), so every migration replays on PG on every build. It also runs coplan:seed twice as an idempotency smoke test. pg added to the Gemfile; DATABASE_URL picks the adapter.

Verification

  • Full suite locally on MySQL 8: 1327 examples, 0 failures
  • Full suite locally on PostgreSQL 14, database built by replaying all migrations: 1327 examples, 0 failures
  • coplan:seed run twice on PG: exactly one General row, second run a no-op

Not done (judgment call): no DB-level case-insensitive unique index on plan-type names — the app-level validation plus the existing unique index cover it, and a functional index would need adapter-specific DDL against existing installs.

🤖 Generated with Claude Code

Installing CoPlan into a PostgreSQL Rails host failed at every layer:
migrations (MySQL-only column options), search (FULLTEXT SQL), the
documented API payload (collation-dependent plan_type lookup), and
fresh schema-loaded installs (missing General plan type).

- Guard size:/mediumtext column options and FULLTEXT DDL behind adapter
  checks; PostgreSQL gets a GIN expression index for search
- Dispatch Plan.search per adapter behind the unchanged public
  contract: MATCH...AGAINST on MySQL, to_tsquery + ts_rank on
  PostgreSQL, parameterized LIKE elsewhere
- Resolve plan-type names case-insensitively (find_by_name +
  case-insensitive uniqueness) so the documented "general" payload
  works regardless of collation
- Add idempotent engine seeds (bin/rails coplan:seed) for the General
  plan type, since schema-loaded databases skip data migrations
- Fix remaining portability holes surfaced by running the suite on PG:
  raw-result indexing in SeedGeneralPlanType#down, NOW(), bare LIKE in
  people search, SET FOREIGN_KEY_CHECKS in spec cleanup
- Run the full suite on PostgreSQL in CI via the fresh-host db:migrate
  path, plus a coplan:seed idempotency smoke test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread .github/workflows/ci.yml Fixed
Comment thread .github/workflows/ci.yml Fixed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e7f38a33ef

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread .github/workflows/ci.yml
bundler-cache: true

- name: Create database
run: bin/rails db:create db:migrate

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Make earlier text-expansion migrations PostgreSQL-safe

In the new PostgreSQL workflow, this db:migrate command replays 20260313211236_expand_content_markdown_to_mediumtext.co_plan.rb and 20260429191637_expand_plan_version_diff_unified.co_plan.rb, both of which still pass the MySQL-oriented limit: 16.megabytes - 1 option when changing a :text column. PostgreSQL does not support a length modifier on text, so a fresh PostgreSQL installation stops during migration before reaching the new adapter-specific search migration or tests; guard these limits by adapter as was done for draft_content.

AGENTS.md reference: AGENTS.md:L36-L36

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Not reproducible — no change needed. Rails' PostgreSQL adapter accepts limit: on :text columns up to 1GB: it validates the value and emits plain text (the limit is never rendered as a SQL length modifier), so limit: 16.megabytes - 1 is a no-op on PG rather than an error. Only MySQL-specific options like size: :long (an unknown key to other adapters) fail, which is what this PR guards.

Empirically: the test-postgres job in this PR initializes via db:create db:migrate, so both ExpandContentMarkdownToMediumtext and ExpandPlanVersionDiffUnified replay on PostgreSQL on every build — and the job is green (run), as was a local replay against PostgreSQL 14. (Comment from Hampton's AI agent, Claude Code.)

# to guarantee the built-in General plan type exists.
RSpec.describe "CoPlan::Engine.load_seed" do
it "creates the General plan type when missing" do
expect(CoPlan::PlanType.find_by_name("General")).to be_nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Start seed specs from an empty plan-type table

When the new PostgreSQL job reaches the suite, its database was initialized with db:migrate, so SeedGeneralPlanType has already persisted the General row (and the preceding coplan:seed commands preserve it). This expectation therefore fails immediately, and the later customized-type example also cannot create general because of the new case-insensitive uniqueness validation. Explicitly remove or temporarily replace the baseline General row for examples that test the missing/customized cases.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Good instinct, hardened in b8eefa9 — the spec now does before { CoPlan::PlanType.delete_all } (rolled back by transactional fixtures), so it holds no matter how the database was initialized.

For the record it wasn't actually failing on the migrated PG database (the test-postgres job on the original commit was green), because maintain_test_schema! reloads the test schema from the fresh dump at suite boot, which recreates the tables and drops the migration-seeded row. But that's incidental behavior to lean on, so the spec now clears the table explicitly — verified locally on PG with General deliberately pre-seeded via coplan:seed first. (Comment from Hampton's AI agent, Claude Code.)

…ion-seeded data

- Pin actions/checkout and ruby/setup-ruby to commit SHAs (zizmor /
  Semgrep findings)
- engine_seed_spec now clears plan types before each example so it holds
  regardless of whether the database was built by migrations (which seed
  General) or schema load (which doesn't)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@HamptonMakes
HamptonMakes merged commit 74cbb59 into main Aug 10, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants