diff --git a/.env b/.env new file mode 100644 index 0000000..1da5c41 --- /dev/null +++ b/.env @@ -0,0 +1,43 @@ +# Selects the run mode. Uncomment exactly one. +# +# The command is always a plain `docker compose up` — switching modes is this one line, so every +# other compose subcommand keeps working the way you expect. +COMPOSE_FILE=docker-compose.yaml:examples/postgres/compose.yaml +# COMPOSE_FILE=docker-compose.yaml:examples/mongodb/compose.yaml +# COMPOSE_FILE=docker-compose.yaml:examples/mysql/compose.yaml +# COMPOSE_FILE=docker-compose.yaml:examples/mssql/compose.yaml +# COMPOSE_FILE=docker-compose.yaml # Adopter Mode — your own database +# +# Append :docker-compose.dev.yaml to any of the above for the development loop, e.g. +# COMPOSE_FILE=docker-compose.yaml:examples/postgres/compose.yaml:docker-compose.dev.yaml + +# DATABASE_TYPE is one of: postgres, mongodb, mysql, mssql +# +# Careful: PowerSync's own replication config in config/service.yaml spells the Postgres +# connector `postgresql`. This variable is read by the write API, which spells it `postgres`. +# Get it wrong and the backend refuses to start and tells you the supported values. +# +# DATABASE_URI shapes, one per type: +# postgres postgres://user:password@host:5432/database +# mongodb mongodb://user:password@host:27017/database +# mysql mysql://user:password@host:3306/database +# mssql mssql://user:password@host:1433/database +# +# A database running on this machine rather than in Docker is reachable at host.docker.internal, +# not localhost — inside a container, localhost is the container. + +# Adopter Mode only. Example Mode overrides both of these with the bundled database. +# DATABASE_TYPE=postgres +# DATABASE_URI= + +# --------------------------------------------------------------------------------------------- +# THROWAWAY SIGNING KEYS — PUBLIC, COMMITTED, AND KNOWN TO EVERYONE WHO HAS CLONED THIS REPO. +# +# They are here so the backend signs with a stable key across restarts. Without that, every +# restart mints a new key and PowerSync rejects tokens it just accepted with +# "PSYNC_S2101 — Could not find an appropriate key in the keystore". +# +# Replace them before this is anything other than a demo: `cd backend && pnpm generate-keys`. +# --------------------------------------------------------------------------------------------- +POWERSYNC_PRIVATE_KEY=eyJrdHkiOiJSU0EiLCJuIjoiNnJPRTVEdWRnbm5TYVh0NlZoVDNUQkpzRzNRdFVFQjA2N3dNZ29ocWstc3pvN3J3bVhOdTctdVJ4REM1OGdHYjBTVTM5R0tmNjVXMTlyVDhBSGVYNXg5bS1IbFUzZTdZMnYtanJLOW5OaGxlTUpfY3NzdElKdFJaaDlsYUJVb2ExejBtRVkzaHBMei1oRU1HNG5XQ3dZV1Q3SzZsR3lVYTFHOTRRSnYxaG5kZVkydmlGMWpMRHVERE1EckhwWkM4NEo3TWgxMkFpVXBUTkwxcWY0ckNoWEE2YXBWOW5DWmRzMERqcWhGUmFEWmhRUkR1MEJVZ1FRSFhkQWQ5X3hrX1JxYi1DQVVHX2tsSmVSRXlWU2p1LUV4M2tJWWlGMjFhSFNOWDFRN3c2amlKUWFnN25Bc2NmelEySVEwSFp0aGQ0Q3lTUzhHbE1RMFkwUFIyM09IYjBRIiwiZSI6IkFRQUIiLCJkIjoiSlFCQVJ5c08zZThPdVFwNmN1X0RQUDc2aENtQXExSS1ISnY4N09kTXhoMGlld0dSeE45cDhmRVZmZlNnbkFLYzZoQVFEanN1TXhuYkloWE9WTlNGNGk0Vk1iOFBIaDMxbWpFTFFNSTJaMVVBZ0hIemZVeUhCM2dhMVV2eTRUcVptSzFQUHgwN0labWFGb2Zxb2ZFY3VCMnpBSEZZSGp0dlMyWjNjdGdqa1JzYlBwbVloUWFoNUJWTFhlSmFEbTJHcTM3blBHZFRfa1pPN01KeVNRckVMTmxOMDZMNmJfMWk5RHRHc2d2WG5ZRmhWOUVPXzBpUk9BOTFDcTJCZzRHMHkzUF8yX0ZQMjRZNW9tQ2NDWGVTR0xrVmZsMVZBWmlhaXltTHVva05UZ3lnVnpYZHdYMnFKeGIwbk9kMmJCUHBvSm15Mnk2dmUwVGpoUzNOV2dCVzhRIiwicCI6Ii1LVWhXQW1RY2FxNFp4cEhWYnRlVmloREV4OU1TZm5kM0Q2TmZMUmYxMy1uZ192VE1JdHJkM2gyRjBuMjVTNmpLckhLQmlJUkNaOW9zcFdfSElCR1BwcGxYalIyaWI5R3BSdkdHeG4tMm5IM3pzcVZ3MjA1ODVqRWFkSElLa3hscnlIRks4b2xlSVc1ZUpzN0VkOU9ZOWxvS21LenU5emFyRUx4ZWpVLUFzOCIsInEiOiI4YVRNbDhQZUxoeTRSOG5rTHY4ZWpWajUxZDQ5elhiaS1GS2ZXZ2l3U2hRRTU2cXpTTHJuMG5kOXQ3cFVRdlpCUnk0d2NMLXJFMWhXNlZtRERJaU9hVTNKQm1mLVk0NVljVFYtcXNENDFXSjVMUC1fdVBydmFHZnNsYlJFVFZRaks3SWVOTTJfc0wyd1hhX3dtY1B3SGNyU2w0OXJ6U0lqTVBCTHE2X3NYMTgiLCJkcCI6InZxR3NETmxZV3kxdXItMmYzNFVGOEx4eG9JbVFiZThhUUNfZFBrejBaajVDNnBmNTlQQVBkc3R1anJCd2tJblBJMzZueTBmM0ZBLVpyOEhMZ2toLWtxVEJMeGEtQXlJWlFhRW5vOE9zZDBLRm9aQUVmbzZScmNma1h0VXR4X0JHelp6d2xJQXBkbHZnTlMyZWZqZGMzSVRrcmdwNmpuX25UOGNMYUl6RmZGVSIsImRxIjoiRXVzdkpYYXRUM2pxS0p5eTQ4Y1Bra3QwQ18yQll6TzZvMng4azJUNHdHUC0ybEJ3QnZLek1iUXZRSkl6QktjWkIwU2pnRUJSV1l0aUNwVDZnS0cwWEtROVotWC1jYmIwVDdDN2dRem9yblF1UG9xcmJRVWdkMUVqb2JqaVhCZUpSV09Gbi1hMzZsTl9tbVlxOVM3MF9yQWhlc0k4MDJ1bnk2NFVqcFdRY1FzIiwicWkiOiJmeV9GT2c4QzFvZG01c1lOejJkSEN4X0JtODdxYXNueG5WUXZYWWlZeXFHSlZrVy1ybkYtVHI4dl95UDZnWTBLNkFwQ1VrOV9yQlVVUEppU2pVcVJhMk0zaUlCQm9LUXVHM0lXQXRKOHc2RThRMVg1Z19kNlRXTnhxYmxRVlY4My11Szhtd25PNUpETzM3NXFCeVRaZkpWUUExSHVHV2ZEUld0cnlNSlNOdG8iLCJhbGciOiJSUzI1NiIsImtpZCI6InBvd2Vyc3luYy0yMTIyNjM5NzY1In0= +POWERSYNC_PUBLIC_KEY=eyJrdHkiOiJSU0EiLCJuIjoiNnJPRTVEdWRnbm5TYVh0NlZoVDNUQkpzRzNRdFVFQjA2N3dNZ29ocWstc3pvN3J3bVhOdTctdVJ4REM1OGdHYjBTVTM5R0tmNjVXMTlyVDhBSGVYNXg5bS1IbFUzZTdZMnYtanJLOW5OaGxlTUpfY3NzdElKdFJaaDlsYUJVb2ExejBtRVkzaHBMei1oRU1HNG5XQ3dZV1Q3SzZsR3lVYTFHOTRRSnYxaG5kZVkydmlGMWpMRHVERE1EckhwWkM4NEo3TWgxMkFpVXBUTkwxcWY0ckNoWEE2YXBWOW5DWmRzMERqcWhGUmFEWmhRUkR1MEJVZ1FRSFhkQWQ5X3hrX1JxYi1DQVVHX2tsSmVSRXlWU2p1LUV4M2tJWWlGMjFhSFNOWDFRN3c2amlKUWFnN25Bc2NmelEySVEwSFp0aGQ0Q3lTUzhHbE1RMFkwUFIyM09IYjBRIiwiZSI6IkFRQUIiLCJhbGciOiJSUzI1NiIsImtpZCI6InBvd2Vyc3luYy0yMTIyNjM5NzY1In0= diff --git a/.gitignore b/.gitignore index 574dccc..f21396f 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ docs/agents/ # Per-repo agent instructions CLAUDE.md + +# Dependencies. Lockfiles stay tracked — the Dockerfiles install with --frozen-lockfile. +node_modules/ diff --git a/README.md b/README.md index 597db39..25507e1 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,169 @@ -# PowerSync Write API Demo +# PowerSync Write API -![Architecture diagram](./diagram.png) +A self-hostable backend for the PowerSync write path: a client uploads its queued local changes to +an HTTP API, which persists them to your source database. PowerSync replicates that database back +to clients. -## Project Layout +Clone it, point it at your own database, and change the code. + +## Quickstart — see it work + +This brings up a complete, self-contained system with **nothing for you to configure**: a seeded +Postgres, the PowerSync service, the write API, and a small demo client. The committed `.env` +already holds everything it needs, including a throwaway signing keypair. + +```bash +docker compose up --build +``` + +- Demo client: http://localhost:5173 +- Write API: http://localhost:6060 +- PowerSync: http://localhost:8080 +- Example Postgres: localhost:5432 + +Open the demo client, add a todo, and watch it land in Postgres and sync back. + +**This is a smoke test, not the product.** The todo schema, the seed data and the demo client are +scaffolding to prove the machinery works before you wire in anything of your own. Everything +specific to it lives under `examples/` and can be deleted in one go. + +## Point it at your own database + +Edit `.env` and select Adopter Mode: + +```bash +COMPOSE_FILE=docker-compose.yaml +DATABASE_TYPE=postgres +DATABASE_URI=postgres://user:password@your-host:5432/your-db +``` + +### Your database needs preparing first + +PowerSync replicates by reading your database's change feed, and every flavour needs that turned +on before anything syncs. This is the part that silently produces an empty app if skipped. + +| Flavour | What must be true of your database | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Postgres** | a publication named `powersync` covering the replicated tables; a user with `SELECT` on them and replication rights. **A Postgres source without a publication replicates nothing.** | +| **MongoDB** | A replica set — change streams and the multi-document transactions the write API uses both require one. Post-images configured (`post_images: auto_configure`), since change streams alone do not carry the pre-update document. | +| **MySQL** | `log_bin` on, `gtid_mode=ON`, `enforce_gtid_consistency=ON`, `binlog_format=ROW`, `binlog_row_image=FULL`, a unique `server-id`; a user with `REPLICATION SLAVE` and `SELECT`. On managed MySQL these usually live in a parameter group and need a restart. | +| **SQL Server** | CDC enabled at database level and per replicated table; a CDC-enabled `_powersync_checkpoints` table; SQL Server Agent **running**, or CDC captures nothing while appearing enabled; the user needs `cdc_reader`, `VIEW DATABASE PERFORMANCE STATE` in the database, and `VIEW SERVER PERFORMANCE STATE` in `master`. | + +Each `examples//README.md` has the worked SQL and the managed-hosting wrinkles. Those +live under `examples/`, which you are invited to delete — so the table above is the version that +survives that, deliberately. + +Then describe your own schema in `config/service.yaml` and `config/sync-config.yaml`. Those two +files are yours from the first minute — no example ever writes to them. + +**Fill in `config/sync-config.yaml` before you start.** It ships empty, because only you know your +schema, and PowerSync will restart in a loop logging `'streams' are required` until it has at +least one stream. Remember `auto_subscribe: true` — without it a stream syncs nothing and reports +no error anywhere. + +If your database runs on this machine rather than in Docker, reach it at `host.docker.internal`, +not `localhost` — inside a container, `localhost` is the container. + +In Adopter Mode there is no bundled database and no demo client. Bring your own client. + +> Bucket storage — PowerSync's own internal store — always runs in a container this project owns, +> in every mode. We never create schemas in a database you merely pointed us at. + +## Switching modes + +Mode selection is the `COMPOSE_FILE` line in `.env`, with the alternatives sitting there commented +out. The command stays a plain `docker compose up`, so `down`, `logs` and `ps` behave normally. + +| `.env` line | What runs | +| ---------------------------------------------------- | ------------------------------------------------------------- | +| `docker-compose.yaml:examples/postgres/compose.yaml` | Example Mode, [Postgres](./examples/postgres/README.md) | +| `docker-compose.yaml:examples/mongodb/compose.yaml` | Example Mode, [MongoDB](./examples/mongodb/README.md) | +| `docker-compose.yaml:examples/mysql/compose.yaml` | Example Mode, [MySQL](./examples/mysql/README.md) (Beta) | +| `docker-compose.yaml:examples/mssql/compose.yaml` | Example Mode, [SQL Server](./examples/mssql/README.md) (Beta) | +| `docker-compose.yaml` | Adopter Mode, your database | + +Only one runs at a time — they share ports, and each has its own Compose project name so switching +never reuses the previous flavour's volumes. + +**Bring the current mode down before switching.** Because each mode is its own Compose project, +`docker compose down` only stops the mode currently selected in `.env`. Edit the line first and the +old containers keep running and holding ports, and the new mode fails with +`Bind for 0.0.0.0:6060 failed: port is already allocated`. Down first, then switch. + +If you would rather be explicit, the same thing without `.env`: + +```bash +docker compose -f docker-compose.yaml -f examples/postgres/compose.yaml up +``` + +## Layout ``` write-api/ -├── docker-compose.yaml # Postgres + MongoDB + PowerSync -├── config/ -│ ├── powersync.yaml # PowerSync service config -│ └── sync_rules.yaml # Sync rules (lists + todos) -├── init-scripts/ -│ └── setup.sql # DB schema + seed data -├── powersync-nodejs-backend-todolist-demo/ # Backend (Express, port 6060) -│ └── .env -└── demo-app/ # Frontend (React/Vite, port 5173) - └── .env.local +├── docker-compose.yaml # Base: PowerSync, bucket storage, write API +├── .env # Mode selection and throwaway dev keys +├── config/ # ADOPTER MODE config — yours to edit +│ ├── service.yaml +│ └── sync-config.yaml +├── docker-compose.dev.yaml # Overlay: edit backend code without rebuilding +├── examples/ # Delete this when you no longer need it +│ ├── postgres/ # Seeded Postgres + demo client +│ │ ├── compose.yaml +│ │ ├── powersync/ # This example's PowerSync config +│ │ └── init-scripts/ # Demo schema + seed data +│ ├── mongodb/ # No source container — shares the bucket-storage replica set +│ ├── mysql/ # Binlog config + seeded schema +│ └── mssql/ # CDC bootstrap container + seeded schema +├── backend/ # The write API (Express, port 6060) +│ └── openapi.yaml # Shared contract, read by both packages +└── frontend/ # Demo client (React/Vite) — a test fixture ``` -## Running Everything +## Changing the backend + +Append the development overlay to whichever mode you are in: ```bash -# 1. Start infrastructure (Postgres, Mongo, PowerSync) -docker compose up -d +COMPOSE_FILE=docker-compose.yaml:examples/postgres/compose.yaml:docker-compose.dev.yaml +``` + +Your working tree is mounted into the container and the process restarts on save — an edit is +serving in about two seconds, with no image rebuild. It works in Adopter Mode too, which is +arguably where it matters more: wiring this into your own database is exactly when you are editing +`src/persistance/` and `src/auth/verifier.ts`. -# 2. Start backend (new terminal) -cd backend && pnpm install && pnpm start +Without the overlay, changes ship on rebuild — the deployment-shaped path: -# 3. Start frontend (new terminal) -cd frontend && pnpm install && pnpm dev +```bash +docker compose up --build ``` -- Frontend: http://localhost:5173 -- Backend: http://localhost:6060 -- PowerSync: http://localhost:8080 -- Postgres: localhost:5432 +The demo client is a Vite app, so its own loop is the usual one, on the host: + +```bash +cd frontend && pnpm dev +``` + +That reads `.env.local` at runtime, so changing a URL needs no rebuild. In the container the client +is a production build with its URLs baked in, which is why it is not part of the overlay. -## Generating Types from OpenAPI Spec +Auth seams worth knowing: `backend/src/auth/verifier.ts` is where you swap the demo's tokens for +your own identity provider — see [auth-verifiers.md](./auth-verifiers.md) for worked examples. -Both the backend and frontend generate TypeScript types from the shared `openapi.yaml` spec. +Replacing the throwaway signing keys is one command: ```bash -# Backend (generates src/generated/api.ts) -cd backend && pnpm generate-types +cd backend && pnpm generate-keys # prints both values for .env +``` + +> The signing keys in `.env` are a **public throwaway pair**, committed so the backend signs +> consistently across restarts. Replace them before this is anything but a demo. + +## Generating types from the contract -# Frontend (generates src/generated/api.d.ts) -cd frontend && pnpm generate +Both packages generate TypeScript from `backend/openapi.yaml`: + +```bash +cd backend && pnpm generate-types # -> src/generated/api.ts +cd frontend && pnpm generate # -> src/generated/api.d.ts ``` diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..3fd4213 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,17 @@ +# Without this, `COPY / ./` overwrites the node_modules installed in the image with the host's — +# macOS-native binaries landing in a Linux container. +node_modules + +# Secrets and local state must never reach the image. +.env +.envrc + +# Build and run artefacts. +*.tsbuildinfo +*.log +dist + +# Not needed at runtime. +Dockerfile +.dockerignore +**/*.test.ts diff --git a/backend/.env.template b/backend/.env.template index 6c5331f..e5e7665 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -1,8 +1,24 @@ +# You probably do not need this file. +# +# The backend runs in Docker Compose, where these come from the root .env and the example +# overlays. See the root README for the two run modes. +# +# It is here for the unusual case of running the backend directly on the host with `pnpm start` — +# note that this collides with the containerised backend on port 6060, so stop that first. + +# One of: postgres, mongodb, mysql, mssql +DATABASE_TYPE=postgres +# Required. The backend refuses to start without it. +DATABASE_URI= + +PORT=6060 + +# Audience and issuer for the tokens this backend mints. +POWERSYNC_URL=powersync-dev +JWT_ISSUER=powersync-dev + +# Base64-encoded JWKs. Leave blank and a temporary pair is generated at boot — convenient for a +# one-off run, but every restart invalidates tokens PowerSync has already accepted. Generate a +# stable pair with `pnpm generate-keys`. POWERSYNC_PRIVATE_KEY= POWERSYNC_PUBLIC_KEY= -POWERSYNC_URL= -PORT= -JWT_ISSUER= -# Either 'mongodb', 'mysql', 'mssql' or 'postgres'. This defaults to Postgres -DATABASE_TYPE= -DATABASE_URI= diff --git a/backend/README.md b/backend/README.md index 7caf29e..9a5b8b4 100644 --- a/backend/README.md +++ b/backend/README.md @@ -25,98 +25,77 @@ The endpoints are as follows: ### Error classification -Every failure is sorted into one of two kinds, in `src/persistance/classify-error.ts`: +Every failure is sorted into one of two kinds. `src/errors.ts` defines the two, and each +supported database maps its driver's errors onto them in its own `*-errors.ts` under +`src/persistance/`: - **retryable** — the environment misbehaved (deadlock, lock timeout, connection loss, resource exhaustion). The client uploads the transaction again after a delay. - **fatal** — the data is wrong and can never be stored (missing required field, constraint violation, malformed or out-of-range value, schema mismatch). The client discards the transaction. ## Packages -[node-postgres](https://github.com/brianc/node-postgres) is used to interact with the Postgres database when a client performs requests to the `/api/data` endpoint. +[node-postgres](https://github.com/brianc/node-postgres), [mongodb](https://www.npmjs.com/package/mongodb), +[mysql2](https://www.npmjs.com/package/mysql2) and [node-mssql](https://www.npmjs.com/package/mssql) back the +four persisters behind `POST /api/data`. [jose](https://github.com/panva/jose) signs and verifies the JWT. -[mongodb](https://www.npmjs.com/package/mongodb) is used to interact with the MongoDB database when a client performs requests to the `/api/data` endpoint. +## Running it -[mysql2](https://www.npmjs.com/package/mysql2) is used to interact with the MySQL database when a client performs requests to the `/api/data` endpoint. +The backend runs in Docker Compose, alongside the PowerSync service and bucket storage. It is not +meant to be started on its own — see the [root README](../README.md) for the two run modes. -[node-mssql](https://www.npmjs.com/package/mssql) is used to connect to a MSSQL database to perform operations from the `/api/data` endpoint. +From the repo root: -[jose](https://github.com/panva/jose) is used to sign the JWT which PowerSync uses for authorization. - -## Requirements - -Based on configuration, this app needs a Postgres, Mongo, MSSQL or MySQL instance. Easiest is probably to use docker containers for these databases. -Hosted free versions that can also be used: - -1. Postgres: For a free version for testing/demo purposes, visit [Supabase](https://supabase.com/). - -## Running the app - -1. Clone the repository -2. Follow the steps outlined in [PowerSync Custom Authentication Example](https://github.com/journeyapps/powersync-jwks-example) → [Generate a key-pair](https://github.com/journeyapps/powersync-jwks-example#1-generate-a-key-pair) to get the keys you need for this app. This is an easy way to get started with this demo app. You can use your own public/private keys as well. Note: This backend will generate a temporary key pair for development purposes if the keys are not present in the `.env` file. This should not be used in production. -3. Create a new `.env` file in the root project directory and add the variables as defined in the `.env` file: - -```shell -cp .env.template .env +```bash +docker compose up --build ``` -4. Install dependancies +To edit backend code without rebuilding the image, append the development overlay to `COMPOSE_FILE` +in the root `.env`: -```shell -nvm use +```bash +COMPOSE_FILE=docker-compose.yaml:examples/postgres/compose.yaml:docker-compose.dev.yaml ``` -```shell -pnpm install -``` +Your working tree is mounted in and the process restarts on save. -## Start App +> Running `pnpm start` on the host as well will fail with `address already in use`, or quietly +> shadow the container — both want port 6060. -1. Run the following to start the application +## Configuration -```shell -pnpm start -``` +Set in the root `.env` and in the Compose overlays, not here: -This will start the app on `http://127.0.0.1:PORT`, where PORT is what you specify in your `.env` file. +| Variable | Meaning | +| --- | --- | +| `DATABASE_TYPE` | `postgres`, `mongodb`, `mysql` or `mssql` | +| `DATABASE_URI` | Connection string for the source database | +| `PORT` | Defaults to 6060 | +| `POWERSYNC_URL`, `JWT_ISSUER` | Audience and issuer for the tokens this backend mints | +| `POWERSYNC_PRIVATE_KEY`, `POWERSYNC_PUBLIC_KEY` | Base64 JWKs for signing | -2. Test if the app is working by opening `http://127.0.0.1:PORT/api/auth/token/` in the browser +The backend refuses to start, before serving any traffic, if `DATABASE_URI` is unset or +`DATABASE_TYPE` is not one of the four — with a message naming the fix rather than a stack trace. -3. You should get a JSON object as the response to that request +If no keypair is configured it generates a temporary one at boot. That is fine for a one-off run +and wrong for everything else: every restart mints a new key, and PowerSync rejects tokens it +accepted moments earlier with `PSYNC_S2101 — Could not find an appropriate key in the keystore`. +The repo ships a committed throwaway pair so this does not happen. To mint your own: -## Connecting the app with PowerSync - -This process is only designed for demo/testing purposes, and is not intended for production use. You won't be using ngrok to host your application and database. - -1. Download and install [ngrok](https://ngrok.com/) -2. Run the ngrok command to create a HTTPS tunnel to your local application - -```shell -ngrok http 8000 +```bash +pnpm generate-keys ``` -This should create the tunnel and a new HTTPS URL should be availible e.g. +## Using your own identity provider -```shell -ngrok by @inconshreveable (Ctrl+C to quit) +`src/auth/verifier.ts` is the seam. The demo verifies the same token this backend mints; replace +that export to accept tokens from Supabase, Clerk, Auth0 or anything else. Worked examples are in +[auth-verifiers.md](../auth-verifiers.md). -Session Status online -Account Michael Barnes (Plan: Free) -Update update available (version 2.3.41, Ctrl-U to update) -Version 2.3.40 -Region United States (us) -Web Interface http://127.0.0.1:4040 -Forwarding http://your_id.ngrok-free.app -> http://localhost:8000 -Forwarding https://your_id.ngrok-free.app -> http://localhost:8000 +## Tests -Connections ttl opn rt1 rt5 p50 p90 - 1957 0 0.04 0.03 0.01 89.93 -``` - -3. Open the [PowerSync Dashboard](https://powersync.journeyapps.com/) and paste the `Forwarding` URL starting with HTTPS into the Credentials tab of your PowerSync instance e.g. - -``` -JWKS URI -https://your_id.ngrok-free.app/api/auth/keys/ +```bash +pnpm test # HTTP against the assembled app, plus the boot-failure contract +pnpm check # types ``` -Pay special attention to the URL, it should include the `/api/auth/keys/` path as this is used by the PowerSync server to validate tokens. +Neither needs Docker. diff --git a/backend/app.test.ts b/backend/app.test.ts new file mode 100644 index 0000000..56c94ed --- /dev/null +++ b/backend/app.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import request from 'supertest'; + +import app from './app.js'; + +/** + * Seam: HTTP requests against the assembled application. + * + * These cover the assembly itself rather than any one route. The validator resolves the OpenAPI + * contract lazily, on the first request rather than at construction — which is why the backend + * image booted, reported itself running, and only then answered everything with a 500. Spec + * resolution happens before the ignorePaths check, so an unreadable contract fails every request + * including the exempted ones: remove the contract and both tests below fail, which is what makes + * them the regression net for that bug. Verified by deleting it and watching them go red. + */ +describe('the assembled application', () => { + it('serves its root route', async () => { + const response = await request(app).get('/'); + + expect(response.status).toBe(200); + }); + + it('rejects a request that violates the OpenAPI contract', async () => { + // A Transaction Batch carrying no transactions violates minItems. A bearer token is supplied + // so the request gets past the security check and fails on the body — a 401 here would mean + // the contract never loaded and the request fell through to the auth gate instead. + const response = await request(app) + .post('/api/data') + .set('Authorization', 'Bearer not-a-real-token') + .send({ transactions: [] }); + + expect(response.status).toBe(400); + }); +}); diff --git a/backend/app.ts b/backend/app.ts index 8d83666..ec6f13e 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -24,10 +24,12 @@ app.use((req: Request, res: Response, next: NextFunction) => { app.use( OpenApiValidator.middleware({ - apiSpec: path.join(__dirname, '..', 'openapi.yaml'), + apiSpec: path.join(__dirname, 'openapi.yaml'), validateRequests: true, validateResponses: false, - ignorePaths: /^\/api\/auth(\/|$)/ + // Two exemptions: the root route, which is the liveness probe and deliberately absent from + // the contract, and the auth routes, which predate it. Everything else is validated. + ignorePaths: /^\/$|^\/api\/auth(\/|$)/ }) ); diff --git a/backend/boot.test.ts b/backend/boot.test.ts new file mode 100644 index 0000000..b8f0c6b --- /dev/null +++ b/backend/boot.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +/** + * Seam: the backend process itself. + * + * Seam 1 (HTTP against the assembled application) cannot observe this, because the behaviour under + * test is a refusal to reach the point where there is anything to make a request to. An adopter + * pointing this at their own database is the most likely person to misconfigure it, and the + * failure has to read as configuration rather than as a bug in their code. + */ + +const backendDir = path.dirname(fileURLToPath(import.meta.url)); +const tsx = path.join(backendDir, 'node_modules', '.bin', 'tsx'); + +interface Boot { + code: number | null; + output: string; + /** True if it was still running when we gave up — a refusal to start should never be. */ + stillRunning: boolean; +} + +const bootWith = (env: Record): Promise => + new Promise((resolve) => { + const child = spawn(tsx, ['index.ts'], { + cwd: backendDir, + // A port nothing else uses, so this cannot bind over a real backend someone is running. + env: { ...process.env, PORT: '6098', ...env } + }); + + let output = ''; + child.stdout.on('data', (d) => (output += d)); + child.stderr.on('data', (d) => (output += d)); + + // Without this, a regression that DOES start the server leaves the promise pending until the + // suite times out, and leaks a listening process. Kill it and report that it was still up. + const deadline = setTimeout(() => { + child.kill('SIGKILL'); + resolve({ code: null, output, stillRunning: true }); + }, 20000); + + child.on('close', (code) => { + clearTimeout(deadline); + resolve({ code, output, stillRunning: false }); + }); + }); + +describe('refusing to start on bad configuration', () => { + it('explains that no connection string is configured, and exits non-zero', async () => { + const { code, output, stillRunning } = await bootWith({ + DATABASE_URI: '', + DATABASE_TYPE: 'postgres' + }); + + expect(stillRunning).toBe(false); + expect(code).not.toBe(0); + expect(output).toContain('DATABASE_URI'); + // The message must name the fix, not just the fault. + expect(output.toLowerCase()).toContain('.env'); + // A raw stack trace is not a readable message. Assert on the shape of one rather than on any + // particular frame, so renaming a function cannot quietly make this vacuous. + expect(output).not.toMatch(/^\s+at .+/m); + }, 40000); + + it('names the supported databases when the type is not one of them', async () => { + const { code, output, stillRunning } = await bootWith({ + DATABASE_URI: 'postgres://u:p@h:5432/d', + DATABASE_TYPE: 'cassandra' + }); + + expect(stillRunning).toBe(false); + expect(code).not.toBe(0); + expect(output).toContain('cassandra'); + for (const supported of ['postgres', 'mongodb', 'mysql', 'mssql']) { + expect(output).toContain(supported); + } + expect(output).not.toMatch(/^\s+at .+/m); + }, 40000); +}); diff --git a/backend/index.ts b/backend/index.ts index 421313e..652aac4 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -1,12 +1,23 @@ import app from './app.js'; import config from './config.js'; import { getPersister } from './src/persistance/persister.js'; +import { ConfigurationError } from './src/errors.js'; const PORT = process.env.PORT || config.port; // Resolving the persister is lazy so that importing the app needs no database. Do it here, before // listening, so a misconfigured database still fails at boot rather than on the first write. -await getPersister(); +try { + await getPersister(); +} catch (error) { + if (error instanceof ConfigurationError) { + // An adopter pointing this at their own database is the most likely person to land here, and + // a stack trace reads like a bug in their code rather than a setting they have not filled in. + console.error(`\nCannot start.\n\n${error.message}\n`); + process.exit(1); + } + throw error; +} app.listen(PORT, () => { console.log(`Server is running @ http://127.0.0.1:${PORT}`); diff --git a/openapi.yaml b/backend/openapi.yaml similarity index 100% rename from openapi.yaml rename to backend/openapi.yaml diff --git a/backend/package.json b/backend/package.json index 896f61a..efcf25e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,10 +7,11 @@ "scripts": { "start": "tsx index.ts", "dev": "tsx watch index.ts", + "generate-keys": "tsx scripts/generate-keys.ts", "format": "prettier --write .", "check": "tsc -b", "test": "vitest run", - "generate-types": "openapi-typescript ../openapi.yaml -o src/generated/api.ts" + "generate-types": "openapi-typescript ./openapi.yaml -o src/generated/api.ts" }, "dependencies": { "dotenv": "^16.3.1", diff --git a/backend/scripts/generate-keys.ts b/backend/scripts/generate-keys.ts new file mode 100644 index 0000000..3d1df08 --- /dev/null +++ b/backend/scripts/generate-keys.ts @@ -0,0 +1,12 @@ +import { generateKeyPair } from '../src/utils/generate-key.js'; + +/** + * Prints a fresh signing keypair in the form .env expects. + * + * The pair committed to .env is public and known to everyone who has cloned this repo. Anything + * beyond a demo needs its own. + */ +const { privateBase64, publicBase64 } = await generateKeyPair(); + +console.log(`POWERSYNC_PRIVATE_KEY=${privateBase64}`); +console.log(`POWERSYNC_PUBLIC_KEY=${publicBase64}`); diff --git a/backend/src/errors.ts b/backend/src/errors.ts index 16b02e2..cb13803 100644 --- a/backend/src/errors.ts +++ b/backend/src/errors.ts @@ -1,3 +1,13 @@ +/** + * The backend is misconfigured and cannot start. Carries a message written for whoever is running + * it, naming the fix rather than only the fault. + */ +export class ConfigurationError extends Error { + constructor(message: string) { + super(message); + } +} + /** Transient failure (deadlock, timeout, connection error). Client should retry. */ export class RetryableError extends Error { constructor(message: string) { diff --git a/backend/src/persistance/persister.ts b/backend/src/persistance/persister.ts index ebb4ed1..c35ece7 100644 --- a/backend/src/persistance/persister.ts +++ b/backend/src/persistance/persister.ts @@ -1,5 +1,6 @@ import config from '../../config.js'; import { factories } from './persister-factories.js'; +import { ConfigurationError } from '../errors.js'; import type { Persister } from '../types.js'; let substituted: Persister | null = null; @@ -32,10 +33,21 @@ export const getPersister = async (): Promise => { const createConfiguredPersister = async (): Promise => { const factory = factories[config.database.type]; if (!factory) { - throw new Error(`Unsupported database type: ${config.database.type}`); + const supported = Object.keys(factories).sort().join(', '); + throw new ConfigurationError( + `DATABASE_TYPE is "${config.database.type}", which is not a database this backend supports.\n\n` + + `Supported: ${supported}\n\n` + + `Set it in .env.` + ); } if (!config.database.uri) { - throw new Error('DATABASE_URI environment variable is required'); + throw new ConfigurationError( + `DATABASE_URI is not set, so there is no source database to write to.\n\n` + + `Set it in .env to a database you already run:\n\n` + + ` DATABASE_URI=postgres://user:password@host:5432/database\n\n` + + `Or select a bundled example instead, which brings its own database:\n\n` + + ` COMPOSE_FILE=docker-compose.yaml:examples/postgres/compose.yaml` + ); } return factory(config.database.uri); diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts new file mode 100644 index 0000000..aafcbe3 --- /dev/null +++ b/backend/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // Explicit, because without a config here vitest walks up and finds the repo root's, whose + // include pattern points at the topology suite and hides every test in this package. + include: ['**/*.test.ts'], + exclude: ['**/node_modules/**', '**/dist/**'], + testTimeout: 60000 + } +}); diff --git a/config/service.yaml b/config/service.yaml new file mode 100644 index 0000000..ce5f086 --- /dev/null +++ b/config/service.yaml @@ -0,0 +1,37 @@ +# yaml-language-server: $schema=https://unpkg.com/@powersync/service-schema@latest/json-schema/powersync-config.json + +# Adopter Mode: this file is YOURS. Edit it to describe your own source database. +# +# Example Mode does not use it — each example mounts its own config over this one. Only variables +# prefixed PS_ can be substituted with !env. + +telemetry: + disable_telemetry_sharing: false + +replication: + connections: + # One of: postgresql, mongodb, mysql, mssql. Only the first connection of a given type is used. + - type: postgresql + uri: !env PS_DATA_SOURCE_URI + # 'disable' is fine on a private network. Use verify-full for anything reachable publicly. + # sslmode is a Postgres-only option; MySQL and SQL Server configure TLS differently. + sslmode: disable + +storage: + # Bucket storage is PowerSync's own internal store, not your data. It stays in a container this + # project owns — we do not create schemas in your database. + type: mongodb + uri: !env PS_MONGO_URI + +port: !env PS_PORT + +sync_config: + path: sync-config.yaml + +client_auth: + jwks_uri: !env PS_JWKS_URL + audience: ["powersync-dev", "powersync"] + +api: + tokens: + - use_a_better_token_in_production diff --git a/config/sync-config.yaml b/config/sync-config.yaml new file mode 100644 index 0000000..d5b9f5a --- /dev/null +++ b/config/sync-config.yaml @@ -0,0 +1,22 @@ +# yaml-language-server: $schema=https://unpkg.com/@powersync/service-sync-rules@latest/schema/sync_rules.json + +# Adopter Mode: this file is YOURS. Describe what your clients should sync. +# +# There is no sensible default here — only you know your schema — so this starts empty. +# +# Until you add a stream, the PowerSync service will restart in a loop logging +# `'streams' are required`. That is expected, not a bug: fill this in before starting Adopter Mode. + +config: + # The compiled-streams edition. Omitting this selects the legacy compiler, which rejects + # `streams` entirely. + edition: 3 + +streams: + # Replace with your own. For example: + # + # my_data: + # # Streams do NOT subscribe by default. Without this line the client syncs nothing and + # # reports no error anywhere. + # auto_subscribe: true + # query: SELECT * FROM my_table WHERE owner_id = auth.user_id() diff --git a/config/sync_rules.yaml b/config/sync_rules.yaml deleted file mode 100644 index 558aebe..0000000 --- a/config/sync_rules.yaml +++ /dev/null @@ -1,5 +0,0 @@ -bucket_definitions: - global: - data: - - SELECT * FROM lists - - SELECT * FROM todos diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml new file mode 100644 index 0000000..5c071bf --- /dev/null +++ b/docker-compose.dev.yaml @@ -0,0 +1,20 @@ +# Development overlay: edit backend code and see it running in seconds, with no image rebuild. +# +# Append it to whichever mode you are in: +# +# COMPOSE_FILE=docker-compose.yaml:examples/postgres/compose.yaml:docker-compose.dev.yaml +# COMPOSE_FILE=docker-compose.yaml:docker-compose.dev.yaml # Adopter Mode +# +# It overrides the backend only. An adopter running Adopter Mode has no demo client for an +# overlay to reference, so the client's own dev loop is `cd frontend && pnpm dev` on the host. + +services: + backend: + # tsx watch, already in the backend's scripts — restarts on save. + command: ["pnpm", "dev"] + volumes: + # Your working tree, live in the container. + - ./backend:/app + # Keeps the image's Linux node_modules. Without this the bind mount above shadows them with + # the host's, which on macOS means native binaries the container cannot run. + - /app/node_modules diff --git a/docker-compose.yaml b/docker-compose.yaml index b16fefb..d7be724 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,36 +1,23 @@ -services: - # PostgreSQL - source database - pg-db: - image: postgres:18 - restart: always - environment: - POSTGRES_USER: postgres - POSTGRES_DB: postgres - POSTGRES_PASSWORD: mypassword - PGPORT: "5432" - volumes: - - pg_data:/var/lib/postgresql - - ./init-scripts:/docker-entrypoint-initdb.d - ports: - - "5432:5432" - command: ["postgres", "-c", "wal_level=logical"] - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] - interval: 5s - timeout: 5s - retries: 5 +# Base definition — everything both run modes share. +# +# On its own this is Adopter Mode: no Example Source Database, no demo client. Point it at a +# database you already run by setting DATABASE_URI in .env. +# +# An example overlay adds a source database, a demo client, and its own PowerSync config. Select +# one with COMPOSE_FILE in .env. - # MongoDB - PowerSync internal storage +services: + # PowerSync's bucket storage. This is the service's own internal store, not your data, and it + # stays inside the docker boundary in every mode — we never create schemas in a database you + # merely pointed us at. mongo: image: mongo:7.0 command: --replSet rs0 --bind_ip_all --quiet restart: unless-stopped - ports: - - "27017:27017" volumes: - mongo_storage:/data/db - # Initializes the MongoDB replica set + # Bucket storage needs a replica set; a single node is fine outside production. mongo-rs-init: image: mongo:7.0 depends_on: @@ -41,7 +28,37 @@ services: - -c - 'mongosh --host mongo:27017 --eval ''try{rs.status().ok && quit(0)} catch {} rs.initiate({_id: "rs0", version: 1, members: [{ _id: 0, host : "mongo:27017" }]})''' - # PowerSync service + # The write API. Built from local source, so your changes land by rebuilding. + backend: + build: + context: ./backend + # Bounded on purpose. A configuration error exits 1, and `unless-stopped` would respawn it + # forever — scrolling the message past and leaving powersync waiting on a healthcheck that can + # never pass. Three attempts still covers a transient crash. + restart: on-failure:3 + environment: + PORT: "6060" + POWERSYNC_URL: powersync-dev + JWT_ISSUER: powersync-dev + POWERSYNC_PRIVATE_KEY: ${POWERSYNC_PRIVATE_KEY} + POWERSYNC_PUBLIC_KEY: ${POWERSYNC_PUBLIC_KEY} + # Adopter Mode: your database. An example overlay replaces both of these. + DATABASE_TYPE: ${DATABASE_TYPE:-postgres} + DATABASE_URI: ${DATABASE_URI:-} + ports: + - "6060:6060" + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://localhost:6060/').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" + ] + interval: 5s + timeout: 2s + retries: 15 + powersync: image: journeyapps/powersync-service:latest restart: unless-stopped @@ -49,18 +66,19 @@ services: depends_on: mongo-rs-init: condition: service_completed_successfully - pg-db: + backend: condition: service_healthy volumes: + # Adopter Mode config. An example overlay replaces this mount with its own. - ./config:/config environment: - POWERSYNC_CONFIG_PATH: /config/powersync.yaml + POWERSYNC_CONFIG_PATH: /config/service.yaml NODE_OPTIONS: --max-old-space-size=1000 - PS_DATA_SOURCE_URI: postgres://postgres:mypassword@pg-db:5432/postgres - PS_MONGO_URI: mongodb://mongo:27017/powersync_demo - # host.docker.internal lets the container reach the backend running on the host - PS_JWKS_URL: http://host.docker.internal:6060/api/auth/keys + PS_MONGO_URI: mongodb://mongo:27017/powersync_bucket_storage + # The backend is containerised, so PowerSync reaches it by service name. + PS_JWKS_URL: http://backend:6060/api/auth/keys PS_PORT: "8080" + PS_DATA_SOURCE_URI: ${DATABASE_URI:-} healthcheck: test: [ @@ -76,5 +94,4 @@ services: - "8080:8080" volumes: - pg_data: mongo_storage: diff --git a/examples/mongodb/README.md b/examples/mongodb/README.md new file mode 100644 index 0000000..bd61c44 --- /dev/null +++ b/examples/mongodb/README.md @@ -0,0 +1,114 @@ +# Example: MongoDB + +A MongoDB Example Source Database. Select it in `.env`: + +```bash +COMPOSE_FILE=docker-compose.yaml:examples/mongodb/compose.yaml +``` + +Then `docker compose up --build`. + +## No source container, and no seeding + +This example adds **no database container at all**. PowerSync already runs a Mongo replica set for +bucket storage, so replication points at a second database on that same server — +`powersync_demo_source` alongside `powersync_bucket_storage`. Separate databases, one process. +Trying this flavour costs your machine nothing extra. + +There is also **no seed script**. Collections are created implicitly by the write API's first +write, so the app starts empty. Add a list in the demo client and it appears. + +## What this flavour needs + +- **A replica set.** Change streams require one, and so do the multi-document transactions the + write API uses to apply a Transaction atomically. A single node is fine outside production; the + base compose file initialises `rs0` for exactly this reason. +- **Pre/post images.** Change streams alone do not carry the document as it was before an update. + `post_images: auto_configure` in `powersync/service.yaml` turns this on for replicated + collections. On Azure DocumentDB, which shares this connector, post-images are unsupported and + this must be `off`. + +MongoDB 6.0 or newer. The connector is **generally available**. + +## The `_id` projection + +MongoDB's primary key is `_id`; the client schema expects `id`. The sync rules bridge that: + +```yaml +- SELECT _id as id, * FROM lists +``` + +Without the projection documents still sync, but arrive without the identifier the rest of the +system keys on. `*` carries `_id` through as well — harmless, since the client ignores columns it +does not declare. + +## Pointing at your own MongoDB instead + +You do not need this example for that. Switch to Adopter Mode in `.env` and set `DATABASE_URI` to +your own server. The requirements above still apply: + +- **A replica set.** Atlas gives you one automatically. A self-managed single node needs + `rs.initiate()` before change streams or transactions work at all. +- **Post-images.** Set `post_images: auto_configure` in `config/service.yaml`, and make sure the + replication user can configure `changeStreamPreAndPostImages` on the replicated collections. +- **Privileges.** `changeStream` at database level, plus read on the collections you replicate. + +**Azure DocumentDB** uses this same connector, but does **not** support post-images — set +`post_images: off` there. + +Bucket storage does not go into your server. Even here, where the example shares one Mongo process +between source and storage, Adopter Mode keeps storage in a container this project owns. + +## Gotchas + +The write API stores `_id` as the **string** id the client generated, not an `ObjectId`. That is +what makes the round trip work: the id a client invents offline survives into Mongo unchanged. + +A single write can produce more than one operation in a bucket for the same document. That is the +op log behaving normally, not a duplicate — operations apply in order and the client converges on +the latest. + +## Verify it end to end + +The automated suite checks the resolved topology, not a live round trip — that is deliberate, since +bringing four flavours up is slow and flaky. This is the manual check it stands in for. + +**1. Everything healthy.** + +```bash +docker compose ps +``` + +**2. Get a token and write through the API.** + +```bash +TOKEN=$(curl -s "http://localhost:6060/api/auth/token?user_id=demo-user" \ + | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])") + +curl -s -X POST http://localhost:6060/api/data \ + -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + -d '{"transactions":[{"transaction_id":1,"crud":[{"op":"PUT","table":"todos", + "id":"11111111-1111-1111-1111-111111111111", + "op_data":{"description":"smoke test","completed":false, + "list_id":"75f89104-d95a-4f16-8309-5363f1bb377a"}}]}]}' +``` + +Expect `{"results":[{"status":"success"}]}`. + +**3. Confirm it reached the database.** + +```bash +docker compose exec -T mongo mongosh --quiet --eval \ + 'db.getSiblingDB("powersync_demo_source").todos.find({description:"smoke test"}).toArray()' +``` + +**4. Confirm it syncs back.** + +```bash +curl -sN -m 10 -X POST http://localhost:8080/sync/stream \ + -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + -d '{"buckets":[],"include_checksum":true,"raw_data":true}' | grep "smoke test" +``` + +This example starts empty, so step 2 is also how you get your first row. The synced document +carries both `id` and `_id`; that is expected. diff --git a/examples/mongodb/compose.yaml b/examples/mongodb/compose.yaml new file mode 100644 index 0000000..7ef230a --- /dev/null +++ b/examples/mongodb/compose.yaml @@ -0,0 +1,40 @@ +# Example Mode: MongoDB. +# +# The cheapest of the examples: it adds NO source database container. PowerSync already runs a +# Mongo replica set for bucket storage, so replication points at a second database on that same +# server. Bucket storage and your data stay in separate databases; they merely share a process. +# +# NOTE: relative paths here resolve against the PROJECT directory — the repo root — not against +# this file's own location. + +name: write-api-mongodb + +services: + backend: + environment: + # The database name is part of the URI; the persister uses whatever it names. + DATABASE_TYPE: mongodb + DATABASE_URI: mongodb://mongo:27017/powersync_demo_source + depends_on: + mongo-rs-init: + condition: service_completed_successfully + + powersync: + volumes: + - ./examples/mongodb/powersync:/config + environment: + PS_DATA_SOURCE_URI: mongodb://mongo:27017/powersync_demo_source + + frontend: + build: + context: ./frontend + args: + # These resolve in your browser, not on the compose network. + VITE_POWERSYNC_URL: http://localhost:8080 + VITE_BACKEND_URL: http://localhost:6060 + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + ports: + - "5173:4173" diff --git a/examples/mongodb/powersync/service.yaml b/examples/mongodb/powersync/service.yaml new file mode 100644 index 0000000..3cbb13b --- /dev/null +++ b/examples/mongodb/powersync/service.yaml @@ -0,0 +1,31 @@ +# yaml-language-server: $schema=https://unpkg.com/@powersync/service-schema@latest/json-schema/powersync-config.json + +telemetry: + disable_telemetry_sharing: false + +replication: + connections: + - type: mongodb + uri: !env PS_DATA_SOURCE_URI + # Change streams alone do not carry the document as it was before an update, which + # replication needs. auto_configure turns on pre/post images for replicated collections. + post_images: auto_configure + +storage: + # A different database on the same replica set as the source above. Bucket storage is still + # ours and still inside the docker boundary. + type: mongodb + uri: !env PS_MONGO_URI + +port: !env PS_PORT + +sync_config: + path: sync-config.yaml + +client_auth: + jwks_uri: !env PS_JWKS_URL + audience: ["powersync-dev", "powersync"] + +api: + tokens: + - use_a_better_token_in_production diff --git a/examples/mongodb/powersync/sync-config.yaml b/examples/mongodb/powersync/sync-config.yaml new file mode 100644 index 0000000..38aa084 --- /dev/null +++ b/examples/mongodb/powersync/sync-config.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://unpkg.com/@powersync/service-sync-rules@latest/schema/sync_rules.json + +config: + edition: 3 + +streams: + global: + # Streams do not subscribe by default — omit this and the client syncs nothing, silently. + auto_subscribe: true + queries: + # MongoDB's primary key is _id. The client schema expects `id`, so project it across — + # without this, documents sync but arrive without the identifier everything else keys on. + # `*` does still carry _id through as well; the extra field is harmless, since the client + # schema ignores columns it does not declare. + - SELECT _id as id, * FROM lists + - SELECT _id as id, * FROM todos diff --git a/examples/mssql/README.md b/examples/mssql/README.md new file mode 100644 index 0000000..0ff02c3 --- /dev/null +++ b/examples/mssql/README.md @@ -0,0 +1,110 @@ +# Example: SQL Server + +A seeded SQL Server Example Source Database. Select it in `.env`: + +```bash +COMPOSE_FILE=docker-compose.yaml:examples/mssql/compose.yaml +``` + +Then `docker compose up --build`. + +> **This connector is Beta**, and it has a sharp edge the others do not — see *Schema changes* +> below before you change any table. + +## Schema changes are not adopted automatically + +SQL Server replicates through **Change Data Capture**, and CDC captures a table's shape at the +moment it is enabled. Alter a table afterwards and replication does not notice: it keeps producing +the old shape, and your new column simply never arrives. Nothing errors. + +Adopting a schema change means redeploying the Sync Config, and re-enabling CDC on the changed +table. In a project whose whole premise is swapping in your own schema, this is the thing most +likely to cost you an afternoon. It is upstream behaviour and cannot be fixed here. + +## What this flavour needs + +The most bootstrap of the four. There is no entrypoint directory to drop SQL into the way Postgres +and MySQL have, so `init-scripts/setup.sql` runs from a one-shot `mssql-setup` container that must +complete before PowerSync starts. It is written to be safe to run more than once. + +- **CDC at database level** — `sys.sp_cdc_enable_db`. +- **A `_powersync_checkpoints` table, with CDC enabled on it.** This is PowerSync's, not yours; it + is how the connector tracks position. +- **CDC per replicated table** — `sys.sp_cdc_enable_table` with `@role_name = N'cdc_reader'`. +- **SQL Server Agent running.** CDC capture and cleanup are Agent jobs. Without the Agent, CDC is + enabled and nothing is ever captured — replication stays silently empty. That is what + `MSSQL_AGENT_ENABLED: "true"` is for. +- **Two permission levels for the replication user.** `VIEW DATABASE PERFORMANCE STATE` in the user + database, and `VIEW SERVER PERFORMANCE STATE` **in `master`**. The second is a server-level + permission on the login; granting only the database-level one leaves replication failing with + *"The user does not have permission to perform this action."* + +The write API connects as `sa`; PowerSync connects as the restricted `powersync_user`. + +SQL Server 2019 or newer, or Azure SQL Database. + +## Running on Apple Silicon + +Microsoft publishes no arm64 image, so this runs under emulation via `platform: linux/amd64`. It +works, but the server is slow to start — the healthcheck allows a long start period for exactly +this reason. The other three flavours run natively. + +## Pointing at your own SQL Server instead + +Switch to Adopter Mode in `.env` and set `DATABASE_URI`. Your database needs everything listed +above. On Azure SQL Database the login is created differently — see PowerSync's docs for +`CREATE USER ... FROM EXTERNAL PROVIDER`. + +## Gotchas + +Ids are `UNIQUEIDENTIFIER`. SQL Server renders them uppercase, so a lowercase id from a client and +its uppercase form in the database are the same value. + +`setup.sql` is idempotent, but the seed data only inserts when the seeded list is absent. Drop the +volume with `docker compose down -v` for a genuinely clean start. + +## Verify it end to end + +The automated suite checks the resolved topology, not a live round trip — that is deliberate, since +bringing four flavours up is slow and flaky. This is the manual check it stands in for. + +**1. Everything healthy.** + +```bash +docker compose ps +``` + +**2. Get a token and write through the API.** + +```bash +TOKEN=$(curl -s "http://localhost:6060/api/auth/token?user_id=demo-user" \ + | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])") + +curl -s -X POST http://localhost:6060/api/data \ + -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + -d '{"transactions":[{"transaction_id":1,"crud":[{"op":"PUT","table":"todos", + "id":"11111111-1111-1111-1111-111111111111", + "op_data":{"description":"smoke test","completed":false, + "list_id":"75F89104-D95A-4F16-8309-5363F1BB377A"}}]}]}' +``` + +Expect `{"results":[{"status":"success"}]}`. + +**3. Confirm it reached the database.** + +```bash +docker compose exec -T mssql-db /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P "Powersync_demo_pw1" -C -Q \ + "USE powersync_demo; SELECT description FROM dbo.todos WHERE description='smoke test';" +``` + +**4. Confirm it syncs back.** + +```bash +curl -sN -m 10 -X POST http://localhost:8080/sync/stream \ + -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + -d '{"buckets":[],"include_checksum":true,"raw_data":true}' | grep "smoke test" +``` + +CDC polls rather than streams, so step 4 can lag a few seconds behind step 3. If it never +arrives, check that SQL Server Agent is running and the CDC capture jobs exist. diff --git a/examples/mssql/compose.yaml b/examples/mssql/compose.yaml new file mode 100644 index 0000000..7d4cdbf --- /dev/null +++ b/examples/mssql/compose.yaml @@ -0,0 +1,97 @@ +# Example Mode: SQL Server. +# +# The heaviest of the examples. SQL Server replicates through Change Data Capture rather than log +# streaming, so it needs a bootstrap step that the other flavours do not, and SQL Server Agent must +# be running for the CDC capture jobs. +# +# NOTE: relative paths here resolve against the PROJECT directory — the repo root — not against +# this file's own location. + +name: write-api-mssql + +# Demo credentials, the same kind of throwaway as the signing keys in .env: public, committed, and +# known to anyone who has cloned this repo. Defined once here so the server, the bootstrap and the +# connection strings cannot drift apart. +x-mssql-sa-password: &sa_password "Powersync_demo_pw1" + +services: + mssql-db: + image: mcr.microsoft.com/mssql/server:2022-latest + # Microsoft publishes no arm64 image, so this runs under emulation on Apple Silicon. It works, + # but it is slow to start — expect the healthcheck to take a while on the first run. + platform: linux/amd64 + restart: always + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: *sa_password + # CDC capture and cleanup run as SQL Agent jobs. Without the Agent, CDC is enabled but + # nothing is ever captured, and replication silently stays empty. + MSSQL_AGENT_ENABLED: "true" + volumes: + - mssql_data:/var/opt/mssql + ports: + - "1433:1433" + healthcheck: + test: + [ + "CMD-SHELL", + "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P \"$$MSSQL_SA_PASSWORD\" -C -Q 'SELECT 1' || exit 1" + ] + interval: 10s + timeout: 10s + retries: 30 + start_period: 60s + + # There is no entrypoint directory to drop SQL into, the way Postgres and MySQL have, so the + # bootstrap is a one-shot container that must complete before PowerSync starts. + mssql-setup: + image: mcr.microsoft.com/mssql/server:2022-latest + platform: linux/amd64 + restart: on-failure + depends_on: + mssql-db: + condition: service_healthy + environment: + MSSQL_SA_PASSWORD: *sa_password + volumes: + - ./examples/mssql/init-scripts/setup.sql:/scripts/setup.sql:ro + entrypoint: + - /bin/bash + - -c + - '/opt/mssql-tools18/bin/sqlcmd -S mssql-db -U sa -P "$$MSSQL_SA_PASSWORD" -C -b -i /scripts/setup.sql' + + backend: + environment: + DATABASE_TYPE: mssql + DATABASE_URI: mssql://sa:Powersync_demo_pw1@mssql-db:1433/powersync_demo + depends_on: + mssql-setup: + condition: service_completed_successfully + + powersync: + volumes: + - ./examples/mssql/powersync:/config + environment: + # Replication connects as the restricted user, not sa. + PS_DATA_SOURCE_URI: mssql://powersync_user:Powersync_demo_pw1@mssql-db:1433/powersync_demo + depends_on: + mssql-setup: + condition: service_completed_successfully + + frontend: + build: + context: ./frontend + args: + # These resolve in your browser, not on the compose network, so they are localhost — + # unlike every server-to-server URL here. Vite bakes them in at build time. + VITE_POWERSYNC_URL: http://localhost:8080 + VITE_BACKEND_URL: http://localhost:6060 + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + ports: + - "5173:4173" + +volumes: + mssql_data: diff --git a/examples/mssql/init-scripts/setup.sql b/examples/mssql/init-scripts/setup.sql new file mode 100644 index 0000000..ddfa6b2 --- /dev/null +++ b/examples/mssql/init-scripts/setup.sql @@ -0,0 +1,103 @@ +-- Safe to run more than once: the setup container may retry while SQL Server finishes starting. +-- +-- SQL Server replicates through Change Data Capture rather than log streaming, so this does more +-- than create tables. CDC must be enabled at database level, then per table, and PowerSync needs +-- its own checkpoints table with CDC enabled on it too. + +IF DB_ID('powersync_demo') IS NULL + EXEC('CREATE DATABASE powersync_demo'); +GO + +USE powersync_demo; +GO + +-- CDC at database level. Everything below depends on this. +IF (SELECT is_cdc_enabled FROM sys.databases WHERE name = 'powersync_demo') = 0 + EXEC sys.sp_cdc_enable_db; +GO + +-- PowerSync's own checkpoints table. Not your data — it is how the connector tracks position. +IF OBJECT_ID('dbo._powersync_checkpoints', 'U') IS NULL + CREATE TABLE dbo._powersync_checkpoints ( + id INT IDENTITY PRIMARY KEY, + last_updated DATETIME NOT NULL DEFAULT GETUTCDATE() + ); +GO + +IF OBJECT_ID('dbo.lists', 'U') IS NULL + CREATE TABLE dbo.lists ( + id UNIQUEIDENTIFIER NOT NULL PRIMARY KEY, + created_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + name NVARCHAR(MAX) NOT NULL, + owner_id UNIQUEIDENTIFIER NOT NULL + ); +GO + +IF OBJECT_ID('dbo.todos', 'U') IS NULL + CREATE TABLE dbo.todos ( + id UNIQUEIDENTIFIER NOT NULL PRIMARY KEY, + created_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + completed_at DATETIME2 NULL, + description NVARCHAR(MAX) NOT NULL, + completed BIT NOT NULL DEFAULT 0, + created_by UNIQUEIDENTIFIER NULL, + completed_by UNIQUEIDENTIFIER NULL, + list_id UNIQUEIDENTIFIER NOT NULL, + photo_id UNIQUEIDENTIFIER NULL + ); +GO + +-- Seed data, so the demo client has something to show on first run. +IF NOT EXISTS (SELECT 1 FROM dbo.lists WHERE id = '75F89104-D95A-4F16-8309-5363F1BB377A') +BEGIN + INSERT INTO dbo.lists (id, name, owner_id) + VALUES ('75F89104-D95A-4F16-8309-5363F1BB377A', 'Getting Started', NEWID()); + INSERT INTO dbo.todos (id, description, list_id, completed) + VALUES (NEWID(), 'Run services locally', '75F89104-D95A-4F16-8309-5363F1BB377A', 1); + INSERT INTO dbo.todos (id, description, list_id, completed) + VALUES (NEWID(), 'Create a todo here. Query the todos table over a SQL Server connection. Your todo should be synced', '75F89104-D95A-4F16-8309-5363F1BB377A', 0); +END +GO + +-- CDC per table. @role_name creates the cdc_reader role if it does not exist. +-- NOTE: this captures the table's shape AS IT IS NOW. Alter a table later and replication does not +-- adopt the change on its own — see this example's README. +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = '_powersync_checkpoints' AND is_tracked_by_cdc = 1) + EXEC sys.sp_cdc_enable_table @source_schema = N'dbo', @source_name = N'_powersync_checkpoints', + @role_name = N'cdc_reader', @supports_net_changes = 0; +GO +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'lists' AND is_tracked_by_cdc = 1) + EXEC sys.sp_cdc_enable_table @source_schema = N'dbo', @source_name = N'lists', + @role_name = N'cdc_reader', @supports_net_changes = 0; +GO +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'todos' AND is_tracked_by_cdc = 1) + EXEC sys.sp_cdc_enable_table @source_schema = N'dbo', @source_name = N'todos', + @role_name = N'cdc_reader', @supports_net_changes = 0; +GO + +-- The restricted user PowerSync replicates as. The write API connects as sa in this example. +IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name = 'powersync_user') + CREATE LOGIN [powersync_user] WITH PASSWORD = 'Powersync_demo_pw1', CHECK_POLICY = ON; +GO +-- VIEW SERVER PERFORMANCE STATE is a SERVER-level permission and must be granted in master to +-- the login, not in the user database. Granting only the database-level equivalent leaves +-- replication failing with "The user does not have permission to perform this action." +USE master; +GO +GRANT VIEW SERVER PERFORMANCE STATE TO [powersync_user]; +GO + +USE powersync_demo; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = 'powersync_user') + CREATE USER [powersync_user] FOR LOGIN [powersync_user]; +GO + +GRANT SELECT ON dbo.lists TO [powersync_user]; +GRANT SELECT ON dbo.todos TO [powersync_user]; +GRANT SELECT, INSERT, UPDATE ON dbo._powersync_checkpoints TO [powersync_user]; +GRANT SELECT ON SCHEMA::cdc TO [powersync_user]; +GRANT VIEW DATABASE PERFORMANCE STATE TO [powersync_user]; +ALTER ROLE cdc_reader ADD MEMBER powersync_user; +GO diff --git a/examples/mssql/powersync/service.yaml b/examples/mssql/powersync/service.yaml new file mode 100644 index 0000000..1dd70e0 --- /dev/null +++ b/examples/mssql/powersync/service.yaml @@ -0,0 +1,31 @@ +# yaml-language-server: $schema=https://unpkg.com/@powersync/service-schema@latest/json-schema/powersync-config.json + +telemetry: + disable_telemetry_sharing: false + +replication: + connections: + - type: mssql + uri: !env PS_DATA_SOURCE_URI + schema: dbo + additionalConfig: + # The demo server uses a self-signed certificate. + trustServerCertificate: true + +storage: + # Still ours, still inside the docker boundary. + type: mongodb + uri: !env PS_MONGO_URI + +port: !env PS_PORT + +sync_config: + path: sync-config.yaml + +client_auth: + jwks_uri: !env PS_JWKS_URL + audience: ["powersync-dev", "powersync"] + +api: + tokens: + - use_a_better_token_in_production diff --git a/examples/mssql/powersync/sync-config.yaml b/examples/mssql/powersync/sync-config.yaml new file mode 100644 index 0000000..e250319 --- /dev/null +++ b/examples/mssql/powersync/sync-config.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://unpkg.com/@powersync/service-sync-rules@latest/schema/sync_rules.json + +config: + edition: 3 + +streams: + global: + # Streams do not subscribe by default — omit this and the client syncs nothing, silently. + auto_subscribe: true + queries: + - SELECT * FROM lists + - SELECT * FROM todos diff --git a/examples/mysql/README.md b/examples/mysql/README.md new file mode 100644 index 0000000..2b2b858 --- /dev/null +++ b/examples/mysql/README.md @@ -0,0 +1,96 @@ +# Example: MySQL + +A seeded MySQL Example Source Database. Select it in `.env`: + +```bash +COMPOSE_FILE=docker-compose.yaml:examples/mysql/compose.yaml +``` + +Then `docker compose up --build`. + +> **This connector is Beta.** Postgres and MongoDB are generally available; MySQL is not. Weigh +> that before building on it. + +## What this flavour needs + +PowerSync replicates MySQL by reading the **binary log**, which has to be enabled and in the right +shape *before the server starts*. That is why `init-scripts/my.cnf` is mounted into the server's +config directory rather than applied by a setup script — by the time a script could run, it is too +late. + +The settings that matter, all verifiable with `SELECT @@log_bin, @@gtid_mode, @@binlog_format;`: + +| Setting | Value | Why | +| --- | --- | --- | +| `log_bin` | on | Nothing replicates without it | +| `gtid_mode` | `ON` | Lets replication resume from a known position | +| `enforce_gtid_consistency` | `ON` | Required alongside GTID mode | +| `binlog_format` | `ROW` | PowerSync needs rows, not the statements that changed them | +| `binlog_row_image` | `FULL` | Partial images replicate updates incorrectly | +| `server-id` | unique | Each replica in a topology needs its own | + +Two users, deliberately: the write API connects as root, while PowerSync connects as a restricted +`powersync` user holding `REPLICATION SLAVE` (to read the binary log) and `SELECT` (to take the +initial snapshot). Splitting them shows the least privilege replication actually needs. + +MySQL 5.7 or newer. + +## Pointing at your own MySQL instead + +Switch to Adopter Mode in `.env` and set `DATABASE_URI`. Your server needs the same binlog settings +above — on managed MySQL that usually means a parameter group rather than a config file. AWS Aurora +in particular needs binary logging and GTID enabled through a DB Parameter Group, which requires a +restart. + +## Gotchas + +`init-scripts/setup.sql` runs **only on the database's first start**. Editing it later does nothing +until you drop the volume with `docker compose down -v`. + +Ids are `CHAR(36)`, not a native UUID type — MySQL has none. The client generates the id offline and +it is stored verbatim, which is what makes the round trip work. + +## Verify it end to end + +The automated suite checks the resolved topology, not a live round trip — that is deliberate, since +bringing four flavours up is slow and flaky. This is the manual check it stands in for. + +**1. Everything healthy.** + +```bash +docker compose ps +``` + +**2. Get a token and write through the API.** + +```bash +TOKEN=$(curl -s "http://localhost:6060/api/auth/token?user_id=demo-user" \ + | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])") + +curl -s -X POST http://localhost:6060/api/data \ + -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + -d '{"transactions":[{"transaction_id":1,"crud":[{"op":"PUT","table":"todos", + "id":"11111111-1111-1111-1111-111111111111", + "op_data":{"description":"smoke test","completed":false, + "list_id":"75f89104-d95a-4f16-8309-5363f1bb377a"}}]}]}' +``` + +Expect `{"results":[{"status":"success"}]}`. + +**3. Confirm it reached the database.** + +```bash +docker compose exec -T mysql-db mysql -uroot -pmypassword -N -B powersync_demo \ + -e "select description from todos where description='smoke test';" +``` + +**4. Confirm it syncs back.** + +```bash +curl -sN -m 10 -X POST http://localhost:8080/sync/stream \ + -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + -d '{"buckets":[],"include_checksum":true,"raw_data":true}' | grep "smoke test" +``` + +If step 3 finds the row but step 4 does not, the binlog settings are the first thing to check: +`select @@log_bin, @@gtid_mode, @@binlog_format, @@binlog_row_image;` diff --git a/examples/mysql/compose.yaml b/examples/mysql/compose.yaml new file mode 100644 index 0000000..ff156d7 --- /dev/null +++ b/examples/mysql/compose.yaml @@ -0,0 +1,66 @@ +# Example Mode: MySQL. +# +# NOTE: relative paths here resolve against the PROJECT directory — the repo root — not against +# this file's own location. + +name: write-api-mysql + +# Demo credentials: public, committed, and known to anyone who has cloned this repo — the same +# kind of throwaway as the signing keys in .env. The powersync user's password also appears in +# init-scripts/setup.sql, which creates it; the two must match. + +services: + mysql-db: + image: mysql:8.4 + restart: always + environment: + MYSQL_ROOT_PASSWORD: mypassword + MYSQL_DATABASE: powersync_demo + volumes: + # Binlog settings must be in place before the server starts; a setup script is too late. + - ./examples/mysql/init-scripts/my.cnf:/etc/mysql/conf.d/powersync.cnf + - ./examples/mysql/init-scripts/setup.sql:/docker-entrypoint-initdb.d/setup.sql + - mysql_data:/var/lib/mysql + ports: + - "3306:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-pmypassword"] + interval: 5s + timeout: 5s + retries: 20 + + backend: + environment: + # The write API connects as root; PowerSync connects as the restricted replication user. + DATABASE_TYPE: mysql + DATABASE_URI: mysql://root:mypassword@mysql-db:3306/powersync_demo + depends_on: + mysql-db: + condition: service_healthy + + powersync: + volumes: + - ./examples/mysql/powersync:/config + environment: + PS_DATA_SOURCE_URI: mysql://powersync:powersyncpw@mysql-db:3306/powersync_demo + depends_on: + mysql-db: + condition: service_healthy + + frontend: + build: + context: ./frontend + args: + # These resolve in your browser, not on the compose network, so they are localhost — + # unlike every server-to-server URL here. Vite bakes them in at build time. + VITE_POWERSYNC_URL: http://localhost:8080 + VITE_BACKEND_URL: http://localhost:6060 + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + ports: + - "5173:4173" + +volumes: + mysql_data: diff --git a/examples/mysql/init-scripts/my.cnf b/examples/mysql/init-scripts/my.cnf new file mode 100644 index 0000000..aacdc61 --- /dev/null +++ b/examples/mysql/init-scripts/my.cnf @@ -0,0 +1,14 @@ +# PowerSync replicates MySQL from the binary log, which must be enabled and in the right shape +# before the server starts — this cannot be turned on afterwards from a setup script. +[mysqld] +log_bin = mysql-bin +server-id = 1 + +# Global transaction identifiers let replication resume from a known position. +gtid_mode = ON +enforce_gtid_consistency = ON + +# Row-based, full images: PowerSync needs the whole row as it was, not the statement that +# changed it. Anything less and updates replicate incorrectly or not at all. +binlog_format = ROW +binlog_row_image = FULL diff --git a/examples/mysql/init-scripts/setup.sql b/examples/mysql/init-scripts/setup.sql new file mode 100644 index 0000000..a298f94 --- /dev/null +++ b/examples/mysql/init-scripts/setup.sql @@ -0,0 +1,38 @@ +-- Runs only on the database's FIRST start. Editing it later does nothing until the volume is +-- dropped with `docker compose down -v`. + +CREATE TABLE lists ( + id CHAR(36) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + name TEXT NOT NULL, + owner_id CHAR(36) NOT NULL, + PRIMARY KEY (id) +); + +CREATE TABLE todos ( + id CHAR(36) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP NULL, + description TEXT NOT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + created_by CHAR(36) NULL, + completed_by CHAR(36) NULL, + list_id CHAR(36) NOT NULL, + photo_id CHAR(36) NULL, + PRIMARY KEY (id) +); + +-- Seed data, so the demo client has something to show on first run. +INSERT INTO lists (id, name, owner_id) + VALUES ('75f89104-d95a-4f16-8309-5363f1bb377a', 'Getting Started', UUID()); +INSERT INTO todos (id, description, list_id, completed) + VALUES (UUID(), 'Run services locally', '75f89104-d95a-4f16-8309-5363f1bb377a', TRUE); +INSERT INTO todos (id, description, list_id, completed) + VALUES (UUID(), 'Create a todo here. Query the todos table over a MySQL connection. Your todo should be synced', '75f89104-d95a-4f16-8309-5363f1bb377a', FALSE); + +-- The replication user PowerSync connects as. REPLICATION SLAVE is what lets it read the binary +-- log; SELECT is what lets it take the initial snapshot. +CREATE USER 'powersync'@'%' IDENTIFIED BY 'powersyncpw'; +GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'powersync'@'%'; +GRANT SELECT ON powersync_demo.* TO 'powersync'@'%'; +FLUSH PRIVILEGES; diff --git a/examples/mysql/powersync/service.yaml b/examples/mysql/powersync/service.yaml new file mode 100644 index 0000000..974c98a --- /dev/null +++ b/examples/mysql/powersync/service.yaml @@ -0,0 +1,27 @@ +# yaml-language-server: $schema=https://unpkg.com/@powersync/service-schema@latest/json-schema/powersync-config.json + +telemetry: + disable_telemetry_sharing: false + +replication: + connections: + - type: mysql + uri: !env PS_DATA_SOURCE_URI + +storage: + # Still ours, still inside the docker boundary. + type: mongodb + uri: !env PS_MONGO_URI + +port: !env PS_PORT + +sync_config: + path: sync-config.yaml + +client_auth: + jwks_uri: !env PS_JWKS_URL + audience: ["powersync-dev", "powersync"] + +api: + tokens: + - use_a_better_token_in_production diff --git a/examples/mysql/powersync/sync-config.yaml b/examples/mysql/powersync/sync-config.yaml new file mode 100644 index 0000000..e250319 --- /dev/null +++ b/examples/mysql/powersync/sync-config.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://unpkg.com/@powersync/service-sync-rules@latest/schema/sync_rules.json + +config: + edition: 3 + +streams: + global: + # Streams do not subscribe by default — omit this and the client syncs nothing, silently. + auto_subscribe: true + queries: + - SELECT * FROM lists + - SELECT * FROM todos diff --git a/examples/postgres/README.md b/examples/postgres/README.md new file mode 100644 index 0000000..ee2c98e --- /dev/null +++ b/examples/postgres/README.md @@ -0,0 +1,88 @@ +# Example: Postgres + +A seeded Postgres Example Source Database, plus the demo client. Select it in `.env`: + +```bash +COMPOSE_FILE=docker-compose.yaml:examples/postgres/compose.yaml +``` + +Then `docker compose up --build`. + +## What this flavour needs + +PowerSync replicates from Postgres via **logical decoding**, which is why the container runs with +`wal_level=logical`. Two things must exist in the database: + +- A publication named `powersync` covering the replicated tables. `init-scripts/setup.sql` creates + it — a Postgres source without one replicates nothing. +- A user with `SELECT` on those tables and replication rights. The example uses the superuser, + which you should not do anywhere real. + +Postgres 11 or newer. The connector is **generally available** — the most mature of the four. + +## Pointing at your own Postgres instead + +You do not need this example for that. Switch to Adopter Mode in `.env` and set `DATABASE_URI`. +The requirements above still apply to your database: logical replication enabled, a `powersync` +publication, and a user that can read the tables. + +If your database is on this machine rather than in Docker, reach it at `host.docker.internal` +rather than `localhost` — inside a container, `localhost` is the container. + +Hosted Postgres generally needs `sslmode` changed from `disable` in `config/service.yaml`. + +## Gotchas + +`init-scripts/setup.sql` runs **only on the database's first start**. Editing it after the fact +does nothing until you drop the volume: + +```bash +docker compose down -v +``` + +The seeded list id is fixed (`75f89104-…`) so the seeded todos can reference it. Changing it means +changing both. + +## Verify it end to end + +The automated suite checks the resolved topology, not a live round trip — that is deliberate, since +bringing four flavours up is slow and flaky. This is the manual check it stands in for. + +**1. Everything healthy.** + +```bash +docker compose ps +``` + +**2. Get a token and write through the API.** + +```bash +TOKEN=$(curl -s "http://localhost:6060/api/auth/token?user_id=demo-user" \ + | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])") + +curl -s -X POST http://localhost:6060/api/data \ + -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + -d '{"transactions":[{"transaction_id":1,"crud":[{"op":"PUT","table":"todos", + "id":"11111111-1111-1111-1111-111111111111", + "op_data":{"description":"smoke test","completed":false, + "list_id":"75f89104-d95a-4f16-8309-5363f1bb377a"}}]}]}' +``` + +Expect `{"results":[{"status":"success"}]}`. + +**3. Confirm it reached the database.** + +```bash +docker compose exec -T pg-db psql -U postgres -d postgres \ + -c "select description from todos where description='smoke test'" +``` + +**4. Confirm it syncs back.** + +```bash +curl -sN -m 10 -X POST http://localhost:8080/sync/stream \ + -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + -d '{"buckets":[],"include_checksum":true,"raw_data":true}' | grep "smoke test" +``` + +If step 4 finds nothing, replication is the problem, not the write — check `docker compose logs powersync`. diff --git a/examples/postgres/compose.yaml b/examples/postgres/compose.yaml new file mode 100644 index 0000000..e1b34e4 --- /dev/null +++ b/examples/postgres/compose.yaml @@ -0,0 +1,74 @@ +# Example Mode: Postgres. +# +# Overlays the base definition with a seeded Example Source Database and the demo client. +# +# NOTE: relative paths here resolve against the PROJECT directory — the repo root — not against +# this file's own location. That is why the mount below is ./examples/postgres/... and not +# ./powersync. It reads wrong; it is correct. + +name: write-api-postgres + +# Demo credentials: public, committed, and known to anyone who has cloned this repo — the same +# kind of throwaway as the signing keys in .env. Replace them before this is anything but a demo. + +services: + # The Example Source Database. Delete this whole directory when you no longer need it. + pg-db: + image: postgres:18 + restart: always + environment: + POSTGRES_USER: postgres + POSTGRES_DB: postgres + POSTGRES_PASSWORD: mypassword + PGPORT: "5432" + volumes: + - pg_data:/var/lib/postgresql + - ./examples/postgres/init-scripts:/docker-entrypoint-initdb.d + ports: + - "5432:5432" + # PowerSync replicates from the write-ahead log, which needs logical decoding. + command: ["postgres", "-c", "wal_level=logical"] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] + interval: 5s + timeout: 5s + retries: 5 + + backend: + environment: + # Facts about this flavour, not things you configure — so they live here, not in .env. + DATABASE_TYPE: postgres + DATABASE_URI: postgres://postgres:mypassword@pg-db:5432/postgres + depends_on: + pg-db: + condition: service_healthy + + powersync: + volumes: + # Replaces the base's ./config mount — Compose matches volumes on their target path. + - ./examples/postgres/powersync:/config + environment: + PS_DATA_SOURCE_URI: postgres://postgres:mypassword@pg-db:5432/postgres + depends_on: + pg-db: + condition: service_healthy + + # The demo client. It is a test fixture bound to this example's schema — bring your own client + # for anything real. + frontend: + build: + context: ./frontend + args: + # These resolve in your browser, not on the compose network, so they are localhost — + # unlike every server-to-server URL here. Vite bakes them in at build time. + VITE_POWERSYNC_URL: http://localhost:8080 + VITE_BACKEND_URL: http://localhost:6060 + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + ports: + - "5173:4173" + +volumes: + pg_data: diff --git a/init-scripts/setup.sql b/examples/postgres/init-scripts/setup.sql similarity index 100% rename from init-scripts/setup.sql rename to examples/postgres/init-scripts/setup.sql diff --git a/config/powersync.yaml b/examples/postgres/powersync/service.yaml similarity index 64% rename from config/powersync.yaml rename to examples/postgres/powersync/service.yaml index 36729ab..7e14901 100644 --- a/config/powersync.yaml +++ b/examples/postgres/powersync/service.yaml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://unpkg.com/@powersync/service-schema@latest/json-schema/powersync-config.json + telemetry: disable_telemetry_sharing: false @@ -8,13 +10,14 @@ replication: sslmode: disable storage: + # Still ours, still inside the docker boundary. type: mongodb uri: !env PS_MONGO_URI port: !env PS_PORT -sync_rules: - path: sync_rules.yaml +sync_config: + path: sync-config.yaml client_auth: jwks_uri: !env PS_JWKS_URL diff --git a/examples/postgres/powersync/sync-config.yaml b/examples/postgres/powersync/sync-config.yaml new file mode 100644 index 0000000..e6f0e49 --- /dev/null +++ b/examples/postgres/powersync/sync-config.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://unpkg.com/@powersync/service-sync-rules@latest/schema/sync_rules.json + +config: + # The compiled-streams edition. Omitting this selects edition 1, the legacy compiler, which + # would reject the `streams` block below. + edition: 3 + +streams: + global: + # Streams do not subscribe by default — omit this and the client syncs nothing, with no error + # reported anywhere. The legacy bucket_definitions format had no equivalent concept, so this + # line is what preserves the old sync-everything-upfront behaviour. + auto_subscribe: true + queries: + - SELECT * FROM lists + - SELECT * FROM todos diff --git a/frontend/.env.template b/frontend/.env.template index fa00f38..2b377fb 100644 --- a/frontend/.env.template +++ b/frontend/.env.template @@ -1,5 +1,7 @@ -# Copy this template: `cp .env.local.template .env.local` -# Edit .env.local and enter your project details. +# Only needed to run the client on the host with `pnpm dev`. In Docker these are supplied as build +# args by the example overlay, because Vite inlines VITE_* variables at build time. +# +# cp .env.template .env.local VITE_BACKEND_URL=http://localhost:6060 VITE_POWERSYNC_URL=http://localhost:8080 diff --git a/frontend/README.md b/frontend/README.md index c9a42b4..9d94a84 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,7 +1,52 @@ -# PowerSync Self hosted demo app +# Demo client -This is a small demonstration for connecting to a self hosted PowerSync instance. Changes made to the Postgres server should be synced to clients. +A small React/Vite app that exercises the write path: it queues local changes, uploads them to the +write API as a transaction batch, and syncs the results back from PowerSync. + +**This is a test fixture, not a starting point.** It is bound to the demo `lists` and `todos` +schema, so it only works against a bundled example — Adopter Mode does not run it. Bring your own +client for anything real. + +## Running it + +It comes up with any of the examples, at http://localhost:5173: + +```bash +docker compose up --build # from the repo root +``` + +In the container it is a production build with its URLs baked in at build time, because Vite +inlines `VITE_*` variables. For a loop that reloads on save and reads configuration at runtime: + +```bash +pnpm install && pnpm dev +``` + +That reads `.env.local` — copy `.env.template` to create it. ## Authentication -This essentially uses anonymous authentication. A random user ID is generated and stored in local storage. The backend returns a valid token which is not linked to a specific user. All data is synced to all users. +Effectively anonymous. A random user id is generated and stored in local storage, and the backend +returns a valid token that is not tied to a specific user. Every client syncs the same data. + +Swapping this for a real identity provider is a backend concern — see +[auth-verifiers.md](../auth-verifiers.md). + +## Upload behaviour + +`.env.template` documents the batching knobs. There is one upload path and one endpoint +(`POST /api/data`), which always takes an ordered run of whole transactions; the variables only +bound how much of the queue goes in each request: + +- `VITE_BATCH_MAX_TRANSACTIONS` — transactions per request, default 10 +- `VITE_BATCH_MAX_OPERATIONS` — operation ceiling, whichever bound is hit first, default 1000 +- `VITE_BATCH_ON_FATAL_ERROR` — `stop` (default) ends the batch at a fatal failure; `skip` drops + that transaction and continues, so a queue blocked by a poison operation can still drain + +## Types + +Generated from the shared contract at `backend/openapi.yaml`: + +```bash +pnpm generate +``` diff --git a/frontend/package.json b/frontend/package.json index ce07029..3e274ec 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,7 +3,7 @@ "version": "0.2.3", "private": true, "scripts": { - "generate": "openapi-typescript ../openapi.yaml -o src/generated/api.d.ts", + "generate": "openapi-typescript ../backend/openapi.yaml -o src/generated/api.d.ts", "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", diff --git a/package.json b/package.json new file mode 100644 index 0000000..467dba2 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "write-api", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run" + }, + "devDependencies": { + "vitest": "^4.1.10" + }, + "packageManager": "pnpm@9.12.3+sha512.cce0f9de9c5a7c95bef944169cc5dfe8741abfb145078c0d508b868056848a87c81e626246cb60967cbd7fd29a6c062ef73ff840d96b3c86c40ac92cf4a813ee" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..7924c36 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,689 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + vitest: + specifier: ^4.1.10 + version: 4.1.11(vite@8.3.0) + +packages: + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@oxc-project/types@0.150.0': + resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} + + '@rolldown/binding-android-arm-eabi@1.2.9': + resolution: {integrity: sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.9': + resolution: {integrity: sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.9': + resolution: {integrity: sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.9': + resolution: {integrity: sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.9': + resolution: {integrity: sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': + resolution: {integrity: sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.9': + resolution: {integrity: sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.9': + resolution: {integrity: sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.9': + resolution: {integrity: sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.9': + resolution: {integrity: sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.9': + resolution: {integrity: sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.9': + resolution: {integrity: sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.9': + resolution: {integrity: sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.9': + resolution: {integrity: sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.9': + resolution: {integrity: sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.2.9: + resolution: {integrity: sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + vite@8.3.0: + resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@oxc-project/types@0.150.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.9': + optional: true + + '@rolldown/binding-android-arm64@1.2.9': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.9': + optional: true + + '@rolldown/binding-darwin-x64@1.2.9': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.9': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.9': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.9': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.9': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.9': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.9': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.11(vite@8.3.0)': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.3.0 + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + assertion-error@2.0.1: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + detect-libc@2.1.2: {} + + es-module-lexer@2.3.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + nanoid@3.3.19: {} + + obug@2.2.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.2.9: + dependencies: + '@oxc-project/types': 0.150.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.9 + '@rolldown/binding-android-arm64': 1.2.9 + '@rolldown/binding-darwin-arm64': 1.2.9 + '@rolldown/binding-darwin-x64': 1.2.9 + '@rolldown/binding-freebsd-x64': 1.2.9 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.9 + '@rolldown/binding-linux-arm64-gnu': 1.2.9 + '@rolldown/binding-linux-arm64-musl': 1.2.9 + '@rolldown/binding-linux-ppc64-gnu': 1.2.9 + '@rolldown/binding-linux-s390x-gnu': 1.2.9 + '@rolldown/binding-linux-x64-gnu': 1.2.9 + '@rolldown/binding-linux-x64-musl': 1.2.9 + '@rolldown/binding-openharmony-arm64': 1.2.9 + '@rolldown/binding-win32-arm64-msvc': 1.2.9 + '@rolldown/binding-win32-x64-msvc': 1.2.9 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinyrainbow@3.1.1: {} + + vite@8.3.0: + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.9 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitest@4.1.11(vite@8.3.0): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.3.0) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.2.1 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.3.0 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/test.txt b/test.txt new file mode 100644 index 0000000..d93248a --- /dev/null +++ b/test.txt @@ -0,0 +1,710 @@ +================================================================================ +MANUAL TEST PLAN — write-api self-hosting starter +================================================================================ + +Every avenue the project claims to support, in an order that builds on itself. +Work top to bottom, or jump to a section — each one says what state it assumes. + +Tick the [ ] as you go. Where a step has an expected result, it is marked with +"->". If something differs from the "->" line, that is the bug, whether the +fault is in the code or in the documentation. + + +-------------------------------------------------------------------------------- +BEFORE YOU START +-------------------------------------------------------------------------------- + +[ ] Docker Desktop running, with at least 6 GB of memory allocated. SQL Server + alone wants ~2 GB and runs under emulation on Apple Silicon. + +[ ] Working from a clean checkout of this branch, at the repo root. + +[ ] Nothing already on ports 5173, 6060, 8080, 5432, 3306 or 1433: + + lsof -nP -iTCP:6060 -iTCP:8080 -iTCP:5173 -sTCP:LISTEN + + -> no output. If a stray backend from a previous run is there, kill it. + +[ ] Know the escape hatch. Any time you want to start over from nothing: + + docker compose down -v --remove-orphans + docker ps -aq --filter "name=write-api-" | xargs -r docker rm -f + + The second line matters because each mode is its own Compose project; + `down` only reaches the project currently named in .env. + +Rough timings on an M-series laptop, cold (image pulls included): + postgres ~1 min mongodb ~1 min + mysql ~2 min mssql ~5 min, and that is not a hang + + +-------------------------------------------------------------------------------- +1. QUICKSTART — THE PATH IN THE README +-------------------------------------------------------------------------------- + +Assumes: nothing running. This is the "clone it and see it work" claim. + +[ ] Confirm .env is untouched from the checkout: + + grep '^COMPOSE_FILE' .env + + -> COMPOSE_FILE=docker-compose.yaml:examples/postgres/compose.yaml + +[ ] Bring it up with no other configuration at all: + + docker compose up --build + + -> images build, four containers plus the client come up, and the log + settles. You are looking for the write API's "Server is running" and + PowerSync reaching "Replication active" / no restart loop. + +[ ] In a second terminal: + + curl -s http://localhost:6060/ + curl -s http://localhost:8080/probes/liveness + + -> {"message":"backend"} and a 200. + +[ ] Open http://localhost:5173 + + -> a todo list named "Getting Started" with two items, one ticked. If the + page loads but the list is empty, sync is broken — not the client. + +[ ] Add a todo in the UI. + + -> it appears immediately (local write), and stays after a page refresh + (it round-tripped through the write API, into Postgres, and back out + through replication). + +[ ] Prove it really reached the source database, not just local storage: + + docker compose exec pg-db psql -U postgres -d postgres \ + -c "select description, completed from todos order by created_at desc limit 5;" + + -> your new todo is the top row. + +[ ] Tick the todo's checkbox in the UI, re-run that query. + + -> completed flipped to t. (PUT and PATCH both go through /api/data.) + +[ ] Delete a todo in the UI, re-run the query. + + -> the row is gone. That is the third CRUD op exercised. + +Leave this running for section 2. + + +-------------------------------------------------------------------------------- +2. THE API DIRECTLY, WITHOUT THE CLIENT +-------------------------------------------------------------------------------- + +Assumes: section 1 is up. This is how an adopter with their own client will +actually drive it. + +[ ] Mint a token: + + TOKEN=$(curl -s "http://localhost:6060/api/auth/token?user_id=manual" \ + | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])") + echo "${TOKEN:0:24}..." + + -> a JWT prefix, not empty. + +[ ] Check the key the service verifies against is being served: + + curl -s http://localhost:6060/api/auth/keys + + -> a JWKS document with one key, and a "kid". + +[ ] Write a transaction batch: + + curl -s -X POST http://localhost:6060/api/data \ + -H "Authorization: Bearer $TOKEN" \ + -H 'content-type: application/json' \ + -d '{"transactions":[{"transaction_id":1,"crud":[ + {"op":"PUT","table":"todos","id":"11111111-1111-1111-1111-111111111111", + "op_data":{"description":"manual test row","completed":false, + "list_id":"75f89104-d95a-4f16-8309-5363f1bb377a"}}]}]}' + + -> {"status":"success",...} + +[ ] Read it back out of the sync stream — this proves persistence AND + replication in one shot: + + curl -sN -m 15 -X POST http://localhost:8080/sync/stream \ + -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + -d '{"buckets":[],"include_checksum":true,"raw_data":true}' \ + | head -c 20000 | grep -c "manual test row" + + -> 1 or more. (Ctrl-C if it hangs after printing; the stream stays open by + design.) + +[ ] Refresh http://localhost:5173 + + -> "manual test row" is in the list. A write made outside the client showed + up inside it. + +[ ] Now the contract. Send a request that violates openapi.yaml: + + curl -s -X POST http://localhost:6060/api/data \ + -H "Authorization: Bearer $TOKEN" \ + -H 'content-type: application/json' \ + -d '{"transactions":"not-an-array"}' + + -> a 400 naming the offending field, from express-openapi-validator. Not a + 500, and not a success. + +[ ] And auth. Same request, no token: + + curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:6060/api/data \ + -H 'content-type: application/json' -d '{"transactions":[]}' + + -> 401. + +[ ] Tear down, keeping the volume: + + docker compose down + + +-------------------------------------------------------------------------------- +3. THE OTHER THREE EXAMPLE DATABASES +-------------------------------------------------------------------------------- + +Assumes: nothing running. Repeat this block per flavour. The point is that +switching source database is ONE line in .env. + +For each flavour below: + + a. Bring the current mode down FIRST (see section 4 for why): + docker compose down -v + + b. Edit .env: comment out the current COMPOSE_FILE line, uncomment the one + for this flavour. Exactly one uncommented. + + c. docker compose up --build + + d. Wait for health: + curl -s http://localhost:6060/ && curl -s http://localhost:8080/probes/liveness + + e. Open http://localhost:5173, add a todo, refresh. + + f. Run the flavour's verification query below. + +--- 3a. MongoDB ----------------------------------------------------------------- + + COMPOSE_FILE=docker-compose.yaml:examples/mongodb/compose.yaml + +[ ] Note what is NOT there: no source database container. Replication points at + a second database on the same Mongo server that holds bucket storage. + + docker compose ps --services + + -> mongo, mongo-rs-init, backend, powersync, frontend. No "mongo-db". + +[ ] Open the client. + + -> EMPTY. This is the one flavour that ships no seed data, deliberately. + An empty list here is correct, not a sync failure. + +[ ] Create a list in the UI, then add a todo to it. + +[ ] Verify: + + docker compose exec mongo mongosh powersync_demo_source --quiet \ + --eval 'db.todos.find({}, {description:1, completed:1}).toArray()' + + -> your todo, with an _id. + +[ ] Confirm the two stores are genuinely separate databases: + + docker compose exec mongo mongosh --quiet --eval 'db.adminCommand({listDatabases:1}).databases.map(d=>d.name)' + + -> both powersync_demo_source and powersync_bucket_storage listed, distinct. + +[ ] Confirm the _id -> id projection works (this is what the sync rules do): + the todo you created is visible in the client after a refresh. If documents + sync but the client shows nothing, the projection is the suspect. + +--- 3b. MySQL (Beta) ------------------------------------------------------------ + + COMPOSE_FILE=docker-compose.yaml:examples/mysql/compose.yaml + +[ ] Client shows "Getting Started" with two todos. Add one. + +[ ] Verify: + + docker compose exec mysql-db mysql -uroot -pmypassword powersync_demo \ + -e "select description, completed from todos;" + +[ ] Confirm the binlog prerequisites actually took (this is what the root + README's table demands of an adopter's own MySQL): + + docker compose exec mysql-db mysql -uroot -pmypassword \ + -e "select @@log_bin, @@gtid_mode, @@enforce_gtid_consistency, @@binlog_format, @@binlog_row_image;" + + -> 1, ON, ON, ROW, FULL. + +--- 3c. SQL Server (Beta) ------------------------------------------------------- + + COMPOSE_FILE=docker-compose.yaml:examples/mssql/compose.yaml + +[ ] Be patient on first start. The mssql-setup container must finish before + PowerSync starts; watch it: + + docker compose logs -f mssql-setup + + -> it runs the CDC bootstrap and exits 0. If it exits non-zero, nothing + downstream will work and that log is the whole story. + +[ ] Client shows "Getting Started" with two todos. Add one. + +[ ] Verify: + + docker compose exec mssql-db /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P 'Powersync_demo_pw1' -C -d powersync_demo \ + -Q "select description, completed from dbo.todos" + +[ ] Confirm CDC is on AND the Agent is running — the pair of things that fail + silently together: + + docker compose exec mssql-db /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P 'Powersync_demo_pw1' -C -d powersync_demo \ + -Q "select name, is_cdc_enabled from sys.databases where name='powersync_demo'" + + -> is_cdc_enabled = 1. + +[ ] Finish with: + + docker compose down -v + + +-------------------------------------------------------------------------------- +4. SWITCHING MODES — INCLUDING GETTING IT WRONG +-------------------------------------------------------------------------------- + +Assumes: nothing running. This tests the warning in the README rather than the +happy path, because this is the mistake everyone makes once. + +[ ] Select postgres in .env and bring it up: docker compose up -d + +[ ] WITHOUT bringing it down, edit .env to select mysql, then: + + docker compose up -d + + -> it fails with something like + "Bind for 0.0.0.0:6060 failed: port is already allocated". + The postgres containers are still running, under their own project name. + +[ ] Confirm both projects exist: + + docker ps --format '{{.Names}}' | sort + + -> write-api-postgres-* containers still up. + +[ ] Recover the documented way: switch .env BACK to postgres, down, then + switch forward: + + # .env -> postgres + docker compose down + # .env -> mysql + docker compose up -d + + -> clean start. + +[ ] Confirm the projects really are isolated (so switching never reuses the + previous flavour's data): + + docker volume ls | grep write-api + + -> volumes are namespaced per project, e.g. write-api-postgres_pg_data and + write-api-mysql_mysql_data. + +[ ] Now the explicit form, with no .env involvement at all: + + docker compose down + docker compose -f docker-compose.yaml -f examples/postgres/compose.yaml up -d + curl -s http://localhost:6060/ + + -> works identically. Then: + docker compose -f docker-compose.yaml -f examples/postgres/compose.yaml down + + +-------------------------------------------------------------------------------- +5. CHANGING THE BACKEND CODE +-------------------------------------------------------------------------------- + +Assumes: nothing running. Two loops are claimed — the fast one and the +deployment-shaped one. Test both. + +--- 5a. The development overlay (no rebuild) ----------------------------------- + +[ ] In .env, append the dev overlay to the postgres line: + + COMPOSE_FILE=docker-compose.yaml:examples/postgres/compose.yaml:docker-compose.dev.yaml + +[ ] docker compose up --build (the build is only needed the first time) + +[ ] Confirm the backend is running under watch, not the built image: + + docker compose logs backend | tail -20 + + -> tsx watch output, and "Server is running". + +[ ] Edit backend/app.ts — change the root route's body: + + res.status(200).send({ message: 'backend' }); + -> + res.status(200).send({ message: 'edited live' }); + +[ ] Save, wait ~2 seconds, then: + + curl -s http://localhost:6060/ + + -> {"message":"edited live"} with NO rebuild and NO restart command. + +[ ] Confirm node_modules were not clobbered by the bind mount (the macOS + native-binary trap): + + docker compose exec backend node -e "require('pg'); console.log('deps ok')" + + -> deps ok. A crash here means the host's node_modules leaked in. + +[ ] Revert the edit, confirm it reverts live. + +[ ] docker compose down + +--- 5b. The rebuild path ------------------------------------------------------- + +[ ] Remove the dev overlay from the .env line, leaving plain postgres mode. + +[ ] docker compose up -d --build then curl -s http://localhost:6060/ + + -> {"message":"backend"} — the original, from the image. + +[ ] Edit backend/app.ts again to 'rebuilt', then WITHOUT rebuilding: + + docker compose restart backend + curl -s http://localhost:6060/ + + -> still {"message":"backend"}. The image is what runs; a restart is not + enough. This is the point of the distinction. + +[ ] Now rebuild: + + docker compose up -d --build backend + curl -s http://localhost:6060/ + + -> {"message":"rebuilt"}. + +[ ] Revert the edit and rebuild once more so the tree is clean. + +--- 5c. The client's own loop -------------------------------------------------- + +[ ] With a mode up in Docker, run the client on the host instead: + + cd frontend + cp .env.template .env.local + pnpm install && pnpm dev + + -> http://localhost:5173 (Vite's own port) serves the same app, reading + .env.local at runtime, reloading on save. Note the containerised client + may also be on 5173 — stop it first, or expect Vite to pick 5174. + +[ ] Edit a label in the client source, save. + + -> the browser updates without a rebuild. + + +-------------------------------------------------------------------------------- +6. ADOPTER MODE — YOUR OWN DATABASE +-------------------------------------------------------------------------------- + +Assumes: nothing running. This is the real product; sections 1-3 are scaffolding. +We fake "your own database" with a Postgres that this project does not manage. + +[ ] Start a database OUTSIDE the project, on a different port: + + docker run -d --name adopter-db -p 5433:5432 \ + -e POSTGRES_PASSWORD=adopterpw -e POSTGRES_DB=adopter \ + postgres:18 -c wal_level=logical + +[ ] Prepare it the way the root README's table demands — schema, then the + publication that is easy to forget: + + docker exec adopter-db psql -U postgres -d adopter -c \ + "create table widgets (id uuid primary key default gen_random_uuid(), name text not null, owner_id uuid)" + + docker exec adopter-db psql -U postgres -d adopter -c \ + "insert into widgets (name, owner_id) values ('first widget', gen_random_uuid())" + + docker exec adopter-db psql -U postgres -d adopter -c \ + "create publication powersync for table widgets" + +[ ] Point the project at it. In .env: + + COMPOSE_FILE=docker-compose.yaml + DATABASE_TYPE=postgres + DATABASE_URI=postgres://postgres:adopterpw@host.docker.internal:5433/adopter + + (host.docker.internal, not localhost — inside a container localhost is the + container.) + +[ ] Describe the schema in config/sync-config.yaml — replace the commented + example under `streams:` with: + + streams: + widgets: + auto_subscribe: true + queries: + - SELECT * FROM widgets + +[ ] docker compose up --build + + -> backend, powersync, mongo (bucket storage) only. NO source database + container and NO client: + + docker compose ps --services + +[ ] Prove the round trip without a client: + + TOKEN=$(curl -s "http://localhost:6060/api/auth/token?user_id=manual" \ + | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])") + + curl -sN -m 15 -X POST http://localhost:8080/sync/stream \ + -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + -d '{"buckets":[],"include_checksum":true,"raw_data":true}' \ + | head -c 20000 | grep -c "first widget" + + -> 1 or more. Your own row, replicating. + +[ ] Write into your own table through the API: + + curl -s -X POST http://localhost:6060/api/data \ + -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + -d '{"transactions":[{"transaction_id":1,"crud":[ + {"op":"PUT","table":"widgets","id":"22222222-2222-2222-2222-222222222222", + "op_data":{"name":"written by the API"}}]}]}' + + docker exec adopter-db psql -U postgres -d adopter -c "select name from widgets;" + + -> "written by the API" is there. + +[ ] THE IMPORTANT ONE — confirm we did not colonise your database: + + docker exec adopter-db psql -U postgres -d adopter -c "\dn" + docker exec adopter-db psql -U postgres -d adopter \ + -c "select tablename from pg_tables where schemaname='public';" + + -> only your own schema and the widgets table. No powersync_* anything. + Bucket storage lives in this project's mongo container; check it is + genuinely there: + + docker compose exec mongo mongosh --quiet \ + --eval 'db.adminCommand({listDatabases:1}).databases.map(d=>d.name)' + + -> powersync_bucket_storage present. + +[ ] Clean up when done with section 6 (keep adopter-db for section 7 if you + want, it costs nothing): + + docker compose down -v + + +-------------------------------------------------------------------------------- +7. FAILURE PATHS — THE MESSAGES SOMEONE WILL ACTUALLY HIT +-------------------------------------------------------------------------------- + +Assumes: Adopter Mode selected in .env, nothing running. Each of these is a +mistake the docs promise to catch. If a message is unhelpful, that is a finding. + +[ ] Missing URI. Comment out DATABASE_URI in .env, then: + + docker compose up backend + + -> the backend exits, having printed a message that names DATABASE_URI, + shows the URI shape, AND offers the example-mode alternative. It must + NOT loop forever: it gives up after a handful of attempts so the message + stays on screen. + +[ ] Unsupported type. Restore DATABASE_URI, set DATABASE_TYPE=postgresql + (the near-miss spelling PowerSync's own config uses), then: + + docker compose up backend + + -> a message saying postgresql is not supported and listing exactly: + mongodb, mssql, mysql, postgres. Set it back to postgres afterwards. + +[ ] Empty sync rules. Blank out the `streams:` entries you added to + config/sync-config.yaml (leave the key with nothing under it), then: + + docker compose up + + -> PowerSync restarts in a loop logging that 'streams' are required. This + is documented as expected, not a bug — confirm the log actually says + something you could act on. Restore the file afterwards. + +[ ] Forgetting auto_subscribe. Restore the stream but DELETE the + `auto_subscribe: true` line, then restart: + + docker compose up -d + docker compose restart powersync + # re-run the sync stream curl from section 6 + + -> zero rows come back, and NOTHING anywhere reports an error. This is the + silent failure the docs warn about; confirm with your own eyes that it + is silent, because that is why the warning exists. Restore the line. + +[ ] Unstable signing keys. Blank POWERSYNC_PRIVATE_KEY and POWERSYNC_PUBLIC_KEY + in .env, then: + + docker compose up -d + # mint a token, then: + docker compose restart backend + # use the OLD token against the sync stream + + -> PSYNC_S2101 "Could not find an appropriate key in the keystore". Now + generate a stable pair and confirm the failure goes away: + + cd backend && pnpm generate-keys + + -> prints both values; paste them into .env, `docker compose up -d`, and a + token survives a backend restart. Restore the committed pair when done, + or keep the new one — either is fine, they are both throwaway. + + +-------------------------------------------------------------------------------- +8. THE TROUBLESHOOTING CLAIMS +-------------------------------------------------------------------------------- + +Assumes: postgres Example Mode, up. Each of these is an assertion in the +README's troubleshooting section; each should be falsifiable. + +[ ] "Sync config changes do nothing — the service reads them at boot." + Edit examples/postgres/powersync/sync-config.yaml, narrowing the stream: + + - SELECT * FROM lists + (delete the todos line) + + -> the client still shows todos. Then: + + docker compose restart powersync + + -> after a refresh, todos are gone from the client, lists remain. Restore + the line and restart again. + +[ ] "Schema or seed changes do nothing — init scripts only run on first start." + Add a row to examples/postgres/init-scripts/setup.sql, then: + + docker compose restart pg-db + + -> the new row is absent. Then: + + docker compose down -v && docker compose up -d + + -> now it is there. Revert the SQL change. + +[ ] "Port already in use." Start something on 6060 on the host, then + `docker compose up`. + + -> a bind error naming 6060. (`pnpm start` in backend/ is the realistic way + to cause this by accident.) + + +-------------------------------------------------------------------------------- +9. THE AUTOMATED SUITES +-------------------------------------------------------------------------------- + +Assumes: nothing running; neither suite needs Docker running containers, +though the root one shells out to `docker compose config`. + +[ ] Root suite — resolves every mode's topology without starting anything: + + pnpm install && pnpm test + + -> all green, in about a second. Nothing was pulled and nothing started; + confirm with `docker ps` before and after. + +[ ] Backend suite: + + cd backend && pnpm test + + -> all green. No Docker involved at all. + +[ ] Typecheck both: + + cd backend && pnpm check + +[ ] Mutation-check the root suite, so you know it is load-bearing. In + examples/postgres/compose.yaml, change the powersync config mount target: + + - ./examples/postgres/powersync:/config + -> + - ./examples/postgres/powersync:/config-typo + + -> `pnpm test` at the root goes red, and says which mode broke. Revert. + (Without this guard the stack would come up perfectly healthy pointing + at the wrong sync rules — hence the test.) + +[ ] Contract generation still works from the shared spec: + + cd backend && pnpm generate-types + cd ../frontend && pnpm generate + git status --short + + -> no diff, i.e. the committed generated types match the contract. + + +-------------------------------------------------------------------------------- +10. DELETING THE EXAMPLES +-------------------------------------------------------------------------------- + +Assumes: nothing running. The README invites an adopter to delete examples/ +in one go. This checks that the invitation is honest. Do this on a scratch +branch or with the diff thrown away afterwards. + +[ ] git switch -c scratch/delete-examples + +[ ] rm -rf examples/ frontend/ + +[ ] Set .env to Adopter Mode (COMPOSE_FILE=docker-compose.yaml) with the + adopter-db URI from section 6, and config/sync-config.yaml filled in. + +[ ] docker compose up --build + + -> it comes up. Nothing in the base compose file, the backend, or config/ + depended on the deleted directories. + +[ ] Read README.md as if for the first time. + + -> the "Your database needs preparing first" table is still there and still + tells you what your own database needs, with no surviving link you must + follow into the deleted examples/ to be able to proceed. Any instruction + that now dead-ends is a documentation bug. + +[ ] cd backend && pnpm test -> still green. +[ ] pnpm test (root) -> the topology suite will fail, since the modes + it resolves are gone. Note whether that is + acceptable or should be handled. + +[ ] git switch - && git branch -D scratch/delete-examples + + +-------------------------------------------------------------------------------- +TEARDOWN +-------------------------------------------------------------------------------- + +[ ] docker compose down -v --remove-orphans +[ ] docker ps -aq --filter "name=write-api-" | xargs -r docker rm -f +[ ] docker rm -f adopter-db +[ ] git status --short -> clean, apart from .env if you changed keys. + + +-------------------------------------------------------------------------------- +NOTES / FINDINGS +-------------------------------------------------------------------------------- + +Section What happened Verdict +------- ------------------------------------------------- ------- + + + + diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..31d611e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // Only this repo's own topology tests. The backend and frontend own their suites, and the + // backend's must stay runnable on a machine with no Docker. + include: ['tests/**/*.test.ts'], + testTimeout: 30000 + } +});