Skip to content

Latest commit

 

History

History
293 lines (212 loc) · 21.8 KB

File metadata and controls

293 lines (212 loc) · 21.8 KB

This is a sophisticated, "bulletproof" architecture. By treating your database schema versions as immutable snapshots and forcing the generators to run as a deliberate manual step, you’re eliminating the "it worked on my machine" drift that plagues C++ development.

The following summary is designed to be pasted into Cursor's composer or chat. It provides the exact technical constraints and folder logic the AI needs to write modular, high-quality CMake files without making "lucky guesses" about your intent.


Design Spec: CppBeSSOT (C++ Backend Single-Source-of-Truth)

1. Context & Objectives

CppBeSSOT is a modular system for managing a C++ backend SSOT. It uses OpenAPI YAML files (one per table) as the source to drive C++ ORM code (ODB), JSON serialization, TypeScript types, and Zod validation schemas.

  • Backend: C++ (ODB ORM + JSON library).
  • Preferred JSON Serdes Provider: nlohmann::json.
  • JSON Serdes Placement: Generated model classes should expose JSON serde in class definitions as class methods (not only external helpers).
  • Frontend: TypeScript + Zod.
  • Migrations: Automated SQL diffs generated by ODB's Schema Evolution engine.
  • Integration: Designed as a git submodule to be included in larger projects.

Dependencies (C++): Generated C++ models depend on the nlohmann/json header-only library for JSON serialization (toJson() / fromJson() and the NLOHMANN_DEFINE_TYPE_INTRUSIVE macro). The build must have access to nlohmann headers (e.g. package nlohmann-json3-dev on Debian/Ubuntu). Treat this as a build-time (and runtime, if you ship the generated code) dependency; the dependency check or project README should document it.

2. Directory Structure & Configuration

  • ${CPPBESSOT_WORKDIR}: Configurable via CMake (default: db). Contains the versioned data.
  • Submodule Path: The CMake logic resides in a submodule (e.g., cmake/cppbessot/).
  • Folder Structure:
${CPPBESSOT_WORKDIR}/
├── v<N>/                       # Immutable version snapshots
│   ├── openapi/                # Source YAMLs
│   ├── generated-ts-types/     # Output: TS interfaces
│   ├── generated-zod/          # Output: Zod TS schemas
│   ├── generated-cpp-source/   # Output: C++ Headers (with ODB & JSON macros)
│   ├── generated-sql-ddl/      # Output: ODB-generated SQL DDL snapshots for this schema version
│   │   ├── sqlite/             # SQLite DDL output for this version
│   │   └── postgre/            # PostgreSQL DDL output for this version
│   └── generated-odb-source/   # Output: ODB compiler artifacts (+ per-backend changelog .xml)
│       ├── sqlite/             # SQLite ORM + changelog (e.g. Agent.xml, DeliveryRequest.xml)
│       └── postgre/            # PostgreSQL ORM + changelog
└── migrations/                 # Version-to-version SQL diffs
    └── v<a>-v<b>/
        ├── sqlite/
        └── postgre/

Schema version numbering: CppBeSSOT schema version folders (e.g. v1.1, v1.2) must start at 1.x. ODB’s schema evolution uses #pragma db model version(base, current) for migration diffing, and both base and current must be non-zero positive integers. Using 0.x (e.g. v0.0, v0.1) would require a zero version in the pragma, which ODB rejects. Therefore the first schema version is v1.1, the next v1.2, and so on.

3. Modular CMake Architecture

All CMake files are located in the submodule directory. The entry point is CppBeSSOT.cmake.

  • CppBeSSOT.cmake: The master include file.
  • dbDependencyCheck.cmake: Checks for odb, npx, java, git, openapi-zod-client, and availability of nlohmann/json headers (e.g. include path or package). Fails if missing.
  • dbGenerationCommon.cmake: Internal utility functions (path resolution, version validation).
  • Individual Logic Files: dbGenTS.cmake, dbGenZod.cmake, dbGenCpp.cmake, dbGenODB.cmake, dbGenSqlDDL.cmake.

4. Primary Build Targets

Target: db_check_schema_changes

  • Action: Runs git status --porcelain ${CPPBESSOT_WORKDIR}/.
  • Behavior: If changes are detected in git-tracked files within the workdir, it issues a WARNING or SEND_ERROR advising the dev to create a new version folder and re-run generation.

