diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2c91615..74c86ba 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -239,8 +239,24 @@ jobs: path: dist/${{ matrix.name == 'apple-xcframework' && 'CloudSync.*' || 'cloudsync.*'}} if-no-files-found: error + postgres-migration-check: + if: ${{ !contains(github.event.head_commit.message, '[auto-update]') }} + runs-on: ubuntu-22.04 + name: postgresql migration script check + timeout-minutes: 2 + steps: + - uses: actions/checkout@v4.2.2 + with: + # Need full history + tags so `git describe` can find the previous + # release tag that the check script compares against. + fetch-depth: 0 + + - name: verify migration script for current CLOUDSYNC_VERSION + run: make postgres-check-migration + postgres-test: if: ${{ !contains(github.event.head_commit.message, '[auto-update]') }} + needs: [postgres-migration-check] runs-on: ubuntu-22.04 name: postgresql ${{ matrix.postgres_tag }} build + test timeout-minutes: 10 @@ -284,6 +300,7 @@ jobs: postgres-build: if: ${{ !contains(github.event.head_commit.message, '[auto-update]') }} + needs: [postgres-migration-check] runs-on: ${{ matrix.os }} name: postgresql${{ matrix.postgres_version }}-${{ matrix.name }}-${{ matrix.arch }} build timeout-minutes: 15 diff --git a/.gitignore b/.gitignore index a17d9d7..a8f72de 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,11 @@ dist/ /curl/src openssl/ +# Generated PostgreSQL extension files (produced from .in templates by +# docker/Makefile.postgresql; version is derived from src/cloudsync.h) +/docker/postgresql/cloudsync.control +/src/postgresql/cloudsync--*.sql + # Test artifacts /coverage unittest diff --git a/README.md b/README.md index 99ea349..b42aaee 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,12 @@ Part of the **[SQLite AI](https://sqlite.ai)** ecosystem: | **[SQLite-JS](https://github.com/sqliteai/sqlite-js)** | Custom SQLite functions in JavaScript | | **[Liteparser](https://github.com/sqliteai/liteparser)** | Fully compliant SQLite SQL parser | +## Versioning + +This project follows [semver](https://semver.org/). The single source of truth is `CLOUDSYNC_VERSION` in `src/cloudsync.h`; all packaged artifacts (NPM, Maven, pub.dev, Swift, Docker, native tarballs) inherit this version. PATCH releases never alter the exposed API — they ship bug fixes, performance improvements, and internal changes only. + +The PostgreSQL extension differs only in how it surfaces the version: its catalog version (`default_version` / `installed_version`) exposes `MAJOR.MINOR` only, so PATCH releases are transparent binary upgrades and only MINOR/MAJOR releases need `ALTER EXTENSION cloudsync UPDATE`. The `cloudsync_version()` SQL function always reports the full semver of the loaded `.so`. See the [PostgreSQL upgrade docs](docs/postgresql/quickstarts/postgres.md#upgrading-a-later-release) for the user-facing procedure. + ## License This project is licensed under the [Elastic License 2.0](./LICENSE.md). For production or managed service use, [contact SQLite Cloud, Inc](mailto:info@sqlitecloud.io) for a commercial license. diff --git a/docker/Makefile.postgresql b/docker/Makefile.postgresql index 8e1a514..f5303bd 100644 --- a/docker/Makefile.postgresql +++ b/docker/Makefile.postgresql @@ -12,7 +12,17 @@ PG_INCLUDEDIR := $(shell $(PG_CONFIG) --includedir-server 2>/dev/null) # Extension metadata EXTENSION = cloudsync -EXTVERSION = 1.0 +# Read the binary version (full semver) from src/cloudsync.h, and derive +# EXTVERSION as just MAJOR.MINOR. Rationale: PATCH bumps are binary-only +# (no SQL surface change, no ALTER EXTENSION UPDATE needed); MINOR/MAJOR bumps +# are the SQL-surface-changing ones that require a per-release upgrade script. +# cloudsync_version() in the .so still reports the full semver for debugging. +# +# Recursive (=) rather than immediate (:=) assignment so the shell calls only +# fire when a PG-related target actually references these variables. Non-PG +# targets parse this included makefile without invoking sed/cut at all. +CLOUDSYNC_VERSION_FULL = $(shell sed -n 's/^\#define CLOUDSYNC_VERSION[[:space:]]*"\([^"]*\)".*/\1/p' src/cloudsync.h) +EXTVERSION = $(shell echo '$(CLOUDSYNC_VERSION_FULL)' | cut -d. -f1-2) # Detect OS for platform-specific settings ifneq ($(OS),Windows_NT) @@ -82,29 +92,61 @@ endif PG_EXTENSION_SQL = src/postgresql/$(EXTENSION)--$(EXTVERSION).sql PG_EXTENSION_CONTROL = docker/postgresql/$(EXTENSION).control +# Input templates (tracked). @EXTVERSION@ is substituted at build time. +PG_EXTENSION_SQL_IN = src/postgresql/$(EXTENSION).sql.in +PG_EXTENSION_CONTROL_IN = docker/postgresql/$(EXTENSION).control.in + +# Upgrade scripts (cloudsync----.sql) are hand-written per release. +PG_MIGRATIONS_DIR = src/postgresql/migrations +PG_MIGRATION_SQLS = $(wildcard $(PG_MIGRATIONS_DIR)/$(EXTENSION)--*--*.sql) + # ============================================================================ # PostgreSQL Build Targets # ============================================================================ -.PHONY: postgres-check postgres-build postgres-install postgres-package postgres-clean postgres-test \ +.PHONY: postgres-check postgres-check-migration postgres-generate-files postgres-build postgres-install postgres-package postgres-clean postgres-test \ postgres-docker-build postgres-docker-build-asan postgres-docker-run postgres-docker-run-asan postgres-docker-stop postgres-docker-rebuild \ postgres-docker-debug-build postgres-docker-debug-run postgres-docker-debug-rebuild \ postgres-docker-shell postgres-dev-rebuild postgres-help unittest-pg \ postgres-supabase-build postgres-supabase-rebuild postgres-supabase-run-smoke-test \ postgres-docker-run-smoke-test +# Verify that a cloudsync----.sql upgrade script exists for the +# current CLOUDSYNC_VERSION in src/cloudsync.h. Release-blocking: a missing +# upgrade script silently breaks ALTER EXTENSION cloudsync UPDATE for every +# existing deployment. Runs in <1s; safe to call on every PR. +postgres-check-migration: + @scripts/check-postgres-migration.sh + # Check if PostgreSQL is available postgres-check: @echo "Checking PostgreSQL installation..." @which $(PG_CONFIG) > /dev/null || (echo "Error: pg_config not found. Install postgresql-server-dev." && exit 1) + @[ -n "$(CLOUDSYNC_VERSION_FULL)" ] || (echo "Error: could not read CLOUDSYNC_VERSION from src/cloudsync.h" && exit 1) + @[ -n "$(EXTVERSION)" ] || (echo "Error: could not derive MAJOR.MINOR EXTVERSION from CLOUDSYNC_VERSION '$(CLOUDSYNC_VERSION_FULL)'" && exit 1) @echo "PostgreSQL version: $$($(PG_CONFIG) --version)" + @echo "CloudSync version : $(CLOUDSYNC_VERSION_FULL) (extension version $(EXTVERSION))" @echo "Extension directory: $(PG_PKGLIBDIR)" @echo "Share directory: $(PG_SHAREDIR)" @echo "Include directory: $(PG_INCLUDEDIR)" +# Render the versioned .sql install script and .control file from their .in +# templates. This is a phony target (rather than file rules keyed on +# $(PG_EXTENSION_SQL) / $(PG_EXTENSION_CONTROL)) so that $(EXTVERSION) never +# appears in a rule's target or prerequisite position — those expansions +# happen at parse time and would force sed/cut to run on every `make` +# invocation, including for non-PG goals. Here, the references live inside +# the recipe body and are only evaluated when this target actually fires. +# Writes via a .tmp + atomic mv so a failed sed can't leave a half-rendered +# output file in the extension share dir. +postgres-generate-files: postgres-check + @echo "Rendering extension files at version $(EXTVERSION) (binary $(CLOUDSYNC_VERSION_FULL))" + @sed 's/@EXTVERSION@/$(EXTVERSION)/g' $(PG_EXTENSION_SQL_IN) > $(PG_EXTENSION_SQL).tmp && mv $(PG_EXTENSION_SQL).tmp $(PG_EXTENSION_SQL) + @sed 's/@EXTVERSION@/$(EXTVERSION)/g' $(PG_EXTENSION_CONTROL_IN) > $(PG_EXTENSION_CONTROL).tmp && mv $(PG_EXTENSION_CONTROL).tmp $(PG_EXTENSION_CONTROL) + # Build PostgreSQL extension -postgres-build: postgres-check - @echo "Building PostgreSQL extension..." +postgres-build: postgres-generate-files + @echo "Building PostgreSQL extension (version $(EXTVERSION))..." @echo "Compiling source files..." @for src in $(PG_ALL_SRC); do \ echo " CC $$src"; \ @@ -125,26 +167,37 @@ postgres-install: postgres-build install -m 644 $(PG_EXTENSION_SQL) $(PG_SHAREDIR)/extension/ @echo "Installing control file to $(PG_SHAREDIR)/extension/" install -m 644 $(PG_EXTENSION_CONTROL) $(PG_SHAREDIR)/extension/ + @if [ -n "$(PG_MIGRATION_SQLS)" ]; then \ + echo "Installing $(words $(PG_MIGRATION_SQLS)) migration script(s) to $(PG_SHAREDIR)/extension/"; \ + install -m 644 $(PG_MIGRATION_SQLS) $(PG_SHAREDIR)/extension/; \ + fi @echo "" @echo "Installation complete!" @echo "To use the extension, run in psql:" @echo " CREATE EXTENSION $(EXTENSION);" + @echo "To upgrade an existing installation, run in psql:" + @echo " ALTER EXTENSION $(EXTENSION) UPDATE;" # Package extension files for distribution PG_DIST_DIR = dist/postgresql postgres-package: postgres-build - @echo "Packaging PostgreSQL extension..." + @echo "Packaging PostgreSQL extension (version $(EXTVERSION))..." @mkdir -p $(PG_DIST_DIR) cp $(PG_EXTENSION_LIB) $(PG_DIST_DIR)/ cp $(PG_EXTENSION_SQL) $(PG_DIST_DIR)/ cp $(PG_EXTENSION_CONTROL) $(PG_DIST_DIR)/ + @if [ -n "$(PG_MIGRATION_SQLS)" ]; then \ + echo "Including $(words $(PG_MIGRATION_SQLS)) migration script(s)"; \ + cp $(PG_MIGRATION_SQLS) $(PG_DIST_DIR)/; \ + fi @echo "Package ready in $(PG_DIST_DIR)/" # Clean PostgreSQL build artifacts postgres-clean: @echo "Cleaning PostgreSQL build artifacts..." rm -f $(PG_OBJS) $(PG_EXTENSION_LIB) + rm -f $(PG_EXTENSION_SQL) $(PG_EXTENSION_CONTROL) @echo "Clean complete" # Test extension (requires running PostgreSQL) @@ -314,7 +367,7 @@ postgres-supabase-build: echo "Using base image: $$supabase_cli_image"; \ echo "Pulling fresh base image to avoid layer accumulation..."; \ docker pull "$$supabase_cli_image" 2>/dev/null || true; \ - docker build --build-arg SUPABASE_POSTGRES_TAG="$(SUPABASE_POSTGRES_TAG)" -f "$$tmp_dockerfile" -t "$$supabase_cli_image" .; \ + docker build --build-arg SUPABASE_POSTGRES_TAG="$(SUPABASE_POSTGRES_TAG)" --build-arg CLOUDSYNC_VERSION="$(CLOUDSYNC_VERSION_FULL)" -f "$$tmp_dockerfile" -t "$$supabase_cli_image" .; \ rm -f "$$tmp_dockerfile"; \ echo "Build complete: $$supabase_cli_image" @@ -361,6 +414,8 @@ postgres-help: @echo "" @echo "Build & Install:" @echo " postgres-check - Verify PostgreSQL installation" + @echo " postgres-check-migration - Verify a migration script exists for the current version" + @echo " postgres-generate-files - Render cloudsync.control and cloudsync--.sql from templates" @echo " postgres-build - Build extension (.so file)" @echo " postgres-install - Install extension to PostgreSQL" @echo " postgres-clean - Clean build artifacts" diff --git a/docker/README.md b/docker/README.md index 27188bc..2aaff4b 100644 --- a/docker/README.md +++ b/docker/README.md @@ -10,7 +10,7 @@ docker/ │ ├── Dockerfile # Custom PostgreSQL image │ ├── docker-compose.yml │ ├── init.sql # CloudSync metadata tables -│ └── cloudsync.control +│ └── cloudsync.control.in # template; cloudsync.control is generated at build time ``` ## Option 1: Standalone PostgreSQL diff --git a/docker/postgresql/Dockerfile.release b/docker/postgresql/Dockerfile.release index 12f5778..4d9e7aa 100644 --- a/docker/postgresql/Dockerfile.release +++ b/docker/postgresql/Dockerfile.release @@ -31,7 +31,7 @@ RUN case "${TARGETARCH}" in \ mkdir -p /tmp/cloudsync && \ tar -xzf /tmp/cloudsync.tar.gz -C /tmp/cloudsync && \ install -m 755 /tmp/cloudsync/cloudsync.so "$(pg_config --pkglibdir)/" && \ - install -m 644 /tmp/cloudsync/cloudsync--1.0.sql "$(pg_config --sharedir)/extension/" && \ + install -m 644 /tmp/cloudsync/cloudsync--*.sql "$(pg_config --sharedir)/extension/" && \ install -m 644 /tmp/cloudsync/cloudsync.control "$(pg_config --sharedir)/extension/" && \ rm -rf /tmp/cloudsync /tmp/cloudsync.tar.gz && \ apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* diff --git a/docker/postgresql/Dockerfile.supabase b/docker/postgresql/Dockerfile.supabase index 0b5cd10..983a74a 100644 --- a/docker/postgresql/Dockerfile.supabase +++ b/docker/postgresql/Dockerfile.supabase @@ -30,13 +30,21 @@ RUN if [ ! -x "$CLOUDSYNC_PG_CONFIG" ]; then \ # Collect build artifacts (avoid installing into the Nix store) RUN mkdir -p /tmp/cloudsync-artifacts/lib /tmp/cloudsync-artifacts/extension && \ cp /tmp/cloudsync/cloudsync.so /tmp/cloudsync-artifacts/lib/ && \ - cp /tmp/cloudsync/src/postgresql/cloudsync--1.0.sql /tmp/cloudsync-artifacts/extension/ && \ - cp /tmp/cloudsync/docker/postgresql/cloudsync.control /tmp/cloudsync-artifacts/extension/ + cp /tmp/cloudsync/src/postgresql/cloudsync--*.sql /tmp/cloudsync-artifacts/extension/ && \ + cp /tmp/cloudsync/docker/postgresql/cloudsync.control /tmp/cloudsync-artifacts/extension/ && \ + # Include per-release upgrade scripts so ALTER EXTENSION ... UPDATE works + if ls /tmp/cloudsync/src/postgresql/migrations/cloudsync--*--*.sql 1>/dev/null 2>&1; then \ + cp /tmp/cloudsync/src/postgresql/migrations/cloudsync--*--*.sql /tmp/cloudsync-artifacts/extension/; \ + fi # Runtime image based on Supabase Postgres ARG SUPABASE_POSTGRES_TAG=17.6.1.071 FROM public.ecr.aws/supabase/postgres:${SUPABASE_POSTGRES_TAG} +# Extension version (derived from src/cloudsync.h by the Makefile and passed in +# as a build arg); used only for the image label. +ARG CLOUDSYNC_VERSION=unknown + # Match builder pg_config path ENV CLOUDSYNC_PG_CONFIG=/root/.nix-profile/bin/pg_config @@ -85,5 +93,5 @@ EXPOSE 5432 WORKDIR / # Add label with extension version -LABEL org.sqliteai.cloudsync.version="1.0" \ +LABEL org.sqliteai.cloudsync.version="${CLOUDSYNC_VERSION}" \ org.sqliteai.cloudsync.description="PostgreSQL with CloudSync CRDT extension" diff --git a/docker/postgresql/Dockerfile.supabase.release b/docker/postgresql/Dockerfile.supabase.release index 10036a4..5ef83f3 100644 --- a/docker/postgresql/Dockerfile.supabase.release +++ b/docker/postgresql/Dockerfile.supabase.release @@ -42,10 +42,10 @@ RUN case "${TARGETARCH}" in \ SHAREDIR_STD="/usr/share/postgresql" && \ install -d "$PKGLIBDIR" "$SHAREDIR_PGCONFIG/extension" && \ install -m 755 /tmp/cloudsync/cloudsync.so "$PKGLIBDIR/" && \ - install -m 644 /tmp/cloudsync/cloudsync--1.0.sql /tmp/cloudsync/cloudsync.control "$SHAREDIR_PGCONFIG/extension/" && \ + install -m 644 /tmp/cloudsync/cloudsync--*.sql /tmp/cloudsync/cloudsync.control "$SHAREDIR_PGCONFIG/extension/" && \ if [ "$SHAREDIR_STD" != "$SHAREDIR_PGCONFIG" ]; then \ install -d "$SHAREDIR_STD/extension" && \ - install -m 644 /tmp/cloudsync/cloudsync--1.0.sql /tmp/cloudsync/cloudsync.control "$SHAREDIR_STD/extension/"; \ + install -m 644 /tmp/cloudsync/cloudsync--*.sql /tmp/cloudsync/cloudsync.control "$SHAREDIR_STD/extension/"; \ fi && \ rm -rf /tmp/cloudsync /tmp/cloudsync.tar.gz && \ apt-get purge -y curl && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* diff --git a/docker/postgresql/cloudsync.control b/docker/postgresql/cloudsync.control deleted file mode 100644 index 31304b8..0000000 --- a/docker/postgresql/cloudsync.control +++ /dev/null @@ -1,22 +0,0 @@ -# CloudSync PostgreSQL Extension Control File - -# Extension name -comment = 'CloudSync - CRDT-based multi-master database synchronization' - -# Default version -default_version = '1.0' - -# Can be loaded into an existing database -relocatable = true - -# Required PostgreSQL version -requires = '' - -# Superuser privileges required for installation -superuser = false - -# Modules to load -module_pathname = '$libdir/cloudsync' - -# Trusted extension (can be installed by non-superusers) -trusted = true diff --git a/docker/postgresql/cloudsync.control.in b/docker/postgresql/cloudsync.control.in new file mode 100644 index 0000000..704af37 --- /dev/null +++ b/docker/postgresql/cloudsync.control.in @@ -0,0 +1,13 @@ +# CloudSync PostgreSQL Extension Control File +# +# Generated from cloudsync.control.in by docker/Makefile.postgresql. +# Do not edit the generated file; edit the .in template instead. +# The version below is read from CLOUDSYNC_VERSION in src/cloudsync.h. + +comment = 'CloudSync - CRDT-based multi-master database synchronization' +default_version = '@EXTVERSION@' +relocatable = true +requires = '' +superuser = false +module_pathname = '$libdir/cloudsync' +trusted = true diff --git a/docs/internal/supabase-flyio.md b/docs/internal/supabase-flyio.md index e55e3a6..762d0ab 100644 --- a/docs/internal/supabase-flyio.md +++ b/docs/internal/supabase-flyio.md @@ -86,10 +86,10 @@ The `make postgres-supabase-build` command does the following: 1. **Pulls the official Supabase Postgres base image** (e.g., `public.ecr.aws/supabase/postgres:15.8.1.085`) — this is Supabase's standard PostgreSQL image that ships with ~30 extensions pre-installed (PostGIS, pgvector, etc.) 2. **Runs a multi-stage Docker build** using `docker/postgresql/Dockerfile.supabase`: - **Stage 1 (builder)**: Installs C build tools (`gcc`, `make`), copies the CloudSync source code (`src/`, `modules/`), and compiles `cloudsync.so` against Supabase's `pg_config` - - **Stage 2 (runtime)**: Starts from a clean Supabase Postgres image and copies in just three files: + - **Stage 2 (runtime)**: Starts from a clean Supabase Postgres image and copies in just three kinds of file: - `cloudsync.so` — the compiled extension binary - - `cloudsync.control` — tells PostgreSQL the extension's name and version - - `cloudsync--1.0.sql` — the SQL that defines all CloudSync functions + - `cloudsync.control` — tells PostgreSQL the extension's name and default version (generated at build time from `cloudsync.control.in`, with the version read from `src/cloudsync.h`) + - `cloudsync--.sql` — the SQL that defines all CloudSync functions for the current release (e.g. `cloudsync--1.0.16.sql`), plus any `cloudsync----.sql` upgrade scripts shipped under `src/postgresql/migrations/` 3. **Tags the result** with the same name as the base image, so it's a drop-in replacement To find the correct tag, clone the Supabase repo and check: @@ -118,7 +118,8 @@ Verify CloudSync is installed inside the image: ```bash docker run --rm /supabase-postgres-cloudsync:15.8.1.085 \ find / -name "cloudsync*" -type f 2>/dev/null -# Should list cloudsync.so, cloudsync.control, and cloudsync--1.0.sql +# Should list cloudsync.so, cloudsync.control, and cloudsync--.sql +# (plus any cloudsync----.sql upgrade scripts) # in /nix/store/...-postgresql-and-plugins-15.8/ paths ``` diff --git a/docs/postgresql/quickstarts/postgres.md b/docs/postgresql/quickstarts/postgres.md index e7b5c3d..8de217c 100644 --- a/docs/postgresql/quickstarts/postgres.md +++ b/docs/postgresql/quickstarts/postgres.md @@ -49,11 +49,11 @@ docker compose up -d If you already run PostgreSQL directly on a VM or bare metal, download the release tarball that matches your operating system, CPU architecture, and PostgreSQL major version. -Extract the archive, then copy the three extension files into PostgreSQL's extension directories: +Extract the archive, then copy the extension files into PostgreSQL's extension directories. The tarball ships `cloudsync.control`, a `cloudsync--.sql` install script for the current release, and — from release 1.0.17 onward — any `cloudsync----.sql` upgrade scripts needed so existing installations can run `ALTER EXTENSION cloudsync UPDATE`. ```bash cp cloudsync.so "$(pg_config --pkglibdir)/" -cp cloudsync.control cloudsync--1.0.sql "$(pg_config --sharedir)/extension/" +cp cloudsync.control cloudsync--*.sql "$(pg_config --sharedir)/extension/" ``` Then connect to PostgreSQL and enable the extension: @@ -80,6 +80,29 @@ psql -U postgres -d postgres -c "SELECT cloudsync_version();" If the extension is installed correctly, PostgreSQL returns the CloudSync version string. +### Upgrading a later release + +CloudSync uses the first two components of its semver as the PostgreSQL extension version (for example, `1.0.17` installs as extension version `1.0`). How you upgrade depends on which component changed: + +- **PATCH release** (e.g. `1.0.17 → 1.0.18`): pull the new Docker image or replace the extension files on disk and restart PostgreSQL. No SQL-level upgrade is needed — `installed_version` stays at `1.0` and the new binary takes over on reconnect. `SELECT cloudsync_version();` confirms the new semver. +- **MINOR or MAJOR release** (e.g. `1.0.x → 1.1.0`): pull the new artifacts as above, then run once per database: + + ```sql + ALTER EXTENSION cloudsync UPDATE; + ``` + + PostgreSQL applies any `cloudsync----.sql` upgrade scripts shipped with the release and moves `installed_version` to the new value. + +You can check the current state at any time: + +```sql +SELECT name, default_version, installed_version +FROM pg_available_extensions +WHERE name = 'cloudsync'; +``` + +If `installed_version` is behind `default_version` after a release, run `ALTER EXTENSION cloudsync UPDATE;` to catch up. + --- ## Step 3: Register Your Database in the CloudSync Dashboard diff --git a/docs/postgresql/quickstarts/supabase-self-hosted.md b/docs/postgresql/quickstarts/supabase-self-hosted.md index a5dd047..30f7d2d 100644 --- a/docs/postgresql/quickstarts/supabase-self-hosted.md +++ b/docs/postgresql/quickstarts/supabase-self-hosted.md @@ -81,6 +81,29 @@ docker compose exec db psql -U supabase_admin -d postgres -c "SELECT cloudsync_v If the extension is installed correctly, PostgreSQL returns the CloudSync version string. +### Upgrading a later release + +CloudSync uses the first two components of its semver as the PostgreSQL extension version (for example, `1.0.17` installs as extension version `1.0`). How you upgrade depends on which component changed: + +- **PATCH release** (e.g. `1.0.17 → 1.0.18`): pull the matching `sqlitecloud/sqlite-sync-supabase:` image and restart the `db` service. No SQL-level upgrade is needed — `installed_version` stays at `1.0` and the new binary takes over on reconnect. `SELECT cloudsync_version();` confirms the new semver. +- **MINOR or MAJOR release** (e.g. `1.0.x → 1.1.0`): pull the new image and restart as above, then run once per database: + + ```sql + ALTER EXTENSION cloudsync UPDATE; + ``` + + PostgreSQL applies any `cloudsync----.sql` upgrade scripts shipped with the release and moves `installed_version` to the new value. + +You can check the current state at any time: + +```sql +SELECT name, default_version, installed_version +FROM pg_available_extensions +WHERE name = 'cloudsync'; +``` + +If `installed_version` is behind `default_version` after a release, run `ALTER EXTENSION cloudsync UPDATE;` to catch up. + --- ## Step 3: Register Your Database in the CloudSync Dashboard diff --git a/scripts/check-postgres-migration.sh b/scripts/check-postgres-migration.sh new file mode 100755 index 0000000..f14acd9 --- /dev/null +++ b/scripts/check-postgres-migration.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# +# Enforce the PostgreSQL extension versioning contract on every PR/push. +# +# The extension version (default_version in cloudsync.control, and the +# cloudsync--.sql filename) is MAJOR.MINOR only — it's derived from the +# first two components of CLOUDSYNC_VERSION in src/cloudsync.h. The full +# semver of the binary is reported by the cloudsync_version() SQL function. +# +# Contract: +# - PATCH bumps (e.g. 1.0.16 -> 1.0.17) keep EXTVERSION the same ('1.0'). +# Binary-only release; no SQL surface changes allowed; no user action +# needed after swapping the .so. +# - MINOR / MAJOR bumps (e.g. 1.0.x -> 1.1.0 or 1.x -> 2.0) move EXTVERSION. +# Must ship a matching cloudsync----.sql upgrade script so +# existing deployments can ALTER EXTENSION cloudsync UPDATE. +# +# This script runs in one of two modes depending on whether EXTVERSION moved +# since the most recent ancestor semver tag: +# +# (a) EXTVERSION unchanged (patch release): +# diff the current cloudsync.sql.in against the previous tag's install +# script. If they differ, fail: SQL surface changed without a MINOR +# bump, which would silently break users whose pg_extension.extversion +# stays at the old value. +# +# (b) EXTVERSION changed (minor/major release): +# require src/postgresql/migrations/cloudsync----.sql. +# +# Exit codes: +# 0 - contract satisfied (no bump, or bump with migration present) +# 1 - contract violated (SQL drift without bump, or missing migration) +# 2 - misconfigured environment (no git, missing header, unresolvable tag) + +set -euo pipefail + +repo_root=$(git rev-parse --show-toplevel 2>/dev/null || true) +if [ -z "$repo_root" ]; then + echo "Error: not inside a git working tree; cannot determine previous release tag." >&2 + exit 2 +fi +cd "$repo_root" + +header="src/cloudsync.h" +sql_template="src/postgresql/cloudsync.sql.in" + +[ -f "$header" ] || { echo "Error: $header not found." >&2; exit 2; } +[ -f "$sql_template" ] || { echo "Error: $sql_template not found." >&2; exit 2; } + +# Read full semver from the header and derive MAJOR.MINOR. +current_full=$(sed -n 's/^#define CLOUDSYNC_VERSION[[:space:]]*"\([^"]*\)".*/\1/p' "$header") +if [ -z "$current_full" ]; then + echo "Error: could not read CLOUDSYNC_VERSION from $header." >&2 + exit 2 +fi +current_ext=$(printf '%s\n' "$current_full" | cut -d. -f1-2) +if [ -z "$current_ext" ]; then + echo "Error: could not derive MAJOR.MINOR from CLOUDSYNC_VERSION '$current_full'." >&2 + exit 2 +fi + +# Find the latest ancestor semver tag. +prev_tag=$(git describe --tags --abbrev=0 --match '[0-9]*.[0-9]*.[0-9]*' 2>/dev/null || true) +if [ -z "$prev_tag" ]; then + echo "No prior semver tag reachable from HEAD; skipping migration check." + echo "(This is expected on an initial release or a shallow clone without tags.)" + exit 0 +fi + +# Resolve what EXTVERSION the previous release shipped with. +# Pre-new-scheme tags: tracked cloudsync.control held default_version. +# New-scheme tags: control is generated; read CLOUDSYNC_VERSION from header +# at that tag and truncate. +prev_full=$(git show "${prev_tag}:${header}" 2>/dev/null \ + | sed -n 's/^#define CLOUDSYNC_VERSION[[:space:]]*"\([^"]*\)".*/\1/p' || true) + +prev_ext=$(git show "${prev_tag}:docker/postgresql/cloudsync.control" 2>/dev/null \ + | sed -n "s/^default_version = '\\([^']*\\)'.*/\\1/p" \ + | head -1 || true) + +if [ -z "$prev_ext" ] && [ -n "$prev_full" ]; then + prev_ext=$(printf '%s\n' "$prev_full" | cut -d. -f1-2) +fi + +if [ -z "$prev_ext" ]; then + echo "Error: could not determine EXTVERSION at tag ${prev_tag}." >&2 + exit 2 +fi + +# --------------------------------------------------------------------------- +# Mode (a): EXTVERSION unchanged — verify SQL surface didn't drift. +# --------------------------------------------------------------------------- +if [ "$prev_ext" = "$current_ext" ]; then + # Produce a normalized view of the previous release's install script. + if git cat-file -e "${prev_tag}:${sql_template}" 2>/dev/null; then + # New-scheme tag: substitute @EXTVERSION@ with that tag's EXTVERSION. + prev_sql=$(git show "${prev_tag}:${sql_template}" \ + | sed "s/@EXTVERSION@/${prev_ext}/g") + else + # Old-scheme tag: the literal cloudsync--.sql was tracked. + prev_install_path="src/postgresql/cloudsync--${prev_ext}.sql" + if ! git cat-file -e "${prev_tag}:${prev_install_path}" 2>/dev/null; then + echo "Error: could not find previous install script at ${prev_tag}:${prev_install_path}." >&2 + exit 2 + fi + prev_sql=$(git show "${prev_tag}:${prev_install_path}") + fi + + # Current install script, rendered (substitute @EXTVERSION@ -> current_ext). + curr_sql=$(sed "s/@EXTVERSION@/${current_ext}/g" "$sql_template") + + if [ "$prev_sql" = "$curr_sql" ]; then + echo "OK: patch-only release candidate." + echo " EXTVERSION unchanged at '${current_ext}' since ${prev_tag}." + echo " Binary semver: ${prev_full:-unknown} -> ${current_full}." + echo " SQL surface identical; no migration script required." + exit 0 + fi + + cat >&2 < ${current_full} + +The install script (src/postgresql/cloudsync.sql.in) differs from what the +previous release shipped, but EXTVERSION is still '${current_ext}'. Existing +deployments have pg_extension.extversion = '${current_ext}' and will NOT run +any upgrade script when they swap in the new .so, so the new SQL bindings +won't be applied to their catalog. This silently breaks users. + +Pick one: + + 1. Bump MINOR in src/cloudsync.h (e.g. 1.0.x -> 1.1.0), and add + src/postgresql/migrations/cloudsync--${current_ext}--.sql + with the DDL deltas (CREATE OR REPLACE FUNCTION ..., etc.). This is the + correct choice for any intentional SQL-surface change. + + 2. Revert the SQL-level change in cloudsync.sql.in if it was accidental + (e.g. a refactor that went further than intended). + +Diff (previous -> current, normalized): + +EOF + # Show a readable diff; fall back to a terse message if diff is unavailable. + if command -v diff >/dev/null 2>&1; then + diff -u <(printf '%s\n' "$prev_sql") <(printf '%s\n' "$curr_sql") >&2 || true + else + echo "(install 'diff' to see line-level changes)" >&2 + fi + exit 1 +fi + +# --------------------------------------------------------------------------- +# Mode (b): EXTVERSION changed — require a migration file. +# --------------------------------------------------------------------------- +expected="src/postgresql/migrations/cloudsync--${prev_ext}--${current_ext}.sql" +if [ ! -f "$expected" ]; then + cat >&2 < ${current_ext} covered)." +echo " Binary semver: ${prev_full:-unknown} -> ${current_full}." diff --git a/src/postgresql/cloudsync--1.0.sql b/src/postgresql/cloudsync.sql.in similarity index 99% rename from src/postgresql/cloudsync--1.0.sql rename to src/postgresql/cloudsync.sql.in index 763ac9b..edfa4d3 100644 --- a/src/postgresql/cloudsync--1.0.sql +++ b/src/postgresql/cloudsync.sql.in @@ -1,5 +1,5 @@ -- CloudSync Extension for PostgreSQL --- Version 1.0 +-- Version @EXTVERSION@ -- Complain if script is sourced in psql, rather than via CREATE EXTENSION \echo Use "CREATE EXTENSION cloudsync" to load this file. \quit diff --git a/src/postgresql/migrations/README.md b/src/postgresql/migrations/README.md new file mode 100644 index 0000000..497bf77 --- /dev/null +++ b/src/postgresql/migrations/README.md @@ -0,0 +1,74 @@ +# CloudSync PostgreSQL Migration Scripts + +This directory holds PostgreSQL extension upgrade scripts of the form: + + cloudsync----.sql + +PostgreSQL uses these to execute `ALTER EXTENSION cloudsync UPDATE` by chaining +one or more files to reach the target version. + +## Versioning model + +The PostgreSQL extension version (`default_version` in `cloudsync.control`) is +**`MAJOR.MINOR`** only — derived from the first two components of +`CLOUDSYNC_VERSION` in `src/cloudsync.h`. The full semver of the compiled +binary is reported by the `cloudsync_version()` SQL function. + +| Release kind | Example | EXTVERSION moves? | Upgrade script? | User action | +| -------------------------------- | --------------- | ----------------- | --------------- | ------------------ | +| PATCH bump (binary only) | 1.0.16 → 1.0.17 | No (stays `1.0`) | Not required | Swap the `.so`. | +| MINOR bump (SQL surface changes) | 1.0.x → 1.1.0 | Yes (`1.0` → `1.1`) | Required | `ALTER EXTENSION cloudsync UPDATE;` | +| MAJOR bump | 1.x → 2.0.0 | Yes (`1.x` → `2.0`) | Required | `ALTER EXTENSION cloudsync UPDATE;` | + +CI enforces this contract via `scripts/check-postgres-migration.sh`: + +- PATCH releases: the script diffs the current `cloudsync.sql.in` against the + previous release's install script. If they differ, the build fails — + accidental SQL surface drift in a PATCH release would silently break users + whose `pg_extension.extversion` would otherwise stay at the old EXTVERSION. +- MINOR/MAJOR releases: the script requires a matching + `cloudsync----.sql` in this directory. + +## When to add an upgrade script + +Add one **only when `EXTVERSION` changes** — i.e. when you bump MINOR or MAJOR +in `src/cloudsync.h`. The filename is literally +`cloudsync----.sql`, not the full semver. + +Examples: + + 1.0.17 -> 1.1.0 → cloudsync--1.0--1.1.sql + 1.1.5 -> 2.0.0 → cloudsync--1.1--2.0.sql + +PATCH-level releases (1.0.16 → 1.0.17 → 1.0.18 → …) require **no file** — the +catalog's `installed_version` stays at the MAJOR.MINOR, and the `.so` swap is +a transparent binary upgrade. + +## Rules for upgrade script content + +- Use `CREATE OR REPLACE` for every function — the underlying C symbol may + have changed even when the SQL signature didn't. +- Drop removed objects explicitly (`DROP FUNCTION IF EXISTS ...`). +- Never run `CREATE EXTENSION`-style bootstrap inside an upgrade script. +- Objects created inside an upgrade script are automatically attached to the + extension via `pg_depend`. +- Scripts are packaged and installed by `make postgres-install` / + `make postgres-package` via a wildcard; no need to list them anywhere. + +## Verifying the chain + +After rebuilding, inside the PG container: + + SELECT * FROM pg_extension_update_paths('cloudsync'); + +All `source -> target` rows should show a non-NULL `path`. + +## I intended a PATCH but the CI check says "SQL surface drift" + +Either: + +1. You meant to change SQL. Bump MINOR in `src/cloudsync.h` and add + `cloudsync----.sql`. +2. You didn't. Revert the change in `src/postgresql/cloudsync.sql.in`. + +The CI error message prints a unified diff to help you decide which.