Skip to content

feat(api-headless-cms): postgres#5402

Merged
brunozoric merged 20 commits into
nextfrom
bruno/feat/api-headless-cms-postgres
Jul 14, 2026
Merged

feat(api-headless-cms): postgres#5402
brunozoric merged 20 commits into
nextfrom
bruno/feat/api-headless-cms-postgres

Conversation

@brunozoric

@brunozoric brunozoric commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Research and design documentation for adding Postgres + OpenSearch storage operations to Webiny Headless CMS.

This branch contains no code changes — only design docs exploring the feasibility and architecture of replacing DynamoDB with Postgres as the primary data store, while keeping OpenSearch for
search/filter/sort.

Documents

  • 01-pure-postgres.md — Pure Postgres approach (evaluated and rejected for production use due to dynamic zone querying limitations at scale)
  • 02-postgres-plus-opensearch.md — Recommended architecture: Postgres as source of truth + OpenSearch for all search/filter/sort, synced via WAL logical
    replication
  • 03-cms-comparison.md — Industry comparison of 9 CMS systems (Strapi, Payload, Contentful, TYPO3, WordPress, AEM, Sanity, Directus, Drupal) — storage patterns,
    nested field filtering capabilities, scaling limits
  • 04-decisions.md — All architectural decisions: table structure mirroring CmsEntry, JSONB values column, WAL sync, upsert pattern, versioning, re-index strategy

Key Decisions

  • Table-per-model, shared tables with tenant column
  • System fields as real columns, user field values as single values JSONB column
  • All search/filter/sort via OpenSearch — Postgres only for point lookups and writes
  • WAL logical replication for Postgres → OpenSearch sync (separate worker process)
  • Upsert pattern (ON CONFLICT DO UPDATE) for CRUD, FOR UPDATE transactions for flag swaps
  • No per-field columns — no DDL changes when model fields change

brunozoric and others added 20 commits July 14, 2026 17:50
Covers how 9 CMS systems handle filtering/sorting on deeply nested
complex fields (object[] -> dynamic zone -> ...). Confirms no SQL-based
CMS supports efficient filtering inside dynamic zones at depth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Distinguish what users can do through the CMS API vs internal
implementation details. Only Sanity (GROQ) supports deep nested
filtering via API. All SQL-based CMSes block it at API level or
require client-side filtering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Only 4 of 9 surveyed CMSes support nested AND/OR conditions via
their public API. Webiny is among them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EAV storage + Solr/Azure Search for queries. Confirms dual-store
pattern. No dynamic zones, no nested AND/OR via API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Webiny and Sanity are the only systems where filtering/sorting/search
works at any depth with zero user configuration. All others require
manual index setup, explicit populate params, or don't support it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Demonstrates why nested JSONB array queries are fundamentally
unindexable in Postgres — GIN covers equality only, B-tree needs
fixed paths, jsonb_array_elements is CPU-bound. Linear degradation
vs OpenSearch O(log n).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
System indexes created once per model table. No per-field index
management. GIN on values JSONB not created (OS handles filtering)
but documented as optional future addition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dynamic zone filtering not yet exposed in GraphQL API, but data is
already indexed in OpenSearch. Enabling it is a GraphQL layer change,
not a storage change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…lder

Not just GraphQL schema — also needs OpenSearch query generation
code for dynamic zone field types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
All four docs now consistently note: dynamic zone data is indexed in
OpenSearch but filtering not yet exposed in GraphQL API. Requires
GraphQL schema + OS query builder work to enable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Users can nest fields as deep as they want — can't offer a limited
nesting mode. Pure Postgres is not viable for Webiny's requirements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SQLite uses database-level write locking (entire file), no change
streaming, no network access, no multi-process support. Postgres
required for production at scale.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add createPostgresConnection() with env var support from <Infra.Postgres>
- Add <Infra.Postgres> config component with all pg connection options
  (host, port, user, password, database, ssl, timeouts, keep-alive, etc.)
- SSL accepts boolean or object with certificate file paths
- Rename createSqliteApiHandler to createSqlApiHandler (old name deprecated)
- Add pg driver dependency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Extract SslEnvVars and OptionalEnvVar components, remove inline conditionals
- Add optionalString helper for undefined-safe stringification
- Add parseInputDatesAsUTC and options props (last serializable pg options)
- Use named interfaces instead of inline types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Split test setup into separate client modules (createSqliteClient.js,
createPgClient.js) with a common interface (createKnex, dropAllTables,
teardown). Base setup files delegate to the correct client based on
WEBINY_SQL_CLIENT env var.

Run with Postgres: WEBINY_SQL_CLIENT=pg yarn test:sql packages/api-headless-cms

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Split SQL test clients into sqlite/pg/pglite modules with common interface
- WEBINY_SQL_CLIENT env var: "pg" for real Postgres, "pglite" for in-memory
- Real Postgres auto-creates test database, defaults to OS user
- Add test:pg and test:pglite scripts to root package.json
- Add PGlite group to GitHub Actions vitest workflow
- Update api-core-sql presets to support pg/pglite cleanup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@brunozoric
brunozoric force-pushed the bruno/feat/api-headless-cms-postgres branch from 6eb2a16 to 0e5970c Compare July 14, 2026 15:51
@brunozoric
brunozoric marked this pull request as ready for review July 14, 2026 16:00
@brunozoric
brunozoric merged commit 394246b into next Jul 14, 2026
19 checks passed
@brunozoric brunozoric added this to the 6.6.0 milestone Jul 17, 2026
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.

1 participant