Target: db_gen_orm_serdes_and_zod

  • Variable: -DDB_SCHEMA_DIR_TO_GENERATE="v1"
  • Sub-targets (One for each generated-* folder):
  1. db_gen_ts: openapi-generator-cli -> TS types.
  2. db_gen_zod: openapi-zod-client --export-schemas -> Zod schemas.
  3. db_gen_cpp_headers: openapi-generator-cli + Mustache templates -> C++ headers with ODB #pragma and JSON macros. Use models-only mode (--global-property models): with nlohmann/json the generated model headers are self-contained (no ModelBase.h or cpp-restsdk support files needed).
  4. db_gen_odb_logic: odb compiler (with --changelog-dir to the same backend subdir under generated-odb-source) -> generated-odb-source.
  5. db_gen_sql_ddl: odb --generate-schema --schema-format sql with --changelog-dir pointing at the corresponding generated-odb-source backend subdir -> generated-sql-ddl (sqlite/postgre DDL snapshots); changelog XML remains in generated-odb-source.

Target: db_gen_migrations

  • Variables: -DDB_SCHEMA_DIR_MIGRATION_FROM="v1" and -DDB_SCHEMA_DIR_MIGRATION_TO="v2"
  • Action: Uses ODB's Schema Evolution engine to compare headers in FROM vs TO.
  • Output: SQL migration scripts for both SQLite and PostgreSQL.

Target: db_gen_sql_ddl

  • Variable: -DDB_SCHEMA_DIR_TO_GENERATE="v1"
  • Action: Runs ODB schema generation for each supported backend for the selected version.
  • Output: Backend-specific SQL DDL under ${CPPBESSOT_WORKDIR}/v1/generated-sql-ddl/ (for example sqlite/ and postgre/).

5. Implementation Requirements for Cursor

  1. No Absolute Paths: All paths must be relative to PROJECT_SOURCE_DIR or calculated via CMAKE_CURRENT_LIST_DIR.
  2. Persistence: Generated files are Git-tracked and must not be deleted by make clean.
  3. Manual Trigger: Targets should be EXCLUDE_FROM_ALL.
  4. Submodule Awareness: The CMake files will be in a subdirectory of the bigproject. Ensure CppBeSSOT.cmake correctly includes its sibling .cmake files.
  5. Generated Library Names:
    • Build a library from db/v<N>/generated-cpp-source/ named libcppBeSsotOpenAiModelGen (static or shared is allowed).
    • Build backend-specific libraries from db/v<N>/generated-odb-source/ named:
      • libcppBeSsotOdbSqlite
      • libcppBeSsotOdbPgSql
    • Treat ODB backend libs as likely shared objects.
  6. Prompt/Instruction File for OpenAPI Authors: In the final generated project, add an instruction file for LLM agents that create or update OpenAPI schema files. The instruction file must state:
    • Use lowerCamelCase for property names.
    • Use UpperCamelCase for class/schema names.
    • Do not use underscores in class names or property names.
  7. No Version in C++ Namespace: Generated C++ model namespaces must not encode schema version names. The wider project should not need version-coupled source code identifiers; schema version selection should be done via include path selection (for example, include_directories pointing at the desired generated version).
  8. Template Bootstrap Input: Use the current checked-in Mustache templates under ${CPPBESSOT_MODULE_ROOT}/openapi/templates/ as initial test inputs when bringing up generation and CMake flows. Treat these templates as the baseline that should work first before further refactoring.

Minimum annotations for ODB migration diffing

For ODB’s schema evolution to produce migration diffs between two schema versions, the following annotations are required.

In each OpenAPI schema YAML (per-table, under openapi/schema/):

  • x-odbModelVersion: "M, N" — Model version for this schema (e.g. "1, 1" for v1.1, "1, 2" for v1.2). Used only for documentation; the Mustache template must emit the actual ODB pragma with non-zero integers (e.g. (1, 1) for first version, (1, 2) for next).
  • x-odbTable: TableName — Exact table name for #pragma db object table("...") (e.g. Agent, DeliveryRequest).
  • x-odbId: true — On the primary-key property (e.g. id) so the template can emit #pragma db id for that member.
  • x-odbAddedIn: "M.N" — On every persistent property, indicating the schema version where the column was added (e.g. "1.0", "1.1"). Enables correct versioning of new columns in migrations.

In the Mustache model-header template:

  • #include <odb/core.hxx> — So ODB sees the pragmas.
  • #pragma db model version(base, current) — One per model; base and current must be non-zero. Typically the first schema version uses (1, 1) and the next (1, 2). The template may hardcode these per version or derive from x-odbModelVersion (mapping 1.0→1,1 and 1.1→1,2).
  • #pragma db object table("...") — From x-odbTable (or classname fallback).
  • #pragma db id — Placed immediately before the member that has x-odbId: true.

