diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 76d890a..c778b78 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -351,7 +351,25 @@ jobs: git add "$PKG" git commit -m "Bump sqlite-sync-dev version to ${{ steps.tag.outputs.version }}" git push origin dev - + + - uses: actions/checkout@v4.2.2 + if: steps.tag.outputs.version != '' + with: + repository: sqliteai/sqlite-sync-react-native + path: sqlite-sync-react-native + token: ${{ secrets.PAT }} + + - name: release sqlite-sync-react-native + if: steps.tag.outputs.version != '' + run: | + cd sqlite-sync-react-native + git config --global user.email "$GITHUB_ACTOR@users.noreply.github.com" + git config --global user.name "$GITHUB_ACTOR" + jq --arg version "${{ steps.tag.outputs.version }}" '.version = $version' package.json > package.tmp.json && mv package.tmp.json package.json + git add package.json + git commit -m "Bump sqlite-sync version to ${{ steps.tag.outputs.version }}" + git push origin main + - uses: actions/setup-java@v4 if: steps.tag.outputs.version != '' with: @@ -446,6 +464,7 @@ jobs: [**Node**](https://www.npmjs.com/package/@sqliteai/sqlite-sync-dev): `npm install @sqliteai/sqlite-sync-dev` [**WASM**](https://www.npmjs.com/package/@sqliteai/sqlite-wasm): `npm install @sqliteai/sqlite-wasm@dev` + [**React Native**](https://www.npmjs.com/package/@sqliteai/sqlite-sync-react-native): `npm install @sqliteai/sqlite-sync-react-native` [**Expo**](https://www.npmjs.com/package/@sqliteai/sqlite-sync-expo-dev): `npm install @sqliteai/sqlite-sync-expo-dev` [**Android**](https://central.sonatype.com/artifact/ai.sqlite/sync.dev): `ai.sqlite:sync.dev:${{ steps.tag.outputs.version }}` [**Swift**](https://github.com/sqliteai/sqlite-sync-dev#swift-package): [Installation Guide](https://github.com/sqliteai/sqlite-sync-dev#swift-package) diff --git a/README.md b/README.md index 8d953e9..921ac72 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,16 @@ if (Platform.OS === 'ios') { } ``` +### React Native + +Install the React Native library: + +```bash +npm install @sqliteai/sqlite-sync-react-native +``` + +Then follow the instructions from the [README](https://www.npmjs.com/package/@sqliteai/sqlite-sync-react-native) + ## Getting Started Here's a quick example to get started with SQLite Sync: diff --git a/docs/postgresql/CLIENT.md b/docs/postgresql/CLIENT.md new file mode 100644 index 0000000..819ea93 --- /dev/null +++ b/docs/postgresql/CLIENT.md @@ -0,0 +1,227 @@ +# SQLite Sync + +**SQLite Sync** is a multi-platform extension that brings a true **local-first experience** to your applications with minimal effort. It extends standard SQLite tables with built-in support for offline work and automatic synchronization, allowing multiple devices to operate independently — even without a network connection — while seamlessly staying in sync. + +With SQLite Sync, developers can build **distributed, collaborative applications** while continuing to rely on the **simplicity, reliability, and performance of SQLite**. + +Under the hood, SQLite Sync uses advanced **CRDT (Conflict-free Replicated Data Type)** algorithms and data structures designed specifically for **collaborative, distributed systems**: + +- Devices can update data independently, even without a network connection. +- When they reconnect, all changes are **merged automatically and without conflicts**. +- **No data loss. No overwrites. No manual conflict resolution.** + +## IMPORTANT + +- Make sure to use version **0.9.96 or newer** + (verify with `SELECT cloudsync_version();`) + +- Until v0.9.96 is released upstream, always use the development fork: + https://github.com/sqliteai/sqlite-sync-dev + and **NOT** the original repository: + https://github.com/sqliteai/sqlite-sync + +- Updated example apps are available [here](https://github.com/sqliteai/sqlite-sync-dev/tree/main/examples): + - sport-tracker app (WASM), see [SPORT_APP_README_SUPABASE.md](SPORT_APP_README_SUPABASE.md) for more details + - to-do app (Expo) + - React Native Library: https://github.com/sqliteai/sqlite-sync-react-native + - Remaining demos will be updated in the next days + +## Conversion Between SQLite and PostgreSQL Tables + +In this version, make sure to **manually create** the same tables in the PostgreSQL database as used in the SQLite client. + +This guide shows how to manually convert a SQLite table definition to PostgreSQL +so CloudSync can sync between a PostgreSQL server and SQLite clients. + +### 1) Primary Keys + +- Use **TEXT NOT NULL** primary keys only (UUIDs as text). +- Generate IDs with `cloudsync_uuid()` on both sides. +- Avoid INTEGER auto-increment PKs. + +SQLite: +```sql +id TEXT PRIMARY KEY NOT NULL +``` + +PostgreSQL: +```sql +id TEXT PRIMARY KEY NOT NULL +``` + +### 2) NOT NULL Columns Must Have DEFAULTs + +CloudSync merges column-by-column. Any NOT NULL (non-PK) column needs a DEFAULT +to avoid constraint failures during merges. + +Example: +```sql +title TEXT NOT NULL DEFAULT '' +count INTEGER NOT NULL DEFAULT 0 +``` + +### 3) Safe Type Mapping + +Use types that map cleanly to CloudSync's DBTYPEs: + +- INTEGER → `INTEGER` (SQLite) / `INTEGER` (Postgres) +- FLOAT → `REAL` / `DOUBLE` (SQLite) / `DOUBLE PRECISION` (Postgres) +- TEXT → `TEXT` (both) +- BLOB → `BLOB` (SQLite) / `BYTEA` (Postgres) + +Avoid: JSON/JSONB, UUID, INET, CIDR, RANGE, ARRAY unless you accept text-cast +behavior. + +### 4) Defaults That Match Semantics + +Use defaults that serialize the same on both sides: + +- TEXT: `DEFAULT ''` +- INTEGER: `DEFAULT 0` +- FLOAT: `DEFAULT 0.0` +- BLOB: `DEFAULT X'00'` (SQLite) vs `DEFAULT E'\\x00'` (Postgres) + +### 5) Foreign Keys and Triggers + +- Foreign keys can cause merge conflicts; test carefully. +- Application triggers will fire during merge; keep them idempotent or disable + in synced tables. + +### 6) Example Conversion + +SQLite: +```sql +CREATE TABLE notes ( + id TEXT PRIMARY KEY NOT NULL, + title TEXT NOT NULL DEFAULT '', + body TEXT DEFAULT '', + views INTEGER NOT NULL DEFAULT 0, + rating REAL DEFAULT 0.0, + data BLOB +); +``` + +PostgreSQL: +```sql +CREATE TABLE notes ( + id TEXT PRIMARY KEY NOT NULL, + title TEXT NOT NULL DEFAULT '', + body TEXT DEFAULT '', + views INTEGER NOT NULL DEFAULT 0, + rating DOUBLE PRECISION DEFAULT 0.0, + data BYTEA +); +``` + +### 7) Enable CloudSync + +SQLite: +```sql +.load dist/cloudsync.dylib +SELECT cloudsync_init('notes'); +``` + +PostgreSQL: +```sql +CREATE EXTENSION cloudsync; +SELECT cloudsync_init('notes'); +``` + +### Checklist + +- [ ] PKs are TEXT + NOT NULL +- [ ] All NOT NULL columns have DEFAULT +- [ ] Only INTEGER/FLOAT/TEXT/BLOB-compatible types +- [ ] Same column names and order +- [ ] Same defaults (semantic match) + +Please follow [these Database Schema Recommendations](https://github.com/sqliteai/sqlite-sync-dev?tab=readme-ov-file#database-schema-recommendations) + +## Pre-built Binaries + +Download the appropriate pre-built binary for your platform from the official [Releases](https://github.com/sqliteai/sqlite-sync-dev/releases) page: + +- Linux: x86 and ARM +- macOS: x86 and ARM +- Windows: x86 +- Android +- iOS + +## Loading the Extension + +``` +-- In SQLite CLI +.load ./cloudsync + +-- In SQL +SELECT load_extension('./cloudsync'); +``` + +## WASM Version -> React client-side + +Make sure to install the extension tagged as **dev** and not **latest** +``` +npm i @sqliteai/sqlite-wasm@dev +``` + +Then follow the instructions from the [README](https://www.npmjs.com/package/@sqliteai/sqlite-wasm) + +## Swift Package + +You can [add this repository as a package dependency to your Swift project](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app#Add-a-package-dependency). After adding the package, you'll need to set up SQLite with extension loading by following steps 4 and 5 of [this guide](https://github.com/sqliteai/sqlite-extensions-guide/blob/main/platforms/ios.md#4-set-up-sqlite-with-extension-loading). + +## Android Package + +Add the [following](https://central.sonatype.com/artifact/ai.sqlite/sync.dev) to your Gradle dependencies: + +``` +implementation 'ai.sqlite:sync.dev:0.9.96' +``` + +## Expo + +Install the Expo package: + +``` +npm install @sqliteai/sqlite-sync-expo-dev +``` + +Then follow the instructions from the [README](https://www.npmjs.com/package/@sqliteai/sqlite-sync-expo-dev) + +## React Native + +Install the React Native library: + +``` +npm install @sqliteai/sqlite-sync-react-native +``` + +Then follow the instructions from the [README](https://www.npmjs.com/package/@sqliteai/sqlite-sync-react-native) + +## Node -> React server-side + +```js +npm i better-sqlite3 +npm i @sqliteai/sqlite-sync-dev + +echo "import { getExtensionPath } from '@sqliteai/sqlite-sync-dev'; +import Database from 'better-sqlite3'; + +const db = new Database(':memory:'); +db.loadExtension(getExtensionPath()); + +// Ready to use +const version = db.prepare('SELECT cloudsync_version()').pluck().get(); +console.log('Sync extension version:', version);" >> index.js + +node index.js +``` + +## Naming Clarification + +- **sqlite-sync** → Client-side SQLite extension +- **cloudsync** → Synchronization server microservice +- **postgres-sync** → PostgreSQL extension + +The sqlite-sync extension is loaded in SQLite under the extension name: +`cloudsync` diff --git a/docs/postgresql/CLOUDSYNC.md b/docs/postgresql/CLOUDSYNC.md new file mode 100644 index 0000000..4d87751 --- /dev/null +++ b/docs/postgresql/CLOUDSYNC.md @@ -0,0 +1,64 @@ +# Demo Deployment + +For the current demo, a single CloudSync node is deployed in **Europe** on Fly.io. + +If testing from other regions, latency will reflect this single-node deployment. +A production deployment would use **geographically distributed nodes with regional routing** for global coverage. + +## Fly.io + +Project Name: **cloudsync-staging** +Fly.io App: https://fly.io/apps/cloudsync-staging +CloudSync Server URL: https://cloudsync-staging.fly.dev/ +Logs: https://fly.io/apps/cloudsync-staging/monitoring + +> Note: This is a **demo-only environment**, not intended for production use. + +## Environment Variables + +Edit in the Fly.io **Secrets** section: +https://fly.io/apps/cloudsync-staging/secrets + +After editing, the machine restarts automatically. + +The server is currently configured to point to a demo Supabase project (https://supabase.com/dashboard/project/ajgnsrqbwmnhytqyesyr). + +Environment variables: + +- `CLOUDSYNC_JOBS_DATABASE_CONNECTION_STRING` — database for jobs table +- `CLOUDSYNC_ARTIFACT_STORE_CONNECTION_STRING` — database for sync artifacts +- `CLOUDSYNC_METRICS_DB_CONNECTION_STRING` — database for metrics +- `CLOUDSYNC_SERVICE_PROJECT_ID` — project ID for service user +- `CLOUDSYNC_SERVICE_DATABASE_CONNECTION_STRING` — service user DB connection + +## Tables + +- **cloudsync_jobs** — queue of asynchronous jobs + - `check`: generate blob of client changes + - `apply`: apply client changes + - `notify`: send Expo push notifications + +- **cloudsync_artifacts** — blobs ready for client download +- **cloudsync_metrics** — collected metrics +- **cloudsync_push_tokens** — Expo push tokens + +## Metrics + +CloudSync integrates a simple metrics collector (authenticated via user JWT). + +To visualize metrics, import `grafana-dashboard.json` into Grafana and configure your PostgreSQL database as a data source. + +Alternatively, call the API directly: + +```bash +curl --request GET --url 'https://cloudsync-staging.fly.dev/v2/cloudsync/metrics?from=&to=&projectId=&database=&siteId=&action=check' --header 'Authorization: Bearer ' +``` + +Filters: + +- `from` +- `to` +- `projectId` +- `database` +- `siteId` +- `action` (`check` / `apply`) diff --git a/docs/postgresql/README.md b/docs/postgresql/README.md new file mode 100644 index 0000000..37eb12d --- /dev/null +++ b/docs/postgresql/README.md @@ -0,0 +1,155 @@ +# Architecture Overview + +The **SQLite AI offline-sync solution** consists of three main components: +* **SQLite Sync**: Native client-side SQLite extension +* **CloudSync**: Synchronization microservice +* **Postgres Sync**: Native PostgreSQL extension + +Together, these components provide a complete, production-grade **offline-first synchronization stack** for SQLite and PostgreSQL. + + +# SQLite Sync + +**SQLite Sync** is a native SQLite extension that must be installed and loaded on all client devices. +We provide prebuilt binaries for: +* Desktop and mobile platforms +* WebAssembly (WASM) +* Popular package managers and frameworks including React Native, Expo, Node, Swift PM and Android AAR + +**Note:** The latest version (v0.9.96) is not yet available in the official SQLite Sync repository. Please use our development fork instead: [https://github.com/sqliteai/sqlite-sync-dev](https://github.com/sqliteai/sqlite-sync-dev) + +
+List of development fork binaries (v0.9.96) + +### Android +- [cloudsync-android-aar-0.9.96.aar](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-android-aar-0.9.96.aar) +- [cloudsync-android-arm64-v8a-0.9.96.tar.gz](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-android-arm64-v8a-0.9.96.tar.gz) +- [cloudsync-android-arm64-v8a-0.9.96.zip](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-android-arm64-v8a-0.9.96.zip) +- [cloudsync-android-armeabi-v7a-0.9.96.tar.gz](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-android-armeabi-v7a-0.9.96.tar.gz) +- [cloudsync-android-armeabi-v7a-0.9.96.zip](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-android-armeabi-v7a-0.9.96.zip) +- [cloudsync-android-x86_64-0.9.96.tar.gz](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-android-x86_64-0.9.96.tar.gz) +- [cloudsync-android-x86_64-0.9.96.zip](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-android-x86_64-0.9.96.zip) + +### Apple (iOS / macOS) +- [cloudsync-apple-xcframework-0.9.96.zip](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-apple-xcframework-0.9.96.zip) +- [cloudsync-ios-0.9.96.tar.gz](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-ios-0.9.96.tar.gz) +- [cloudsync-ios-0.9.96.zip](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-ios-0.9.96.zip) +- [cloudsync-ios-sim-0.9.96.tar.gz](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-ios-sim-0.9.96.tar.gz) +- [cloudsync-ios-sim-0.9.96.zip](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-ios-sim-0.9.96.zip) +- [cloudsync-macos-0.9.96.tar.gz](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-macos-0.9.96.tar.gz) +- [cloudsync-macos-0.9.96.zip](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-macos-0.9.96.zip) +- [cloudsync-macos-arm64-0.9.96.tar.gz](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-macos-arm64-0.9.96.tar.gz) +- [cloudsync-macos-arm64-0.9.96.zip](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-macos-arm64-0.9.96.zip) +- [cloudsync-macos-x86_64-0.9.96.tar.gz](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-macos-x86_64-0.9.96.tar.gz) +- [cloudsync-macos-x86_64-0.9.96.zip](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-macos-x86_64-0.9.96.zip) + +### Linux +- [cloudsync-linux-arm64-0.9.96.tar.gz](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-linux-arm64-0.9.96.tar.gz) +- [cloudsync-linux-arm64-0.9.96.zip](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-linux-arm64-0.9.96.zip) +- [cloudsync-linux-musl-arm64-0.9.96.tar.gz](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-linux-musl-arm64-0.9.96.tar.gz) +- [cloudsync-linux-musl-arm64-0.9.96.zip](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-linux-musl-arm64-0.9.96.zip) +- [cloudsync-linux-musl-x86_64-0.9.96.tar.gz](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-linux-musl-x86_64-0.9.96.tar.gz) +- [cloudsync-linux-musl-x86_64-0.9.96.zip](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-linux-musl-x86_64-0.9.96.zip) +- [cloudsync-linux-x86_64-0.9.96.tar.gz](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-linux-x86_64-0.9.96.tar.gz) +- [cloudsync-linux-x86_64-0.9.96.zip](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-linux-x86_64-0.9.96.zip) + +### Windows +- [cloudsync-windows-x86_64-0.9.96.tar.gz](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-windows-x86_64-0.9.96.tar.gz) +- [cloudsync-windows-x86_64-0.9.96.zip](https://github.com/sqliteai/sqlite-sync-dev/releases/download/0.9.96/cloudsync-windows-x86_64-0.9.96.zip) + +
+ +### Architecture Refactoring +The extension has been refactored to support both **SQLite** and **PostgreSQL** backends. +* All database-specific native calls have been isolated in [database.h](../../src/database.h) +* Each database engine implements its own engine-dependent layer +* The core **CRDT logic** is fully shared across engines + +This modular design improves **portability**, **maintainability**, and **cross-database consistency**. +### Testing & Reliability +* Shared CRDT and SQLite components include extensive unit tests +* Code coverage exceeds **90%** +* PostgreSQL-specific code has its own dedicated test suite + +### Key Features +* Deep integration with SQLite — the default database for Edge applications +* Built-in network layer exposed as ordinary SQLite functions +* Cross-platform, language-agnostic payload format +* Works seamlessly in any framework or programming language + +Unlike other offline-sync solutions, **SQLite Sync embeds networking directly inside SQLite**, eliminating external sync SDKs. + +### Supported CRDTs +Currently implemented CRDT algorithms: +* **Last-Write-Wins (LWW)** +* **Grow-Only Set (G-Set)** + +Additional CRDTs can be implemented if needed, though LWW covers most real-world use cases. + + +# CloudSync + +**CloudSync** is a lightweight, stateless microservice responsible for synchronizing clients with central servers. +### Responsibilities +* Synchronizes clients with: + * **SQLite Cloud servers** + * **PostgreSQL servers** +* Manages upload and download of CRDT payloads +* Stores payloads via **AWS S3** +* Collects operational metrics (connected devices, sync volume, traffic, etc.) +* Exposes a complete **REST API** + +### Technology Stack + +* Written in **Go** +* Built on the high-performance **Gin Web Framework** +* Fully **multitenant** +* Connects to multiple DBMS backends +* Stateless architecture enables horizontal scaling simply by adding nodes +* Serialized job queue ensures **no job loss**, even after restarts + +### Observability + +* Metrics dashboard available in [grafana-dashboard.json](grafana-dashboard.json) + +* Additional logs available via the Fly.io monitoring dashboard + +### Demo Deployment + +For the current demo, a single CloudSync node is deployed in **Europe** on Fly.io. +If testing from other regions, latency will reflect this single-node deployment. A production deployment would use **geographically distributed nodes with regional routing** for global coverage. + + +# Postgres Sync + +**Postgres Sync** is a native PostgreSQL extension derived from SQLite Sync. +### Features +* Implements the same CRDT algorithms available in SQLite Sync +* Applies CRDT logic to: + * Changes coming from synchronized clients + * Changes made directly in PostgreSQL (CLI, Drizzle, dashboards, etc.) + +This ensures **full bidirectional consistency**, regardless of where changes originate. + +### Schema Handling +SQLite does not support schemas, while PostgreSQL does. To bridge this difference, Postgres Sync introduces a mechanism to: + +* Associate each synchronized table with a specific PostgreSQL schema +* Allow different schemas per table + +This preserves PostgreSQL-native organization while maintaining SQLite compatibility. + +# Current Limitations + +The PostgreSQL integration is actively evolving. Current limitations include: + +* **User Impersonation**: The microservice currently applies server changes using the Supabase Admin user. In the next version, changes will be applied under the identity associated with the client’s JWT. +* **Table Creation**: Tables must currently be created manually in PostgreSQL before synchronization. We are implementing automatic translation of SQLite CREATE TABLE statements to PostgreSQL syntax. +* **Row-Level Security**: RLS is fully implemented for SQLite Cloud servers. PostgreSQL RLS integration is in progress and will be included in the final release. +* **Beta Status**: While extensively tested, the PostgreSQL sync stack should currently be considered **beta software**. Please report any issues; we are committed to resolving them quickly. + +# Next +* [CLIENT](CLIENT.md) installation and setup +* [CLOUDSYNC](CLOUDSYNC.md) microservice configuration and setup +* [SUPABASE](SUPABASE.md) configuration and setup +* [SPORT-TRACKER APP](SPORT_APP_README_SUPABASE.md) demo web app based on SQLite Sync WASM \ No newline at end of file diff --git a/docs/postgresql/SPORT_APP_README_SUPABASE.md b/docs/postgresql/SPORT_APP_README_SUPABASE.md new file mode 100644 index 0000000..7c4ed2b --- /dev/null +++ b/docs/postgresql/SPORT_APP_README_SUPABASE.md @@ -0,0 +1,43 @@ +# Sport Tracker app with SQLite Sync 🚵 + +A Vite/React demonstration app showcasing [**SQLite Sync (Dev)**](https://github.com/sqliteai/sqlite-sync-dev) implementation for **offline-first** data synchronization across multiple devices. This example illustrates how to integrate SQLite AI's sync capabilities into modern web applications with proper authentication via [Access Token](https://docs.sqlitecloud.io/docs/access-tokens) and [Row-Level Security (RLS)](https://docs.sqlitecloud.io/docs/rls). + +> This app uses the packed WASM version of SQLite with the [SQLite Sync extension enabled](https://www.npmjs.com/package/@sqliteai/sqlite-wasm). + +**The source code is located in [examples/sport-tracker-app](../../examples/sport-tracker-app/)** + +## Setup Instructions + +### 1. Prerequisites +- Node.js 20.x or \>=22.12.0 + +### 2. Database Setup +1. Create database +2. Execute the schema with [sport-tracker-schema-postgres.sql](../../examples/sport-tracker-app/sport-tracker-schema-postgres.sql). +3. Enable CloudSync for all tables on the remote database with: + ```sql + CREATE EXTENSION IF NOT EXISTS cloudsync; + SELECT cloudsync_init('users_sport'); + SELECT cloudsync_init('workouts'); + SELECT cloudsync_init('activities'); + ``` + +### 3. Environment Configuration + +Rename the `.env.example` into `.env` and fill with your values. + +- `VITE_SQLITECLOUD_CONNECTION_STRING`: the url to the CloudSync server: https://cloudsync-staging.fly.dev/ +- `VITE_SQLITECLOUD_DATABASE`: remote database name. +- `VITE_SQLITECLOUD_API_KEY`: a valid user's JWT token. Refresh it when it expires. +- `VITE_SQLITECLOUD_API_URL`: Supabase project API URL. + +### 4. Installation & Run + +```bash +npm install +npm run dev +``` + +### Demo + +Continue reading on the official [README](https://github.com/sqliteai/sqlite-sync-dev/blob/main/examples/sport-tracker-app/README.md#demo-use-case-multi-user-sync-scenario). \ No newline at end of file diff --git a/docs/postgresql/SUPABASE.md b/docs/postgresql/SUPABASE.md new file mode 100644 index 0000000..94aa466 --- /dev/null +++ b/docs/postgresql/SUPABASE.md @@ -0,0 +1,88 @@ +# Supabase Installation & Testing (CloudSync PostgreSQL Extension) + +This guide explains how to install and test the CloudSync PostgreSQL extension +inside Supabase, both for the Supabase CLI local stack and for self-hosted +Supabase deployments. + +## Prerequisites + +- Docker running +- Supabase stack running (CLI local or self-hosted) +- The Supabase Postgres image tag in use (e.g. `public.ecr.aws/supabase/postgres:17.6.1.071`) + +## Option A: Supabase CLI Local Stack + +1) Start the stack once so the Postgres image is present: +```bash +supabase init +supabase start +``` + +2) Build a new Postgres image with CloudSync installed (same tag as Supabase uses): +```bash +# From this repo root: +make postgres-supabase-build + +# If auto-detect fails, set the tag explicitly: +SUPABASE_CLI_IMAGE=public.ecr.aws/supabase/postgres: make postgres-supabase-build +``` +You can also set the Supabase base image tag explicitly (defaults to +`17.6.1.071`). This only affects the base image used in the Dockerfile: +```bash +SUPABASE_POSTGRES_TAG=17.6.1.071 make postgres-supabase-build +``` + +3) Restart Supabase: +```bash +supabase stop +supabase start +``` + +4) Enable the extension: +```bash +psql postgresql://supabase_admin:postgres@127.0.0.1:54322/postgres +``` + +```sql +CREATE EXTENSION IF NOT EXISTS cloudsync; +SELECT cloudsync_version(); +``` + + + +## Option B: Self-Hosted Supabase (Docker Compose / Kubernetes) + +1) Build a custom image based on the Supabase Postgres tag in use: +```bash +# From this repo root: +docker build -f docker/postgresql/Dockerfile.supabase \ + -t myorg/supabase-postgres-cloudsync: . +``` + +2) Update your deployment to use `myorg/supabase-postgres-cloudsync:` +for the database image. + +3) Restart the stack. + +4) Enable the extension: +```sql +CREATE EXTENSION IF NOT EXISTS cloudsync; +SELECT cloudsync_version(); +``` + + + +## Quick Smoke Test + +```sql +CREATE TABLE notes ( + id TEXT PRIMARY KEY NOT NULL, + body TEXT DEFAULT '' +); + +SELECT cloudsync_init('notes'); +INSERT INTO notes VALUES (cloudsync_uuid(), 'hello'); +SELECT * FROM cloudsync_changes; +``` + +You should see one pending change row returned. diff --git a/docs/postgresql/grafana-dashboard.json b/docs/postgresql/grafana-dashboard.json new file mode 100644 index 0000000..ace2326 --- /dev/null +++ b/docs/postgresql/grafana-dashboard.json @@ -0,0 +1,590 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "dfaqrid9yhvk0b" + }, + "description": "N. of distinct devices which made at least one request", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [ + "sum" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "dataset": "postgres", + "editorMode": "code", + "format": "time_series", + "rawQuery": true, + "rawSql": "SELECT\n $__unixEpochGroup(created_at, '1h') AS time,\n COUNT(DISTINCT project_id || '/' || database || '/' || site_id) AS devices\nFROM cloudsync_metrics\nWHERE $__unixEpochFilter(created_at)\n AND ('${ProjectID}' = '' OR project_id ~ '${ProjectID}')\n AND ('${SiteID}' = '' OR site_id ~ '${SiteID}')\nGROUP BY 1\nORDER BY 1;\n", + "refId": "A", + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + } + } + ], + "title": "Activity (distinct devices)", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "dfaqrid9yhvk0b" + }, + "description": "Total number of requests", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [ + "sum" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "dataset": "postgres", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "dfaqrid9yhvk0b" + }, + "editorMode": "code", + "format": "time_series", + "rawQuery": true, + "rawSql": "SELECT\n $__unixEpochGroup(created_at, '1h') AS time,\n COUNT(*) AS requests\nFROM cloudsync_metrics\nWHERE $__unixEpochFilter(created_at)\n AND ('${ProjectID}' = '' OR project_id ~ '${ProjectID}')\n AND ('${SiteID}' = '' OR site_id ~ '${SiteID}')\nGROUP BY 1\nORDER BY 1;\n", + "refId": "A", + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + } + } + ], + "title": "Total Activity", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "dfaqrid9yhvk0b" + }, + "description": "Total amount of traffic in MB", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 5, + "options": { + "legend": { + "calcs": [ + "sum" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "dataset": "postgres", + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n $__unixEpochGroup(created_at, '1h') AS time,\n SUM(bytes_in + bytes_out) / 1024 / 1024 AS megabytes\nFROM cloudsync_metrics\nWHERE $__unixEpochFilter(created_at)\n AND ('${ProjectID}' = '' OR project_id ~ '${ProjectID}')\n AND ('${SiteID}' = '' OR site_id ~ '${SiteID}')\nGROUP BY 1\nORDER BY 1;\n", + "refId": "A", + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + } + } + ], + "title": "Traffic MB (In+Out)", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "dfaqrid9yhvk0b" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "megabytes apply" + }, + "properties": [ + { + "id": "displayName", + "value": "Out (MB)" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "megabytes check" + }, + "properties": [ + { + "id": "displayName", + "value": "In (MB)" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": [ + "sum", + "max" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "dataset": "postgres", + "editorMode": "code", + "format": "time_series", + "rawQuery": true, + "rawSql": "SELECT\n $__unixEpochGroup(created_at, '1h') AS time,\n action,\n SUM(bytes_in + bytes_out) / 1024 / 1024 AS megabytes\nFROM cloudsync_metrics\nWHERE $__unixEpochFilter(created_at)\n AND action ~ '${action:regex}'\n AND ('${ProjectID}' = '' OR project_id ~ '${ProjectID}')\n AND ('${SiteID}' = '' OR site_id ~ '${SiteID}')\nGROUP BY 1, 2\nORDER BY 1;\n", + "refId": "A", + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + } + } + ], + "title": "Traffic MB", + "type": "timeseries" + } + ], + "preload": false, + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [ + { + "allValue": ".*", + "current": { + "text": [ + "check", + "apply" + ], + "value": [ + "check", + "apply" + ] + }, + "definition": "SELECT DISTINCT action\nFROM cloudsync_metrics\nORDER BY action;", + "description": "", + "includeAll": false, + "label": "Action", + "multi": true, + "name": "action", + "options": [], + "query": "SELECT DISTINCT action\nFROM cloudsync_metrics\nORDER BY action;", + "refresh": 2, + "regex": "", + "type": "query" + }, + { + "current": { + "text": "", + "value": "" + }, + "label": "ProjectID", + "name": "ProjectID", + "options": [ + { + "selected": true, + "text": "", + "value": "" + } + ], + "query": "", + "type": "textbox" + }, + { + "current": { + "text": "", + "value": "" + }, + "name": "SiteID", + "options": [ + { + "selected": true, + "text": "", + "value": "" + } + ], + "query": "", + "type": "textbox" + } + ] + }, + "time": { + "from": "now-2d", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "CloudSync", + "uid": "advqx5s", + "version": 40 +} \ No newline at end of file diff --git a/examples/sport-tracker-app/package.json b/examples/sport-tracker-app/package.json index f1a6da7..dc0f068 100644 --- a/examples/sport-tracker-app/package.json +++ b/examples/sport-tracker-app/package.json @@ -13,10 +13,11 @@ "vite": "^7.0.0" }, "dependencies": { - "@sqliteai/sqlite-wasm": "*", + "@sqliteai/sqlite-wasm": "dev", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "pg": "^8.17.2", "react": "^19.1.0", "react-dom": "^19.1.0" } diff --git a/examples/sport-tracker-app/sport-tracker-schema-postgres.sql b/examples/sport-tracker-app/sport-tracker-schema-postgres.sql new file mode 100644 index 0000000..1288f16 --- /dev/null +++ b/examples/sport-tracker-app/sport-tracker-schema-postgres.sql @@ -0,0 +1,29 @@ +-- PostgreSQL schema +-- Use this schema to create the remote database on PostgreSQL/PostgREST + +CREATE TABLE IF NOT EXISTS users_sport ( + id TEXT PRIMARY KEY NOT NULL, -- UUID's HIGHLY RECOMMENDED for global uniqueness + name TEXT UNIQUE NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS activities ( + id TEXT PRIMARY KEY NOT NULL, -- UUID's HIGHLY RECOMMENDED for global uniqueness + type TEXT NOT NULL DEFAULT 'runnning', + duration INTEGER, + distance DOUBLE PRECISION, + calories INTEGER, + date TEXT, + notes TEXT, + user_id TEXT REFERENCES users_sport (id) +); + +CREATE TABLE IF NOT EXISTS workouts ( + id TEXT PRIMARY KEY NOT NULL, -- UUID's HIGHLY RECOMMENDED for global uniqueness + name TEXT, + type TEXT, + duration INTEGER, + exercises TEXT, + date TEXT, + completed INTEGER DEFAULT 0, + user_id TEXT +); diff --git a/examples/sport-tracker-app/sport-tracker-schema.sql b/examples/sport-tracker-app/sport-tracker-schema.sql index 9e8ce1a..8877730 100644 --- a/examples/sport-tracker-app/sport-tracker-schema.sql +++ b/examples/sport-tracker-app/sport-tracker-schema.sql @@ -1,7 +1,7 @@ -- SQL schema -- Use this exact schema to create the remote database on the on SQLite Cloud -CREATE TABLE IF NOT EXISTS users ( +CREATE TABLE IF NOT EXISTS users_sport ( id TEXT PRIMARY KEY NOT NULL, -- UUID's HIGHLY RECOMMENDED for global uniqueness name TEXT UNIQUE NOT NULL DEFAULT '' ); @@ -15,7 +15,7 @@ CREATE TABLE IF NOT EXISTS activities ( date TEXT, notes TEXT, user_id TEXT, - FOREIGN KEY (user_id) REFERENCES users (id) + FOREIGN KEY (user_id) REFERENCES users_sport (id) ); CREATE TABLE IF NOT EXISTS workouts ( diff --git a/examples/sport-tracker-app/src/SQLiteSync.ts b/examples/sport-tracker-app/src/SQLiteSync.ts index 3140b25..0b639d6 100644 --- a/examples/sport-tracker-app/src/SQLiteSync.ts +++ b/examples/sport-tracker-app/src/SQLiteSync.ts @@ -111,21 +111,23 @@ export class SQLiteSync { const now = new Date(); if (!token) { - console.log("SQLite Sync: No token available, requesting new one from API"); - const tokenData = await this.fetchNewToken(userId, name); - localStorage.setItem( - SQLiteSync.TOKEN_KEY_PREFIX, - JSON.stringify(tokenData) + console.log( + "SQLite Sync: No token available, requesting new one from API", ); + const tokenData = await this.fetchNewToken(userId, name); + // localStorage.setItem( + // SQLiteSync.TOKEN_KEY_PREFIX, + // JSON.stringify(tokenData), + // ); token = tokenData.token; console.log("SQLite Sync: New token obtained and stored in localStorage"); } else if (tokenExpiry && tokenExpiry <= now) { console.warn("SQLite Sync: Token expired, requesting new one from API"); const tokenData = await this.fetchNewToken(userId, name); - localStorage.setItem( - SQLiteSync.TOKEN_KEY_PREFIX, - JSON.stringify(tokenData) - ); + // localStorage.setItem( + // SQLiteSync.TOKEN_KEY_PREFIX, + // JSON.stringify(tokenData), + // ); token = tokenData.token; console.log("SQLite Sync: New token obtained and stored in localStorage"); } else { @@ -143,69 +145,18 @@ export class SQLiteSync { */ private async fetchNewToken( userId: string, - name: string + name: string, ): Promise> { - const response = await fetch( - `${import.meta.env.VITE_SQLITECLOUD_API_URL}/v2/tokens`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${import.meta.env.VITE_SQLITECLOUD_API_KEY}`, - }, - body: JSON.stringify({ - userId, - name, - expiresAt: new Date( - Date.now() + SQLiteSync.TOKEN_EXPIRY_MINUTES * 60 * 1000 - ).toISOString(), - }), - } - ); - - if (!response.ok) { - throw new Error(`Failed to get token: ${response.status}`); - } - - const result = await response.json(); - return result.data; + const jwt = await Promise.resolve(import.meta.env.VITE_SQLITECLOUD_API_KEY); + return { + token: jwt + }; } /** * Checks if a valid token exists in localStorage */ static hasValidToken(): boolean { - const storedTokenData = localStorage.getItem(SQLiteSync.TOKEN_KEY_PREFIX); - - if (!storedTokenData) { - console.log("SQLite Sync: No token data found in localStorage"); - return false; - } - - try { - const parsed: TokenData = JSON.parse(storedTokenData); - - // Check if token exists - if (!parsed.token) { - console.log("SQLite Sync: Token data exists but no token found"); - return false; - } - - // Check if token is expired - if (parsed.expiresAt) { - const tokenExpiry = new Date(parsed.expiresAt); - const now = new Date(); - if (tokenExpiry <= now) { - console.log("SQLite Sync: Token found but expired"); - return false; - } - } - - console.log("SQLite Sync: Valid token found in localStorage"); - return true; - } catch (e) { - console.error("SQLite Sync: Failed to parse stored token:", e); - return false; - } + return false; } } diff --git a/examples/sport-tracker-app/src/components/UserCreation.tsx b/examples/sport-tracker-app/src/components/UserCreation.tsx index 05defb1..fd90b15 100644 --- a/examples/sport-tracker-app/src/components/UserCreation.tsx +++ b/examples/sport-tracker-app/src/components/UserCreation.tsx @@ -15,26 +15,22 @@ interface UserCreationProps { */ const fetchRemoteUsers = async (): Promise => { const response = await fetch( - `${import.meta.env.VITE_SQLITECLOUD_API_URL}/v2/weblite/sql`, + `${import.meta.env.VITE_SQLITECLOUD_API_URL}/rest/v1/users_sport?select=id,name`, { - method: "POST", + method: "GET", headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${import.meta.env.VITE_SQLITECLOUD_API_KEY}`, + Accept: "application/json", + Authorization: `Bearer ${import.meta.env.VITE_SQLITECLOUD_API_KEY || ""}`, }, - body: JSON.stringify({ - sql: "SELECT id, name FROM users;", - database: import.meta.env.VITE_SQLITECLOUD_DATABASE || "", - }), } ); - + if (!response.ok) { throw new Error(`Failed to fetch users: ${response.status}`); } const result = await response.json(); - return result.data; + return result as User[]; }; const UserCreation: React.FC = ({ @@ -58,6 +54,7 @@ const UserCreation: React.FC = ({ setRemoteUsers(users); } catch (error) { console.error("Failed to load remote users:", error); + alert("Failed to load remote users. Error: " + error); } finally { setIsLoadingRemoteUsers(false); } diff --git a/examples/sport-tracker-app/src/db/databaseOperations.ts b/examples/sport-tracker-app/src/db/databaseOperations.ts index 5bae19a..2413d27 100644 --- a/examples/sport-tracker-app/src/db/databaseOperations.ts +++ b/examples/sport-tracker-app/src/db/databaseOperations.ts @@ -216,7 +216,7 @@ export const getDatabaseOperations = (db: any) => ({ try { db.exec({ - sql: "INSERT INTO users (id, name) VALUES (?, ?)", + sql: "INSERT INTO users_sport (id, name) VALUES (?, ?)", bind: [userId, name], }); return { id: userId, name }; @@ -228,7 +228,7 @@ export const getDatabaseOperations = (db: any) => ({ getUsers() { const users: any[] = []; db.exec({ - sql: "SELECT id, name FROM users ORDER BY name", + sql: "SELECT id, name FROM users_sport ORDER BY name", callback: (row: any) => { users.push({ id: row[0], @@ -243,7 +243,7 @@ export const getDatabaseOperations = (db: any) => ({ const { id } = data; let user = null; db.exec({ - sql: "SELECT id, name FROM users WHERE id = ?", + sql: "SELECT id, name FROM users_sport WHERE id = ?", bind: [id], callback: (row: any) => { user = { @@ -268,7 +268,7 @@ export const getDatabaseOperations = (db: any) => ({ // Get total counts (always show total for comparison) db.exec({ - sql: "SELECT COUNT(*) FROM users WHERE name != ?", + sql: "SELECT COUNT(*) FROM users_sport WHERE name != ?", bind: ["coach"], callback: (row: any) => (counts.totalUsers = row[0]), }); @@ -287,7 +287,7 @@ export const getDatabaseOperations = (db: any) => ({ if (user_id && !is_coach) { // Regular user - count only their data db.exec({ - sql: "SELECT COUNT(*) FROM users WHERE name != ?", + sql: "SELECT COUNT(*) FROM users_sport WHERE name != ?", bind: ["coach"], callback: (row: any) => (counts.users = row[0]), }); diff --git a/examples/sport-tracker-app/src/db/sqliteSyncOperations.ts b/examples/sport-tracker-app/src/db/sqliteSyncOperations.ts index 97329db..90e8982 100644 --- a/examples/sport-tracker-app/src/db/sqliteSyncOperations.ts +++ b/examples/sport-tracker-app/src/db/sqliteSyncOperations.ts @@ -26,7 +26,7 @@ export const getSqliteSyncOperations = (db: any) => ({ * This will push changes to the cloud and check for changes from the cloud. * The first attempt may not find anything to apply, but subsequent attempts * will find changes if they exist. - */ + */ sqliteSyncNetworkSync() { console.log("SQLite Sync - Starting sync..."); db.exec("SELECT cloudsync_network_sync(1000, 2);"); @@ -38,7 +38,7 @@ export const getSqliteSyncOperations = (db: any) => ({ */ sqliteSyncSendChanges() { console.log( - "SQLite Sync - Sending changes to your the SQLite Cloud node..." + "SQLite Sync - Sending changes to your the SQLite Cloud node...", ); db.exec("SELECT cloudsync_network_send_changes();"); console.log("SQLite Sync - Changes sent"); @@ -84,7 +84,7 @@ export const initSQLiteSync = (db: any) => { } // Initialize SQLite Sync - db.exec(`SELECT cloudsync_init('users');`); + db.exec(`SELECT cloudsync_init('users_sport');`); db.exec(`SELECT cloudsync_init('activities');`); db.exec(`SELECT cloudsync_init('workouts');`); // ...or initialize all tables at once diff --git a/examples/to-do-app/plugins/CloudSyncSetup.js b/examples/to-do-app/plugins/CloudSyncSetup.js index f483901..e523460 100644 --- a/examples/to-do-app/plugins/CloudSyncSetup.js +++ b/examples/to-do-app/plugins/CloudSyncSetup.js @@ -50,7 +50,7 @@ async function getLatestReleaseUrl(asset_pattern) { return new Promise((resolve, reject) => { const options = { hostname: 'api.github.com', - path: '/repos/sqliteai/sqlite-sync/releases/latest', + path: '/repos/sqliteai/sqlite-sync-dev/releases/latest', headers: { 'User-Agent': 'expo-cloudsync-plugin' } @@ -281,4 +281,4 @@ const withCloudSync = (config) => { return config; }; -module.exports = withCloudSync; \ No newline at end of file +module.exports = withCloudSync; diff --git a/packages/node/README.md b/packages/node/README.md index 25a278a..934a447 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -136,6 +136,8 @@ Error thrown when the SQLite Sync extension cannot be found for the current plat ## Related Projects +- **[@sqliteai/sqlite-sync-react-native](https://www.npmjs.com/package/@sqliteai/sqlite-sync-react-native)** - SQLite Sync for React Native +- **[@sqliteai/sqlite-sync-expo-dev](https://www.npmjs.com/package/@sqliteai/sqlite-sync-expo-dev)** - SQLite Sync for Expo - **[@sqliteai/sqlite-vector](https://www.npmjs.com/package/@sqliteai/sqlite-vector)** - Vector search and similarity matching - **[@sqliteai/sqlite-ai](https://www.npmjs.com/package/@sqliteai/sqlite-ai)** - On-device AI inference and embedding generation - **[@sqliteai/sqlite-js](https://www.npmjs.com/package/@sqliteai/sqlite-js)** - Define SQLite functions in JavaScript diff --git a/src/cloudsync.h b/src/cloudsync.h index 368dfeb..05f29b2 100644 --- a/src/cloudsync.h +++ b/src/cloudsync.h @@ -17,7 +17,7 @@ extern "C" { #endif -#define CLOUDSYNC_VERSION "0.9.96" +#define CLOUDSYNC_VERSION "0.9.98" #define CLOUDSYNC_MAX_TABLENAME_LEN 512 #define CLOUDSYNC_VALUE_NOTSET -1 diff --git a/src/postgresql/cloudsync--1.0.sql b/src/postgresql/cloudsync--1.0.sql index 22418fe..7d4517c 100644 --- a/src/postgresql/cloudsync--1.0.sql +++ b/src/postgresql/cloudsync--1.0.sql @@ -22,7 +22,7 @@ LANGUAGE C STABLE; -- Generate a new UUID CREATE OR REPLACE FUNCTION cloudsync_uuid() -RETURNS bytea +RETURNS uuid AS 'MODULE_PATHNAME', 'cloudsync_uuid' LANGUAGE C VOLATILE; diff --git a/src/postgresql/cloudsync_postgresql.c b/src/postgresql/cloudsync_postgresql.c index c35d1a2..fb86df8 100644 --- a/src/postgresql/cloudsync_postgresql.c +++ b/src/postgresql/cloudsync_postgresql.c @@ -176,15 +176,17 @@ PG_FUNCTION_INFO_V1(cloudsync_uuid); Datum cloudsync_uuid (PG_FUNCTION_ARGS) { UNUSED_PARAMETER(fcinfo); - uint8_t uuid[UUID_LEN]; - cloudsync_uuid_v7(uuid); + uint8_t uuid_bytes[UUID_LEN]; + cloudsync_uuid_v7(uuid_bytes); - // Return as bytea - bytea *result = (bytea *)palloc(VARHDRSZ + UUID_LEN); - SET_VARSIZE(result, VARHDRSZ + UUID_LEN); - memcpy(VARDATA(result), uuid, UUID_LEN); + // Format as text with dashes (matches SQLite implementation) + char uuid_str[UUID_STR_MAXLEN]; + cloudsync_uuid_v7_stringify(uuid_bytes, uuid_str, true); - PG_RETURN_BYTEA_P(result); + // Parse into PostgreSQL UUID type + Datum uuid_datum = DirectFunctionCall1(uuid_in, CStringGetDatum(uuid_str)); + + PG_RETURN_DATUM(uuid_datum); } // cloudsync_db_version() - Get current database version diff --git a/test/postgresql/01_unittest.sql b/test/postgresql/01_unittest.sql index 9210088..2394031 100644 --- a/test/postgresql/01_unittest.sql +++ b/test/postgresql/01_unittest.sql @@ -19,12 +19,43 @@ CREATE EXTENSION IF NOT EXISTS cloudsync; SELECT cloudsync_version() AS version \gset \echo [PASS] (:testid) Test cloudsync_version: :version --- 'Test uuid generation' -SELECT (length(cloudsync_uuid()) > 0) AS uuid_ok \gset -\if :uuid_ok -\echo [PASS] (:testid) Test uuid generation +-- Test uuid generation +SELECT cloudsync_uuid() AS uuid1 \gset +SELECT cloudsync_uuid() AS uuid2 \gset + +-- Test 1: Format check (UUID v7 has standard format: xxxxxxxx-xxxx-7xxx-xxxx-xxxxxxxxxxxx) +SELECT (:'uuid1' ~ '^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$') AS uuid_format_ok \gset +\if :uuid_format_ok +\echo [PASS] (:testid) UUID format valid (UUIDv7 pattern) +\else +\echo [FAIL] (:testid) UUID format invalid - Got: :uuid1 +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Test 2: Uniqueness check +SELECT (:'uuid1' != :'uuid2') AS uuid_unique_ok \gset +\if :uuid_unique_ok +\echo [PASS] (:testid) UUID uniqueness (two calls generated different UUIDs) +\else +\echo [FAIL] (:testid) UUID uniqueness - Both calls returned: :uuid1 +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Test 3: Monotonicity check (UUIDv7 should be sortable by timestamp) +SELECT (:'uuid1' < :'uuid2') AS uuid_monotonic_ok \gset +\if :uuid_monotonic_ok +\echo [PASS] (:testid) UUID monotonicity (UUIDs are time-ordered) +\else +\echo [FAIL] (:testid) UUID monotonicity - uuid1: :uuid1, uuid2: :uuid2 +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Test 4: Type check (ensure it's actually UUID type, not text) +SELECT (pg_typeof(cloudsync_uuid())::text = 'uuid') AS uuid_type_ok \gset +\if :uuid_type_ok +\echo [PASS] (:testid) UUID type is correct (uuid, not text or bytea) \else -\echo [FAIL] (:testid) Test uuid generation +\echo [FAIL] (:testid) UUID type incorrect - Got: (pg_typeof(cloudsync_uuid())::text) SELECT (:fail::int + 1) AS fail \gset \endif diff --git a/test/postgresql/14_datatype_roundtrip.sql b/test/postgresql/14_datatype_roundtrip.sql index 72a03ae..b4efb76 100644 --- a/test/postgresql/14_datatype_roundtrip.sql +++ b/test/postgresql/14_datatype_roundtrip.sql @@ -399,6 +399,6 @@ SELECT (:fail::int + 1) AS fail \gset \ir helper_test_cleanup.sql \if :should_cleanup --- DROP DATABASE IF EXISTS cloudsync_test_14a; --- DROP DATABASE IF EXISTS cloudsync_test_14b; +DROP DATABASE IF EXISTS cloudsync_test_14a; +DROP DATABASE IF EXISTS cloudsync_test_14b; \endif diff --git a/test/postgresql/full_test.sql b/test/postgresql/full_test.sql index cf681d3..9927026 100644 --- a/test/postgresql/full_test.sql +++ b/test/postgresql/full_test.sql @@ -22,7 +22,7 @@ \ir 12_repeated_table_multi_schemas.sql \ir 13_per_table_schema_tracking.sql \ir 14_datatype_roundtrip.sql --- \ir 15_datatype_roundtrip_unmapped.sql +\ir 15_datatype_roundtrip_unmapped.sql \ir 16_composite_pk_text_int_roundtrip.sql -- 'Test summary'