Without these, ODB will not generate schema DDL or migration diffs correctly.

Keeping changelog XML per backend in generated-odb-source

ODB writes schema evolution changelog files (e.g. Agent.xml, DeliveryRequest.xml) next to the input .h files by default; --output-dir does not change that. Changelog files are database-specific, so if you run SQLite then PostgreSQL (or vice versa) without redirecting them, the second run sees the first backend’s changelog and can fail with "wrong database" errors.

Use --changelog-dir so changelog is read from and written to the same backend-specific directory as the ORM output:

  • For ORM (odb ... -q -o .../generated-odb-source/sqlite): add --changelog-dir .../generated-odb-source/sqlite so any changelog lives under generated-odb-source/sqlite/.
  • For SQL DDL (odb ... --generate-schema --schema-format sql -o .../generated-sql-ddl/sqlite): add --changelog-dir .../generated-odb-source/sqlite so the changelog stays in generated-odb-source (not next to the headers or in generated-sql-ddl). Same for postgre with generated-odb-source/postgre and generated-sql-ddl/postgre.

Result: each backend has its own generated-odb-source/<backend>/*.xml; you can run SQLite and PostgreSQL in any order without deleting changelog between runs. CMake/scripts should pass --changelog-dir on every odb invocation (ORM and DDL) for each backend.


6. Manual generation commands (OpenAPI → migrations)

Run all commands from the project repository root. Prerequisites: npx, odb compiler, and nlohmann/json headers. The current proof-of-concept uses schema versions v1.1 (first) and v1.2 (second, adds e.g. booyah to Agent).

v1.1 — from OpenAPI through SQL DDL

# 1. C++ model headers (models-only: no CMake/ModelBase; nlohmann makes headers self-contained)
npx @openapitools/openapi-generator-cli generate -i db/v1.1/openapi/openapi.yaml -g cpp-restsdk -t openapi/templates/cpp-odb-json -c openapi/templates/cpp-odb-json/config.yaml -o db/v1.1/generated-cpp-source --global-property models

# 2. Create ODB and DDL output dirs
mkdir -p db/v1.1/generated-odb-source/sqlite db/v1.1/generated-odb-source/postgre \
         db/v1.1/generated-sql-ddl/sqlite db/v1.1/generated-sql-ddl/postgre

# 3. ODB ORM (changelog goes to generated-odb-source/<backend>)
odb -I db/v1.1/generated-cpp-source/include --std c++11 -d sqlite -q \
  -o db/v1.1/generated-odb-source/sqlite --changelog-dir db/v1.1/generated-odb-source/sqlite \
  db/v1.1/generated-cpp-source/include/*/model/Agent.h db/v1.1/generated-cpp-source/include/*/model/DeliveryRequest.h

odb -I db/v1.1/generated-cpp-source/include --std c++11 -d pgsql -q \
  -o db/v1.1/generated-odb-source/postgre --changelog-dir db/v1.1/generated-odb-source/postgre \
  db/v1.1/generated-cpp-source/include/*/model/Agent.h db/v1.1/generated-cpp-source/include/*/model/DeliveryRequest.h

# 4. SQL DDL
odb -I db/v1.1/generated-cpp-source/include --std c++11 -d sqlite --generate-schema --schema-format sql -q \
  -o db/v1.1/generated-sql-ddl/sqlite --changelog-dir db/v1.1/generated-odb-source/sqlite \
  db/v1.1/generated-cpp-source/include/*/model/Agent.h db/v1.1/generated-cpp-source/include/*/model/DeliveryRequest.h

odb -I db/v1.1/generated-cpp-source/include --std c++11 -d pgsql --generate-schema --schema-format sql -q \
  -o db/v1.1/generated-sql-ddl/postgre --changelog-dir db/v1.1/generated-odb-source/postgre \
  db/v1.1/generated-cpp-source/include/*/model/Agent.h db/v1.1/generated-cpp-source/include/*/model/DeliveryRequest.h

v1.2 — from OpenAPI through SQL DDL

# 1. C++ model headers (models-only)
npx @openapitools/openapi-generator-cli generate -i db/v1.2/openapi/openapi.yaml -g cpp-restsdk -t openapi/templates/cpp-odb-json -c openapi/templates/cpp-odb-json/config.yaml -o db/v1.2/generated-cpp-source --global-property models

# 2. Create ODB and DDL output dirs
mkdir -p db/v1.2/generated-odb-source/sqlite db/v1.2/generated-odb-source/postgre \
         db/v1.2/generated-sql-ddl/sqlite db/v1.2/generated-sql-ddl/postgre

# 3. ODB ORM
odb -I db/v1.2/generated-cpp-source/include --std c++11 -d sqlite -q \
  -o db/v1.2/generated-odb-source/sqlite --changelog-dir db/v1.2/generated-odb-source/sqlite \
  db/v1.2/generated-cpp-source/include/*/model/Agent.h db/v1.2/generated-cpp-source/include/*/model/DeliveryRequest.h

odb -I db/v1.2/generated-cpp-source/include --std c++11 -d pgsql -q \
  -o db/v1.2/generated-odb-source/postgre --changelog-dir db/v1.2/generated-odb-source/postgre \
  db/v1.2/generated-cpp-source/include/*/model/Agent.h db/v1.2/generated-cpp-source/include/*/model/DeliveryRequest.h

# 4. SQL DDL
odb -I db/v1.2/generated-cpp-source/include --std c++11 -d sqlite --generate-schema --schema-format sql -q \
  -o db/v1.2/generated-sql-ddl/sqlite --changelog-dir db/v1.2/generated-odb-source/sqlite \
  db/v1.2/generated-cpp-source/include/*/model/Agent.h db/v1.2/generated-cpp-source/include/*/model/DeliveryRequest.h

odb -I db/v1.2/generated-cpp-source/include --std c++11 -d pgsql --generate-schema --schema-format sql -q \
  -o db/v1.2/generated-sql-ddl/postgre --changelog-dir db/v1.2/generated-odb-source/postgre \
  db/v1.2/generated-cpp-source/include/*/model/Agent.h db/v1.2/generated-cpp-source/include/*/model/DeliveryRequest.h

Migration v1.1 → v1.2

Uses v1.1 changelog XML as input and v1.2 headers; writes migration SQL to db/migrations/v1.1-v1.2/<backend>/. One ODB invocation per model per backend (ODB requires one --changelog-in / --changelog-out pair per input header).

mkdir -p db/migrations/v1.1-v1.2/sqlite db/migrations/v1.1-v1.2/postgre

# SQLite
odb -I db/v1.2/generated-cpp-source/include --std c++11 -d sqlite --generate-schema --schema-format sql -q \
  -o db/migrations/v1.1-v1.2/sqlite \
  --changelog-in db/v1.1/generated-odb-source/sqlite/Agent.xml \
  --changelog-out db/v1.2/generated-odb-source/sqlite/Agent.xml \
  db/v1.2/generated-cpp-source/include/*/model/Agent.h

odb -I db/v1.2/generated-cpp-source/include --std c++11 -d sqlite --generate-schema --schema-format sql -q \
  -o db/migrations/v1.1-v1.2/sqlite \
  --changelog-in db/v1.1/generated-odb-source/sqlite/DeliveryRequest.xml \
  --changelog-out db/v1.2/generated-odb-source/sqlite/DeliveryRequest.xml \
  db/v1.2/generated-cpp-source/include/*/model/DeliveryRequest.h

# PostgreSQL
odb -I db/v1.2/generated-cpp-source/include --std c++11 -d pgsql --generate-schema --schema-format sql -q \
  -o db/migrations/v1.1-v1.2/postgre \
  --changelog-in db/v1.1/generated-odb-source/postgre/Agent.xml \
  --changelog-out db/v1.2/generated-odb-source/postgre/Agent.xml \
  db/v1.2/generated-cpp-source/include/*/model/Agent.h

odb -I db/v1.2/generated-cpp-source/include --std c++11 -d pgsql --generate-schema --schema-format sql -q \
  -o db/migrations/v1.1-v1.2/postgre \
  --changelog-in db/v1.1/generated-odb-source/postgre/DeliveryRequest.xml \
  --changelog-out db/v1.2/generated-odb-source/postgre/DeliveryRequest.xml \
  db/v1.2/generated-cpp-source/include/*/model/DeliveryRequest.h

Suggested First Prompt for Cursor:

"Based on the CppBeSSOT Design Spec, please create the entry-point cmake/cppbessot/CppBeSSOT.cmake and the cmake/cppbessot/dbDependencyCheck.cmake file. Ensure the dependency check specifically looks for the odb compiler and npx, and provide clear error messages for Ubuntu users if they are missing."

Would you like me to draft a sample dbGenerationCommon.cmake that handles the boilerplate of resolving the folder paths for a specific version?


Prompt that generated this output from Gemini:

Yea: so I've decided my tech stack for SSOT, JSON serdes, ORM and DB migrations will be ODB + a C++ Json lib. The migration diffs are more powerful and maintainable; the cost of just adding a line to the openapi template is much lower than the cost of potentially having to deal with [semi-]manual migrations. Ok. Write up a summary of the design I'm trying to get done here, in a format that is accurate enough to immediately inform Cursor of what I'm trying to build, and therefore able to help Cursor to generate the build system commands for CMake that are required to construct the chain of operations from the OpenAPI SSOT to the end result of the generated ODB headers, JSON serdes support, migrations, ODB ORM, and Zod schema enforcement.

The ideal thing is that I have a folder structure like this: /project

  • db/ # DB related stuff.   * v/ # Each version of the DB is saved in its own folder. We can look at past versions. Creating a new version just consists of copying the old one and then modifying it and incrementing the version number.     * openapi/ # OpenAPI stuff in here.       *       * schema/ # OpenAPI yaml files for each table in here.     * generated-ts-types/ # In here we place the OpenAPI-generated TS types.     * generated-zod/ # In here we place the openapi-zod-client generated Zod TS schema files.     * generated-cpp-source/ # In here we place the openapi-generator-cli generated C++ headers and JSON serdes.     * generated-odb-source/ # In here we place the ODB generated C++ ORM stuff.   * migrations/ # Subfolders here contain SQL statements to go from v to v.     * v-v/       * sqlite/ # Migrations SQL specific to SQLite.       * postgre/ # Migrations SQL specific to postgre.

    The content in the generated-* subdirs will be added to git version control. The goal is to have them generated once on each schema change, and then accessible thereafter forever. So generating the content in the generated-* subdirs is not a standard build step. It's a separate build/make target which the dev must run manually when xe updates the schema. Perhaps we could have a build system step that checks the git diff or git status, and if any files under the db/ subdir have been changed, we have CMake print a warning, advising the dev to create a new version and re-run the generation targets.

    There are 2 targets we want for the build system:

    1. Generate all generated-* content in the generated-* subdirs of the selected schema dir. The schema dir basename is supplied via CMake -DDB_SCHEMA_DIR_TO_GENERATE="<schema_dir_name>". The name of the target that runs this should be db_gen_orm_serdes_and_zod.
    2. This one should be basically agnostic of what the "current version" is, because it should be supplied with 2 input arguments via -DDB_SCHEMA_DIR_MIGRATION_FROM="<from_schema_dir_name>" and -DDB_SCHEMA_DIR_MIGRATION_TO="<to_schema_dir_name>". It should then output the migrations required to go from <from> to <to> inside of /db/migrations/<from>-<to>/. This build target's name should be db_gen_migrations.

    The name of the target that checks for git-tracked changes in the db/ subdir should be "db_check_schema_changes".

    All of these build system targets should be built modularly, across multiple files to be placed in the top level /cmake subdir. Use subfunctions liberally. Split logically separate or repeated flows into their own callable functions. No long-ass CMake scrawl. For example, maybe each of the different generated-*/ subfolders has its own .cmake file with its specific functions, and there may be a dbGenerationCommon.cmake file with functions included and used by all of them in common.

    Each of the generated-*/ subfolders should have its own CMake target, and all of them should be sub-targets of the main db_gen_orm_serdes_and_zod target.

    The name of the top level "db" folder must be configurable as a CMake var -DCPPBESSOT_WORKDIR="foo", but its default value should be "db". The dev should be able to just change the value of that var and then manually rename the top-level dir, and the CMake scripts should run perfectly fine. There should be a separate .cmake file (in /cmake) which checks for the availability of the dependencies (odb, npx, openapi-zod-client, and the various openapi cli tools) and fails if they're not available.

    The goal is for this C++ backend SSOT (project name: cppbessot) system to be a git submodule of multiple such C++ backend-based systems I'm making. So in practice, the cmake files in /cmake will be part of a cppbessot/ submodule of a larger project. The CPPBESSOT_WORKDIR will be a subdir of the larger project, while the CMake files will be in the submodule. In practice, the deployed system will look something like:

    /bigproject CMakeLists.txt # Bigproject's own CMakeLists, which includes cppbessot/cmake/CppBeSSOT.cmake

    • cmake/ # bigproject's own .cmake files.
      • cppbessot/ # Git submodule containing the CppBeSSOT subproject and its .cmake files.
    • ${CPPBESSOT_WORKDIR}
      • v/
      • migrations/

    So we need a single CMake file that we can include, maybe called CppBeSSOT.cmake, while will include the other .cmake files, and make the cppbessot system available to the larger project.