diff --git a/.changeset/cold-items-explain.md b/.changeset/cold-items-explain.md
new file mode 100644
index 000000000..78baa7bfc
--- /dev/null
+++ b/.changeset/cold-items-explain.md
@@ -0,0 +1,5 @@
+---
+'@powersync/service-module-mongodb': patch
+---
+
+Fix diagnostics schema authorization issues for MongoDB
diff --git a/.changeset/fifty-dogs-reply.md b/.changeset/fifty-dogs-reply.md
new file mode 100644
index 000000000..d19c4b701
--- /dev/null
+++ b/.changeset/fifty-dogs-reply.md
@@ -0,0 +1,5 @@
+---
+'@powersync/service-module-mongodb': minor
+---
+
+Reduce permissions required for replicating a single mongodb database
diff --git a/.changeset/gentle-icons-try.md b/.changeset/gentle-icons-try.md
new file mode 100644
index 000000000..103672131
--- /dev/null
+++ b/.changeset/gentle-icons-try.md
@@ -0,0 +1,5 @@
+---
+'@powersync/service-module-mysql': patch
+---
+
+Fixed MySQL version checking to better handle non-semantic version strings
diff --git a/.changeset/green-peas-roll.md b/.changeset/green-peas-roll.md
new file mode 100644
index 000000000..61762c082
--- /dev/null
+++ b/.changeset/green-peas-roll.md
@@ -0,0 +1,6 @@
+---
+'@powersync/service-module-mongodb': minor
+'@powersync/service-image': minor
+---
+
+Add MongoDB support (Alpha)
diff --git a/.changeset/healthy-rules-arrive.md b/.changeset/healthy-rules-arrive.md
new file mode 100644
index 000000000..a64c34b1b
--- /dev/null
+++ b/.changeset/healthy-rules-arrive.md
@@ -0,0 +1,5 @@
+---
+'@powersync/service-module-mysql': patch
+---
+
+Fixed mysql schema json parsing
diff --git a/.changeset/heavy-shirts-chew.md b/.changeset/heavy-shirts-chew.md
new file mode 100644
index 000000000..9bcb5e662
--- /dev/null
+++ b/.changeset/heavy-shirts-chew.md
@@ -0,0 +1,5 @@
+---
+'@powersync/service-core': patch
+---
+
+Improved sync rules storage cached parsed sync rules, accommodating different parsing options where necessary.
diff --git a/.changeset/lemon-terms-play.md b/.changeset/lemon-terms-play.md
new file mode 100644
index 000000000..218d1ebff
--- /dev/null
+++ b/.changeset/lemon-terms-play.md
@@ -0,0 +1,8 @@
+---
+'@powersync/service-module-mysql': minor
+---
+
+Generate random serverId based on syncrule id for MySQL replication client
+Consolidated type mappings between snapshot and replicated values
+Enabled MySQL tests in CI
+
diff --git a/.changeset/olive-spoons-stare.md b/.changeset/olive-spoons-stare.md
new file mode 100644
index 000000000..df58ecaf4
--- /dev/null
+++ b/.changeset/olive-spoons-stare.md
@@ -0,0 +1,5 @@
+---
+'@powersync/service-core': patch
+---
+
+Moved tag variable initialization in diagnostics route to ensure it is initialized before usage
diff --git a/.changeset/orange-eagles-tap.md b/.changeset/orange-eagles-tap.md
new file mode 100644
index 000000000..7a21eec33
--- /dev/null
+++ b/.changeset/orange-eagles-tap.md
@@ -0,0 +1,5 @@
+---
+'@powersync/lib-services-framework': minor
+---
+
+Added disposable listeners and observers
diff --git a/.changeset/popular-snails-cough.md b/.changeset/popular-snails-cough.md
new file mode 100644
index 000000000..5dc9e2d4d
--- /dev/null
+++ b/.changeset/popular-snails-cough.md
@@ -0,0 +1,6 @@
+---
+'@powersync/service-core': minor
+'@powersync/service-sync-rules': minor
+---
+
+Added ability to emit data replication events
diff --git a/.changeset/rotten-pumas-protect.md b/.changeset/rotten-pumas-protect.md
new file mode 100644
index 000000000..65fc44e61
--- /dev/null
+++ b/.changeset/rotten-pumas-protect.md
@@ -0,0 +1,9 @@
+---
+'@powersync/service-core': minor
+'@powersync/service-module-mysql': minor
+'@powersync/service-sync-rules': minor
+---
+
+Introduced alpha support for MySQL as a datasource for replication.
+Bunch of cleanup
+
diff --git a/.changeset/slow-stingrays-kiss.md b/.changeset/slow-stingrays-kiss.md
new file mode 100644
index 000000000..a93126de7
--- /dev/null
+++ b/.changeset/slow-stingrays-kiss.md
@@ -0,0 +1,5 @@
+---
+'@powersync/service-core': minor
+---
+
+Moved Write Checkpoint APIs to SyncBucketStorage
diff --git a/.changeset/sour-turkeys-collect.md b/.changeset/sour-turkeys-collect.md
new file mode 100644
index 000000000..d9bc279fb
--- /dev/null
+++ b/.changeset/sour-turkeys-collect.md
@@ -0,0 +1,7 @@
+---
+'@powersync/service-module-postgres': patch
+'@powersync/service-rsocket-router': patch
+'@powersync/service-types': patch
+---
+
+Updates from Replication events changes
diff --git a/.changeset/tender-vans-impress.md b/.changeset/tender-vans-impress.md
new file mode 100644
index 000000000..106960369
--- /dev/null
+++ b/.changeset/tender-vans-impress.md
@@ -0,0 +1,16 @@
+---
+'@powersync/service-core': minor
+'@powersync/service-sync-rules': minor
+'@powersync/lib-services-framework': minor
+'@powersync/service-jpgwire': minor
+'@powersync/service-types': minor
+'@powersync/service-image': major
+'@powersync/service-module-postgres': patch
+---
+
+- Introduced modules to the powersync service architecture
+ - Core functionality has been moved to "engine" classes. Modules can register additional functionality with these engines.
+ - The sync API functionality used by the routes has been abstracted to an interface. API routes are now managed by the RouterEngine.
+ - Replication is managed by the ReplicationEngine and new replication data sources can be registered to the engine by modules.
+- Refactored existing Postgres replication as a module.
+- Removed Postgres specific code from the core service packages.
diff --git a/.changeset/violet-garlics-know.md b/.changeset/violet-garlics-know.md
new file mode 100644
index 000000000..cb973e611
--- /dev/null
+++ b/.changeset/violet-garlics-know.md
@@ -0,0 +1,5 @@
+---
+'@powersync/service-sync-rules': minor
+---
+
+Support json_each as a table-valued function.
diff --git a/.changeset/weak-cats-hug.md b/.changeset/weak-cats-hug.md
new file mode 100644
index 000000000..df152386e
--- /dev/null
+++ b/.changeset/weak-cats-hug.md
@@ -0,0 +1,5 @@
+---
+'@powersync/service-sync-rules': minor
+---
+
+Optionally include original types in generated schemas as a comment.
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index a1a8d4899..9f5fbd055 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -22,7 +22,7 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- - name: Build and push
+ - name: Test Build Docker Image
uses: docker/build-push-action@v5
with:
cache-from: type=registry,ref=stevenontong/${{vars.DOCKER_REGISTRY}}:cache
@@ -30,14 +30,59 @@ jobs:
platforms: linux/amd64
push: false
file: ./service/Dockerfile
- # TODO remove this when removing Journey Micro
- build-args: |
- GITHUB_TOKEN=${{secrets.RESTRICTED_PACKAGES_TOKEN}}
- run-tests:
- name: Test
+ run-core-tests:
+ name: Core Test
runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Start MongoDB
+ uses: supercharge/mongodb-github-action@1.8.0
+ with:
+ mongodb-version: '6.0'
+ mongodb-replica-set: test-rs
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version-file: '.nvmrc'
+
+ - uses: pnpm/action-setup@v4
+ name: Install pnpm
+ with:
+ version: 9
+ run_install: false
+
+ - name: Get pnpm store directory
+ shell: bash
+ run: |
+ echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
+
+ - uses: actions/cache@v3
+ name: Setup pnpm cache
+ with:
+ path: ${{ env.STORE_PATH }}
+ key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
+ restore-keys: |
+ ${{ runner.os }}-pnpm-store-
+
+ - name: Install dependencies
+ run: pnpm install
+
+ - name: Build
+ shell: bash
+ run: pnpm build
+
+ - name: Test
+ run: pnpm test --filter '!./modules/*'
+
+ run-postgres-tests:
+ name: Postgres Test
+ runs-on: ubuntu-latest
+ needs: run-core-tests
+
strategy:
fail-fast: false
matrix:
@@ -97,4 +142,123 @@ jobs:
run: pnpm build
- name: Test
- run: pnpm test
+ run: pnpm test --filter='./modules/module-postgres'
+
+ run-mysql-tests:
+ name: MySQL Test
+ runs-on: ubuntu-latest
+ needs: run-core-tests
+
+ strategy:
+ fail-fast: false
+ matrix:
+ mysql-version: [5.7, 8.0, 8.4]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Start MySQL
+ run: |
+ docker run \
+ --name MySQLTestDatabase \
+ -e MYSQL_ROOT_PASSWORD=mypassword \
+ -e MYSQL_DATABASE=mydatabase \
+ -p 3306:3306 \
+ -d mysql:${{ matrix.mysql-version }} \
+ --log-bin=/var/lib/mysql/mysql-bin.log \
+ --gtid_mode=ON \
+ --enforce_gtid_consistency=ON \
+ --server-id=1
+
+ - name: Start MongoDB
+ uses: supercharge/mongodb-github-action@1.8.0
+ with:
+ mongodb-version: '6.0'
+ mongodb-replica-set: test-rs
+
+ - name: Setup NodeJS
+ uses: actions/setup-node@v4
+ with:
+ node-version-file: '.nvmrc'
+
+ - uses: pnpm/action-setup@v4
+ name: Install pnpm
+ with:
+ version: 9
+ run_install: false
+
+ - name: Get pnpm store directory
+ shell: bash
+ run: |
+ echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
+
+ - uses: actions/cache@v3
+ name: Setup pnpm cache
+ with:
+ path: ${{ env.STORE_PATH }}
+ key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
+ restore-keys: |
+ ${{ runner.os }}-pnpm-store-
+
+ - name: Install dependencies
+ run: pnpm install
+
+ - name: Build
+ shell: bash
+ run: pnpm build
+
+ - name: Test
+ run: pnpm test --filter='./modules/module-mysql'
+
+ run-mongodb-tests:
+ name: MongoDB Test
+ runs-on: ubuntu-latest
+ needs: run-core-tests
+
+ strategy:
+ fail-fast: false
+ matrix:
+ mongodb-version: ['6.0', '7.0', '8.0']
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Start MongoDB
+ uses: supercharge/mongodb-github-action@1.8.0
+ with:
+ mongodb-version: ${{ matrix.mongodb-version }}
+ mongodb-replica-set: test-rs
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version-file: '.nvmrc'
+
+ - uses: pnpm/action-setup@v4
+ name: Install pnpm
+ with:
+ version: 9
+ run_install: false
+
+ - name: Get pnpm store directory
+ shell: bash
+ run: |
+ echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
+
+ - uses: actions/cache@v3
+ name: Setup pnpm cache
+ with:
+ path: ${{ env.STORE_PATH }}
+ key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
+ restore-keys: |
+ ${{ runner.os }}-pnpm-store-
+
+ - name: Install dependencies
+ run: pnpm install
+
+ - name: Build
+ shell: bash
+ run: pnpm build
+
+ - name: Test
+ run: pnpm test --filter='./modules/module-mongodb'
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 000000000..a9893037d
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,5 @@
+**/.git
+**/node_modules
+dist
+lib
+pnpm-lock.yaml
diff --git a/README.md b/README.md
index 06a11cf0a..1e9ac4fbb 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-*[PowerSync](https://www.powersync.com) is a sync engine for building local-first apps with instantly-responsive UI/UX and simplified state transfer. Syncs between SQLite on the client-side and Postgres, MongoDB or MySQL on the server-side.*
+_[PowerSync](https://www.powersync.com) is a sync engine for building local-first apps with instantly-responsive UI/UX and simplified state transfer. Syncs between SQLite on the client-side and Postgres, MongoDB or MySQL on the server-side._
# PowerSync Service
@@ -11,6 +11,7 @@
The service can be started using the public Docker image. See the image [notes](./service/README.md)
# Monorepo Structure:
+
## Packages
- [packages/service-core](./packages/service-core/README.md)
@@ -52,13 +53,13 @@ Contains the PowerSync Service code. This project is used to build the `journeya
- [docs](./docs/README.md)
-Technical documentation regarding the implementation of PowerSync.
+Technical documentation regarding the implementation of PowerSync.
## Test Client
- [test-client](./test-client/README.md)
-Contains a minimal client demonstrating direct usage of the HTTP stream sync API. This can be used to test sync rules in contexts such as automated testing.
+Contains a minimal client demonstrating direct usage of the HTTP stream sync API. This can be used to test sync rules in contexts such as automated testing.
# Developing
diff --git a/libs/lib-services/package.json b/libs/lib-services/package.json
index 77e175aa3..add5fc587 100644
--- a/libs/lib-services/package.json
+++ b/libs/lib-services/package.json
@@ -26,11 +26,13 @@
"dotenv": "^16.4.5",
"lodash": "^4.17.21",
"ts-codec": "^1.2.2",
+ "uuid": "^9.0.1",
"winston": "^3.13.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/lodash": "^4.17.5",
- "vitest": "^0.34.6"
+ "@types/uuid": "^9.0.4",
+ "vitest": "^2.1.1"
}
}
diff --git a/libs/lib-services/src/container.ts b/libs/lib-services/src/container.ts
index 0448a9f0d..4e015284f 100644
--- a/libs/lib-services/src/container.ts
+++ b/libs/lib-services/src/container.ts
@@ -23,14 +23,34 @@ export type ContainerImplementationDefaultGenerators = {
[type in ContainerImplementation]: () => ContainerImplementationTypes[type];
};
+/**
+ * Helper for identifying constructors
+ */
+export interface Abstract {
+ prototype: T;
+}
+/**
+ * A basic class constructor
+ */
+export type Newable = new (...args: never[]) => T;
+
+/**
+ * Identifier used to get and register implementations
+ */
+export type ServiceIdentifier = string | symbol | Newable | Abstract | ContainerImplementation;
+
const DEFAULT_GENERATORS: ContainerImplementationDefaultGenerators = {
[ContainerImplementation.REPORTER]: () => NoOpReporter,
[ContainerImplementation.PROBES]: () => createFSProbe(),
[ContainerImplementation.TERMINATION_HANDLER]: () => createTerminationHandler()
};
+/**
+ * A container which provides means for registering and getting various
+ * function implementations.
+ */
export class Container {
- protected implementations: Partial;
+ protected implementations: Map, any>;
/**
* Manager for system health probes
@@ -54,13 +74,39 @@ export class Container {
}
constructor() {
- this.implementations = {};
+ this.implementations = new Map();
+ }
+
+ /**
+ * Gets an implementation given an identifier.
+ * An exception is thrown if the implementation has not been registered.
+ * Core [ContainerImplementation] identifiers are mapped to their respective implementation types.
+ * This also allows for getting generic implementations (unknown to the core framework) which have been registered.
+ */
+ getImplementation(identifier: Newable | Abstract): T;
+ getImplementation(identifier: T): ContainerImplementationTypes[T];
+ getImplementation(identifier: ServiceIdentifier): T;
+ getImplementation(identifier: ServiceIdentifier): T {
+ const implementation = this.implementations.get(identifier);
+ if (!implementation) {
+ throw new Error(`Implementation for ${String(identifier)} has not been registered.`);
+ }
+ return implementation;
}
- getImplementation(type: Type) {
- const implementation = this.implementations[type];
+ /**
+ * Gets an implementation given an identifier.
+ * Null is returned if the implementation has not been registered yet.
+ * Core [ContainerImplementation] identifiers are mapped to their respective implementation types.
+ * This also allows for getting generic implementations (unknown to the core framework) which have been registered.
+ */
+ getOptional(identifier: Newable | Abstract): T | null;
+ getOptional(identifier: T): ContainerImplementationTypes[T] | null;
+ getOptional(identifier: ServiceIdentifier): T | null;
+ getOptional(identifier: ServiceIdentifier): T | null {
+ const implementation = this.implementations.get(identifier);
if (!implementation) {
- throw new Error(`Implementation for ${type} has not been registered.`);
+ return null;
}
return implementation;
}
@@ -71,15 +117,15 @@ export class Container {
registerDefaults(options?: RegisterDefaultsOptions) {
_.difference(Object.values(ContainerImplementation), options?.skip ?? []).forEach((type) => {
const generator = DEFAULT_GENERATORS[type];
- this.implementations[type] = generator() as any; // :(
+ this.register(type, generator());
});
}
/**
- * Allows for overriding a default implementation
+ * Allows for registering core and generic implementations of services/helpers.
*/
- register(type: Type, implementation: ContainerImplementationTypes[Type]) {
- this.implementations[type] = implementation;
+ register(identifier: ServiceIdentifier, implementation: T) {
+ this.implementations.set(identifier, implementation);
}
}
diff --git a/libs/lib-services/src/system/LifeCycledSystem.ts b/libs/lib-services/src/system/LifeCycledSystem.ts
index 3cd77c938..bcbc911be 100644
--- a/libs/lib-services/src/system/LifeCycledSystem.ts
+++ b/libs/lib-services/src/system/LifeCycledSystem.ts
@@ -20,7 +20,7 @@ export type ComponentLifecycle = PartialLifecycle & {
};
export type LifecycleHandler = () => ComponentLifecycle;
-export abstract class LifeCycledSystem {
+export class LifeCycledSystem {
components: ComponentLifecycle[] = [];
constructor() {
diff --git a/libs/lib-services/src/utils/BaseObserver.ts b/libs/lib-services/src/utils/BaseObserver.ts
new file mode 100644
index 000000000..937fde59a
--- /dev/null
+++ b/libs/lib-services/src/utils/BaseObserver.ts
@@ -0,0 +1,33 @@
+import { v4 as uuid } from 'uuid';
+
+export interface ObserverClient {
+ registerListener(listener: Partial): () => void;
+}
+
+export class BaseObserver implements ObserverClient {
+ protected listeners: { [id: string]: Partial };
+
+ constructor() {
+ this.listeners = {};
+ }
+
+ registerListener(listener: Partial): () => void {
+ const id = uuid();
+ this.listeners[id] = listener;
+ return () => {
+ delete this.listeners[id];
+ };
+ }
+
+ iterateListeners(cb: (listener: Partial) => any) {
+ for (let i in this.listeners) {
+ cb(this.listeners[i]);
+ }
+ }
+
+ async iterateAsyncListeners(cb: (listener: Partial) => Promise) {
+ for (let i in this.listeners) {
+ await cb(this.listeners[i]);
+ }
+ }
+}
diff --git a/libs/lib-services/src/utils/DisposableObserver.ts b/libs/lib-services/src/utils/DisposableObserver.ts
new file mode 100644
index 000000000..1440d57e7
--- /dev/null
+++ b/libs/lib-services/src/utils/DisposableObserver.ts
@@ -0,0 +1,37 @@
+import { BaseObserver, ObserverClient } from './BaseObserver.js';
+
+export interface DisposableListener {
+ /**
+ * Event which is fired when the `[Symbol.disposed]` method is called.
+ */
+ disposed: () => void;
+}
+
+export interface DisposableObserverClient extends ObserverClient, Disposable {
+ /**
+ * Registers a listener that is automatically disposed when the parent is disposed.
+ * This is useful for disposing nested listeners.
+ */
+ registerManagedListener: (parent: DisposableObserverClient, cb: Partial) => () => void;
+}
+
+export class DisposableObserver
+ extends BaseObserver
+ implements DisposableObserverClient
+{
+ registerManagedListener(parent: DisposableObserverClient, cb: Partial) {
+ const disposer = this.registerListener(cb);
+ parent.registerListener({
+ disposed: () => {
+ disposer();
+ }
+ });
+ return disposer;
+ }
+
+ [Symbol.dispose]() {
+ this.iterateListeners((cb) => cb.disposed?.());
+ // Delete all callbacks
+ Object.keys(this.listeners).forEach((key) => delete this.listeners[key]);
+ }
+}
diff --git a/libs/lib-services/src/utils/utils-index.ts b/libs/lib-services/src/utils/utils-index.ts
index 17384042b..59b89d274 100644
--- a/libs/lib-services/src/utils/utils-index.ts
+++ b/libs/lib-services/src/utils/utils-index.ts
@@ -1 +1,3 @@
+export * from './BaseObserver.js';
+export * from './DisposableObserver.js';
export * from './environment-variables.js';
diff --git a/libs/lib-services/test/src/DisposeableObserver.test.ts b/libs/lib-services/test/src/DisposeableObserver.test.ts
new file mode 100644
index 000000000..1cde6a58b
--- /dev/null
+++ b/libs/lib-services/test/src/DisposeableObserver.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, test } from 'vitest';
+
+import { DisposableListener, DisposableObserver } from '../../src/utils/DisposableObserver.js';
+
+describe('DisposableObserver', () => {
+ test('it should dispose all listeners on dispose', () => {
+ const listener = new DisposableObserver();
+
+ let wasDisposed = false;
+ listener.registerListener({
+ disposed: () => {
+ wasDisposed = true;
+ }
+ });
+
+ listener[Symbol.dispose]();
+
+ expect(wasDisposed).equals(true);
+ expect(Object.keys(listener['listeners']).length).equals(0);
+ });
+
+ test('it should dispose nested listeners for managed listeners', () => {
+ interface ParentListener extends DisposableListener {
+ childCreated: (child: DisposableObserver) => void;
+ }
+ class ParentObserver extends DisposableObserver {
+ createChild() {
+ const child = new DisposableObserver();
+ this.iterateListeners((cb) => cb.childCreated?.(child));
+ }
+ }
+
+ const parent = new ParentObserver();
+ let aChild: DisposableObserver | null = null;
+
+ parent.registerListener({
+ childCreated: (child) => {
+ aChild = child;
+ child.registerManagedListener(parent, {
+ test: () => {
+ // this does nothing
+ }
+ });
+ }
+ });
+
+ parent.createChild();
+
+ // The managed listener should add a `disposed` listener
+ expect(Object.keys(parent['listeners']).length).equals(2);
+ expect(Object.keys(aChild!['listeners']).length).equals(1);
+
+ parent[Symbol.dispose]();
+ expect(Object.keys(parent['listeners']).length).equals(0);
+ // The listener attached to the child should be disposed when the parent was disposed
+ expect(Object.keys(aChild!['listeners']).length).equals(0);
+ });
+});
diff --git a/modules/module-mongodb/CHANGELOG.md b/modules/module-mongodb/CHANGELOG.md
new file mode 100644
index 000000000..05f7d8b81
--- /dev/null
+++ b/modules/module-mongodb/CHANGELOG.md
@@ -0,0 +1 @@
+# @powersync/service-module-mongodb
diff --git a/modules/module-mongodb/LICENSE b/modules/module-mongodb/LICENSE
new file mode 100644
index 000000000..c8efd46cc
--- /dev/null
+++ b/modules/module-mongodb/LICENSE
@@ -0,0 +1,67 @@
+# Functional Source License, Version 1.1, Apache 2.0 Future License
+
+## Abbreviation
+
+FSL-1.1-Apache-2.0
+
+## Notice
+
+Copyright 2023-2024 Journey Mobile, Inc.
+
+## Terms and Conditions
+
+### Licensor ("We")
+
+The party offering the Software under these Terms and Conditions.
+
+### The Software
+
+The "Software" is each version of the software that we make available under these Terms and Conditions, as indicated by our inclusion of these Terms and Conditions with the Software.
+
+### License Grant
+
+Subject to your compliance with this License Grant and the Patents, Redistribution and Trademark clauses below, we hereby grant you the right to use, copy, modify, create derivative works, publicly perform, publicly display and redistribute the Software for any Permitted Purpose identified below.
+
+### Permitted Purpose
+
+A Permitted Purpose is any purpose other than a Competing Use. A Competing Use means making the Software available to others in a commercial product or service that:
+
+1. substitutes for the Software;
+2. substitutes for any other product or service we offer using the Software that exists as of the date we make the Software available; or
+3. offers the same or substantially similar functionality as the Software.
+
+Permitted Purposes specifically include using the Software:
+
+1. for your internal use and access;
+2. for non-commercial education;
+3. for non-commercial research; and
+4. in connection with professional services that you provide to a licensee using the Software in accordance with these Terms and Conditions.
+
+### Patents
+
+To the extent your use for a Permitted Purpose would necessarily infringe our patents, the license grant above includes a license under our patents. If you make a claim against any party that the Software infringes or contributes to the infringement of any patent, then your patent license to the Software ends immediately.
+
+### Redistribution
+
+The Terms and Conditions apply to all copies, modifications and derivatives of the Software.
+If you redistribute any copies, modifications or derivatives of the Software, you must include a copy of or a link to these Terms and Conditions and not remove any copyright notices provided in or with the Software.
+
+### Disclaimer
+
+THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
+IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
+
+### Trademarks
+
+Except for displaying the License Details and identifying us as the origin of the Software, you have no right under these Terms and Conditions to use our trademarks, trade names, service marks or product names.
+
+## Grant of Future License
+
+We hereby irrevocably grant you an additional license to use the Software under the Apache License, Version 2.0 that is effective on the second anniversary of the date we make the Software available. On or after that date, you may use the Software under the Apache License, Version 2.0, in which case the following will apply:
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
diff --git a/modules/module-mongodb/README.md b/modules/module-mongodb/README.md
new file mode 100644
index 000000000..f9e9e4c64
--- /dev/null
+++ b/modules/module-mongodb/README.md
@@ -0,0 +1,3 @@
+# PowerSync Service Module MongoDB
+
+MongoDB replication module for PowerSync
diff --git a/modules/module-mongodb/package.json b/modules/module-mongodb/package.json
new file mode 100644
index 000000000..8236e3b29
--- /dev/null
+++ b/modules/module-mongodb/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "@powersync/service-module-mongodb",
+ "repository": "https://github.com/powersync-ja/powersync-service",
+ "types": "dist/index.d.ts",
+ "version": "0.0.1",
+ "main": "dist/index.js",
+ "license": "FSL-1.1-Apache-2.0",
+ "type": "module",
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "build": "tsc -b",
+ "build:tests": "tsc -b test/tsconfig.json",
+ "clean": "rm -rf ./dist && tsc -b --clean",
+ "test": "vitest"
+ },
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "require": "./dist/index.js",
+ "default": "./dist/index.js"
+ },
+ "./types": {
+ "import": "./dist/types/types.js",
+ "require": "./dist/types/types.js",
+ "default": "./dist/types/types.js"
+ }
+ },
+ "dependencies": {
+ "@powersync/lib-services-framework": "workspace:*",
+ "@powersync/service-core": "workspace:*",
+ "@powersync/service-jsonbig": "workspace:*",
+ "@powersync/service-sync-rules": "workspace:*",
+ "@powersync/service-types": "workspace:*",
+ "mongodb": "^6.7.0",
+ "ts-codec": "^1.2.2",
+ "uuid": "^9.0.1",
+ "uri-js": "^4.4.1"
+ },
+ "devDependencies": {
+ "@types/uuid": "^9.0.4"
+ }
+}
diff --git a/modules/module-mongodb/src/api/MongoRouteAPIAdapter.ts b/modules/module-mongodb/src/api/MongoRouteAPIAdapter.ts
new file mode 100644
index 000000000..a0bc519ec
--- /dev/null
+++ b/modules/module-mongodb/src/api/MongoRouteAPIAdapter.ts
@@ -0,0 +1,366 @@
+import { api, ParseSyncRulesOptions, SourceTable } from '@powersync/service-core';
+import * as mongo from 'mongodb';
+
+import * as sync_rules from '@powersync/service-sync-rules';
+import * as service_types from '@powersync/service-types';
+import { MongoManager } from '../replication/MongoManager.js';
+import { constructAfterRecord, createCheckpoint } from '../replication/MongoRelation.js';
+import * as types from '../types/types.js';
+import { escapeRegExp } from '../utils.js';
+import { CHECKPOINTS_COLLECTION } from '../replication/replication-utils.js';
+
+export class MongoRouteAPIAdapter implements api.RouteAPI {
+ protected client: mongo.MongoClient;
+ public db: mongo.Db;
+
+ connectionTag: string;
+ defaultSchema: string;
+
+ constructor(protected config: types.ResolvedConnectionConfig) {
+ const manager = new MongoManager(config);
+ this.client = manager.client;
+ this.db = manager.db;
+ this.defaultSchema = manager.db.databaseName;
+ this.connectionTag = config.tag ?? sync_rules.DEFAULT_TAG;
+ }
+
+ getParseSyncRulesOptions(): ParseSyncRulesOptions {
+ return {
+ defaultSchema: this.defaultSchema
+ };
+ }
+
+ async shutdown(): Promise {
+ await this.client.close();
+ }
+
+ async [Symbol.asyncDispose]() {
+ await this.shutdown();
+ }
+
+ async getSourceConfig(): Promise {
+ return this.config;
+ }
+
+ async getConnectionStatus(): Promise {
+ const base = {
+ id: this.config.id,
+ uri: types.baseUri(this.config)
+ };
+
+ try {
+ await this.client.connect();
+ await this.db.command({ hello: 1 });
+ } catch (e) {
+ return {
+ ...base,
+ connected: false,
+ errors: [{ level: 'fatal', message: e.message }]
+ };
+ }
+ return {
+ ...base,
+ connected: true,
+ errors: []
+ };
+ }
+
+ async executeQuery(query: string, params: any[]): Promise {
+ return service_types.internal_routes.ExecuteSqlResponse.encode({
+ results: {
+ columns: [],
+ rows: []
+ },
+ success: false,
+ error: 'SQL querying is not supported for MongoDB'
+ });
+ }
+
+ async getDebugTablesInfo(
+ tablePatterns: sync_rules.TablePattern[],
+ sqlSyncRules: sync_rules.SqlSyncRules
+ ): Promise {
+ let result: api.PatternResult[] = [];
+
+ const validatePostImages = (schema: string, collection: mongo.CollectionInfo): service_types.ReplicationError[] => {
+ if (this.config.postImages == types.PostImagesOption.OFF) {
+ return [];
+ } else if (!collection.options?.changeStreamPreAndPostImages?.enabled) {
+ if (this.config.postImages == types.PostImagesOption.READ_ONLY) {
+ return [
+ { level: 'fatal', message: `changeStreamPreAndPostImages not enabled on ${schema}.${collection.name}` }
+ ];
+ } else {
+ return [
+ {
+ level: 'warning',
+ message: `changeStreamPreAndPostImages not enabled on ${schema}.${collection.name}, will be enabled automatically`
+ }
+ ];
+ }
+ } else {
+ return [];
+ }
+ };
+
+ for (let tablePattern of tablePatterns) {
+ const schema = tablePattern.schema;
+
+ let patternResult: api.PatternResult = {
+ schema: schema,
+ pattern: tablePattern.tablePattern,
+ wildcard: tablePattern.isWildcard
+ };
+ result.push(patternResult);
+
+ let nameFilter: RegExp | string;
+ if (tablePattern.isWildcard) {
+ nameFilter = new RegExp('^' + escapeRegExp(tablePattern.tablePrefix));
+ } else {
+ nameFilter = tablePattern.name;
+ }
+
+ // Check if the collection exists
+ const collections = await this.client
+ .db(schema)
+ .listCollections(
+ {
+ name: nameFilter
+ },
+ { nameOnly: false }
+ )
+ .toArray();
+
+ if (tablePattern.isWildcard) {
+ patternResult.tables = [];
+ for (let collection of collections) {
+ const sourceTable = new SourceTable(
+ 0,
+ this.connectionTag,
+ collection.name,
+ schema,
+ collection.name,
+ [],
+ true
+ );
+ let errors: service_types.ReplicationError[] = [];
+ if (collection.type == 'view') {
+ errors.push({ level: 'warning', message: `Collection ${schema}.${tablePattern.name} is a view` });
+ } else {
+ errors.push(...validatePostImages(schema, collection));
+ }
+ const syncData = sqlSyncRules.tableSyncsData(sourceTable);
+ const syncParameters = sqlSyncRules.tableSyncsParameters(sourceTable);
+ patternResult.tables.push({
+ schema,
+ name: collection.name,
+ replication_id: ['_id'],
+ data_queries: syncData,
+ parameter_queries: syncParameters,
+ errors: errors
+ });
+ }
+ } else {
+ const sourceTable = new SourceTable(
+ 0,
+ this.connectionTag,
+ tablePattern.name,
+ schema,
+ tablePattern.name,
+ [],
+ true
+ );
+
+ const syncData = sqlSyncRules.tableSyncsData(sourceTable);
+ const syncParameters = sqlSyncRules.tableSyncsParameters(sourceTable);
+ const collection = collections[0];
+
+ let errors: service_types.ReplicationError[] = [];
+ if (collections.length != 1) {
+ errors.push({ level: 'warning', message: `Collection ${schema}.${tablePattern.name} not found` });
+ } else if (collection.type == 'view') {
+ errors.push({ level: 'warning', message: `Collection ${schema}.${tablePattern.name} is a view` });
+ } else if (!collection.options?.changeStreamPreAndPostImages?.enabled) {
+ errors.push(...validatePostImages(schema, collection));
+ }
+
+ patternResult.table = {
+ schema,
+ name: tablePattern.name,
+ replication_id: ['_id'],
+ data_queries: syncData,
+ parameter_queries: syncParameters,
+ errors
+ };
+ }
+ }
+ return result;
+ }
+
+ async getReplicationLag(options: api.ReplicationLagOptions): Promise {
+ // There is no fast way to get replication lag in bytes in MongoDB.
+ // We can get replication lag in seconds, but need a different API for that.
+ return undefined;
+ }
+
+ async getReplicationHead(): Promise {
+ return createCheckpoint(this.client, this.db);
+ }
+
+ async getConnectionSchema(): Promise {
+ const sampleSize = 50;
+
+ const databases = await this.db.admin().listDatabases({ nameOnly: true });
+ const filteredDatabases = databases.databases.filter((db) => {
+ return !['local', 'admin', 'config'].includes(db.name);
+ });
+ const databaseSchemas = await Promise.all(
+ filteredDatabases.map(async (db) => {
+ /**
+ * Filtering the list of database with `authorizedDatabases: true`
+ * does not produce the full list of databases under some circumstances.
+ * This catches any potential auth errors.
+ */
+ let collections: mongo.CollectionInfo[];
+ try {
+ collections = await this.client.db(db.name).listCollections().toArray();
+ } catch (e) {
+ if (e instanceof mongo.MongoServerError && e.codeName == 'Unauthorized') {
+ // Ignore databases we're not authorized to query
+ return null;
+ }
+ throw e;
+ }
+
+ let tables: service_types.TableSchema[] = [];
+ for (let collection of collections) {
+ if ([CHECKPOINTS_COLLECTION].includes(collection.name)) {
+ continue;
+ }
+ if (collection.name.startsWith('system.')) {
+ // system.views, system.js, system.profile, system.buckets
+ // https://www.mongodb.com/docs/manual/reference/system-collections/
+ continue;
+ }
+ if (collection.type == 'view') {
+ continue;
+ }
+ try {
+ const sampleDocuments = await this.db
+ .collection(collection.name)
+ .aggregate([{ $sample: { size: sampleSize } }])
+ .toArray();
+
+ if (sampleDocuments.length > 0) {
+ const columns = this.getColumnsFromDocuments(sampleDocuments);
+
+ tables.push({
+ name: collection.name,
+ // Since documents are sampled in a random order, we need to sort
+ // to get a consistent order
+ columns: columns.sort((a, b) => a.name.localeCompare(b.name))
+ });
+ } else {
+ tables.push({
+ name: collection.name,
+ columns: []
+ });
+ }
+ } catch (e) {
+ if (e instanceof mongo.MongoServerError && e.codeName == 'Unauthorized') {
+ // Ignore collections we're not authorized to query
+ continue;
+ }
+ throw e;
+ }
+ }
+
+ return {
+ name: db.name,
+ tables: tables
+ } satisfies service_types.DatabaseSchema;
+ })
+ );
+ return databaseSchemas.filter((schema) => !!schema);
+ }
+
+ private getColumnsFromDocuments(documents: mongo.BSON.Document[]) {
+ let columns = new Map }>();
+ for (const document of documents) {
+ const parsed = constructAfterRecord(document);
+ for (const key in parsed) {
+ const value = parsed[key];
+ const type = sync_rules.sqliteTypeOf(value);
+ const sqliteType = sync_rules.ExpressionType.fromTypeText(type);
+ let entry = columns.get(key);
+ if (entry == null) {
+ entry = { sqliteType, bsonTypes: new Set() };
+ columns.set(key, entry);
+ } else {
+ entry.sqliteType = entry.sqliteType.or(sqliteType);
+ }
+ const bsonType = this.getBsonType(document[key]);
+ if (bsonType != null) {
+ entry.bsonTypes.add(bsonType);
+ }
+ }
+ }
+ return [...columns.entries()].map(([key, value]) => {
+ const internal_type = value.bsonTypes.size == 0 ? '' : [...value.bsonTypes].join(' | ');
+ return {
+ name: key,
+ type: internal_type,
+ sqlite_type: value.sqliteType.typeFlags,
+ internal_type,
+ pg_type: internal_type
+ };
+ });
+ }
+
+ private getBsonType(data: any): string | null {
+ if (data == null) {
+ // null or undefined
+ return 'Null';
+ } else if (typeof data == 'string') {
+ return 'String';
+ } else if (typeof data == 'number') {
+ if (Number.isInteger(data)) {
+ return 'Integer';
+ } else {
+ return 'Double';
+ }
+ } else if (typeof data == 'bigint') {
+ return 'Long';
+ } else if (typeof data == 'boolean') {
+ return 'Boolean';
+ } else if (data instanceof mongo.ObjectId) {
+ return 'ObjectId';
+ } else if (data instanceof mongo.UUID) {
+ return 'UUID';
+ } else if (data instanceof Date) {
+ return 'Date';
+ } else if (data instanceof mongo.Timestamp) {
+ return 'Timestamp';
+ } else if (data instanceof mongo.Binary) {
+ return 'Binary';
+ } else if (data instanceof mongo.Long) {
+ return 'Long';
+ } else if (data instanceof RegExp) {
+ return 'RegExp';
+ } else if (data instanceof mongo.MinKey) {
+ return 'MinKey';
+ } else if (data instanceof mongo.MaxKey) {
+ return 'MaxKey';
+ } else if (data instanceof mongo.Decimal128) {
+ return 'Decimal';
+ } else if (Array.isArray(data)) {
+ return 'Array';
+ } else if (data instanceof Uint8Array) {
+ return 'Binary';
+ } else if (typeof data == 'object') {
+ return 'Object';
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/modules/module-mongodb/src/index.ts b/modules/module-mongodb/src/index.ts
new file mode 100644
index 000000000..6ecba2a8e
--- /dev/null
+++ b/modules/module-mongodb/src/index.ts
@@ -0,0 +1 @@
+export * from './module/MongoModule.js';
diff --git a/modules/module-mongodb/src/module/MongoModule.ts b/modules/module-mongodb/src/module/MongoModule.ts
new file mode 100644
index 000000000..bbd9ab869
--- /dev/null
+++ b/modules/module-mongodb/src/module/MongoModule.ts
@@ -0,0 +1,65 @@
+import { api, ConfigurationFileSyncRulesProvider, replication, system, TearDownOptions } from '@powersync/service-core';
+import { MongoRouteAPIAdapter } from '../api/MongoRouteAPIAdapter.js';
+import { ConnectionManagerFactory } from '../replication/ConnectionManagerFactory.js';
+import { MongoErrorRateLimiter } from '../replication/MongoErrorRateLimiter.js';
+import { ChangeStreamReplicator } from '../replication/ChangeStreamReplicator.js';
+import * as types from '../types/types.js';
+import { MongoManager } from '../replication/MongoManager.js';
+import { checkSourceConfiguration } from '../replication/replication-utils.js';
+
+export class MongoModule extends replication.ReplicationModule {
+ constructor() {
+ super({
+ name: 'MongoDB',
+ type: types.MONGO_CONNECTION_TYPE,
+ configSchema: types.MongoConnectionConfig
+ });
+ }
+
+ async initialize(context: system.ServiceContextContainer): Promise {
+ await super.initialize(context);
+ }
+
+ protected createRouteAPIAdapter(): api.RouteAPI {
+ return new MongoRouteAPIAdapter(this.resolveConfig(this.decodedConfig!));
+ }
+
+ protected createReplicator(context: system.ServiceContext): replication.AbstractReplicator {
+ const normalisedConfig = this.resolveConfig(this.decodedConfig!);
+ const syncRuleProvider = new ConfigurationFileSyncRulesProvider(context.configuration.sync_rules);
+ const connectionFactory = new ConnectionManagerFactory(normalisedConfig);
+
+ return new ChangeStreamReplicator({
+ id: this.getDefaultId(normalisedConfig.database ?? ''),
+ syncRuleProvider: syncRuleProvider,
+ storageEngine: context.storageEngine,
+ connectionFactory: connectionFactory,
+ rateLimiter: new MongoErrorRateLimiter()
+ });
+ }
+
+ /**
+ * Combines base config with normalized connection settings
+ */
+ private resolveConfig(config: types.MongoConnectionConfig): types.ResolvedConnectionConfig {
+ return {
+ ...config,
+ ...types.normalizeConnectionConfig(config)
+ };
+ }
+
+ async teardown(options: TearDownOptions): Promise {
+ // TODO: Implement?
+ }
+
+ async testConnection(config: types.MongoConnectionConfig): Promise {
+ this.decodeConfig(config);
+ const normalisedConfig = this.resolveConfig(this.decodedConfig!);
+ const connectionManager = new MongoManager(normalisedConfig);
+ try {
+ return checkSourceConfiguration(connectionManager);
+ } finally {
+ await connectionManager.end();
+ }
+ }
+}
diff --git a/modules/module-mongodb/src/replication/ChangeStream.ts b/modules/module-mongodb/src/replication/ChangeStream.ts
new file mode 100644
index 000000000..730f39813
--- /dev/null
+++ b/modules/module-mongodb/src/replication/ChangeStream.ts
@@ -0,0 +1,670 @@
+import { container, logger } from '@powersync/lib-services-framework';
+import { Metrics, SaveOperationTag, SourceEntityDescriptor, SourceTable, storage } from '@powersync/service-core';
+import { DatabaseInputRow, SqliteRow, SqlSyncRules, TablePattern } from '@powersync/service-sync-rules';
+import * as mongo from 'mongodb';
+import { MongoManager } from './MongoManager.js';
+import {
+ constructAfterRecord,
+ createCheckpoint,
+ getMongoLsn,
+ getMongoRelation,
+ mongoLsnToTimestamp
+} from './MongoRelation.js';
+import { escapeRegExp } from '../utils.js';
+import { CHECKPOINTS_COLLECTION } from './replication-utils.js';
+import { PostImagesOption } from '../types/types.js';
+
+export const ZERO_LSN = '0000000000000000';
+
+export interface ChangeStreamOptions {
+ connections: MongoManager;
+ storage: storage.SyncRulesBucketStorage;
+ abort_signal: AbortSignal;
+}
+
+interface InitResult {
+ needsInitialSync: boolean;
+}
+
+/**
+ * Thrown when the change stream is not valid anymore, and replication
+ * must be restarted.
+ *
+ * Possible reasons:
+ * * Some change stream documents do not have postImages.
+ * * startAfter/resumeToken is not valid anymore.
+ */
+export class ChangeStreamInvalidatedError extends Error {
+ constructor(message: string) {
+ super(message);
+ }
+}
+
+export class ChangeStream {
+ sync_rules: SqlSyncRules;
+ group_id: number;
+
+ connection_id = 1;
+
+ private readonly storage: storage.SyncRulesBucketStorage;
+
+ private connections: MongoManager;
+ private readonly client: mongo.MongoClient;
+ private readonly defaultDb: mongo.Db;
+
+ private abort_signal: AbortSignal;
+
+ private relation_cache = new Map();
+
+ constructor(options: ChangeStreamOptions) {
+ this.storage = options.storage;
+ this.group_id = options.storage.group_id;
+ this.connections = options.connections;
+ this.client = this.connections.client;
+ this.defaultDb = this.connections.db;
+ this.sync_rules = options.storage.getParsedSyncRules({
+ defaultSchema: this.defaultDb.databaseName
+ });
+
+ this.abort_signal = options.abort_signal;
+ this.abort_signal.addEventListener(
+ 'abort',
+ () => {
+ // TODO: Fast abort?
+ },
+ { once: true }
+ );
+ }
+
+ get stopped() {
+ return this.abort_signal.aborted;
+ }
+
+ private get usePostImages() {
+ return this.connections.options.postImages != PostImagesOption.OFF;
+ }
+
+ private get configurePostImages() {
+ return this.connections.options.postImages == PostImagesOption.AUTO_CONFIGURE;
+ }
+
+ /**
+ * This resolves a pattern, persists the related metadata, and returns
+ * the resulting SourceTables.
+ *
+ * This implicitly checks the collection postImage configuration.
+ */
+ async resolveQualifiedTableNames(
+ batch: storage.BucketStorageBatch,
+ tablePattern: TablePattern
+ ): Promise {
+ const schema = tablePattern.schema;
+ if (tablePattern.connectionTag != this.connections.connectionTag) {
+ return [];
+ }
+
+ let nameFilter: RegExp | string;
+ if (tablePattern.isWildcard) {
+ nameFilter = new RegExp('^' + escapeRegExp(tablePattern.tablePrefix));
+ } else {
+ nameFilter = tablePattern.name;
+ }
+ let result: storage.SourceTable[] = [];
+
+ // Check if the collection exists
+ const collections = await this.client
+ .db(schema)
+ .listCollections(
+ {
+ name: nameFilter
+ },
+ { nameOnly: false }
+ )
+ .toArray();
+
+ if (!tablePattern.isWildcard && collections.length == 0) {
+ logger.warn(`Collection ${schema}.${tablePattern.name} not found`);
+ }
+
+ for (let collection of collections) {
+ const table = await this.handleRelation(
+ batch,
+ {
+ name: collection.name,
+ schema,
+ objectId: collection.name,
+ replicationColumns: [{ name: '_id' }]
+ } as SourceEntityDescriptor,
+ // This is done as part of the initial setup - snapshot is handled elsewhere
+ { snapshot: false, collectionInfo: collection }
+ );
+
+ result.push(table);
+ }
+
+ return result;
+ }
+
+ async initSlot(): Promise {
+ const status = await this.storage.getStatus();
+ if (status.snapshot_done && status.checkpoint_lsn) {
+ logger.info(`Initial replication already done`);
+ return { needsInitialSync: false };
+ }
+
+ return { needsInitialSync: true };
+ }
+
+ async estimatedCount(table: storage.SourceTable): Promise {
+ const db = this.client.db(table.schema);
+ const count = db.collection(table.table).estimatedDocumentCount();
+ return `~${count}`;
+ }
+
+ /**
+ * Start initial replication.
+ *
+ * If (partial) replication was done before on this slot, this clears the state
+ * and starts again from scratch.
+ */
+ async startInitialReplication() {
+ await this.storage.clear();
+ await this.initialReplication();
+ }
+
+ async initialReplication() {
+ const sourceTables = this.sync_rules.getSourceTables();
+ await this.client.connect();
+
+ // We need to get the snapshot time before taking the initial snapshot.
+ const hello = await this.defaultDb.command({ hello: 1 });
+ const snapshotTime = hello.lastWrite?.majorityOpTime?.ts as mongo.Timestamp;
+ if (hello.msg == 'isdbgrid') {
+ throw new Error('Sharded MongoDB Clusters are not supported yet (including MongoDB Serverless instances).');
+ } else if (hello.setName == null) {
+ throw new Error('Standalone MongoDB instances are not supported - use a replicaset.');
+ } else if (snapshotTime == null) {
+ // Not known where this would happen apart from the above cases
+ throw new Error('MongoDB lastWrite timestamp not found.');
+ }
+ // We previously used {snapshot: true} for the snapshot session.
+ // While it gives nice consistency guarantees, it fails when the
+ // snapshot takes longer than 5 minutes, due to minSnapshotHistoryWindowInSeconds
+ // expiring the snapshot.
+ const session = await this.client.startSession();
+ try {
+ await this.storage.startBatch(
+ { zeroLSN: ZERO_LSN, defaultSchema: this.defaultDb.databaseName, storeCurrentData: false },
+ async (batch) => {
+ // Start by resolving all tables.
+ // This checks postImage configuration, and that should fail as
+ // earlier as possible.
+ let allSourceTables: SourceTable[] = [];
+ for (let tablePattern of sourceTables) {
+ const tables = await this.resolveQualifiedTableNames(batch, tablePattern);
+ allSourceTables.push(...tables);
+ }
+
+ for (let table of allSourceTables) {
+ await this.snapshotTable(batch, table, session);
+ await batch.markSnapshotDone([table], ZERO_LSN);
+
+ await touch();
+ }
+
+ const lsn = getMongoLsn(snapshotTime);
+ logger.info(`Snapshot commit at ${snapshotTime.inspect()} / ${lsn}`);
+ await batch.commit(lsn);
+ }
+ );
+ } finally {
+ session.endSession();
+ }
+ }
+
+ private async setupCheckpointsCollection() {
+ const collection = await this.getCollectionInfo(this.defaultDb.databaseName, CHECKPOINTS_COLLECTION);
+ if (collection == null) {
+ await this.defaultDb.createCollection(CHECKPOINTS_COLLECTION, {
+ changeStreamPreAndPostImages: { enabled: true }
+ });
+ } else if (this.usePostImages && collection.options?.changeStreamPreAndPostImages?.enabled != true) {
+ // Drop + create requires less permissions than collMod,
+ // and we don't care about the data in this collection.
+ await this.defaultDb.dropCollection(CHECKPOINTS_COLLECTION);
+ await this.defaultDb.createCollection(CHECKPOINTS_COLLECTION, {
+ changeStreamPreAndPostImages: { enabled: true }
+ });
+ }
+ }
+
+ private getSourceNamespaceFilters(): { $match: any; multipleDatabases: boolean } {
+ const sourceTables = this.sync_rules.getSourceTables();
+
+ let $inFilters: any[] = [{ db: this.defaultDb.databaseName, coll: CHECKPOINTS_COLLECTION }];
+ let $refilters: any[] = [];
+ let multipleDatabases = false;
+ for (let tablePattern of sourceTables) {
+ if (tablePattern.connectionTag != this.connections.connectionTag) {
+ continue;
+ }
+
+ if (tablePattern.schema != this.defaultDb.databaseName) {
+ multipleDatabases = true;
+ }
+
+ if (tablePattern.isWildcard) {
+ $refilters.push({
+ 'ns.db': tablePattern.schema,
+ 'ns.coll': new RegExp('^' + escapeRegExp(tablePattern.tablePrefix))
+ });
+ } else {
+ $inFilters.push({
+ db: tablePattern.schema,
+ coll: tablePattern.name
+ });
+ }
+ }
+ if ($refilters.length > 0) {
+ return { $match: { $or: [{ ns: { $in: $inFilters } }, ...$refilters] }, multipleDatabases };
+ }
+ return { $match: { ns: { $in: $inFilters } }, multipleDatabases };
+ }
+
+ static *getQueryData(results: Iterable): Generator {
+ for (let row of results) {
+ yield constructAfterRecord(row);
+ }
+ }
+
+ private async snapshotTable(
+ batch: storage.BucketStorageBatch,
+ table: storage.SourceTable,
+ session?: mongo.ClientSession
+ ) {
+ logger.info(`Replicating ${table.qualifiedName}`);
+ const estimatedCount = await this.estimatedCount(table);
+ let at = 0;
+
+ const db = this.client.db(table.schema);
+ const collection = db.collection(table.table);
+ const query = collection.find({}, { session, readConcern: { level: 'majority' } });
+
+ const cursor = query.stream();
+
+ for await (let document of cursor) {
+ if (this.abort_signal.aborted) {
+ throw new Error(`Aborted initial replication`);
+ }
+
+ at += 1;
+
+ const record = constructAfterRecord(document);
+
+ // This auto-flushes when the batch reaches its size limit
+ await batch.save({
+ tag: SaveOperationTag.INSERT,
+ sourceTable: table,
+ before: undefined,
+ beforeReplicaId: undefined,
+ after: record,
+ afterReplicaId: document._id
+ });
+
+ at += 1;
+ Metrics.getInstance().rows_replicated_total.add(1);
+
+ await touch();
+ }
+
+ await batch.flush();
+ logger.info(`Replicated ${at} documents for ${table.qualifiedName}`);
+ }
+
+ private async getRelation(
+ batch: storage.BucketStorageBatch,
+ descriptor: SourceEntityDescriptor
+ ): Promise {
+ const existing = this.relation_cache.get(descriptor.objectId);
+ if (existing != null) {
+ return existing;
+ }
+
+ // Note: collection may have been dropped at this point, so we handle
+ // missing values.
+ const collection = await this.getCollectionInfo(descriptor.schema, descriptor.name);
+
+ return this.handleRelation(batch, descriptor, { snapshot: false, collectionInfo: collection });
+ }
+
+ private async getCollectionInfo(db: string, name: string): Promise {
+ const collection = (
+ await this.client
+ .db(db)
+ .listCollections(
+ {
+ name: name
+ },
+ { nameOnly: false }
+ )
+ .toArray()
+ )[0];
+ return collection;
+ }
+
+ private async checkPostImages(db: string, collectionInfo: mongo.CollectionInfo) {
+ if (!this.usePostImages) {
+ // Nothing to check
+ return;
+ }
+
+ const enabled = collectionInfo.options?.changeStreamPreAndPostImages?.enabled == true;
+
+ if (!enabled && this.configurePostImages) {
+ await this.client.db(db).command({
+ collMod: collectionInfo.name,
+ changeStreamPreAndPostImages: { enabled: true }
+ });
+ logger.info(`Enabled postImages on ${db}.${collectionInfo.name}`);
+ } else if (!enabled) {
+ throw new Error(`postImages not enabled on ${db}.${collectionInfo.name}`);
+ }
+ }
+
+ async handleRelation(
+ batch: storage.BucketStorageBatch,
+ descriptor: SourceEntityDescriptor,
+ options: { snapshot: boolean; collectionInfo: mongo.CollectionInfo | undefined }
+ ) {
+ if (options.collectionInfo != null) {
+ await this.checkPostImages(descriptor.schema, options.collectionInfo);
+ } else {
+ // If collectionInfo is null, the collection may have been dropped.
+ // Ignore the postImages check in this case.
+ }
+
+ const snapshot = options.snapshot;
+ if (!descriptor.objectId && typeof descriptor.objectId != 'string') {
+ throw new Error('objectId expected');
+ }
+ const result = await this.storage.resolveTable({
+ group_id: this.group_id,
+ connection_id: this.connection_id,
+ connection_tag: this.connections.connectionTag,
+ entity_descriptor: descriptor,
+ sync_rules: this.sync_rules
+ });
+ this.relation_cache.set(descriptor.objectId, result.table);
+
+ // Drop conflicting tables. This includes for example renamed tables.
+ await batch.drop(result.dropTables);
+
+ // Snapshot if:
+ // 1. Snapshot is requested (false for initial snapshot, since that process handles it elsewhere)
+ // 2. Snapshot is not already done, AND:
+ // 3. The table is used in sync rules.
+ const shouldSnapshot = snapshot && !result.table.snapshotComplete && result.table.syncAny;
+ if (shouldSnapshot) {
+ // Truncate this table, in case a previous snapshot was interrupted.
+ await batch.truncate([result.table]);
+
+ await this.snapshotTable(batch, result.table);
+ const no_checkpoint_before_lsn = await createCheckpoint(this.client, this.defaultDb);
+
+ const [table] = await batch.markSnapshotDone([result.table], no_checkpoint_before_lsn);
+ return table;
+ }
+
+ return result.table;
+ }
+
+ async writeChange(
+ batch: storage.BucketStorageBatch,
+ table: storage.SourceTable,
+ change: mongo.ChangeStreamDocument
+ ): Promise {
+ if (!table.syncAny) {
+ logger.debug(`Collection ${table.qualifiedName} not used in sync rules - skipping`);
+ return null;
+ }
+
+ Metrics.getInstance().rows_replicated_total.add(1);
+ if (change.operationType == 'insert') {
+ const baseRecord = constructAfterRecord(change.fullDocument);
+ return await batch.save({
+ tag: SaveOperationTag.INSERT,
+ sourceTable: table,
+ before: undefined,
+ beforeReplicaId: undefined,
+ after: baseRecord,
+ afterReplicaId: change.documentKey._id
+ });
+ } else if (change.operationType == 'update' || change.operationType == 'replace') {
+ if (change.fullDocument == null) {
+ // Treat as delete
+ return await batch.save({
+ tag: SaveOperationTag.DELETE,
+ sourceTable: table,
+ before: undefined,
+ beforeReplicaId: change.documentKey._id
+ });
+ }
+ const after = constructAfterRecord(change.fullDocument!);
+ return await batch.save({
+ tag: SaveOperationTag.UPDATE,
+ sourceTable: table,
+ before: undefined,
+ beforeReplicaId: undefined,
+ after: after,
+ afterReplicaId: change.documentKey._id
+ });
+ } else if (change.operationType == 'delete') {
+ return await batch.save({
+ tag: SaveOperationTag.DELETE,
+ sourceTable: table,
+ before: undefined,
+ beforeReplicaId: change.documentKey._id
+ });
+ } else {
+ throw new Error(`Unsupported operation: ${change.operationType}`);
+ }
+ }
+
+ async replicate() {
+ try {
+ // If anything errors here, the entire replication process is halted, and
+ // all connections automatically closed, including this one.
+
+ await this.initReplication();
+ await this.streamChanges();
+ } catch (e) {
+ await this.storage.reportError(e);
+ throw e;
+ }
+ }
+
+ async initReplication() {
+ const result = await this.initSlot();
+ await this.setupCheckpointsCollection();
+ if (result.needsInitialSync) {
+ await this.startInitialReplication();
+ }
+ }
+
+ async streamChanges() {
+ try {
+ await this.streamChangesInternal();
+ } catch (e) {
+ if (
+ e instanceof mongo.MongoServerError &&
+ e.codeName == 'NoMatchingDocument' &&
+ e.errmsg?.includes('post-image was not found')
+ ) {
+ throw new ChangeStreamInvalidatedError(e.errmsg);
+ }
+ throw e;
+ }
+ }
+
+ async streamChangesInternal() {
+ // Auto-activate as soon as initial replication is done
+ await this.storage.autoActivate();
+
+ await this.storage.startBatch(
+ { zeroLSN: ZERO_LSN, defaultSchema: this.defaultDb.databaseName, storeCurrentData: false },
+ async (batch) => {
+ const lastLsn = batch.lastCheckpointLsn;
+ const startAfter = mongoLsnToTimestamp(lastLsn) ?? undefined;
+ logger.info(`Resume streaming at ${startAfter?.inspect()} / ${lastLsn}`);
+
+ const filters = this.getSourceNamespaceFilters();
+
+ const pipeline: mongo.Document[] = [
+ {
+ $match: filters.$match
+ },
+ { $changeStreamSplitLargeEvent: {} }
+ ];
+
+ let fullDocument: 'required' | 'updateLookup';
+
+ if (this.usePostImages) {
+ // 'read_only' or 'auto_configure'
+ // Configuration happens during snapshot, or when we see new
+ // collections.
+ fullDocument = 'required';
+ } else {
+ fullDocument = 'updateLookup';
+ }
+
+ const streamOptions: mongo.ChangeStreamOptions = {
+ startAtOperationTime: startAfter,
+ showExpandedEvents: true,
+ useBigInt64: true,
+ maxAwaitTimeMS: 200,
+ fullDocument: fullDocument
+ };
+ let stream: mongo.ChangeStream;
+ if (filters.multipleDatabases) {
+ // Requires readAnyDatabase@admin on Atlas
+ stream = this.client.watch(pipeline, streamOptions);
+ } else {
+ // Same general result, but requires less permissions than the above
+ stream = this.defaultDb.watch(pipeline, streamOptions);
+ }
+
+ if (this.abort_signal.aborted) {
+ stream.close();
+ return;
+ }
+
+ this.abort_signal.addEventListener('abort', () => {
+ stream.close();
+ });
+
+ // Always start with a checkpoint.
+ // This helps us to clear erorrs when restarting, even if there is
+ // no data to replicate.
+ let waitForCheckpointLsn: string | null = await createCheckpoint(this.client, this.defaultDb);
+
+ let splitDocument: mongo.ChangeStreamDocument | null = null;
+
+ while (true) {
+ if (this.abort_signal.aborted) {
+ break;
+ }
+
+ const originalChangeDocument = await stream.tryNext();
+
+ if (originalChangeDocument == null || this.abort_signal.aborted) {
+ continue;
+ }
+ await touch();
+
+ if (startAfter != null && originalChangeDocument.clusterTime?.lte(startAfter)) {
+ continue;
+ }
+
+ let changeDocument = originalChangeDocument;
+ if (originalChangeDocument?.splitEvent != null) {
+ // Handle split events from $changeStreamSplitLargeEvent.
+ // This is only relevant for very large update operations.
+ const splitEvent = originalChangeDocument?.splitEvent;
+
+ if (splitDocument == null) {
+ splitDocument = originalChangeDocument;
+ } else {
+ splitDocument = Object.assign(splitDocument, originalChangeDocument);
+ }
+
+ if (splitEvent.fragment == splitEvent.of) {
+ // Got all fragments
+ changeDocument = splitDocument;
+ splitDocument = null;
+ } else {
+ // Wait for more fragments
+ continue;
+ }
+ } else if (splitDocument != null) {
+ // We were waiting for fragments, but got a different event
+ throw new Error(`Incomplete splitEvent: ${JSON.stringify(splitDocument.splitEvent)}`);
+ }
+
+ // console.log('event', changeDocument);
+
+ if (
+ (changeDocument.operationType == 'insert' ||
+ changeDocument.operationType == 'update' ||
+ changeDocument.operationType == 'replace') &&
+ changeDocument.ns.coll == CHECKPOINTS_COLLECTION
+ ) {
+ const lsn = getMongoLsn(changeDocument.clusterTime!);
+ if (waitForCheckpointLsn != null && lsn >= waitForCheckpointLsn) {
+ waitForCheckpointLsn = null;
+ }
+ await batch.commit(lsn);
+ } else if (
+ changeDocument.operationType == 'insert' ||
+ changeDocument.operationType == 'update' ||
+ changeDocument.operationType == 'replace' ||
+ changeDocument.operationType == 'delete'
+ ) {
+ if (waitForCheckpointLsn == null) {
+ waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb);
+ }
+ const rel = getMongoRelation(changeDocument.ns);
+ const table = await this.getRelation(batch, rel);
+ if (table.syncAny) {
+ await this.writeChange(batch, table, changeDocument);
+ }
+ } else if (changeDocument.operationType == 'drop') {
+ const rel = getMongoRelation(changeDocument.ns);
+ const table = await this.getRelation(batch, rel);
+ if (table.syncAny) {
+ await batch.drop([table]);
+ this.relation_cache.delete(table.objectId);
+ }
+ } else if (changeDocument.operationType == 'rename') {
+ const relFrom = getMongoRelation(changeDocument.ns);
+ const relTo = getMongoRelation(changeDocument.to);
+ const tableFrom = await this.getRelation(batch, relFrom);
+ if (tableFrom.syncAny) {
+ await batch.drop([tableFrom]);
+ this.relation_cache.delete(tableFrom.objectId);
+ }
+ // Here we do need to snapshot the new table
+ const collection = await this.getCollectionInfo(relTo.schema, relTo.name);
+ await this.handleRelation(batch, relTo, { snapshot: true, collectionInfo: collection });
+ }
+ }
+ }
+ );
+ }
+}
+
+async function touch() {
+ // FIXME: The hosted Kubernetes probe does not actually check the timestamp on this.
+ // FIXME: We need a timeout of around 5+ minutes in Kubernetes if we do start checking the timestamp,
+ // or reduce PING_INTERVAL here.
+ return container.probes.touch();
+}
diff --git a/modules/module-mongodb/src/replication/ChangeStreamReplicationJob.ts b/modules/module-mongodb/src/replication/ChangeStreamReplicationJob.ts
new file mode 100644
index 000000000..78842fd36
--- /dev/null
+++ b/modules/module-mongodb/src/replication/ChangeStreamReplicationJob.ts
@@ -0,0 +1,103 @@
+import { container } from '@powersync/lib-services-framework';
+import { ChangeStreamInvalidatedError, ChangeStream } from './ChangeStream.js';
+
+import { replication } from '@powersync/service-core';
+import { ConnectionManagerFactory } from './ConnectionManagerFactory.js';
+
+import * as mongo from 'mongodb';
+
+export interface ChangeStreamReplicationJobOptions extends replication.AbstractReplicationJobOptions {
+ connectionFactory: ConnectionManagerFactory;
+}
+
+export class ChangeStreamReplicationJob extends replication.AbstractReplicationJob {
+ private connectionFactory: ConnectionManagerFactory;
+
+ constructor(options: ChangeStreamReplicationJobOptions) {
+ super(options);
+ this.connectionFactory = options.connectionFactory;
+ }
+
+ async cleanUp(): Promise {
+ // TODO: Implement?
+ }
+
+ async keepAlive() {
+ // TODO: Implement?
+ }
+
+ private get slotName() {
+ return this.options.storage.slot_name;
+ }
+
+ async replicate() {
+ try {
+ await this.replicateLoop();
+ } catch (e) {
+ // Fatal exception
+ container.reporter.captureException(e, {
+ metadata: {}
+ });
+ this.logger.error(`Replication failed`, e);
+
+ if (e instanceof ChangeStreamInvalidatedError) {
+ // This stops replication on this slot, and creates a new slot
+ await this.options.storage.factory.slotRemoved(this.slotName);
+ }
+ } finally {
+ this.abortController.abort();
+ }
+ }
+
+ async replicateLoop() {
+ while (!this.isStopped) {
+ await this.replicateOnce();
+
+ if (!this.isStopped) {
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+ }
+ }
+ }
+
+ async replicateOnce() {
+ // New connections on every iteration (every error with retry),
+ // otherwise we risk repeating errors related to the connection,
+ // such as caused by cached PG schemas.
+ const connectionManager = this.connectionFactory.create();
+ try {
+ await this.rateLimiter?.waitUntilAllowed({ signal: this.abortController.signal });
+ if (this.isStopped) {
+ return;
+ }
+ const stream = new ChangeStream({
+ abort_signal: this.abortController.signal,
+ storage: this.options.storage,
+ connections: connectionManager
+ });
+ await stream.replicate();
+ } catch (e) {
+ if (this.abortController.signal.aborted) {
+ return;
+ }
+ this.logger.error(`Replication error`, e);
+ if (e.cause != null) {
+ // Without this additional log, the cause may not be visible in the logs.
+ this.logger.error(`cause`, e.cause);
+ }
+ if (e instanceof ChangeStreamInvalidatedError) {
+ throw e;
+ } else if (e instanceof mongo.MongoError && e.hasErrorLabel('NonResumableChangeStreamError')) {
+ throw new ChangeStreamInvalidatedError(e.message);
+ } else {
+ // Report the error if relevant, before retrying
+ container.reporter.captureException(e, {
+ metadata: {}
+ });
+ // This sets the retry delay
+ this.rateLimiter?.reportError(e);
+ }
+ } finally {
+ await connectionManager.end();
+ }
+ }
+}
diff --git a/modules/module-mongodb/src/replication/ChangeStreamReplicator.ts b/modules/module-mongodb/src/replication/ChangeStreamReplicator.ts
new file mode 100644
index 000000000..2cf96c494
--- /dev/null
+++ b/modules/module-mongodb/src/replication/ChangeStreamReplicator.ts
@@ -0,0 +1,36 @@
+import { storage, replication } from '@powersync/service-core';
+import { ChangeStreamReplicationJob } from './ChangeStreamReplicationJob.js';
+import { ConnectionManagerFactory } from './ConnectionManagerFactory.js';
+import { MongoErrorRateLimiter } from './MongoErrorRateLimiter.js';
+
+export interface ChangeStreamReplicatorOptions extends replication.AbstractReplicatorOptions {
+ connectionFactory: ConnectionManagerFactory;
+}
+
+export class ChangeStreamReplicator extends replication.AbstractReplicator {
+ private readonly connectionFactory: ConnectionManagerFactory;
+
+ constructor(options: ChangeStreamReplicatorOptions) {
+ super(options);
+ this.connectionFactory = options.connectionFactory;
+ }
+
+ createJob(options: replication.CreateJobOptions): ChangeStreamReplicationJob {
+ return new ChangeStreamReplicationJob({
+ id: this.createJobId(options.storage.group_id),
+ storage: options.storage,
+ connectionFactory: this.connectionFactory,
+ lock: options.lock,
+ rateLimiter: new MongoErrorRateLimiter()
+ });
+ }
+
+ async cleanUp(syncRulesStorage: storage.SyncRulesBucketStorage): Promise {
+ // TODO: Implement anything?
+ }
+
+ async stop(): Promise {
+ await super.stop();
+ await this.connectionFactory.shutdown();
+ }
+}
diff --git a/modules/module-mongodb/src/replication/ConnectionManagerFactory.ts b/modules/module-mongodb/src/replication/ConnectionManagerFactory.ts
new file mode 100644
index 000000000..c84c28e05
--- /dev/null
+++ b/modules/module-mongodb/src/replication/ConnectionManagerFactory.ts
@@ -0,0 +1,27 @@
+import { logger } from '@powersync/lib-services-framework';
+import { NormalizedMongoConnectionConfig } from '../types/types.js';
+import { MongoManager } from './MongoManager.js';
+
+export class ConnectionManagerFactory {
+ private readonly connectionManagers: MongoManager[];
+ private readonly dbConnectionConfig: NormalizedMongoConnectionConfig;
+
+ constructor(dbConnectionConfig: NormalizedMongoConnectionConfig) {
+ this.dbConnectionConfig = dbConnectionConfig;
+ this.connectionManagers = [];
+ }
+
+ create() {
+ const manager = new MongoManager(this.dbConnectionConfig);
+ this.connectionManagers.push(manager);
+ return manager;
+ }
+
+ async shutdown() {
+ logger.info('Shutting down MongoDB connection Managers...');
+ for (const manager of this.connectionManagers) {
+ await manager.end();
+ }
+ logger.info('MongoDB connection Managers shutdown completed.');
+ }
+}
diff --git a/modules/module-mongodb/src/replication/MongoErrorRateLimiter.ts b/modules/module-mongodb/src/replication/MongoErrorRateLimiter.ts
new file mode 100644
index 000000000..17b65c66c
--- /dev/null
+++ b/modules/module-mongodb/src/replication/MongoErrorRateLimiter.ts
@@ -0,0 +1,38 @@
+import { ErrorRateLimiter } from '@powersync/service-core';
+import { setTimeout } from 'timers/promises';
+
+export class MongoErrorRateLimiter implements ErrorRateLimiter {
+ nextAllowed: number = Date.now();
+
+ async waitUntilAllowed(options?: { signal?: AbortSignal | undefined } | undefined): Promise {
+ const delay = Math.max(0, this.nextAllowed - Date.now());
+ // Minimum delay between connections, even without errors
+ this.setDelay(500);
+ await setTimeout(delay, undefined, { signal: options?.signal });
+ }
+
+ mayPing(): boolean {
+ return Date.now() >= this.nextAllowed;
+ }
+
+ reportError(e: any): void {
+ // FIXME: Check mongodb-specific requirements
+ const message = (e.message as string) ?? '';
+ if (message.includes('password authentication failed')) {
+ // Wait 15 minutes, to avoid triggering Supabase's fail2ban
+ this.setDelay(900_000);
+ } else if (message.includes('ENOTFOUND')) {
+ // DNS lookup issue - incorrect URI or deleted instance
+ this.setDelay(120_000);
+ } else if (message.includes('ECONNREFUSED')) {
+ // Could be fail2ban or similar
+ this.setDelay(120_000);
+ } else {
+ this.setDelay(30_000);
+ }
+ }
+
+ private setDelay(delay: number) {
+ this.nextAllowed = Math.max(this.nextAllowed, Date.now() + delay);
+ }
+}
diff --git a/modules/module-mongodb/src/replication/MongoManager.ts b/modules/module-mongodb/src/replication/MongoManager.ts
new file mode 100644
index 000000000..cb2f9d54f
--- /dev/null
+++ b/modules/module-mongodb/src/replication/MongoManager.ts
@@ -0,0 +1,47 @@
+import * as mongo from 'mongodb';
+import { NormalizedMongoConnectionConfig } from '../types/types.js';
+
+export class MongoManager {
+ /**
+ * Do not use this for any transactions.
+ */
+ public readonly client: mongo.MongoClient;
+ public readonly db: mongo.Db;
+
+ constructor(public options: NormalizedMongoConnectionConfig) {
+ // The pool is lazy - no connections are opened until a query is performed.
+ this.client = new mongo.MongoClient(options.uri, {
+ auth: {
+ username: options.username,
+ password: options.password
+ },
+ // Time for connection to timeout
+ connectTimeoutMS: 5_000,
+ // Time for individual requests to timeout
+ socketTimeoutMS: 60_000,
+ // How long to wait for new primary selection
+ serverSelectionTimeoutMS: 30_000,
+
+ // Avoid too many connections:
+ // 1. It can overwhelm the source database.
+ // 2. Processing too many queries in parallel can cause the process to run out of memory.
+ maxPoolSize: 8,
+
+ maxConnecting: 3,
+ maxIdleTimeMS: 60_000
+ });
+ this.db = this.client.db(options.database, {});
+ }
+
+ public get connectionTag() {
+ return this.options.tag;
+ }
+
+ async end(): Promise {
+ await this.client.close();
+ }
+
+ async destroy() {
+ // TODO: Implement?
+ }
+}
diff --git a/modules/module-mongodb/src/replication/MongoRelation.ts b/modules/module-mongodb/src/replication/MongoRelation.ts
new file mode 100644
index 000000000..e2dc675e1
--- /dev/null
+++ b/modules/module-mongodb/src/replication/MongoRelation.ts
@@ -0,0 +1,171 @@
+import { storage } from '@powersync/service-core';
+import { SqliteRow, SqliteValue, toSyncRulesRow } from '@powersync/service-sync-rules';
+import * as mongo from 'mongodb';
+import { JSONBig, JsonContainer } from '@powersync/service-jsonbig';
+import { CHECKPOINTS_COLLECTION } from './replication-utils.js';
+
+export function getMongoRelation(source: mongo.ChangeStreamNameSpace): storage.SourceEntityDescriptor {
+ return {
+ name: source.coll,
+ schema: source.db,
+ objectId: source.coll,
+ replicationColumns: [{ name: '_id' }]
+ } satisfies storage.SourceEntityDescriptor;
+}
+
+export function getMongoLsn(timestamp: mongo.Timestamp) {
+ const a = timestamp.high.toString(16).padStart(8, '0');
+ const b = timestamp.low.toString(16).padStart(8, '0');
+ return a + b;
+}
+
+export function mongoLsnToTimestamp(lsn: string | null) {
+ if (lsn == null) {
+ return null;
+ }
+ const a = parseInt(lsn.substring(0, 8), 16);
+ const b = parseInt(lsn.substring(8, 16), 16);
+ return mongo.Timestamp.fromBits(b, a);
+}
+
+export function constructAfterRecord(document: mongo.Document): SqliteRow {
+ let record: SqliteRow = {};
+ for (let key of Object.keys(document)) {
+ record[key] = toMongoSyncRulesValue(document[key]);
+ }
+ return record;
+}
+
+export function toMongoSyncRulesValue(data: any): SqliteValue {
+ const autoBigNum = true;
+ if (data == null) {
+ // null or undefined
+ return data;
+ } else if (typeof data == 'string') {
+ return data;
+ } else if (typeof data == 'number') {
+ if (Number.isInteger(data) && autoBigNum) {
+ return BigInt(data);
+ } else {
+ return data;
+ }
+ } else if (typeof data == 'bigint') {
+ return data;
+ } else if (typeof data == 'boolean') {
+ return data ? 1n : 0n;
+ } else if (data instanceof mongo.ObjectId) {
+ return data.toHexString();
+ } else if (data instanceof mongo.UUID) {
+ return data.toHexString();
+ } else if (data instanceof Date) {
+ return data.toISOString().replace('T', ' ');
+ } else if (data instanceof mongo.Binary) {
+ return new Uint8Array(data.buffer);
+ } else if (data instanceof mongo.Long) {
+ return data.toBigInt();
+ } else if (data instanceof mongo.Decimal128) {
+ return data.toString();
+ } else if (data instanceof mongo.MinKey || data instanceof mongo.MaxKey) {
+ return null;
+ } else if (data instanceof RegExp) {
+ return JSON.stringify({ pattern: data.source, options: data.flags });
+ } else if (Array.isArray(data)) {
+ // We may be able to avoid some parse + stringify cycles here for JsonSqliteContainer.
+ return JSONBig.stringify(data.map((element) => filterJsonData(element)));
+ } else if (data instanceof Uint8Array) {
+ return data;
+ } else if (data instanceof JsonContainer) {
+ return data.toString();
+ } else if (typeof data == 'object') {
+ let record: Record = {};
+ for (let key of Object.keys(data)) {
+ record[key] = filterJsonData(data[key]);
+ }
+ return JSONBig.stringify(record);
+ } else {
+ return null;
+ }
+}
+
+const DEPTH_LIMIT = 20;
+
+function filterJsonData(data: any, depth = 0): any {
+ const autoBigNum = true;
+ if (depth > DEPTH_LIMIT) {
+ // This is primarily to prevent infinite recursion
+ throw new Error(`json nested object depth exceeds the limit of ${DEPTH_LIMIT}`);
+ }
+ if (data == null) {
+ return data; // null or undefined
+ } else if (typeof data == 'string') {
+ return data;
+ } else if (typeof data == 'number') {
+ if (autoBigNum && Number.isInteger(data)) {
+ return BigInt(data);
+ } else {
+ return data;
+ }
+ } else if (typeof data == 'boolean') {
+ return data ? 1n : 0n;
+ } else if (typeof data == 'bigint') {
+ return data;
+ } else if (data instanceof Date) {
+ return data.toISOString().replace('T', ' ');
+ } else if (data instanceof mongo.ObjectId) {
+ return data.toHexString();
+ } else if (data instanceof mongo.UUID) {
+ return data.toHexString();
+ } else if (data instanceof mongo.Binary) {
+ return undefined;
+ } else if (data instanceof mongo.Long) {
+ return data.toBigInt();
+ } else if (data instanceof mongo.Decimal128) {
+ return data.toString();
+ } else if (data instanceof mongo.MinKey || data instanceof mongo.MaxKey) {
+ return null;
+ } else if (data instanceof RegExp) {
+ return { pattern: data.source, options: data.flags };
+ } else if (Array.isArray(data)) {
+ return data.map((element) => filterJsonData(element, depth + 1));
+ } else if (ArrayBuffer.isView(data)) {
+ return undefined;
+ } else if (data instanceof JsonContainer) {
+ // Can be stringified directly when using our JSONBig implementation
+ return data;
+ } else if (typeof data == 'object') {
+ let record: Record = {};
+ for (let key of Object.keys(data)) {
+ record[key] = filterJsonData(data[key], depth + 1);
+ }
+ return record;
+ } else {
+ return undefined;
+ }
+}
+
+export async function createCheckpoint(client: mongo.MongoClient, db: mongo.Db): Promise {
+ const session = client.startSession();
+ try {
+ // Note: If multiple PowerSync instances are replicating the same source database,
+ // they'll modify the same checkpoint document. This is fine - it could create
+ // more replication load than required, but won't break anything.
+ await db.collection(CHECKPOINTS_COLLECTION).findOneAndUpdate(
+ {
+ _id: 'checkpoint' as any
+ },
+ {
+ $inc: { i: 1 }
+ },
+ {
+ upsert: true,
+ returnDocument: 'after',
+ session
+ }
+ );
+ const time = session.operationTime!;
+ // TODO: Use the above when we support custom write checkpoints
+ return getMongoLsn(time);
+ } finally {
+ await session.endSession();
+ }
+}
diff --git a/modules/module-mongodb/src/replication/replication-index.ts b/modules/module-mongodb/src/replication/replication-index.ts
new file mode 100644
index 000000000..4ff43b56a
--- /dev/null
+++ b/modules/module-mongodb/src/replication/replication-index.ts
@@ -0,0 +1,4 @@
+export * from './MongoRelation.js';
+export * from './ChangeStream.js';
+export * from './ChangeStreamReplicator.js';
+export * from './ChangeStreamReplicationJob.js';
diff --git a/modules/module-mongodb/src/replication/replication-utils.ts b/modules/module-mongodb/src/replication/replication-utils.ts
new file mode 100644
index 000000000..5370fdd5e
--- /dev/null
+++ b/modules/module-mongodb/src/replication/replication-utils.ts
@@ -0,0 +1,13 @@
+import { MongoManager } from './MongoManager.js';
+
+export const CHECKPOINTS_COLLECTION = '_powersync_checkpoints';
+
+export async function checkSourceConfiguration(connectionManager: MongoManager): Promise {
+ const db = connectionManager.db;
+ const hello = await db.command({ hello: 1 });
+ if (hello.msg == 'isdbgrid') {
+ throw new Error('Sharded MongoDB Clusters are not supported yet (including MongoDB Serverless instances).');
+ } else if (hello.setName == null) {
+ throw new Error('Standalone MongoDB instances are not supported - use a replicaset.');
+ }
+}
diff --git a/modules/module-mongodb/src/types/types.ts b/modules/module-mongodb/src/types/types.ts
new file mode 100644
index 000000000..1498193f5
--- /dev/null
+++ b/modules/module-mongodb/src/types/types.ts
@@ -0,0 +1,105 @@
+import { normalizeMongoConfig } from '@powersync/service-core';
+import * as service_types from '@powersync/service-types';
+import * as t from 'ts-codec';
+
+export const MONGO_CONNECTION_TYPE = 'mongodb' as const;
+
+export enum PostImagesOption {
+ /**
+ * Use fullDocument: updateLookup on the changeStream.
+ *
+ * This does not guarantee consistency - operations may
+ * arrive out of order, especially when there is replication lag.
+ *
+ * This is the default option for backwards-compatability.
+ */
+ OFF = 'off',
+
+ /**
+ * Use fullDocument: required on the changeStream.
+ *
+ * Collections are automatically configured with:
+ * `changeStreamPreAndPostImages: { enabled: true }`
+ *
+ * This is the recommended behavior for new instances.
+ */
+ AUTO_CONFIGURE = 'auto_configure',
+
+ /**
+ *
+ * Use fullDocument: required on the changeStream.
+ *
+ * Collections are not automatically configured. Each
+ * collection must be configured configured manually before
+ * replicating with:
+ *
+ * `changeStreamPreAndPostImages: { enabled: true }`
+ *
+ * Use when the collMod permission is not available.
+ */
+ READ_ONLY = 'read_only'
+}
+
+export interface NormalizedMongoConnectionConfig {
+ id: string;
+ tag: string;
+
+ uri: string;
+ database: string;
+
+ username?: string;
+ password?: string;
+
+ postImages: PostImagesOption;
+}
+
+export const MongoConnectionConfig = service_types.configFile.DataSourceConfig.and(
+ t.object({
+ type: t.literal(MONGO_CONNECTION_TYPE),
+ /** Unique identifier for the connection - optional when a single connection is present. */
+ id: t.string.optional(),
+ /** Tag used as reference in sync rules. Defaults to "default". Does not have to be unique. */
+ tag: t.string.optional(),
+ uri: t.string,
+ username: t.string.optional(),
+ password: t.string.optional(),
+ database: t.string.optional(),
+
+ post_images: t.literal('off').or(t.literal('auto_configure')).or(t.literal('read_only')).optional()
+ })
+);
+
+/**
+ * Config input specified when starting services
+ */
+export type MongoConnectionConfig = t.Decoded;
+
+/**
+ * Resolved version of {@link MongoConnectionConfig}
+ */
+export type ResolvedConnectionConfig = MongoConnectionConfig & NormalizedMongoConnectionConfig;
+
+/**
+ * Validate and normalize connection options.
+ *
+ * Returns destructured options.
+ */
+export function normalizeConnectionConfig(options: MongoConnectionConfig): NormalizedMongoConnectionConfig {
+ const base = normalizeMongoConfig(options);
+
+ return {
+ ...base,
+ id: options.id ?? 'default',
+ tag: options.tag ?? 'default',
+ postImages: (options.post_images as PostImagesOption | undefined) ?? PostImagesOption.OFF
+ };
+}
+
+/**
+ * Construct a mongodb URI, without username, password or ssl options.
+ *
+ * Only contains hostname, port, database.
+ */
+export function baseUri(options: NormalizedMongoConnectionConfig) {
+ return options.uri;
+}
diff --git a/modules/module-mongodb/src/utils.ts b/modules/module-mongodb/src/utils.ts
new file mode 100644
index 000000000..badee3083
--- /dev/null
+++ b/modules/module-mongodb/src/utils.ts
@@ -0,0 +1,4 @@
+export function escapeRegExp(string: string) {
+ // https://stackoverflow.com/a/3561711/214837
+ return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
+}
diff --git a/modules/module-mongodb/test/src/change_stream.test.ts b/modules/module-mongodb/test/src/change_stream.test.ts
new file mode 100644
index 000000000..0380fce6b
--- /dev/null
+++ b/modules/module-mongodb/test/src/change_stream.test.ts
@@ -0,0 +1,517 @@
+import { putOp, removeOp } from '@core-tests/stream_utils.js';
+import { MONGO_STORAGE_FACTORY } from '@core-tests/util.js';
+import { BucketStorageFactory } from '@powersync/service-core';
+import * as crypto from 'crypto';
+import * as mongo from 'mongodb';
+import { setTimeout } from 'node:timers/promises';
+import { describe, expect, test, vi } from 'vitest';
+import { ChangeStreamTestContext } from './change_stream_utils.js';
+import { PostImagesOption } from '@module/types/types.js';
+
+type StorageFactory = () => Promise;
+
+const BASIC_SYNC_RULES = `
+bucket_definitions:
+ global:
+ data:
+ - SELECT _id as id, description FROM "test_data"
+`;
+
+describe('change stream - mongodb', { timeout: 20_000 }, function () {
+ defineChangeStreamTests(MONGO_STORAGE_FACTORY);
+});
+
+function defineChangeStreamTests(factory: StorageFactory) {
+ test('replicating basic values', async () => {
+ await using context = await ChangeStreamTestContext.open(factory);
+ const { db } = context;
+ await context.updateSyncRules(`
+bucket_definitions:
+ global:
+ data:
+ - SELECT _id as id, description, num FROM "test_data"`);
+
+ await db.createCollection('test_data', {
+ changeStreamPreAndPostImages: { enabled: false }
+ });
+ const collection = db.collection('test_data');
+
+ await context.replicateSnapshot();
+
+ context.startStreaming();
+
+ const result = await collection.insertOne({ description: 'test1', num: 1152921504606846976n });
+ const test_id = result.insertedId;
+ await setTimeout(30);
+ await collection.updateOne({ _id: test_id }, { $set: { description: 'test2' } });
+ await setTimeout(30);
+ await collection.replaceOne({ _id: test_id }, { description: 'test3' });
+ await setTimeout(30);
+ await collection.deleteOne({ _id: test_id });
+
+ const data = await context.getBucketData('global[]');
+
+ expect(data).toMatchObject([
+ putOp('test_data', { id: test_id.toHexString(), description: 'test1', num: 1152921504606846976n }),
+ putOp('test_data', { id: test_id.toHexString(), description: 'test2', num: 1152921504606846976n }),
+ putOp('test_data', { id: test_id.toHexString(), description: 'test3' }),
+ removeOp('test_data', test_id.toHexString())
+ ]);
+ });
+
+ test('replicating wildcard', async () => {
+ await using context = await ChangeStreamTestContext.open(factory);
+ const { db } = context;
+ await context.updateSyncRules(`
+bucket_definitions:
+ global:
+ data:
+ - SELECT _id as id, description, num FROM "test_%"`);
+
+ await db.createCollection('test_data', {
+ changeStreamPreAndPostImages: { enabled: false }
+ });
+ const collection = db.collection('test_data');
+
+ const result = await collection.insertOne({ description: 'test1', num: 1152921504606846976n });
+ const test_id = result.insertedId;
+
+ await context.replicateSnapshot();
+
+ context.startStreaming();
+
+ await setTimeout(30);
+ await collection.updateOne({ _id: test_id }, { $set: { description: 'test2' } });
+
+ const data = await context.getBucketData('global[]');
+
+ expect(data).toMatchObject([
+ putOp('test_data', { id: test_id.toHexString(), description: 'test1', num: 1152921504606846976n }),
+ putOp('test_data', { id: test_id.toHexString(), description: 'test2', num: 1152921504606846976n })
+ ]);
+ });
+
+ test('updateLookup - no fullDocument available', async () => {
+ await using context = await ChangeStreamTestContext.open(factory, { postImages: PostImagesOption.OFF });
+ const { db, client } = context;
+ await context.updateSyncRules(`
+bucket_definitions:
+ global:
+ data:
+ - SELECT _id as id, description, num FROM "test_data"`);
+
+ await db.createCollection('test_data', {
+ changeStreamPreAndPostImages: { enabled: false }
+ });
+ const collection = db.collection('test_data');
+
+ await context.replicateSnapshot();
+ context.startStreaming();
+
+ const session = client.startSession();
+ let test_id: mongo.ObjectId | undefined;
+ try {
+ await session.withTransaction(async () => {
+ const result = await collection.insertOne({ description: 'test1', num: 1152921504606846976n }, { session });
+ test_id = result.insertedId;
+ await collection.updateOne({ _id: test_id }, { $set: { description: 'test2' } }, { session });
+ await collection.replaceOne({ _id: test_id }, { description: 'test3' }, { session });
+ await collection.deleteOne({ _id: test_id }, { session });
+ });
+ } finally {
+ await session.endSession();
+ }
+
+ const data = await context.getBucketData('global[]');
+
+ expect(data).toMatchObject([
+ putOp('test_data', { id: test_id!.toHexString(), description: 'test1', num: 1152921504606846976n }),
+ // fullDocument is not available at the point this is replicated, resulting in it treated as a remove
+ removeOp('test_data', test_id!.toHexString()),
+ putOp('test_data', { id: test_id!.toHexString(), description: 'test3' }),
+ removeOp('test_data', test_id!.toHexString())
+ ]);
+ });
+
+ test('postImages - autoConfigure', async () => {
+ // Similar to the above test, but with postImages enabled.
+ // This resolves the consistency issue.
+ await using context = await ChangeStreamTestContext.open(factory, { postImages: PostImagesOption.AUTO_CONFIGURE });
+ const { db, client } = context;
+ await context.updateSyncRules(`
+bucket_definitions:
+ global:
+ data:
+ - SELECT _id as id, description, num FROM "test_data"`);
+
+ await db.createCollection('test_data', {
+ // enabled: false here, but autoConfigure will enable it.
+ changeStreamPreAndPostImages: { enabled: false }
+ });
+ const collection = db.collection('test_data');
+
+ await context.replicateSnapshot();
+
+ context.startStreaming();
+
+ const session = client.startSession();
+ let test_id: mongo.ObjectId | undefined;
+ try {
+ await session.withTransaction(async () => {
+ const result = await collection.insertOne({ description: 'test1', num: 1152921504606846976n }, { session });
+ test_id = result.insertedId;
+ await collection.updateOne({ _id: test_id }, { $set: { description: 'test2' } }, { session });
+ await collection.replaceOne({ _id: test_id }, { description: 'test3' }, { session });
+ await collection.deleteOne({ _id: test_id }, { session });
+ });
+ } finally {
+ await session.endSession();
+ }
+
+ const data = await context.getBucketData('global[]');
+
+ expect(data).toMatchObject([
+ putOp('test_data', { id: test_id!.toHexString(), description: 'test1', num: 1152921504606846976n }),
+ // The postImage helps us get this data
+ putOp('test_data', { id: test_id!.toHexString(), description: 'test2', num: 1152921504606846976n }),
+ putOp('test_data', { id: test_id!.toHexString(), description: 'test3' }),
+ removeOp('test_data', test_id!.toHexString())
+ ]);
+ });
+
+ test('postImages - on', async () => {
+ // Similar to postImages - autoConfigure, but does not auto-configure.
+ // changeStreamPreAndPostImages must be manually configured.
+ await using context = await ChangeStreamTestContext.open(factory, { postImages: PostImagesOption.READ_ONLY });
+ const { db, client } = context;
+ await context.updateSyncRules(`
+bucket_definitions:
+ global:
+ data:
+ - SELECT _id as id, description, num FROM "test_data"`);
+
+ await db.createCollection('test_data', {
+ changeStreamPreAndPostImages: { enabled: true }
+ });
+ const collection = db.collection('test_data');
+
+ await context.replicateSnapshot();
+
+ context.startStreaming();
+
+ const session = client.startSession();
+ let test_id: mongo.ObjectId | undefined;
+ try {
+ await session.withTransaction(async () => {
+ const result = await collection.insertOne({ description: 'test1', num: 1152921504606846976n }, { session });
+ test_id = result.insertedId;
+ await collection.updateOne({ _id: test_id }, { $set: { description: 'test2' } }, { session });
+ await collection.replaceOne({ _id: test_id }, { description: 'test3' }, { session });
+ await collection.deleteOne({ _id: test_id }, { session });
+ });
+ } finally {
+ await session.endSession();
+ }
+
+ const data = await context.getBucketData('global[]');
+
+ expect(data).toMatchObject([
+ putOp('test_data', { id: test_id!.toHexString(), description: 'test1', num: 1152921504606846976n }),
+ // The postImage helps us get this data
+ putOp('test_data', { id: test_id!.toHexString(), description: 'test2', num: 1152921504606846976n }),
+ putOp('test_data', { id: test_id!.toHexString(), description: 'test3' }),
+ removeOp('test_data', test_id!.toHexString())
+ ]);
+ });
+
+ test('replicating case sensitive table', async () => {
+ await using context = await ChangeStreamTestContext.open(factory);
+ const { db } = context;
+ await context.updateSyncRules(`
+ bucket_definitions:
+ global:
+ data:
+ - SELECT _id as id, description FROM "test_DATA"
+ `);
+
+ await context.replicateSnapshot();
+
+ context.startStreaming();
+
+ const collection = db.collection('test_DATA');
+ const result = await collection.insertOne({ description: 'test1' });
+ const test_id = result.insertedId.toHexString();
+
+ const data = await context.getBucketData('global[]');
+
+ expect(data).toMatchObject([putOp('test_DATA', { id: test_id, description: 'test1' })]);
+ });
+
+ test('replicating large values', async () => {
+ await using context = await ChangeStreamTestContext.open(factory);
+ const { db } = context;
+ await context.updateSyncRules(`
+ bucket_definitions:
+ global:
+ data:
+ - SELECT _id as id, name, description FROM "test_data"
+ `);
+
+ await context.replicateSnapshot();
+ context.startStreaming();
+
+ const largeDescription = crypto.randomBytes(20_000).toString('hex');
+
+ const collection = db.collection('test_data');
+ const result = await collection.insertOne({ name: 'test1', description: largeDescription });
+ const test_id = result.insertedId;
+
+ await collection.updateOne({ _id: test_id }, { $set: { name: 'test2' } });
+
+ const data = await context.getBucketData('global[]');
+ expect(data.slice(0, 1)).toMatchObject([
+ putOp('test_data', { id: test_id.toHexString(), name: 'test1', description: largeDescription })
+ ]);
+ expect(data.slice(1)).toMatchObject([
+ putOp('test_data', { id: test_id.toHexString(), name: 'test2', description: largeDescription })
+ ]);
+ });
+
+ test('replicating dropCollection', async () => {
+ await using context = await ChangeStreamTestContext.open(factory);
+ const { db } = context;
+ const syncRuleContent = `
+bucket_definitions:
+ global:
+ data:
+ - SELECT _id as id, description FROM "test_data"
+ by_test_data:
+ parameters: SELECT _id as id FROM test_data WHERE id = token_parameters.user_id
+ data: []
+`;
+ await context.updateSyncRules(syncRuleContent);
+ await context.replicateSnapshot();
+ context.startStreaming();
+
+ const collection = db.collection('test_data');
+ const result = await collection.insertOne({ description: 'test1' });
+ const test_id = result.insertedId.toHexString();
+
+ await collection.drop();
+
+ const data = await context.getBucketData('global[]');
+
+ expect(data).toMatchObject([
+ putOp('test_data', { id: test_id, description: 'test1' }),
+ removeOp('test_data', test_id)
+ ]);
+ });
+
+ test('replicating renameCollection', async () => {
+ await using context = await ChangeStreamTestContext.open(factory);
+ const { db } = context;
+ const syncRuleContent = `
+bucket_definitions:
+ global:
+ data:
+ - SELECT _id as id, description FROM "test_data1"
+ - SELECT _id as id, description FROM "test_data2"
+`;
+ await context.updateSyncRules(syncRuleContent);
+ await context.replicateSnapshot();
+ context.startStreaming();
+
+ const collection = db.collection('test_data1');
+ const result = await collection.insertOne({ description: 'test1' });
+ const test_id = result.insertedId.toHexString();
+
+ await collection.rename('test_data2');
+
+ const data = await context.getBucketData('global[]');
+
+ expect(data).toMatchObject([
+ putOp('test_data1', { id: test_id, description: 'test1' }),
+ removeOp('test_data1', test_id),
+ putOp('test_data2', { id: test_id, description: 'test1' })
+ ]);
+ });
+
+ test('initial sync', async () => {
+ await using context = await ChangeStreamTestContext.open(factory);
+ const { db } = context;
+ await context.updateSyncRules(BASIC_SYNC_RULES);
+
+ const collection = db.collection('test_data');
+ const result = await collection.insertOne({ description: 'test1' });
+ const test_id = result.insertedId.toHexString();
+
+ await context.replicateSnapshot();
+ context.startStreaming();
+
+ const data = await context.getBucketData('global[]');
+ expect(data).toMatchObject([putOp('test_data', { id: test_id, description: 'test1' })]);
+ });
+
+ test('large record', async () => {
+ // Test a large update.
+
+ // Without $changeStreamSplitLargeEvent, we get this error:
+ // MongoServerError: PlanExecutor error during aggregation :: caused by :: BSONObj size: 33554925 (0x20001ED) is invalid.
+ // Size must be between 0 and 16793600(16MB)
+
+ await using context = await ChangeStreamTestContext.open(factory);
+ await context.updateSyncRules(`bucket_definitions:
+ global:
+ data:
+ - SELECT _id as id, name, other FROM "test_data"`);
+ const { db } = context;
+
+ await context.replicateSnapshot();
+
+ const collection = db.collection('test_data');
+ const result = await collection.insertOne({ name: 't1' });
+ const test_id = result.insertedId;
+
+ // 12MB field.
+ // The field appears twice in the ChangeStream event, so the total size
+ // is > 16MB.
+
+ // We don't actually have this description field in the sync rules,
+ // That causes other issues, not relevant for this specific test.
+ const largeDescription = crypto.randomBytes(12000000 / 2).toString('hex');
+
+ await collection.updateOne({ _id: test_id }, { $set: { description: largeDescription } });
+ context.startStreaming();
+
+ const data = await context.getBucketData('global[]');
+ expect(data.length).toEqual(2);
+ const row1 = JSON.parse(data[0].data as string);
+ expect(row1).toEqual({ id: test_id.toHexString(), name: 't1' });
+ delete data[0].data;
+ expect(data[0]).toMatchObject({
+ object_id: test_id.toHexString(),
+ object_type: 'test_data',
+ op: 'PUT',
+ op_id: '1'
+ });
+ const row2 = JSON.parse(data[1].data as string);
+ expect(row2).toEqual({ id: test_id.toHexString(), name: 't1' });
+ delete data[1].data;
+ expect(data[1]).toMatchObject({
+ object_id: test_id.toHexString(),
+ object_type: 'test_data',
+ op: 'PUT',
+ op_id: '2'
+ });
+ });
+
+ test('collection not in sync rules', async () => {
+ await using context = await ChangeStreamTestContext.open(factory);
+ const { db } = context;
+ await context.updateSyncRules(BASIC_SYNC_RULES);
+
+ await context.replicateSnapshot();
+
+ context.startStreaming();
+
+ const collection = db.collection('test_donotsync');
+ const result = await collection.insertOne({ description: 'test' });
+
+ const data = await context.getBucketData('global[]');
+
+ expect(data).toMatchObject([]);
+ });
+
+ test('postImages - new collection with postImages enabled', async () => {
+ await using context = await ChangeStreamTestContext.open(factory, { postImages: PostImagesOption.AUTO_CONFIGURE });
+ const { db } = context;
+ await context.updateSyncRules(`
+bucket_definitions:
+ global:
+ data:
+ - SELECT _id as id, description FROM "test_%"`);
+
+ await context.replicateSnapshot();
+
+ await db.createCollection('test_data', {
+ // enabled: true here - everything should work
+ changeStreamPreAndPostImages: { enabled: true }
+ });
+ const collection = db.collection('test_data');
+ const result = await collection.insertOne({ description: 'test1' });
+ const test_id = result.insertedId;
+ await collection.updateOne({ _id: test_id }, { $set: { description: 'test2' } });
+
+ context.startStreaming();
+
+ const data = await context.getBucketData('global[]');
+ expect(data).toMatchObject([
+ putOp('test_data', { id: test_id!.toHexString(), description: 'test1' }),
+ putOp('test_data', { id: test_id!.toHexString(), description: 'test2' })
+ ]);
+ });
+
+ test('postImages - new collection with postImages disabled', async () => {
+ await using context = await ChangeStreamTestContext.open(factory, { postImages: PostImagesOption.AUTO_CONFIGURE });
+ const { db } = context;
+ await context.updateSyncRules(`
+bucket_definitions:
+ global:
+ data:
+ - SELECT _id as id, description FROM "test_data%"`);
+
+ await context.replicateSnapshot();
+
+ await db.createCollection('test_data', {
+ // enabled: false here, but autoConfigure will enable it.
+ // Unfortunately, that is too late, and replication must be restarted.
+ changeStreamPreAndPostImages: { enabled: false }
+ });
+ const collection = db.collection('test_data');
+ const result = await collection.insertOne({ description: 'test1' });
+ const test_id = result.insertedId;
+ await collection.updateOne({ _id: test_id }, { $set: { description: 'test2' } });
+
+ context.startStreaming();
+
+ await expect(() => context.getBucketData('global[]')).rejects.toMatchObject({
+ message: expect.stringContaining('stream was configured to require a post-image for all update events')
+ });
+ });
+
+ test('recover from error', async () => {
+ await using context = await ChangeStreamTestContext.open(factory);
+ const { db } = context;
+ await context.updateSyncRules(`
+bucket_definitions:
+ global:
+ data:
+ - SELECT _id as id, description, num FROM "test_data"`);
+
+ await db.createCollection('test_data', {
+ changeStreamPreAndPostImages: { enabled: false }
+ });
+
+ const collection = db.collection('test_data');
+ await collection.insertOne({ description: 'test1', num: 1152921504606846976n });
+
+ await context.replicateSnapshot();
+
+ // Simulate an error
+ await context.storage!.reportError(new Error('simulated error'));
+ expect((await context.factory.getActiveSyncRulesContent())?.last_fatal_error).toEqual('simulated error');
+
+ // startStreaming() should automatically clear the error.
+ context.startStreaming();
+
+ // getBucketData() creates a checkpoint that clears the error, so we don't do that
+ // Just wait, and check that the error is cleared automatically.
+ await vi.waitUntil(
+ async () => {
+ const error = (await context.factory.getActiveSyncRulesContent())?.last_fatal_error;
+ return error == null;
+ },
+ { timeout: 2_000 }
+ );
+ });
+}
diff --git a/modules/module-mongodb/test/src/change_stream_utils.ts b/modules/module-mongodb/test/src/change_stream_utils.ts
new file mode 100644
index 000000000..77a5d9647
--- /dev/null
+++ b/modules/module-mongodb/test/src/change_stream_utils.ts
@@ -0,0 +1,178 @@
+import { ActiveCheckpoint, BucketStorageFactory, OpId, SyncRulesBucketStorage } from '@powersync/service-core';
+
+import { TEST_CONNECTION_OPTIONS, clearTestDb } from './util.js';
+import { fromAsync } from '@core-tests/stream_utils.js';
+import { MongoManager } from '@module/replication/MongoManager.js';
+import { ChangeStream, ChangeStreamOptions } from '@module/replication/ChangeStream.js';
+import * as mongo from 'mongodb';
+import { createCheckpoint } from '@module/replication/MongoRelation.js';
+import { NormalizedMongoConnectionConfig } from '@module/types/types.js';
+
+export class ChangeStreamTestContext {
+ private _walStream?: ChangeStream;
+ private abortController = new AbortController();
+ private streamPromise?: Promise;
+ public storage?: SyncRulesBucketStorage;
+
+ /**
+ * Tests operating on the mongo change stream need to configure the stream and manage asynchronous
+ * replication, which gets a little tricky.
+ *
+ * This configures all the context, and tears it down afterwards.
+ */
+ static async open(factory: () => Promise, options?: Partial) {
+ const f = await factory();
+ const connectionManager = new MongoManager({ ...TEST_CONNECTION_OPTIONS, ...options });
+
+ await clearTestDb(connectionManager.db);
+ return new ChangeStreamTestContext(f, connectionManager);
+ }
+
+ constructor(
+ public factory: BucketStorageFactory,
+ public connectionManager: MongoManager
+ ) {}
+
+ async dispose() {
+ this.abortController.abort();
+ await this.streamPromise?.catch((e) => e);
+ await this.connectionManager.destroy();
+ }
+
+ async [Symbol.asyncDispose]() {
+ await this.dispose();
+ }
+
+ get client() {
+ return this.connectionManager.client;
+ }
+
+ get db() {
+ return this.connectionManager.db;
+ }
+
+ get connectionTag() {
+ return this.connectionManager.connectionTag;
+ }
+
+ async updateSyncRules(content: string) {
+ const syncRules = await this.factory.updateSyncRules({ content: content });
+ this.storage = this.factory.getInstance(syncRules);
+ return this.storage!;
+ }
+
+ get walStream() {
+ if (this.storage == null) {
+ throw new Error('updateSyncRules() first');
+ }
+ if (this._walStream) {
+ return this._walStream;
+ }
+ const options: ChangeStreamOptions = {
+ storage: this.storage,
+ connections: this.connectionManager,
+ abort_signal: this.abortController.signal
+ };
+ this._walStream = new ChangeStream(options);
+ return this._walStream!;
+ }
+
+ async replicateSnapshot() {
+ await this.walStream.initReplication();
+ await this.storage!.autoActivate();
+ }
+
+ startStreaming() {
+ this.streamPromise = this.walStream.streamChanges();
+ }
+
+ async getCheckpoint(options?: { timeout?: number }) {
+ let checkpoint = await Promise.race([
+ getClientCheckpoint(this.client, this.db, this.factory, { timeout: options?.timeout ?? 15_000 }),
+ this.streamPromise
+ ]);
+ if (typeof checkpoint == 'undefined') {
+ // This indicates an issue with the test setup - streamingPromise completed instead
+ // of getClientCheckpoint()
+ throw new Error('Test failure - streamingPromise completed');
+ }
+ return checkpoint as string;
+ }
+
+ async getBucketsDataBatch(buckets: Record, options?: { timeout?: number }) {
+ let checkpoint = await this.getCheckpoint(options);
+ const map = new Map(Object.entries(buckets));
+ return fromAsync(this.storage!.getBucketDataBatch(checkpoint, map));
+ }
+
+ async getBucketData(
+ bucket: string,
+ start?: string,
+ options?: { timeout?: number; limit?: number; chunkLimitBytes?: number }
+ ) {
+ start ??= '0';
+ let checkpoint = await this.getCheckpoint(options);
+ const map = new Map([[bucket, start]]);
+ const batch = this.storage!.getBucketDataBatch(checkpoint, map, {
+ limit: options?.limit,
+ chunkLimitBytes: options?.chunkLimitBytes
+ });
+ const batches = await fromAsync(batch);
+ return batches[0]?.batch.data ?? [];
+ }
+
+ async getChecksums(buckets: string[], options?: { timeout?: number }) {
+ let checkpoint = await this.getCheckpoint(options);
+ return this.storage!.getChecksums(checkpoint, buckets);
+ }
+
+ async getChecksum(bucket: string, options?: { timeout?: number }) {
+ let checkpoint = await this.getCheckpoint(options);
+ const map = await this.storage!.getChecksums(checkpoint, [bucket]);
+ return map.get(bucket);
+ }
+}
+
+export async function getClientCheckpoint(
+ client: mongo.MongoClient,
+ db: mongo.Db,
+ bucketStorage: BucketStorageFactory,
+ options?: { timeout?: number }
+): Promise {
+ const start = Date.now();
+ const lsn = await createCheckpoint(client, db);
+ // This old API needs a persisted checkpoint id.
+ // Since we don't use LSNs anymore, the only way to get that is to wait.
+
+ const timeout = options?.timeout ?? 50_000;
+ let lastCp: ActiveCheckpoint | null = null;
+
+ while (Date.now() - start < timeout) {
+ const cp = await bucketStorage.getActiveCheckpoint();
+ lastCp = cp;
+ if (!cp.hasSyncRules()) {
+ throw new Error('No sync rules available');
+ }
+ if (cp.lsn && cp.lsn >= lsn) {
+ return cp.checkpoint;
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ }
+
+ throw new Error(`Timeout while waiting for checkpoint ${lsn}. Last checkpoint: ${lastCp?.lsn}`);
+}
+
+export async function setSnapshotHistorySeconds(client: mongo.MongoClient, seconds: number) {
+ const { minSnapshotHistoryWindowInSeconds: currentValue } = await client
+ .db('admin')
+ .command({ getParameter: 1, minSnapshotHistoryWindowInSeconds: 1 });
+
+ await client.db('admin').command({ setParameter: 1, minSnapshotHistoryWindowInSeconds: seconds });
+
+ return {
+ async [Symbol.asyncDispose]() {
+ await client.db('admin').command({ setParameter: 1, minSnapshotHistoryWindowInSeconds: currentValue });
+ }
+ };
+}
diff --git a/modules/module-mongodb/test/src/env.ts b/modules/module-mongodb/test/src/env.ts
new file mode 100644
index 000000000..e460c80b3
--- /dev/null
+++ b/modules/module-mongodb/test/src/env.ts
@@ -0,0 +1,7 @@
+import { utils } from '@powersync/lib-services-framework';
+
+export const env = utils.collectEnvironmentVariables({
+ MONGO_TEST_DATA_URL: utils.type.string.default('mongodb://localhost:27017/powersync_test_data'),
+ CI: utils.type.boolean.default('false'),
+ SLOW_TESTS: utils.type.boolean.default('false')
+});
diff --git a/modules/module-mongodb/test/src/mongo_test.test.ts b/modules/module-mongodb/test/src/mongo_test.test.ts
new file mode 100644
index 000000000..5d30067da
--- /dev/null
+++ b/modules/module-mongodb/test/src/mongo_test.test.ts
@@ -0,0 +1,449 @@
+import { MongoRouteAPIAdapter } from '@module/api/MongoRouteAPIAdapter.js';
+import { ChangeStream } from '@module/replication/ChangeStream.js';
+import { constructAfterRecord } from '@module/replication/MongoRelation.js';
+import { SqliteRow, SqlSyncRules } from '@powersync/service-sync-rules';
+import * as mongo from 'mongodb';
+import { describe, expect, test } from 'vitest';
+import { clearTestDb, connectMongoData, TEST_CONNECTION_OPTIONS } from './util.js';
+import { PostImagesOption } from '@module/types/types.js';
+
+describe('mongo data types', () => {
+ async function setupTable(db: mongo.Db) {
+ await clearTestDb(db);
+ }
+
+ async function insert(collection: mongo.Collection) {
+ await collection.insertMany([
+ {
+ _id: 1 as any,
+ null: null,
+ text: 'text',
+ uuid: new mongo.UUID('baeb2514-4c57-436d-b3cc-c1256211656d'),
+ bool: true,
+ bytea: Buffer.from('test'),
+ int2: 1000,
+ int4: 1000000,
+ int8: 9007199254740993n,
+ float: 3.14,
+ decimal: new mongo.Decimal128('3.14')
+ },
+ { _id: 2 as any, nested: { test: 'thing' } },
+ { _id: 3 as any, date: new Date('2023-03-06 15:47+02') },
+ {
+ _id: 4 as any,
+ timestamp: mongo.Timestamp.fromBits(123, 456),
+ objectId: mongo.ObjectId.createFromHexString('66e834cc91d805df11fa0ecb'),
+ regexp: new mongo.BSONRegExp('test', 'i'),
+ minKey: new mongo.MinKey(),
+ maxKey: new mongo.MaxKey(),
+ symbol: new mongo.BSONSymbol('test'),
+ js: new mongo.Code('testcode'),
+ js2: new mongo.Code('testcode', { foo: 'bar' }),
+ pointer: new mongo.DBRef('mycollection', mongo.ObjectId.createFromHexString('66e834cc91d805df11fa0ecb')),
+ pointer2: new mongo.DBRef(
+ 'mycollection',
+ mongo.ObjectId.createFromHexString('66e834cc91d805df11fa0ecb'),
+ 'mydb',
+ { foo: 'bar' }
+ ),
+ undefined: undefined
+ }
+ ]);
+ }
+
+ async function insertNested(collection: mongo.Collection) {
+ await collection.insertMany([
+ {
+ _id: 1 as any,
+ null: [null],
+ text: ['text'],
+ uuid: [new mongo.UUID('baeb2514-4c57-436d-b3cc-c1256211656d')],
+ bool: [true],
+ bytea: [Buffer.from('test')],
+ int2: [1000],
+ int4: [1000000],
+ int8: [9007199254740993n],
+ float: [3.14],
+ decimal: [new mongo.Decimal128('3.14')]
+ },
+ { _id: 2 as any, nested: [{ test: 'thing' }] },
+ { _id: 3 as any, date: [new Date('2023-03-06 15:47+02')] },
+ {
+ _id: 10 as any,
+ timestamp: [mongo.Timestamp.fromBits(123, 456)],
+ objectId: [mongo.ObjectId.createFromHexString('66e834cc91d805df11fa0ecb')],
+ regexp: [new mongo.BSONRegExp('test', 'i')],
+ minKey: [new mongo.MinKey()],
+ maxKey: [new mongo.MaxKey()],
+ symbol: [new mongo.BSONSymbol('test')],
+ js: [new mongo.Code('testcode')],
+ pointer: [new mongo.DBRef('mycollection', mongo.ObjectId.createFromHexString('66e834cc91d805df11fa0ecb'))],
+ undefined: [undefined]
+ }
+ ]);
+ }
+
+ function checkResults(transformed: Record[]) {
+ expect(transformed[0]).toMatchObject({
+ _id: 1n,
+ text: 'text',
+ uuid: 'baeb2514-4c57-436d-b3cc-c1256211656d',
+ bool: 1n,
+ bytea: new Uint8Array([116, 101, 115, 116]),
+ int2: 1000n,
+ int4: 1000000n,
+ int8: 9007199254740993n,
+ float: 3.14,
+ null: null,
+ decimal: '3.14'
+ });
+ expect(transformed[1]).toMatchObject({
+ _id: 2n,
+ nested: '{"test":"thing"}'
+ });
+
+ expect(transformed[2]).toMatchObject({
+ _id: 3n,
+ date: '2023-03-06 13:47:00.000Z'
+ });
+
+ expect(transformed[3]).toMatchObject({
+ _id: 4n,
+ objectId: '66e834cc91d805df11fa0ecb',
+ timestamp: 1958505087099n,
+ regexp: '{"pattern":"test","options":"i"}',
+ minKey: null,
+ maxKey: null,
+ symbol: 'test',
+ js: '{"code":"testcode","scope":null}',
+ js2: '{"code":"testcode","scope":{"foo":"bar"}}',
+ pointer: '{"collection":"mycollection","oid":"66e834cc91d805df11fa0ecb","fields":{}}',
+ pointer2: '{"collection":"mycollection","oid":"66e834cc91d805df11fa0ecb","db":"mydb","fields":{"foo":"bar"}}',
+ undefined: null
+ });
+ }
+
+ function checkResultsNested(transformed: Record[]) {
+ expect(transformed[0]).toMatchObject({
+ _id: 1n,
+ text: `["text"]`,
+ uuid: '["baeb2514-4c57-436d-b3cc-c1256211656d"]',
+ bool: '[1]',
+ bytea: '[null]',
+ int2: '[1000]',
+ int4: '[1000000]',
+ int8: `[9007199254740993]`,
+ float: '[3.14]',
+ null: '[null]'
+ });
+
+ // Note: Depending on to what extent we use the original postgres value, the whitespace may change, and order may change.
+ // We do expect that decimals and big numbers are preserved.
+ expect(transformed[1]).toMatchObject({
+ _id: 2n,
+ nested: '[{"test":"thing"}]'
+ });
+
+ expect(transformed[2]).toMatchObject({
+ _id: 3n,
+ date: '["2023-03-06 13:47:00.000Z"]'
+ });
+
+ expect(transformed[3]).toMatchObject({
+ _id: 10n,
+ objectId: '["66e834cc91d805df11fa0ecb"]',
+ timestamp: '[1958505087099]',
+ regexp: '[{"pattern":"test","options":"i"}]',
+ symbol: '["test"]',
+ js: '[{"code":"testcode","scope":null}]',
+ pointer: '[{"collection":"mycollection","oid":"66e834cc91d805df11fa0ecb","fields":{}}]',
+ minKey: '[null]',
+ maxKey: '[null]',
+ undefined: '[null]'
+ });
+ }
+
+ test('test direct queries', async () => {
+ const { db, client } = await connectMongoData();
+ const collection = db.collection('test_data');
+ try {
+ await setupTable(db);
+
+ await insert(collection);
+
+ const transformed = [...ChangeStream.getQueryData(await db.collection('test_data').find().toArray())];
+
+ checkResults(transformed);
+ } finally {
+ await client.close();
+ }
+ });
+
+ test('test direct queries - arrays', async () => {
+ const { db, client } = await connectMongoData();
+ const collection = db.collection('test_data_arrays');
+ try {
+ await setupTable(db);
+
+ await insertNested(collection);
+
+ const transformed = [...ChangeStream.getQueryData(await db.collection('test_data_arrays').find().toArray())];
+
+ checkResultsNested(transformed);
+ } finally {
+ await client.close();
+ }
+ });
+
+ test('test replication', async () => {
+ // With MongoDB, replication uses the exact same document format
+ // as normal queries. We test it anyway.
+ const { db, client } = await connectMongoData();
+ const collection = db.collection('test_data');
+ try {
+ await setupTable(db);
+
+ const stream = db.watch([], {
+ useBigInt64: true,
+ maxAwaitTimeMS: 50,
+ fullDocument: 'updateLookup'
+ });
+
+ await stream.tryNext();
+
+ await insert(collection);
+
+ const transformed = await getReplicationTx(stream, 4);
+
+ checkResults(transformed);
+ } finally {
+ await client.close();
+ }
+ });
+
+ test('test replication - arrays', async () => {
+ const { db, client } = await connectMongoData();
+ const collection = db.collection('test_data');
+ try {
+ await setupTable(db);
+
+ const stream = db.watch([], {
+ useBigInt64: true,
+ maxAwaitTimeMS: 50,
+ fullDocument: 'updateLookup'
+ });
+
+ await stream.tryNext();
+
+ await insertNested(collection);
+
+ const transformed = await getReplicationTx(stream, 4);
+
+ checkResultsNested(transformed);
+ } finally {
+ await client.close();
+ }
+ });
+
+ test('connection schema', async () => {
+ await using adapter = new MongoRouteAPIAdapter({
+ type: 'mongodb',
+ ...TEST_CONNECTION_OPTIONS
+ });
+ const db = adapter.db;
+ await clearTestDb(db);
+
+ const collection = db.collection('test_data');
+ await setupTable(db);
+ await insert(collection);
+
+ const schema = await adapter.getConnectionSchema();
+ const dbSchema = schema.filter((s) => s.name == TEST_CONNECTION_OPTIONS.database)[0];
+ expect(dbSchema).not.toBeNull();
+ expect(dbSchema.tables).toMatchObject([
+ {
+ name: 'test_data',
+ columns: [
+ { name: '_id', sqlite_type: 4, internal_type: 'Integer' },
+ { name: 'bool', sqlite_type: 4, internal_type: 'Boolean' },
+ { name: 'bytea', sqlite_type: 1, internal_type: 'Binary' },
+ { name: 'date', sqlite_type: 2, internal_type: 'Date' },
+ { name: 'decimal', sqlite_type: 2, internal_type: 'Decimal' },
+ { name: 'float', sqlite_type: 8, internal_type: 'Double' },
+ { name: 'int2', sqlite_type: 4, internal_type: 'Integer' },
+ { name: 'int4', sqlite_type: 4, internal_type: 'Integer' },
+ { name: 'int8', sqlite_type: 4, internal_type: 'Long' },
+ // We can fix these later
+ { name: 'js', sqlite_type: 2, internal_type: 'Object' },
+ { name: 'js2', sqlite_type: 2, internal_type: 'Object' },
+ { name: 'maxKey', sqlite_type: 0, internal_type: 'MaxKey' },
+ { name: 'minKey', sqlite_type: 0, internal_type: 'MinKey' },
+ { name: 'nested', sqlite_type: 2, internal_type: 'Object' },
+ { name: 'null', sqlite_type: 0, internal_type: 'Null' },
+ { name: 'objectId', sqlite_type: 2, internal_type: 'ObjectId' },
+ // We can fix these later
+ { name: 'pointer', sqlite_type: 2, internal_type: 'Object' },
+ { name: 'pointer2', sqlite_type: 2, internal_type: 'Object' },
+ { name: 'regexp', sqlite_type: 2, internal_type: 'RegExp' },
+ // Can fix this later
+ { name: 'symbol', sqlite_type: 2, internal_type: 'String' },
+ { name: 'text', sqlite_type: 2, internal_type: 'String' },
+ { name: 'timestamp', sqlite_type: 4, internal_type: 'Timestamp' },
+ { name: 'undefined', sqlite_type: 0, internal_type: 'Null' },
+ { name: 'uuid', sqlite_type: 2, internal_type: 'UUID' }
+ ]
+ }
+ ]);
+ });
+
+ test('validate postImages', async () => {
+ await using adapter = new MongoRouteAPIAdapter({
+ type: 'mongodb',
+ ...TEST_CONNECTION_OPTIONS,
+ postImages: PostImagesOption.READ_ONLY
+ });
+ const db = adapter.db;
+ await clearTestDb(db);
+
+ const collection = db.collection('test_data');
+ await setupTable(db);
+ await insert(collection);
+
+ const rules = SqlSyncRules.fromYaml(
+ `
+bucket_definitions:
+ global:
+ data:
+ - select _id as id, * from test_data
+
+ `,
+ {
+ ...adapter.getParseSyncRulesOptions(),
+ // No schema-based validation at this point
+ schema: undefined
+ }
+ );
+ const source_table_patterns = rules.getSourceTables();
+ const results = await adapter.getDebugTablesInfo(source_table_patterns, rules);
+
+ const result = results[0];
+ expect(result).not.toBeNull();
+ expect(result.table).toMatchObject({
+ schema: 'powersync_test_data',
+ name: 'test_data',
+ replication_id: ['_id'],
+ data_queries: true,
+ parameter_queries: false,
+ errors: [
+ {
+ level: 'fatal',
+ message: 'changeStreamPreAndPostImages not enabled on powersync_test_data.test_data'
+ }
+ ]
+ });
+ });
+
+ test('validate postImages - auto-configure', async () => {
+ await using adapter = new MongoRouteAPIAdapter({
+ type: 'mongodb',
+ ...TEST_CONNECTION_OPTIONS,
+ postImages: PostImagesOption.AUTO_CONFIGURE
+ });
+ const db = adapter.db;
+ await clearTestDb(db);
+
+ const collection = db.collection('test_data');
+ await setupTable(db);
+ await insert(collection);
+
+ const rules = SqlSyncRules.fromYaml(
+ `
+bucket_definitions:
+ global:
+ data:
+ - select _id as id, * from test_data
+
+ `,
+ {
+ ...adapter.getParseSyncRulesOptions(),
+ // No schema-based validation at this point
+ schema: undefined
+ }
+ );
+ const source_table_patterns = rules.getSourceTables();
+ const results = await adapter.getDebugTablesInfo(source_table_patterns, rules);
+
+ const result = results[0];
+ expect(result).not.toBeNull();
+ expect(result.table).toMatchObject({
+ schema: 'powersync_test_data',
+ name: 'test_data',
+ replication_id: ['_id'],
+ data_queries: true,
+ parameter_queries: false,
+ errors: [
+ {
+ level: 'warning',
+ message:
+ 'changeStreamPreAndPostImages not enabled on powersync_test_data.test_data, will be enabled automatically'
+ }
+ ]
+ });
+ });
+
+ test('validate postImages - off', async () => {
+ await using adapter = new MongoRouteAPIAdapter({
+ type: 'mongodb',
+ ...TEST_CONNECTION_OPTIONS,
+ postImages: PostImagesOption.OFF
+ });
+ const db = adapter.db;
+ await clearTestDb(db);
+
+ const collection = db.collection('test_data');
+ await setupTable(db);
+ await insert(collection);
+
+ const rules = SqlSyncRules.fromYaml(
+ `
+bucket_definitions:
+ global:
+ data:
+ - select _id as id, * from test_data
+
+ `,
+ {
+ ...adapter.getParseSyncRulesOptions(),
+ // No schema-based validation at this point
+ schema: undefined
+ }
+ );
+ const source_table_patterns = rules.getSourceTables();
+ const results = await adapter.getDebugTablesInfo(source_table_patterns, rules);
+
+ const result = results[0];
+ expect(result).not.toBeNull();
+ expect(result.table).toMatchObject({
+ schema: 'powersync_test_data',
+ name: 'test_data',
+ replication_id: ['_id'],
+ data_queries: true,
+ parameter_queries: false,
+ errors: []
+ });
+ });
+});
+
+/**
+ * Return all the inserts from the first transaction in the replication stream.
+ */
+async function getReplicationTx(replicationStream: mongo.ChangeStream, count: number) {
+ let transformed: SqliteRow[] = [];
+ for await (const doc of replicationStream) {
+ transformed.push(constructAfterRecord((doc as any).fullDocument));
+ if (transformed.length == count) {
+ break;
+ }
+ }
+ return transformed;
+}
diff --git a/modules/module-mongodb/test/src/setup.ts b/modules/module-mongodb/test/src/setup.ts
new file mode 100644
index 000000000..b924cf736
--- /dev/null
+++ b/modules/module-mongodb/test/src/setup.ts
@@ -0,0 +1,7 @@
+import { container } from '@powersync/lib-services-framework';
+import { beforeAll } from 'vitest';
+
+beforeAll(() => {
+ // Executes for every test file
+ container.registerDefaults();
+});
diff --git a/modules/module-mongodb/test/src/slow_tests.test.ts b/modules/module-mongodb/test/src/slow_tests.test.ts
new file mode 100644
index 000000000..535e967c4
--- /dev/null
+++ b/modules/module-mongodb/test/src/slow_tests.test.ts
@@ -0,0 +1,109 @@
+import { MONGO_STORAGE_FACTORY } from '@core-tests/util.js';
+import { BucketStorageFactory } from '@powersync/service-core';
+import * as mongo from 'mongodb';
+import { setTimeout } from 'node:timers/promises';
+import { describe, expect, test } from 'vitest';
+import { ChangeStreamTestContext, setSnapshotHistorySeconds } from './change_stream_utils.js';
+import { env } from './env.js';
+
+type StorageFactory = () => Promise;
+
+const BASIC_SYNC_RULES = `
+bucket_definitions:
+ global:
+ data:
+ - SELECT _id as id, description FROM "test_data"
+`;
+
+describe('change stream slow tests - mongodb', { timeout: 60_000 }, function () {
+ if (env.CI || env.SLOW_TESTS) {
+ defineSlowTests(MONGO_STORAGE_FACTORY);
+ } else {
+ // Need something in this file.
+ test('no-op', () => {});
+ }
+});
+
+function defineSlowTests(factory: StorageFactory) {
+ test('replicating snapshot with lots of data', async () => {
+ await using context = await ChangeStreamTestContext.open(factory);
+ // Test with low minSnapshotHistoryWindowInSeconds, to trigger:
+ // > Read timestamp .. is older than the oldest available timestamp.
+ // This happened when we had {snapshot: true} in the initial
+ // snapshot session.
+ await using _ = await setSnapshotHistorySeconds(context.client, 1);
+ const { db } = context;
+ await context.updateSyncRules(`
+bucket_definitions:
+ global:
+ data:
+ - SELECT _id as id, description, num FROM "test_data1"
+ - SELECT _id as id, description, num FROM "test_data2"
+ `);
+
+ const collection1 = db.collection('test_data1');
+ const collection2 = db.collection('test_data2');
+
+ let operations: mongo.AnyBulkWriteOperation[] = [];
+ for (let i = 0; i < 10_000; i++) {
+ operations.push({ insertOne: { document: { description: `pre${i}`, num: i } } });
+ }
+ await collection1.bulkWrite(operations);
+ await collection2.bulkWrite(operations);
+
+ await context.replicateSnapshot();
+ context.startStreaming();
+ const checksum = await context.getChecksum('global[]');
+ expect(checksum).toMatchObject({
+ count: 20_000
+ });
+ });
+
+ test('writes concurrently with snapshot', async () => {
+ // If there is an issue with snapshotTime (the start LSN for the
+ // changestream), we may miss updates, which this test would
+ // hopefully catch.
+
+ await using context = await ChangeStreamTestContext.open(factory);
+ const { db } = context;
+ await context.updateSyncRules(`
+bucket_definitions:
+ global:
+ data:
+ - SELECT _id as id, description, num FROM "test_data"
+ `);
+
+ const collection = db.collection('test_data');
+
+ let operations: mongo.AnyBulkWriteOperation[] = [];
+ for (let i = 0; i < 5_000; i++) {
+ operations.push({ insertOne: { document: { description: `pre${i}`, num: i } } });
+ }
+ await collection.bulkWrite(operations);
+
+ const snapshotPromise = context.replicateSnapshot();
+
+ for (let i = 49; i >= 0; i--) {
+ await collection.updateMany(
+ { num: { $gte: i * 100, $lt: i * 100 + 100 } },
+ { $set: { description: 'updated' + i } }
+ );
+ await setTimeout(20);
+ }
+
+ await snapshotPromise;
+ context.startStreaming();
+
+ const data = await context.getBucketData('global[]', undefined, { limit: 50_000, chunkLimitBytes: 60_000_000 });
+
+ const preDocuments = data.filter((d) => JSON.parse(d.data! as string).description.startsWith('pre')).length;
+ const updatedDocuments = data.filter((d) => JSON.parse(d.data! as string).description.startsWith('updated')).length;
+
+ // If the test works properly, preDocuments should be around 2000-3000.
+ // The total should be around 9000-9900.
+ // However, it is very sensitive to timing, so we allow a wide range.
+ // updatedDocuments must be strictly >= 5000, otherwise something broke.
+ expect(updatedDocuments).toBeGreaterThanOrEqual(5_000);
+ expect(preDocuments).toBeLessThanOrEqual(5_000);
+ });
+}
diff --git a/modules/module-mongodb/test/src/util.ts b/modules/module-mongodb/test/src/util.ts
new file mode 100644
index 000000000..a101f77a5
--- /dev/null
+++ b/modules/module-mongodb/test/src/util.ts
@@ -0,0 +1,52 @@
+import * as types from '@module/types/types.js';
+import { BucketStorageFactory, Metrics, MongoBucketStorage, OpId } from '@powersync/service-core';
+
+import { env } from './env.js';
+import { logger } from '@powersync/lib-services-framework';
+import { connectMongo } from '@core-tests/util.js';
+import * as mongo from 'mongodb';
+
+// The metrics need to be initialized before they can be used
+await Metrics.initialise({
+ disable_telemetry_sharing: true,
+ powersync_instance_id: 'test',
+ internal_metrics_endpoint: 'unused.for.tests.com'
+});
+Metrics.getInstance().resetCounters();
+
+export const TEST_URI = env.MONGO_TEST_DATA_URL;
+
+export const TEST_CONNECTION_OPTIONS = types.normalizeConnectionConfig({
+ type: 'mongodb',
+ uri: TEST_URI
+});
+
+export type StorageFactory = () => Promise;
+
+export const INITIALIZED_MONGO_STORAGE_FACTORY: StorageFactory = async () => {
+ const db = await connectMongo();
+
+ // None of the PG tests insert data into this collection, so it was never created
+ if (!(await db.db.listCollections({ name: db.bucket_parameters.collectionName }).hasNext())) {
+ await db.db.createCollection('bucket_parameters');
+ }
+
+ await db.clear();
+
+ return new MongoBucketStorage(db, { slot_name_prefix: 'test_' });
+};
+
+export async function clearTestDb(db: mongo.Db) {
+ await db.dropDatabase();
+}
+
+export async function connectMongoData() {
+ const client = new mongo.MongoClient(env.MONGO_TEST_DATA_URL, {
+ connectTimeoutMS: env.CI ? 15_000 : 5_000,
+ socketTimeoutMS: env.CI ? 15_000 : 5_000,
+ serverSelectionTimeoutMS: env.CI ? 15_000 : 2_500,
+ useBigInt64: true
+ });
+ const dbname = new URL(env.MONGO_TEST_DATA_URL).pathname.substring(1);
+ return { client, db: client.db(dbname) };
+}
diff --git a/modules/module-mongodb/test/tsconfig.json b/modules/module-mongodb/test/tsconfig.json
new file mode 100644
index 000000000..18898c4ee
--- /dev/null
+++ b/modules/module-mongodb/test/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "baseUrl": "./",
+ "noEmit": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "paths": {
+ "@/*": ["../../../packages/service-core/src/*"],
+ "@module/*": ["../src/*"],
+ "@core-tests/*": ["../../../packages/service-core/test/src/*"]
+ }
+ },
+ "include": ["src"],
+ "references": [
+ {
+ "path": "../"
+ },
+ {
+ "path": "../../../packages/service-core/test"
+ },
+ {
+ "path": "../../../packages/service-core/"
+ }
+ ]
+}
diff --git a/modules/module-mongodb/tsconfig.json b/modules/module-mongodb/tsconfig.json
new file mode 100644
index 000000000..6afdde02f
--- /dev/null
+++ b/modules/module-mongodb/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "sourceMap": true
+ },
+ "include": ["src"],
+ "references": [
+ {
+ "path": "../../packages/types"
+ },
+ {
+ "path": "../../packages/jsonbig"
+ },
+ {
+ "path": "../../packages/sync-rules"
+ },
+ {
+ "path": "../../packages/service-core"
+ },
+ {
+ "path": "../../libs/lib-services"
+ }
+ ]
+}
diff --git a/modules/module-mongodb/vitest.config.ts b/modules/module-mongodb/vitest.config.ts
new file mode 100644
index 000000000..7a39c1f71
--- /dev/null
+++ b/modules/module-mongodb/vitest.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from 'vitest/config';
+import tsconfigPaths from 'vite-tsconfig-paths';
+
+export default defineConfig({
+ plugins: [tsconfigPaths()],
+ test: {
+ setupFiles: './test/src/setup.ts',
+ poolOptions: {
+ threads: {
+ singleThread: true
+ }
+ },
+ pool: 'threads'
+ }
+});
diff --git a/modules/module-mysql/LICENSE b/modules/module-mysql/LICENSE
new file mode 100644
index 000000000..c8efd46cc
--- /dev/null
+++ b/modules/module-mysql/LICENSE
@@ -0,0 +1,67 @@
+# Functional Source License, Version 1.1, Apache 2.0 Future License
+
+## Abbreviation
+
+FSL-1.1-Apache-2.0
+
+## Notice
+
+Copyright 2023-2024 Journey Mobile, Inc.
+
+## Terms and Conditions
+
+### Licensor ("We")
+
+The party offering the Software under these Terms and Conditions.
+
+### The Software
+
+The "Software" is each version of the software that we make available under these Terms and Conditions, as indicated by our inclusion of these Terms and Conditions with the Software.
+
+### License Grant
+
+Subject to your compliance with this License Grant and the Patents, Redistribution and Trademark clauses below, we hereby grant you the right to use, copy, modify, create derivative works, publicly perform, publicly display and redistribute the Software for any Permitted Purpose identified below.
+
+### Permitted Purpose
+
+A Permitted Purpose is any purpose other than a Competing Use. A Competing Use means making the Software available to others in a commercial product or service that:
+
+1. substitutes for the Software;
+2. substitutes for any other product or service we offer using the Software that exists as of the date we make the Software available; or
+3. offers the same or substantially similar functionality as the Software.
+
+Permitted Purposes specifically include using the Software:
+
+1. for your internal use and access;
+2. for non-commercial education;
+3. for non-commercial research; and
+4. in connection with professional services that you provide to a licensee using the Software in accordance with these Terms and Conditions.
+
+### Patents
+
+To the extent your use for a Permitted Purpose would necessarily infringe our patents, the license grant above includes a license under our patents. If you make a claim against any party that the Software infringes or contributes to the infringement of any patent, then your patent license to the Software ends immediately.
+
+### Redistribution
+
+The Terms and Conditions apply to all copies, modifications and derivatives of the Software.
+If you redistribute any copies, modifications or derivatives of the Software, you must include a copy of or a link to these Terms and Conditions and not remove any copyright notices provided in or with the Software.
+
+### Disclaimer
+
+THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
+IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
+
+### Trademarks
+
+Except for displaying the License Details and identifying us as the origin of the Software, you have no right under these Terms and Conditions to use our trademarks, trade names, service marks or product names.
+
+## Grant of Future License
+
+We hereby irrevocably grant you an additional license to use the Software under the Apache License, Version 2.0 that is effective on the second anniversary of the date we make the Software available. On or after that date, you may use the Software under the Apache License, Version 2.0, in which case the following will apply:
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
diff --git a/modules/module-mysql/README.md b/modules/module-mysql/README.md
new file mode 100644
index 000000000..93b33d14c
--- /dev/null
+++ b/modules/module-mysql/README.md
@@ -0,0 +1,3 @@
+# PowerSync MySQL Module
+
+This is a module which provides MySQL replication to PowerSync.
diff --git a/modules/module-mysql/dev/.env.template b/modules/module-mysql/dev/.env.template
new file mode 100644
index 000000000..d82ac9668
--- /dev/null
+++ b/modules/module-mysql/dev/.env.template
@@ -0,0 +1,2 @@
+PS_MONGO_URI=mongodb://mongo:27017/powersync_demo
+PS_PORT=8080
\ No newline at end of file
diff --git a/modules/module-mysql/dev/README.md b/modules/module-mysql/dev/README.md
new file mode 100644
index 000000000..fe62ef533
--- /dev/null
+++ b/modules/module-mysql/dev/README.md
@@ -0,0 +1,9 @@
+# MySQL Development Helpers
+
+This folder contains some helpers for developing with MySQL.
+
+- `./.env.template` contains basic settings to be applied to a root `.env` file
+- `./config` contains YAML configuration files for a MySQL todo list application
+- `./docker/mysql` contains a docker compose file for starting Mysql
+
+TODO this does not contain any auth or backend functionality.
diff --git a/modules/module-mysql/dev/config/sync_rules.yaml b/modules/module-mysql/dev/config/sync_rules.yaml
new file mode 100644
index 000000000..5c0eb9932
--- /dev/null
+++ b/modules/module-mysql/dev/config/sync_rules.yaml
@@ -0,0 +1,10 @@
+# See Documentation for more information:
+# https://docs.powersync.com/usage/sync-rules
+# Note that changes to this file are not watched.
+# The service needs to be restarted for changes to take effect.
+
+bucket_definitions:
+ global:
+ data:
+ - SELECT * FROM lists
+ - SELECT * FROM todos
diff --git a/modules/module-mysql/dev/docker/mysql/docker-compose.yaml b/modules/module-mysql/dev/docker/mysql/docker-compose.yaml
new file mode 100644
index 000000000..50dfd2d2b
--- /dev/null
+++ b/modules/module-mysql/dev/docker/mysql/docker-compose.yaml
@@ -0,0 +1,17 @@
+services:
+ mysql:
+ image: mysql:8.0
+ environment:
+ MYSQL_ROOT_PASSWORD: root_password
+ MYSQL_DATABASE: mydatabase
+ MYSQL_USER: myuser
+ MYSQL_PASSWORD: mypassword
+ ports:
+ - '3306:3306'
+ volumes:
+ - ./init-scripts/my.cnf:/etc/mysql/my.cnf
+ - ./init-scripts/mysql.sql:/docker-entrypoint-initdb.d/init_user.sql
+ - mysql_data:/var/lib/mysql
+
+volumes:
+ mysql_data:
diff --git a/modules/module-mysql/dev/docker/mysql/init-scripts/my.cnf b/modules/module-mysql/dev/docker/mysql/init-scripts/my.cnf
new file mode 100644
index 000000000..99f01c70a
--- /dev/null
+++ b/modules/module-mysql/dev/docker/mysql/init-scripts/my.cnf
@@ -0,0 +1,9 @@
+[mysqld]
+gtid_mode = ON
+enforce-gtid-consistency = ON
+# Row format required for ZongJi
+binlog_format = row
+log_bin=mysql-bin
+server-id=1
+binlog-do-db=mydatabase
+replicate-do-table=mydatabase.lists
\ No newline at end of file
diff --git a/modules/module-mysql/dev/docker/mysql/init-scripts/mysql.sql b/modules/module-mysql/dev/docker/mysql/init-scripts/mysql.sql
new file mode 100644
index 000000000..8e5cb3538
--- /dev/null
+++ b/modules/module-mysql/dev/docker/mysql/init-scripts/mysql.sql
@@ -0,0 +1,38 @@
+-- Create a user with necessary privileges
+CREATE USER 'repl_user'@'%' IDENTIFIED BY 'good_password';
+
+-- Grant replication client privilege
+GRANT REPLICATION SLAVE, REPLICATION CLIENT, RELOAD ON *.* TO 'repl_user'@'%';
+GRANT REPLICATION SLAVE, REPLICATION CLIENT, RELOAD ON *.* TO 'myuser'@'%';
+
+-- Grant access to the specific database
+GRANT ALL PRIVILEGES ON mydatabase.* TO 'repl_user'@'%';
+
+-- Apply changes
+FLUSH PRIVILEGES;
+
+CREATE TABLE lists (
+ id CHAR(36) NOT NULL DEFAULT (UUID()), -- String UUID (36 characters)
+ 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 DEFAULT (UUID()), -- String UUID (36 characters)
+ 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,
+ PRIMARY KEY (id),
+ FOREIGN KEY (list_id) REFERENCES lists (id) ON DELETE CASCADE
+);
+
+-- TODO fix case where no data is present
+INSERT INTO lists (id, name, owner_id)
+VALUES
+ (UUID(), 'Do a demo', UUID());
\ No newline at end of file
diff --git a/modules/module-mysql/package.json b/modules/module-mysql/package.json
new file mode 100644
index 000000000..584ff1fa9
--- /dev/null
+++ b/modules/module-mysql/package.json
@@ -0,0 +1,49 @@
+{
+ "name": "@powersync/service-module-mysql",
+ "repository": "https://github.com/powersync-ja/powersync-service",
+ "types": "dist/index.d.ts",
+ "version": "0.0.1",
+ "license": "FSL-1.1-Apache-2.0",
+ "main": "dist/index.js",
+ "type": "module",
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "build": "tsc -b",
+ "build:tests": "tsc -b test/tsconfig.json",
+ "clean": "rm -rf ./dist && tsc -b --clean",
+ "test": "vitest"
+ },
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "require": "./dist/index.js",
+ "default": "./dist/index.js"
+ },
+ "./types": {
+ "import": "./dist/types/types.js",
+ "require": "./dist/types/types.js",
+ "default": "./dist/types/types.js"
+ }
+ },
+ "dependencies": {
+ "@powersync/lib-services-framework": "workspace:*",
+ "@powersync/service-core": "workspace:*",
+ "@powersync/service-sync-rules": "workspace:*",
+ "@powersync/service-types": "workspace:*",
+ "@powersync/service-jsonbig": "workspace:*",
+ "@powersync/mysql-zongji": "^0.1.0",
+ "semver": "^7.5.4",
+ "async": "^3.2.4",
+ "mysql2": "^3.11.0",
+ "ts-codec": "^1.2.2",
+ "uri-js": "^4.4.1",
+ "uuid": "^9.0.1"
+ },
+ "devDependencies": {
+ "@types/semver": "^7.5.4",
+ "@types/async": "^3.2.24",
+ "@types/uuid": "^9.0.4"
+ }
+}
diff --git a/modules/module-mysql/src/api/MySQLRouteAPIAdapter.ts b/modules/module-mysql/src/api/MySQLRouteAPIAdapter.ts
new file mode 100644
index 000000000..faa140adc
--- /dev/null
+++ b/modules/module-mysql/src/api/MySQLRouteAPIAdapter.ts
@@ -0,0 +1,359 @@
+import { api, ParseSyncRulesOptions, storage } from '@powersync/service-core';
+
+import * as sync_rules from '@powersync/service-sync-rules';
+import * as service_types from '@powersync/service-types';
+import mysql from 'mysql2/promise';
+import * as common from '../common/common-index.js';
+import * as mysql_utils from '../utils/mysql-utils.js';
+import * as types from '../types/types.js';
+import { toExpressionTypeFromMySQLType } from '../common/common-index.js';
+
+type SchemaResult = {
+ schema_name: string;
+ table_name: string;
+ columns: string;
+};
+
+export class MySQLRouteAPIAdapter implements api.RouteAPI {
+ protected pool: mysql.Pool;
+
+ constructor(protected config: types.ResolvedConnectionConfig) {
+ this.pool = mysql_utils.createPool(config).promise();
+ }
+
+ async shutdown(): Promise {
+ return this.pool.end();
+ }
+
+ async getSourceConfig(): Promise {
+ return this.config;
+ }
+
+ getParseSyncRulesOptions(): ParseSyncRulesOptions {
+ return {
+ // In MySQL Schema and Database are the same thing. There is no default database
+ defaultSchema: this.config.database
+ };
+ }
+
+ async getConnectionStatus(): Promise {
+ const base = {
+ id: this.config.id,
+ uri: `mysql://${this.config.hostname}:${this.config.port}/${this.config.database}`
+ };
+ try {
+ await this.retriedQuery({
+ query: `SELECT 'PowerSync connection test'`
+ });
+ } catch (e) {
+ return {
+ ...base,
+ connected: false,
+ errors: [{ level: 'fatal', message: `${e.code} - message: ${e.message}` }]
+ };
+ }
+ const connection = await this.pool.getConnection();
+ try {
+ const errors = await common.checkSourceConfiguration(connection);
+ if (errors.length) {
+ return {
+ ...base,
+ connected: true,
+ errors: errors.map((e) => ({ level: 'fatal', message: e }))
+ };
+ }
+ } catch (e) {
+ return {
+ ...base,
+ connected: true,
+ errors: [{ level: 'fatal', message: e.message }]
+ };
+ } finally {
+ connection.release();
+ }
+ return {
+ ...base,
+ connected: true,
+ errors: []
+ };
+ }
+
+ async executeQuery(query: string, params: any[]): Promise {
+ if (!this.config.debug_api) {
+ return service_types.internal_routes.ExecuteSqlResponse.encode({
+ results: {
+ columns: [],
+ rows: []
+ },
+ success: false,
+ error: 'SQL querying is not enabled'
+ });
+ }
+ try {
+ const [results, fields] = await this.pool.query(query, params);
+ return service_types.internal_routes.ExecuteSqlResponse.encode({
+ success: true,
+ results: {
+ columns: fields.map((c) => c.name),
+ rows: results.map((row) => {
+ /**
+ * Row will be in the format:
+ * @rows: [ { test: 2 } ]
+ */
+ return fields.map((c) => {
+ const value = row[c.name];
+ const sqlValue = sync_rules.toSyncRulesValue(value);
+ if (typeof sqlValue == 'bigint') {
+ return Number(value);
+ } else if (value instanceof Date) {
+ return value.toISOString();
+ } else if (sync_rules.isJsonValue(sqlValue)) {
+ return sqlValue;
+ } else {
+ return null;
+ }
+ });
+ })
+ }
+ });
+ } catch (e) {
+ return service_types.internal_routes.ExecuteSqlResponse.encode({
+ results: {
+ columns: [],
+ rows: []
+ },
+ success: false,
+ error: e.message
+ });
+ }
+ }
+
+ async getDebugTablesInfo(
+ tablePatterns: sync_rules.TablePattern[],
+ sqlSyncRules: sync_rules.SqlSyncRules
+ ): Promise {
+ let result: api.PatternResult[] = [];
+
+ for (let tablePattern of tablePatterns) {
+ const schema = tablePattern.schema;
+ let patternResult: api.PatternResult = {
+ schema: schema,
+ pattern: tablePattern.tablePattern,
+ wildcard: tablePattern.isWildcard
+ };
+ result.push(patternResult);
+
+ if (tablePattern.isWildcard) {
+ patternResult.tables = [];
+ const prefix = tablePattern.tablePrefix;
+
+ const [results] = await this.pool.query(
+ `SELECT
+ TABLE_NAME AS table_name
+ FROM
+ INFORMATION_SCHEMA.TABLES
+ WHERE
+ TABLE_SCHEMA = ?
+ AND TABLE_NAME LIKE ?`,
+ [schema, tablePattern.tablePattern]
+ );
+
+ for (let row of results) {
+ const name = row.table_name as string;
+
+ if (!name.startsWith(prefix)) {
+ continue;
+ }
+
+ const details = await this.getDebugTableInfo(tablePattern, name, sqlSyncRules);
+ patternResult.tables.push(details);
+ }
+ } else {
+ const [results] = await this.pool.query(
+ `SELECT
+ TABLE_NAME AS table_name
+ FROM
+ INFORMATION_SCHEMA.TABLES
+ WHERE
+ TABLE_SCHEMA = ?
+ AND TABLE_NAME = ?`,
+ [tablePattern.schema, tablePattern.tablePattern]
+ );
+
+ if (results.length == 0) {
+ // Table not found
+ patternResult.table = await this.getDebugTableInfo(tablePattern, tablePattern.name, sqlSyncRules);
+ } else {
+ const row = results[0];
+ patternResult.table = await this.getDebugTableInfo(tablePattern, row.table_name, sqlSyncRules);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ protected async getDebugTableInfo(
+ tablePattern: sync_rules.TablePattern,
+ tableName: string,
+ syncRules: sync_rules.SqlSyncRules
+ ): Promise {
+ const { schema } = tablePattern;
+
+ let idColumnsResult: common.ReplicationIdentityColumnsResult | null = null;
+ let idColumnsError: service_types.ReplicationError | null = null;
+ let connection: mysql.PoolConnection | null = null;
+ try {
+ connection = await this.pool.getConnection();
+ idColumnsResult = await common.getReplicationIdentityColumns({
+ connection: connection,
+ schema,
+ table_name: tableName
+ });
+ } catch (ex) {
+ idColumnsError = { level: 'fatal', message: ex.message };
+ } finally {
+ connection?.release();
+ }
+
+ const idColumns = idColumnsResult?.columns ?? [];
+ const sourceTable = new storage.SourceTable(0, this.config.tag, tableName, schema, tableName, idColumns, true);
+ const syncData = syncRules.tableSyncsData(sourceTable);
+ const syncParameters = syncRules.tableSyncsParameters(sourceTable);
+
+ if (idColumns.length == 0 && idColumnsError == null) {
+ let message = `No replication id found for ${sourceTable.qualifiedName}. Replica identity: ${idColumnsResult?.identity}.`;
+ if (idColumnsResult?.identity == 'default') {
+ message += ' Configure a primary key on the table.';
+ }
+ idColumnsError = { level: 'fatal', message };
+ }
+
+ let selectError: service_types.ReplicationError | null = null;
+ try {
+ await this.retriedQuery({
+ query: `SELECT * FROM ${sourceTable.table} LIMIT 1`
+ });
+ } catch (e) {
+ selectError = { level: 'fatal', message: e.message };
+ }
+
+ return {
+ schema: schema,
+ name: tableName,
+ pattern: tablePattern.isWildcard ? tablePattern.tablePattern : undefined,
+ replication_id: idColumns.map((c) => c.name),
+ data_queries: syncData,
+ parameter_queries: syncParameters,
+ errors: [idColumnsError, selectError].filter((error) => error != null) as service_types.ReplicationError[]
+ };
+ }
+
+ async getReplicationLag(options: api.ReplicationLagOptions): Promise {
+ const { bucketStorage } = options;
+ const lastCheckpoint = await bucketStorage.getCheckpoint();
+
+ const current = lastCheckpoint.lsn
+ ? common.ReplicatedGTID.fromSerialized(lastCheckpoint.lsn)
+ : common.ReplicatedGTID.ZERO;
+
+ const connection = await this.pool.getConnection();
+ const head = await common.readExecutedGtid(connection);
+ const lag = await current.distanceTo(connection, head);
+ connection.release();
+ if (lag == null) {
+ throw new Error(`Could not determine replication lag`);
+ }
+
+ return lag;
+ }
+
+ async getReplicationHead(): Promise {
+ const connection = await this.pool.getConnection();
+ const result = await common.readExecutedGtid(connection);
+ connection.release();
+ return result.comparable;
+ }
+
+ async getConnectionSchema(): Promise {
+ const [results] = await this.retriedQuery({
+ query: `
+ SELECT
+ tbl.schema_name,
+ tbl.table_name,
+ tbl.quoted_name,
+ JSON_ARRAYAGG(JSON_OBJECT('column_name', a.column_name, 'data_type', a.data_type)) AS columns
+ FROM
+ (
+ SELECT
+ TABLE_SCHEMA AS schema_name,
+ TABLE_NAME AS table_name,
+ CONCAT('\`', TABLE_SCHEMA, '\`.\`', TABLE_NAME, '\`') AS quoted_name
+ FROM
+ INFORMATION_SCHEMA.TABLES
+ WHERE
+ TABLE_TYPE = 'BASE TABLE'
+ AND TABLE_SCHEMA NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys')
+ ) AS tbl
+ LEFT JOIN
+ (
+ SELECT
+ TABLE_SCHEMA AS schema_name,
+ TABLE_NAME AS table_name,
+ COLUMN_NAME AS column_name,
+ COLUMN_TYPE AS data_type
+ FROM
+ INFORMATION_SCHEMA.COLUMNS
+ ) AS a
+ ON
+ tbl.schema_name = a.schema_name
+ AND tbl.table_name = a.table_name
+ GROUP BY
+ tbl.schema_name, tbl.table_name, tbl.quoted_name;
+ `
+ });
+
+ /**
+ * Reduces the SQL results into a Record of {@link DatabaseSchema}
+ * then returns the values as an array.
+ */
+
+ return Object.values(
+ (results as SchemaResult[]).reduce((hash: Record, result) => {
+ const schema =
+ hash[result.schema_name] ||
+ (hash[result.schema_name] = {
+ name: result.schema_name,
+ tables: []
+ });
+
+ const columns = JSON.parse(result.columns).map((column: { data_type: string; column_name: string }) => ({
+ name: column.column_name,
+ type: column.data_type,
+ sqlite_type: toExpressionTypeFromMySQLType(column.data_type).typeFlags,
+ internal_type: column.data_type,
+ pg_type: column.data_type
+ }));
+
+ schema.tables.push({
+ name: result.table_name,
+ columns: columns
+ });
+
+ return hash;
+ }, {})
+ );
+ }
+
+ protected async retriedQuery(options: { query: string; params?: any[] }) {
+ const connection = await this.pool.getConnection();
+
+ return mysql_utils
+ .retriedQuery({
+ connection: connection,
+ query: options.query,
+ params: options.params
+ })
+ .finally(() => connection.release());
+ }
+}
diff --git a/modules/module-mysql/src/common/ReplicatedGTID.ts b/modules/module-mysql/src/common/ReplicatedGTID.ts
new file mode 100644
index 000000000..d51d43a73
--- /dev/null
+++ b/modules/module-mysql/src/common/ReplicatedGTID.ts
@@ -0,0 +1,158 @@
+import mysql from 'mysql2/promise';
+import * as uuid from 'uuid';
+import * as mysql_utils from '../utils/mysql-utils.js';
+
+export type BinLogPosition = {
+ filename: string;
+ offset: number;
+};
+
+export type ReplicatedGTIDSpecification = {
+ raw_gtid: string;
+ /**
+ * The (end) position in a BinLog file where this transaction has been replicated in.
+ */
+ position: BinLogPosition;
+};
+
+export type BinLogGTIDFormat = {
+ server_id: Buffer;
+ transaction_range: number;
+};
+
+export type BinLogGTIDEvent = {
+ raw_gtid: BinLogGTIDFormat;
+ position: BinLogPosition;
+};
+
+/**
+ * A wrapper around the MySQL GTID value.
+ * This adds and tracks additional metadata such as the BinLog filename
+ * and position where this GTID could be located.
+ */
+export class ReplicatedGTID {
+ static fromSerialized(comparable: string): ReplicatedGTID {
+ return new ReplicatedGTID(ReplicatedGTID.deserialize(comparable));
+ }
+
+ private static deserialize(comparable: string): ReplicatedGTIDSpecification {
+ const components = comparable.split('|');
+ if (components.length < 3) {
+ throw new Error(`Invalid serialized GTID: ${comparable}`);
+ }
+
+ return {
+ raw_gtid: components[1],
+ position: {
+ filename: components[2],
+ offset: parseInt(components[3])
+ } satisfies BinLogPosition
+ };
+ }
+
+ static fromBinLogEvent(event: BinLogGTIDEvent) {
+ const { raw_gtid, position } = event;
+ const stringGTID = `${uuid.stringify(raw_gtid.server_id)}:${raw_gtid.transaction_range}`;
+ return new ReplicatedGTID({
+ raw_gtid: stringGTID,
+ position
+ });
+ }
+
+ /**
+ * Special case for the zero GTID which means no transactions have been executed.
+ */
+ static ZERO = new ReplicatedGTID({ raw_gtid: '0:0', position: { filename: '', offset: 0 } });
+
+ constructor(protected options: ReplicatedGTIDSpecification) {}
+
+ /**
+ * Get the BinLog position of this replicated GTID event
+ */
+ get position() {
+ return this.options.position;
+ }
+
+ /**
+ * Get the raw Global Transaction ID. This of the format `server_id:transaction_ranges`
+ */
+ get raw() {
+ return this.options.raw_gtid;
+ }
+
+ get serverId() {
+ return this.options.raw_gtid.split(':')[0];
+ }
+
+ /**
+ * Transforms a GTID into a comparable string format, ensuring lexicographical
+ * order aligns with the GTID's relative age. This assumes that all GTIDs
+ * have the same server ID.
+ *
+ * @returns A comparable string in the format
+ * `padded_end_transaction|raw_gtid|binlog_filename|binlog_position`
+ */
+ get comparable() {
+ const { raw, position } = this;
+ const [, transactionRanges] = this.raw.split(':');
+
+ let maxTransactionId = 0;
+
+ for (const range of transactionRanges.split(',')) {
+ const [start, end] = range.split('-');
+ maxTransactionId = Math.max(maxTransactionId, parseInt(start, 10), parseInt(end || start, 10));
+ }
+
+ const paddedTransactionId = maxTransactionId.toString().padStart(16, '0');
+ return [paddedTransactionId, raw, position.filename, position.offset].join('|');
+ }
+
+ toString() {
+ return this.comparable;
+ }
+
+ /**
+ * Calculates the distance in bytes from this GTID to the provided argument.
+ */
+ async distanceTo(connection: mysql.Connection, to: ReplicatedGTID): Promise {
+ const [logFiles] = await mysql_utils.retriedQuery({
+ connection,
+ query: `SHOW BINARY LOGS;`
+ });
+
+ // Default to the first file for the start to handle the zero GTID case.
+ const startFileIndex = Math.max(
+ logFiles.findIndex((f) => f['Log_name'] == this.position.filename),
+ 0
+ );
+ const startFileEntry = logFiles[startFileIndex];
+
+ if (!startFileEntry) {
+ return null;
+ }
+
+ /**
+ * Fall back to the next position for comparison if the replicated position is not present
+ */
+ const endPosition = to.position;
+
+ // Default to the past the last file to cater for the HEAD case
+ const testEndFileIndex = logFiles.findIndex((f) => f['Log_name'] == endPosition?.filename);
+ // If the endPosition is not defined and found. Fallback to the last file as the end
+ const endFileIndex = testEndFileIndex < 0 && !endPosition ? logFiles.length : logFiles.length - 1;
+
+ const endFileEntry = logFiles[endFileIndex];
+
+ if (!endFileEntry) {
+ return null;
+ }
+
+ return (
+ startFileEntry['File_size'] -
+ this.position.offset -
+ endFileEntry['File_size'] +
+ endPosition.offset +
+ logFiles.slice(startFileIndex + 1, endFileIndex).reduce((sum, file) => sum + file['File_size'], 0)
+ );
+ }
+}
diff --git a/modules/module-mysql/src/common/check-source-configuration.ts b/modules/module-mysql/src/common/check-source-configuration.ts
new file mode 100644
index 000000000..6319fc3b7
--- /dev/null
+++ b/modules/module-mysql/src/common/check-source-configuration.ts
@@ -0,0 +1,58 @@
+import mysqlPromise from 'mysql2/promise';
+import * as mysql_utils from '../utils/mysql-utils.js';
+
+const MIN_SUPPORTED_VERSION = '5.7.0';
+
+export async function checkSourceConfiguration(connection: mysqlPromise.Connection): Promise {
+ const errors: string[] = [];
+
+ const version = await mysql_utils.getMySQLVersion(connection);
+ if (!mysql_utils.isVersionAtLeast(version, MIN_SUPPORTED_VERSION)) {
+ errors.push(`MySQL versions older than ${MIN_SUPPORTED_VERSION} are not supported. Your version is: ${version}.`);
+ }
+
+ const [[result]] = await mysql_utils.retriedQuery({
+ connection,
+ query: `
+ SELECT
+ @@GLOBAL.gtid_mode AS gtid_mode,
+ @@GLOBAL.log_bin AS log_bin,
+ @@GLOBAL.server_id AS server_id,
+ @@GLOBAL.log_bin_basename AS binlog_file,
+ @@GLOBAL.log_bin_index AS binlog_index_file
+ `
+ });
+
+ if (result.gtid_mode != 'ON') {
+ errors.push(`GTID is not enabled, it is currently set to ${result.gtid_mode}. Please enable it.`);
+ }
+
+ if (result.log_bin != 1) {
+ errors.push('Binary logging is not enabled. Please enable it.');
+ }
+
+ if (result.server_id < 0) {
+ errors.push(
+ `Your Server ID setting is too low, it must be greater than 0. It is currently ${result.server_id}. Please correct your configuration.`
+ );
+ }
+
+ if (!result.binlog_file) {
+ errors.push('Binary log file is not set. Please check your settings.');
+ }
+
+ if (!result.binlog_index_file) {
+ errors.push('Binary log index file is not set. Please check your settings.');
+ }
+
+ const [[binLogFormatResult]] = await mysql_utils.retriedQuery({
+ connection,
+ query: `SHOW VARIABLES LIKE 'binlog_format';`
+ });
+
+ if (binLogFormatResult.Value !== 'ROW') {
+ errors.push('Binary log format must be set to "ROW". Please correct your configuration');
+ }
+
+ return errors;
+}
diff --git a/modules/module-mysql/src/common/common-index.ts b/modules/module-mysql/src/common/common-index.ts
new file mode 100644
index 000000000..6da005718
--- /dev/null
+++ b/modules/module-mysql/src/common/common-index.ts
@@ -0,0 +1,6 @@
+export * from './check-source-configuration.js';
+export * from './get-replication-columns.js';
+export * from './get-tables-from-pattern.js';
+export * from './mysql-to-sqlite.js';
+export * from './read-executed-gtid.js';
+export * from './ReplicatedGTID.js';
diff --git a/modules/module-mysql/src/common/get-replication-columns.ts b/modules/module-mysql/src/common/get-replication-columns.ts
new file mode 100644
index 000000000..fa0eb8fde
--- /dev/null
+++ b/modules/module-mysql/src/common/get-replication-columns.ts
@@ -0,0 +1,124 @@
+import { storage } from '@powersync/service-core';
+import mysqlPromise from 'mysql2/promise';
+import * as mysql_utils from '../utils/mysql-utils.js';
+
+export type GetReplicationColumnsOptions = {
+ connection: mysqlPromise.Connection;
+ schema: string;
+ table_name: string;
+};
+
+export type ReplicationIdentityColumnsResult = {
+ columns: storage.ColumnDescriptor[];
+ // TODO maybe export an enum from the core package
+ identity: string;
+};
+
+export async function getReplicationIdentityColumns(
+ options: GetReplicationColumnsOptions
+): Promise {
+ const { connection, schema, table_name } = options;
+ const [primaryKeyColumns] = await mysql_utils.retriedQuery({
+ connection: connection,
+ query: `
+ SELECT
+ s.COLUMN_NAME AS name,
+ c.DATA_TYPE AS type
+ FROM
+ INFORMATION_SCHEMA.STATISTICS s
+ JOIN
+ INFORMATION_SCHEMA.COLUMNS c
+ ON
+ s.TABLE_SCHEMA = c.TABLE_SCHEMA
+ AND s.TABLE_NAME = c.TABLE_NAME
+ AND s.COLUMN_NAME = c.COLUMN_NAME
+ WHERE
+ s.TABLE_SCHEMA = ?
+ AND s.TABLE_NAME = ?
+ AND s.INDEX_NAME = 'PRIMARY'
+ ORDER BY
+ s.SEQ_IN_INDEX;
+ `,
+ params: [schema, table_name]
+ });
+
+ if (primaryKeyColumns.length) {
+ return {
+ columns: primaryKeyColumns.map((row) => ({
+ name: row.name,
+ type: row.type
+ })),
+ identity: 'default'
+ };
+ }
+
+ // TODO: test code with tables with unique keys, compound key etc.
+ // No primary key, find the first valid unique key
+ const [uniqueKeyColumns] = await mysql_utils.retriedQuery({
+ connection: connection,
+ query: `
+ SELECT
+ s.INDEX_NAME,
+ s.COLUMN_NAME,
+ c.DATA_TYPE,
+ s.NON_UNIQUE,
+ s.NULLABLE
+ FROM
+ INFORMATION_SCHEMA.STATISTICS s
+ JOIN
+ INFORMATION_SCHEMA.COLUMNS c
+ ON
+ s.TABLE_SCHEMA = c.TABLE_SCHEMA
+ AND s.TABLE_NAME = c.TABLE_NAME
+ AND s.COLUMN_NAME = c.COLUMN_NAME
+ WHERE
+ s.TABLE_SCHEMA = ?
+ AND s.TABLE_NAME = ?
+ AND s.INDEX_NAME != 'PRIMARY'
+ AND s.NON_UNIQUE = 0
+ ORDER BY s.SEQ_IN_INDEX;
+ `,
+ params: [schema, table_name]
+ });
+
+ if (uniqueKeyColumns.length > 0) {
+ return {
+ columns: uniqueKeyColumns.map((col) => ({
+ name: col.COLUMN_NAME,
+ type: col.DATA_TYPE
+ })),
+ identity: 'index'
+ };
+ }
+
+ const [allColumns] = await mysql_utils.retriedQuery({
+ connection: connection,
+ query: `
+ SELECT
+ s.COLUMN_NAME AS name,
+ c.DATA_TYPE as type
+ FROM
+ INFORMATION_SCHEMA.COLUMNS s
+ JOIN
+ INFORMATION_SCHEMA.COLUMNS c
+ ON
+ s.TABLE_SCHEMA = c.TABLE_SCHEMA
+ AND s.TABLE_NAME = c.TABLE_NAME
+ AND s.COLUMN_NAME = c.COLUMN_NAME
+ WHERE
+ s.TABLE_SCHEMA = ?
+ AND s.TABLE_NAME = ?
+ ORDER BY
+ s.ORDINAL_POSITION;
+ `,
+ params: [schema, table_name]
+ });
+
+ return {
+ columns: allColumns.map((row) => ({
+ name: row.name,
+ type: row.type
+ })),
+ identity: 'full'
+ };
+}
diff --git a/modules/module-mysql/src/common/get-tables-from-pattern.ts b/modules/module-mysql/src/common/get-tables-from-pattern.ts
new file mode 100644
index 000000000..166bf93a0
--- /dev/null
+++ b/modules/module-mysql/src/common/get-tables-from-pattern.ts
@@ -0,0 +1,44 @@
+import * as sync_rules from '@powersync/service-sync-rules';
+import mysql from 'mysql2/promise';
+
+export type GetDebugTablesInfoOptions = {
+ connection: mysql.Connection;
+ tablePattern: sync_rules.TablePattern;
+};
+
+export async function getTablesFromPattern(options: GetDebugTablesInfoOptions): Promise> {
+ const { connection, tablePattern } = options;
+ const schema = tablePattern.schema;
+
+ if (tablePattern.isWildcard) {
+ const [results] = await connection.query(
+ `SELECT
+ TABLE_NAME AS table_name
+ FROM
+ INFORMATION_SCHEMA.TABLES
+ WHERE
+ TABLE_SCHEMA = ?
+ AND TABLE_NAME LIKE ?`,
+ [schema, tablePattern.tablePattern]
+ );
+
+ return new Set(
+ results
+ .filter((result) => result.table_name.startsWith(tablePattern.tablePrefix))
+ .map((result) => result.table_name)
+ );
+ } else {
+ const [[match]] = await connection.query(
+ `SELECT
+ TABLE_NAME AS table_name
+ FROM
+ INFORMATION_SCHEMA.TABLES
+ WHERE
+ TABLE_SCHEMA = ?
+ AND TABLE_NAME = ?`,
+ [tablePattern.schema, tablePattern.tablePattern]
+ );
+ // Only return the first result
+ return new Set([match.table_name]);
+ }
+}
diff --git a/modules/module-mysql/src/common/mysql-to-sqlite.ts b/modules/module-mysql/src/common/mysql-to-sqlite.ts
new file mode 100644
index 000000000..8cc2487d8
--- /dev/null
+++ b/modules/module-mysql/src/common/mysql-to-sqlite.ts
@@ -0,0 +1,206 @@
+import * as sync_rules from '@powersync/service-sync-rules';
+import { ExpressionType } from '@powersync/service-sync-rules';
+import { ColumnDescriptor } from '@powersync/service-core';
+import mysql from 'mysql2';
+import { JSONBig, JsonContainer } from '@powersync/service-jsonbig';
+import { ColumnDefinition, TableMapEntry } from '@powersync/mysql-zongji';
+
+export enum ADDITIONAL_MYSQL_TYPES {
+ DATETIME2 = 18,
+ TIMESTAMP2 = 17,
+ BINARY = 100,
+ VARBINARY = 101,
+ TEXT = 102
+}
+
+export const MySQLTypesMap: { [key: number]: string } = {};
+for (const [name, code] of Object.entries(mysql.Types)) {
+ MySQLTypesMap[code as number] = name;
+}
+for (const [name, code] of Object.entries(ADDITIONAL_MYSQL_TYPES)) {
+ MySQLTypesMap[code as number] = name;
+}
+
+export function toColumnDescriptors(columns: mysql.FieldPacket[]): Map;
+export function toColumnDescriptors(tableMap: TableMapEntry): Map;
+
+export function toColumnDescriptors(columns: mysql.FieldPacket[] | TableMapEntry): Map {
+ const columnMap = new Map();
+ if (Array.isArray(columns)) {
+ for (const column of columns) {
+ columnMap.set(column.name, toColumnDescriptorFromFieldPacket(column));
+ }
+ } else {
+ for (const column of columns.columns) {
+ columnMap.set(column.name, toColumnDescriptorFromDefinition(column));
+ }
+ }
+
+ return columnMap;
+}
+
+export function toColumnDescriptorFromFieldPacket(column: mysql.FieldPacket): ColumnDescriptor {
+ let typeId = column.type!;
+ const BINARY_FLAG = 128;
+ const MYSQL_ENUM_FLAG = 256;
+ const MYSQL_SET_FLAG = 2048;
+
+ switch (column.type) {
+ case mysql.Types.STRING:
+ if (((column.flags as number) & BINARY_FLAG) !== 0) {
+ typeId = ADDITIONAL_MYSQL_TYPES.BINARY;
+ } else if (((column.flags as number) & MYSQL_ENUM_FLAG) !== 0) {
+ typeId = mysql.Types.ENUM;
+ } else if (((column.flags as number) & MYSQL_SET_FLAG) !== 0) {
+ typeId = mysql.Types.SET;
+ }
+ break;
+
+ case mysql.Types.VAR_STRING:
+ typeId = ((column.flags as number) & BINARY_FLAG) !== 0 ? ADDITIONAL_MYSQL_TYPES.VARBINARY : column.type;
+ break;
+ case mysql.Types.BLOB:
+ typeId = ((column.flags as number) & BINARY_FLAG) === 0 ? ADDITIONAL_MYSQL_TYPES.TEXT : column.type;
+ break;
+ }
+
+ const columnType = MySQLTypesMap[typeId];
+
+ return {
+ name: column.name,
+ type: columnType,
+ typeId: typeId
+ };
+}
+
+export function toColumnDescriptorFromDefinition(column: ColumnDefinition): ColumnDescriptor {
+ let typeId = column.type;
+
+ switch (column.type) {
+ case mysql.Types.STRING:
+ typeId = !column.charset ? ADDITIONAL_MYSQL_TYPES.BINARY : column.type;
+ break;
+ case mysql.Types.VAR_STRING:
+ case mysql.Types.VARCHAR:
+ typeId = !column.charset ? ADDITIONAL_MYSQL_TYPES.VARBINARY : column.type;
+ break;
+ case mysql.Types.BLOB:
+ typeId = column.charset ? ADDITIONAL_MYSQL_TYPES.TEXT : column.type;
+ break;
+ }
+
+ const columnType = MySQLTypesMap[typeId];
+
+ return {
+ name: column.name,
+ type: columnType,
+ typeId: typeId
+ };
+}
+
+export function toSQLiteRow(row: Record, columns: Map): sync_rules.SqliteRow {
+ for (let key in row) {
+ // We are very much expecting the column to be there
+ const column = columns.get(key)!;
+
+ if (row[key] !== null) {
+ switch (column.typeId) {
+ case mysql.Types.DATE:
+ // Only parse the date part
+ row[key] = row[key].toISOString().split('T')[0];
+ break;
+ case mysql.Types.DATETIME:
+ case ADDITIONAL_MYSQL_TYPES.DATETIME2:
+ case mysql.Types.TIMESTAMP:
+ case ADDITIONAL_MYSQL_TYPES.TIMESTAMP2:
+ row[key] = row[key].toISOString();
+ break;
+ case mysql.Types.JSON:
+ if (typeof row[key] === 'string') {
+ row[key] = new JsonContainer(row[key]);
+ }
+ break;
+ case mysql.Types.BIT:
+ case mysql.Types.BLOB:
+ case mysql.Types.TINY_BLOB:
+ case mysql.Types.MEDIUM_BLOB:
+ case mysql.Types.LONG_BLOB:
+ case ADDITIONAL_MYSQL_TYPES.BINARY:
+ case ADDITIONAL_MYSQL_TYPES.VARBINARY:
+ row[key] = new Uint8Array(Object.values(row[key]));
+ break;
+ case mysql.Types.LONGLONG:
+ if (typeof row[key] === 'string') {
+ row[key] = BigInt(row[key]);
+ } else if (typeof row[key] === 'number') {
+ // Zongji returns BIGINT as a number when it can be represented as a number
+ row[key] = BigInt(row[key]);
+ }
+ break;
+ case mysql.Types.TINY:
+ case mysql.Types.SHORT:
+ case mysql.Types.LONG:
+ case mysql.Types.INT24:
+ // Handle all integer values a BigInt
+ if (typeof row[key] === 'number') {
+ row[key] = BigInt(row[key]);
+ }
+ break;
+ case mysql.Types.SET:
+ // Convert to JSON array from string
+ const values = row[key].split(',');
+ row[key] = JSONBig.stringify(values);
+ break;
+ }
+ }
+ }
+ return sync_rules.toSyncRulesRow(row);
+}
+
+export function toExpressionTypeFromMySQLType(mysqlType: string | undefined): ExpressionType {
+ if (!mysqlType) {
+ return ExpressionType.TEXT;
+ }
+
+ const upperCaseType = mysqlType.toUpperCase();
+ // Handle type with parameters like VARCHAR(255), DECIMAL(10,2), etc.
+ const baseType = upperCaseType.split('(')[0];
+
+ switch (baseType) {
+ case 'BIT':
+ case 'BOOL':
+ case 'BOOLEAN':
+ case 'TINYINT':
+ case 'SMALLINT':
+ case 'MEDIUMINT':
+ case 'INT':
+ case 'INTEGER':
+ case 'BIGINT':
+ case 'UNSIGNED BIGINT':
+ return ExpressionType.INTEGER;
+ case 'BINARY':
+ case 'VARBINARY':
+ case 'TINYBLOB':
+ case 'MEDIUMBLOB':
+ case 'LONGBLOB':
+ case 'BLOB':
+ case 'GEOMETRY':
+ case 'POINT':
+ case 'LINESTRING':
+ case 'POLYGON':
+ case 'MULTIPOINT':
+ case 'MULTILINESTRING':
+ case 'MULTIPOLYGON':
+ case 'GEOMETRYCOLLECTION':
+ return ExpressionType.BLOB;
+ case 'FLOAT':
+ case 'DOUBLE':
+ case 'REAL':
+ return ExpressionType.REAL;
+ case 'JSON':
+ return ExpressionType.TEXT;
+ default:
+ // In addition to the normal text types, includes: DECIMAL, NUMERIC, DATE, TIME, DATETIME, TIMESTAMP, YEAR, ENUM, SET
+ return ExpressionType.TEXT;
+ }
+}
diff --git a/modules/module-mysql/src/common/read-executed-gtid.ts b/modules/module-mysql/src/common/read-executed-gtid.ts
new file mode 100644
index 000000000..9f60c3362
--- /dev/null
+++ b/modules/module-mysql/src/common/read-executed-gtid.ts
@@ -0,0 +1,48 @@
+import mysqlPromise from 'mysql2/promise';
+import * as mysql_utils from '../utils/mysql-utils.js';
+import { ReplicatedGTID } from './ReplicatedGTID.js';
+
+/**
+ * Gets the current master HEAD GTID
+ */
+export async function readExecutedGtid(connection: mysqlPromise.Connection): Promise {
+ const version = await mysql_utils.getMySQLVersion(connection);
+
+ let binlogStatus: mysqlPromise.RowDataPacket;
+ if (mysql_utils.isVersionAtLeast(version, '8.4.0')) {
+ // Syntax for the below query changed in 8.4.0
+ const [[binLogResult]] = await mysql_utils.retriedQuery({
+ connection,
+ query: `SHOW BINARY LOG STATUS`
+ });
+ binlogStatus = binLogResult;
+ } else {
+ const [[binLogResult]] = await mysql_utils.retriedQuery({
+ connection,
+ query: `SHOW MASTER STATUS`
+ });
+ binlogStatus = binLogResult;
+ }
+ const position = {
+ filename: binlogStatus.File,
+ offset: parseInt(binlogStatus.Position)
+ };
+
+ return new ReplicatedGTID({
+ // The head always points to the next position to start replication from
+ position,
+ raw_gtid: binlogStatus.Executed_Gtid_Set
+ });
+}
+
+export async function isBinlogStillAvailable(
+ connection: mysqlPromise.Connection,
+ binlogFile: string
+): Promise {
+ const [logFiles] = await mysql_utils.retriedQuery({
+ connection,
+ query: `SHOW BINARY LOGS;`
+ });
+
+ return logFiles.some((f) => f['Log_name'] == binlogFile);
+}
diff --git a/modules/module-mysql/src/index.ts b/modules/module-mysql/src/index.ts
new file mode 100644
index 000000000..3abe77fc5
--- /dev/null
+++ b/modules/module-mysql/src/index.ts
@@ -0,0 +1 @@
+export * from './module/MySQLModule.js';
diff --git a/modules/module-mysql/src/module/MySQLModule.ts b/modules/module-mysql/src/module/MySQLModule.ts
new file mode 100644
index 000000000..a4ab36bae
--- /dev/null
+++ b/modules/module-mysql/src/module/MySQLModule.ts
@@ -0,0 +1,71 @@
+import { api, ConfigurationFileSyncRulesProvider, replication, system, TearDownOptions } from '@powersync/service-core';
+
+import { MySQLRouteAPIAdapter } from '../api/MySQLRouteAPIAdapter.js';
+import { BinLogReplicator } from '../replication/BinLogReplicator.js';
+import { MySQLErrorRateLimiter } from '../replication/MySQLErrorRateLimiter.js';
+import * as types from '../types/types.js';
+import { MySQLConnectionManagerFactory } from '../replication/MySQLConnectionManagerFactory.js';
+import { MySQLConnectionConfig } from '../types/types.js';
+import { checkSourceConfiguration } from '../common/check-source-configuration.js';
+import { MySQLConnectionManager } from '../replication/MySQLConnectionManager.js';
+
+export class MySQLModule extends replication.ReplicationModule {
+ constructor() {
+ super({
+ name: 'MySQL',
+ type: types.MYSQL_CONNECTION_TYPE,
+ configSchema: types.MySQLConnectionConfig
+ });
+ }
+
+ async initialize(context: system.ServiceContextContainer): Promise {
+ await super.initialize(context);
+ }
+
+ protected createRouteAPIAdapter(): api.RouteAPI {
+ return new MySQLRouteAPIAdapter(this.resolveConfig(this.decodedConfig!));
+ }
+
+ protected createReplicator(context: system.ServiceContext): replication.AbstractReplicator {
+ const normalisedConfig = this.resolveConfig(this.decodedConfig!);
+ const syncRuleProvider = new ConfigurationFileSyncRulesProvider(context.configuration.sync_rules);
+ const connectionFactory = new MySQLConnectionManagerFactory(normalisedConfig);
+
+ return new BinLogReplicator({
+ id: this.getDefaultId(normalisedConfig.database),
+ syncRuleProvider: syncRuleProvider,
+ storageEngine: context.storageEngine,
+ connectionFactory: connectionFactory,
+ rateLimiter: new MySQLErrorRateLimiter()
+ });
+ }
+
+ /**
+ * Combines base config with normalized connection settings
+ */
+ private resolveConfig(config: types.MySQLConnectionConfig): types.ResolvedConnectionConfig {
+ return {
+ ...config,
+ ...types.normalizeConnectionConfig(config)
+ };
+ }
+
+ async teardown(options: TearDownOptions): Promise {
+ // No specific teardown required for MySQL
+ }
+
+ async testConnection(config: MySQLConnectionConfig): Promise {
+ this.decodeConfig(config);
+ const normalisedConfig = this.resolveConfig(this.decodedConfig!);
+ const connectionManager = new MySQLConnectionManager(normalisedConfig, {});
+ const connection = await connectionManager.getConnection();
+ try {
+ const errors = await checkSourceConfiguration(connection);
+ if (errors.length > 0) {
+ throw new Error(errors.join('\n'));
+ }
+ } finally {
+ await connectionManager.end();
+ }
+ }
+}
diff --git a/modules/module-mysql/src/replication/BinLogReplicationJob.ts b/modules/module-mysql/src/replication/BinLogReplicationJob.ts
new file mode 100644
index 000000000..aa1a838b2
--- /dev/null
+++ b/modules/module-mysql/src/replication/BinLogReplicationJob.ts
@@ -0,0 +1,94 @@
+import { container } from '@powersync/lib-services-framework';
+import { replication } from '@powersync/service-core';
+import { BinlogConfigurationError, BinLogStream } from './BinLogStream.js';
+import { MySQLConnectionManagerFactory } from './MySQLConnectionManagerFactory.js';
+
+export interface BinLogReplicationJobOptions extends replication.AbstractReplicationJobOptions {
+ connectionFactory: MySQLConnectionManagerFactory;
+}
+
+export class BinLogReplicationJob extends replication.AbstractReplicationJob {
+ private connectionFactory: MySQLConnectionManagerFactory;
+
+ constructor(options: BinLogReplicationJobOptions) {
+ super(options);
+ this.connectionFactory = options.connectionFactory;
+ }
+
+ get slot_name() {
+ return this.options.storage.slot_name;
+ }
+
+ async keepAlive() {}
+
+ async replicate() {
+ try {
+ await this.replicateLoop();
+ } catch (e) {
+ // Fatal exception
+ container.reporter.captureException(e, {
+ metadata: {
+ replication_slot: this.slot_name
+ }
+ });
+ this.logger.error(`Replication failed on ${this.slot_name}`, e);
+ } finally {
+ this.abortController.abort();
+ }
+ }
+
+ async replicateLoop() {
+ while (!this.isStopped) {
+ await this.replicateOnce();
+
+ if (!this.isStopped) {
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+ }
+ }
+ }
+
+ async replicateOnce() {
+ // New connections on every iteration (every error with retry),
+ // otherwise we risk repeating errors related to the connection,
+ // such as caused by cached PG schemas.
+ const connectionManager = this.connectionFactory.create({
+ // Pool connections are only used intermittently.
+ idleTimeout: 30_000
+ });
+ try {
+ await this.rateLimiter?.waitUntilAllowed({ signal: this.abortController.signal });
+ if (this.isStopped) {
+ return;
+ }
+ const stream = new BinLogStream({
+ abortSignal: this.abortController.signal,
+ storage: this.options.storage,
+ connections: connectionManager
+ });
+ await stream.replicate();
+ } catch (e) {
+ if (this.abortController.signal.aborted) {
+ return;
+ }
+ this.logger.error(`Replication error`, e);
+ if (e.cause != null) {
+ this.logger.error(`cause`, e.cause);
+ }
+
+ if (e instanceof BinlogConfigurationError) {
+ throw e;
+ } else {
+ // Report the error if relevant, before retrying
+ container.reporter.captureException(e, {
+ metadata: {
+ replication_slot: this.slot_name
+ }
+ });
+ // This sets the retry delay
+ this.rateLimiter?.reportError(e);
+ }
+ } finally {
+ await connectionManager.end();
+ }
+ }
+}
diff --git a/modules/module-mysql/src/replication/BinLogReplicator.ts b/modules/module-mysql/src/replication/BinLogReplicator.ts
new file mode 100644
index 000000000..ca07f4a0a
--- /dev/null
+++ b/modules/module-mysql/src/replication/BinLogReplicator.ts
@@ -0,0 +1,35 @@
+import { replication, storage } from '@powersync/service-core';
+import { BinLogReplicationJob } from './BinLogReplicationJob.js';
+import { MySQLConnectionManagerFactory } from './MySQLConnectionManagerFactory.js';
+
+export interface BinLogReplicatorOptions extends replication.AbstractReplicatorOptions {
+ connectionFactory: MySQLConnectionManagerFactory;
+}
+
+export class BinLogReplicator extends replication.AbstractReplicator {
+ private readonly connectionFactory: MySQLConnectionManagerFactory;
+
+ constructor(options: BinLogReplicatorOptions) {
+ super(options);
+ this.connectionFactory = options.connectionFactory;
+ }
+
+ createJob(options: replication.CreateJobOptions): BinLogReplicationJob {
+ return new BinLogReplicationJob({
+ id: this.createJobId(options.storage.group_id),
+ storage: options.storage,
+ lock: options.lock,
+ connectionFactory: this.connectionFactory,
+ rateLimiter: this.rateLimiter
+ });
+ }
+
+ async cleanUp(syncRulesStorage: storage.SyncRulesBucketStorage): Promise {
+ // The MySQL module does not create anything which requires cleanup on the MySQL server.
+ }
+
+ async stop(): Promise {
+ await super.stop();
+ await this.connectionFactory.shutdown();
+ }
+}
diff --git a/modules/module-mysql/src/replication/BinLogStream.ts b/modules/module-mysql/src/replication/BinLogStream.ts
new file mode 100644
index 000000000..d5e0c43ad
--- /dev/null
+++ b/modules/module-mysql/src/replication/BinLogStream.ts
@@ -0,0 +1,601 @@
+import { logger } from '@powersync/lib-services-framework';
+import * as sync_rules from '@powersync/service-sync-rules';
+import async from 'async';
+
+import { ColumnDescriptor, framework, getUuidReplicaIdentityBson, Metrics, storage } from '@powersync/service-core';
+import mysql, { FieldPacket } from 'mysql2';
+
+import { BinLogEvent, StartOptions, TableMapEntry } from '@powersync/mysql-zongji';
+import * as common from '../common/common-index.js';
+import * as zongji_utils from './zongji/zongji-utils.js';
+import { MySQLConnectionManager } from './MySQLConnectionManager.js';
+import { isBinlogStillAvailable, ReplicatedGTID, toColumnDescriptors } from '../common/common-index.js';
+import mysqlPromise from 'mysql2/promise';
+import { createRandomServerId } from '../utils/mysql-utils.js';
+
+export interface BinLogStreamOptions {
+ connections: MySQLConnectionManager;
+ storage: storage.SyncRulesBucketStorage;
+ abortSignal: AbortSignal;
+}
+
+interface MysqlRelId {
+ schema: string;
+ name: string;
+}
+
+interface WriteChangePayload {
+ type: storage.SaveOperationTag;
+ data: Data;
+ previous_data?: Data;
+ database: string;
+ table: string;
+ sourceTable: storage.SourceTable;
+ columns: Map;
+}
+
+export type Data = Record;
+
+export class BinlogConfigurationError extends Error {
+ constructor(message: string) {
+ super(message);
+ }
+}
+
+/**
+ * MySQL does not have same relation structure. Just returning unique key as string.
+ * @param source
+ */
+function getMysqlRelId(source: MysqlRelId): string {
+ return `${source.schema}.${source.name}`;
+}
+
+export class BinLogStream {
+ private readonly syncRules: sync_rules.SqlSyncRules;
+ private readonly groupId: number;
+
+ private readonly storage: storage.SyncRulesBucketStorage;
+
+ private readonly connections: MySQLConnectionManager;
+
+ private abortSignal: AbortSignal;
+
+ private tableCache = new Map();
+
+ constructor(protected options: BinLogStreamOptions) {
+ this.storage = options.storage;
+ this.connections = options.connections;
+ this.syncRules = options.storage.getParsedSyncRules({ defaultSchema: this.defaultSchema });
+ this.groupId = options.storage.group_id;
+ this.abortSignal = options.abortSignal;
+ }
+
+ get connectionTag() {
+ return this.connections.connectionTag;
+ }
+
+ get connectionId() {
+ // Default to 1 if not set
+ return this.connections.connectionId ? Number.parseInt(this.connections.connectionId) : 1;
+ }
+
+ get stopped() {
+ return this.abortSignal.aborted;
+ }
+
+ get defaultSchema() {
+ return this.connections.databaseName;
+ }
+
+ async handleRelation(batch: storage.BucketStorageBatch, entity: storage.SourceEntityDescriptor, snapshot: boolean) {
+ const result = await this.storage.resolveTable({
+ group_id: this.groupId,
+ connection_id: this.connectionId,
+ connection_tag: this.connectionTag,
+ entity_descriptor: entity,
+ sync_rules: this.syncRules
+ });
+ this.tableCache.set(entity.objectId, result.table);
+
+ // Drop conflicting tables. This includes for example renamed tables.
+ await batch.drop(result.dropTables);
+
+ // Snapshot if:
+ // 1. Snapshot is requested (false for initial snapshot, since that process handles it elsewhere)
+ // 2. Snapshot is not already done, AND:
+ // 3. The table is used in sync rules.
+ const shouldSnapshot = snapshot && !result.table.snapshotComplete && result.table.syncAny;
+
+ if (shouldSnapshot) {
+ // Truncate this table, in case a previous snapshot was interrupted.
+ await batch.truncate([result.table]);
+
+ let gtid: common.ReplicatedGTID;
+ // Start the snapshot inside a transaction.
+ // We use a dedicated connection for this.
+ const connection = await this.connections.getStreamingConnection();
+ const promiseConnection = (connection as mysql.Connection).promise();
+ try {
+ await promiseConnection.query('BEGIN');
+ try {
+ gtid = await common.readExecutedGtid(promiseConnection);
+ await this.snapshotTable(connection.connection, batch, result.table);
+ await promiseConnection.query('COMMIT');
+ } catch (e) {
+ await promiseConnection.query('ROLLBACK');
+ throw e;
+ }
+ } finally {
+ connection.release();
+ }
+ const [table] = await batch.markSnapshotDone([result.table], gtid.comparable);
+ return table;
+ }
+
+ return result.table;
+ }
+
+ async getQualifiedTableNames(
+ batch: storage.BucketStorageBatch,
+ tablePattern: sync_rules.TablePattern
+ ): Promise {
+ if (tablePattern.connectionTag != this.connectionTag) {
+ return [];
+ }
+
+ let tableRows: any[];
+ const prefix = tablePattern.isWildcard ? tablePattern.tablePrefix : undefined;
+ if (tablePattern.isWildcard) {
+ const result = await this.connections.query(
+ `SELECT TABLE_NAME
+FROM information_schema.tables
+WHERE TABLE_SCHEMA = ? AND TABLE_NAME LIKE ?;
+`,
+ [tablePattern.schema, tablePattern.tablePattern]
+ );
+ tableRows = result[0];
+ } else {
+ const result = await this.connections.query(
+ `SELECT TABLE_NAME
+FROM information_schema.tables
+WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?;
+`,
+ [tablePattern.schema, tablePattern.tablePattern]
+ );
+ tableRows = result[0];
+ }
+ let tables: storage.SourceTable[] = [];
+
+ for (let row of tableRows) {
+ const name = row['TABLE_NAME'] as string;
+ if (prefix && !name.startsWith(prefix)) {
+ continue;
+ }
+
+ const result = await this.connections.query(
+ `SELECT 1
+FROM information_schema.tables
+WHERE table_schema = ? AND table_name = ?
+AND table_type = 'BASE TABLE';`,
+ [tablePattern.schema, tablePattern.name]
+ );
+ if (result[0].length == 0) {
+ logger.info(`Skipping ${tablePattern.schema}.${name} - no table exists/is not a base table`);
+ continue;
+ }
+
+ const connection = await this.connections.getConnection();
+ const replicationColumns = await common.getReplicationIdentityColumns({
+ connection: connection,
+ schema: tablePattern.schema,
+ table_name: tablePattern.name
+ });
+ connection.release();
+
+ const table = await this.handleRelation(
+ batch,
+ {
+ name,
+ schema: tablePattern.schema,
+ objectId: getMysqlRelId(tablePattern),
+ replicationColumns: replicationColumns.columns
+ },
+ false
+ );
+
+ tables.push(table);
+ }
+ return tables;
+ }
+
+ /**
+ * Checks if the initial sync has been completed yet.
+ */
+ protected async checkInitialReplicated(): Promise {
+ const status = await this.storage.getStatus();
+ const lastKnowGTID = status.checkpoint_lsn ? common.ReplicatedGTID.fromSerialized(status.checkpoint_lsn) : null;
+ if (status.snapshot_done && status.checkpoint_lsn) {
+ logger.info(`Initial replication already done.`);
+
+ if (lastKnowGTID) {
+ // Check if the binlog is still available. If it isn't we need to snapshot again.
+ const connection = await this.connections.getConnection();
+ try {
+ const isAvailable = await isBinlogStillAvailable(connection, lastKnowGTID.position.filename);
+ if (!isAvailable) {
+ logger.info(
+ `Binlog file ${lastKnowGTID.position.filename} is no longer available, starting initial replication again.`
+ );
+ }
+ return isAvailable;
+ } finally {
+ connection.release();
+ }
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Does the initial replication of the database tables.
+ *
+ * If (partial) replication was done before on this slot, this clears the state
+ * and starts again from scratch.
+ */
+ async startInitialReplication() {
+ await this.storage.clear();
+ // Replication will be performed in a single transaction on this connection
+ const connection = await this.connections.getStreamingConnection();
+ const promiseConnection = (connection as mysql.Connection).promise();
+ const headGTID = await common.readExecutedGtid(promiseConnection);
+ logger.info(`Using snapshot checkpoint GTID: '${headGTID}'`);
+ try {
+ logger.info(`Starting initial replication`);
+ await promiseConnection.query(
+ 'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY'
+ );
+ await promiseConnection.query('START TRANSACTION');
+ const sourceTables = this.syncRules.getSourceTables();
+ await this.storage.startBatch(
+ { zeroLSN: ReplicatedGTID.ZERO.comparable, defaultSchema: this.defaultSchema, storeCurrentData: true },
+ async (batch) => {
+ for (let tablePattern of sourceTables) {
+ const tables = await this.getQualifiedTableNames(batch, tablePattern);
+ for (let table of tables) {
+ await this.snapshotTable(connection as mysql.Connection, batch, table);
+ await batch.markSnapshotDone([table], headGTID.comparable);
+ await framework.container.probes.touch();
+ }
+ }
+ await batch.commit(headGTID.comparable);
+ }
+ );
+ logger.info(`Initial replication done`);
+ await promiseConnection.query('COMMIT');
+ } catch (e) {
+ await promiseConnection.query('ROLLBACK');
+ throw e;
+ } finally {
+ connection.release();
+ }
+ }
+
+ private async snapshotTable(
+ connection: mysql.Connection,
+ batch: storage.BucketStorageBatch,
+ table: storage.SourceTable
+ ) {
+ logger.info(`Replicating ${table.qualifiedName}`);
+ // TODO count rows and log progress at certain batch sizes
+
+ let columns: Map;
+ return new Promise((resolve, reject) => {
+ // MAX_EXECUTION_TIME(0) hint disables execution timeout for this query
+ connection
+ .query(`SELECT /*+ MAX_EXECUTION_TIME(0) */ * FROM ${table.schema}.${table.table}`)
+ .on('error', (err) => {
+ reject(err);
+ })
+ .on('fields', (fields: FieldPacket[]) => {
+ // Map the columns and their types
+ columns = toColumnDescriptors(fields);
+ })
+ .on('result', async (row) => {
+ connection.pause();
+ const record = common.toSQLiteRow(row, columns);
+
+ await batch.save({
+ tag: storage.SaveOperationTag.INSERT,
+ sourceTable: table,
+ before: undefined,
+ beforeReplicaId: undefined,
+ after: record,
+ afterReplicaId: getUuidReplicaIdentityBson(record, table.replicaIdColumns)
+ });
+ connection.resume();
+ Metrics.getInstance().rows_replicated_total.add(1);
+ })
+ .on('end', async function () {
+ await batch.flush();
+ resolve();
+ });
+ });
+ }
+
+ async replicate() {
+ try {
+ // If anything errors here, the entire replication process is halted, and
+ // all connections automatically closed, including this one.
+ await this.initReplication();
+ await this.streamChanges();
+ logger.info('BinlogStream has been shut down');
+ } catch (e) {
+ await this.storage.reportError(e);
+ throw e;
+ }
+ }
+
+ async initReplication() {
+ const connection = await this.connections.getConnection();
+ const errors = await common.checkSourceConfiguration(connection);
+ connection.release();
+
+ if (errors.length > 0) {
+ throw new BinlogConfigurationError(`Binlog Configuration Errors: ${errors.join(', ')}`);
+ }
+
+ const initialReplicationCompleted = await this.checkInitialReplicated();
+ if (!initialReplicationCompleted) {
+ await this.startInitialReplication();
+ }
+ }
+
+ private getTable(tableId: string): storage.SourceTable {
+ const table = this.tableCache.get(tableId);
+ if (table == null) {
+ // We should always receive a replication message before the relation is used.
+ // If we can't find it, it's a bug.
+ throw new Error(`Missing relation cache for ${tableId}`);
+ }
+ return table;
+ }
+
+ async streamChanges() {
+ // Auto-activate as soon as initial replication is done
+ await this.storage.autoActivate();
+ const serverId = createRandomServerId(this.storage.group_id);
+ logger.info(`Starting replication. Created replica client with serverId:${serverId}`);
+
+ const connection = await this.connections.getConnection();
+ const { checkpoint_lsn } = await this.storage.getStatus();
+ if (checkpoint_lsn) {
+ logger.info(`Existing checkpoint found: ${checkpoint_lsn}`);
+ }
+
+ const fromGTID = checkpoint_lsn
+ ? common.ReplicatedGTID.fromSerialized(checkpoint_lsn)
+ : await common.readExecutedGtid(connection);
+ const binLogPositionState = fromGTID.position;
+ connection.release();
+
+ if (!this.stopped) {
+ await this.storage.startBatch(
+ { zeroLSN: ReplicatedGTID.ZERO.comparable, defaultSchema: this.defaultSchema, storeCurrentData: true },
+ async (batch) => {
+ const zongji = this.connections.createBinlogListener();
+
+ let currentGTID: common.ReplicatedGTID | null = null;
+
+ const queue = async.queue(async (evt: BinLogEvent) => {
+ // State machine
+ switch (true) {
+ case zongji_utils.eventIsGTIDLog(evt):
+ currentGTID = common.ReplicatedGTID.fromBinLogEvent({
+ raw_gtid: {
+ server_id: evt.serverId,
+ transaction_range: evt.transactionRange
+ },
+ position: {
+ filename: binLogPositionState.filename,
+ offset: evt.nextPosition
+ }
+ });
+ break;
+ case zongji_utils.eventIsRotation(evt):
+ // Update the position
+ binLogPositionState.filename = evt.binlogName;
+ binLogPositionState.offset = evt.position;
+ break;
+ case zongji_utils.eventIsWriteMutation(evt):
+ const writeTableInfo = evt.tableMap[evt.tableId];
+ await this.writeChanges(batch, {
+ type: storage.SaveOperationTag.INSERT,
+ data: evt.rows,
+ tableEntry: writeTableInfo
+ });
+ break;
+ case zongji_utils.eventIsUpdateMutation(evt):
+ const updateTableInfo = evt.tableMap[evt.tableId];
+ await this.writeChanges(batch, {
+ type: storage.SaveOperationTag.UPDATE,
+ data: evt.rows.map((row) => row.after),
+ previous_data: evt.rows.map((row) => row.before),
+ tableEntry: updateTableInfo
+ });
+ break;
+ case zongji_utils.eventIsDeleteMutation(evt):
+ const deleteTableInfo = evt.tableMap[evt.tableId];
+ await this.writeChanges(batch, {
+ type: storage.SaveOperationTag.DELETE,
+ data: evt.rows,
+ tableEntry: deleteTableInfo
+ });
+ break;
+ case zongji_utils.eventIsXid(evt):
+ Metrics.getInstance().transactions_replicated_total.add(1);
+ // Need to commit with a replicated GTID with updated next position
+ await batch.commit(
+ new common.ReplicatedGTID({
+ raw_gtid: currentGTID!.raw,
+ position: {
+ filename: binLogPositionState.filename,
+ offset: evt.nextPosition
+ }
+ }).comparable
+ );
+ currentGTID = null;
+ // chunks_replicated_total.add(1);
+ break;
+ }
+ }, 1);
+
+ zongji.on('binlog', (evt: BinLogEvent) => {
+ if (!this.stopped) {
+ logger.info(`Received Binlog event:${evt.getEventName()}`);
+ queue.push(evt);
+ } else {
+ logger.info(`Replication is busy stopping, ignoring event ${evt.getEventName()}`);
+ }
+ });
+
+ if (this.stopped) {
+ // Powersync is shutting down, don't start replicating
+ return;
+ }
+
+ logger.info(`Reading binlog from: ${binLogPositionState.filename}:${binLogPositionState.offset}`);
+
+ // Only listen for changes to tables in the sync rules
+ const includedTables = [...this.tableCache.values()].map((table) => table.table);
+ zongji.start({
+ includeEvents: ['tablemap', 'writerows', 'updaterows', 'deleterows', 'xid', 'rotate', 'gtidlog'],
+ excludeEvents: [],
+ includeSchema: { [this.defaultSchema]: includedTables },
+ filename: binLogPositionState.filename,
+ position: binLogPositionState.offset,
+ serverId: serverId
+ } satisfies StartOptions);
+
+ // Forever young
+ await new Promise((resolve, reject) => {
+ zongji.on('error', (error) => {
+ logger.error('Error on Binlog listener:', error);
+ zongji.stop();
+ queue.kill();
+ reject(error);
+ });
+
+ zongji.on('stopped', () => {
+ logger.info('Binlog listener stopped. Replication ended.');
+ resolve();
+ });
+
+ queue.error((error) => {
+ logger.error('Binlog listener queue error:', error);
+ zongji.stop();
+ queue.kill();
+ reject(error);
+ });
+
+ this.abortSignal.addEventListener(
+ 'abort',
+ () => {
+ logger.info('Abort signal received, stopping replication...');
+ zongji.stop();
+ queue.kill();
+ resolve();
+ },
+ { once: true }
+ );
+ });
+ }
+ );
+ }
+ }
+
+ private async writeChanges(
+ batch: storage.BucketStorageBatch,
+ msg: {
+ type: storage.SaveOperationTag;
+ data: Data[];
+ previous_data?: Data[];
+ tableEntry: TableMapEntry;
+ }
+ ): Promise {
+ const columns = toColumnDescriptors(msg.tableEntry);
+
+ for (const [index, row] of msg.data.entries()) {
+ await this.writeChange(batch, {
+ type: msg.type,
+ database: msg.tableEntry.parentSchema,
+ sourceTable: this.getTable(
+ getMysqlRelId({
+ schema: msg.tableEntry.parentSchema,
+ name: msg.tableEntry.tableName
+ })
+ ),
+ table: msg.tableEntry.tableName,
+ columns: columns,
+ data: row,
+ previous_data: msg.previous_data?.[index]
+ });
+ }
+ return null;
+ }
+
+ private async writeChange(
+ batch: storage.BucketStorageBatch,
+ payload: WriteChangePayload
+ ): Promise {
+ switch (payload.type) {
+ case storage.SaveOperationTag.INSERT:
+ Metrics.getInstance().rows_replicated_total.add(1);
+ const record = common.toSQLiteRow(payload.data, payload.columns);
+ return await batch.save({
+ tag: storage.SaveOperationTag.INSERT,
+ sourceTable: payload.sourceTable,
+ before: undefined,
+ beforeReplicaId: undefined,
+ after: record,
+ afterReplicaId: getUuidReplicaIdentityBson(record, payload.sourceTable.replicaIdColumns)
+ });
+ case storage.SaveOperationTag.UPDATE:
+ Metrics.getInstance().rows_replicated_total.add(1);
+ // "before" may be null if the replica id columns are unchanged
+ // It's fine to treat that the same as an insert.
+ const beforeUpdated = payload.previous_data
+ ? common.toSQLiteRow(payload.previous_data, payload.columns)
+ : undefined;
+ const after = common.toSQLiteRow(payload.data, payload.columns);
+
+ return await batch.save({
+ tag: storage.SaveOperationTag.UPDATE,
+ sourceTable: payload.sourceTable,
+ before: beforeUpdated,
+ beforeReplicaId: beforeUpdated
+ ? getUuidReplicaIdentityBson(beforeUpdated, payload.sourceTable.replicaIdColumns)
+ : undefined,
+ after: common.toSQLiteRow(payload.data, payload.columns),
+ afterReplicaId: getUuidReplicaIdentityBson(after, payload.sourceTable.replicaIdColumns)
+ });
+
+ case storage.SaveOperationTag.DELETE:
+ Metrics.getInstance().rows_replicated_total.add(1);
+ const beforeDeleted = common.toSQLiteRow(payload.data, payload.columns);
+
+ return await batch.save({
+ tag: storage.SaveOperationTag.DELETE,
+ sourceTable: payload.sourceTable,
+ before: beforeDeleted,
+ beforeReplicaId: getUuidReplicaIdentityBson(beforeDeleted, payload.sourceTable.replicaIdColumns),
+ after: undefined,
+ afterReplicaId: undefined
+ });
+ default:
+ return null;
+ }
+ }
+}
diff --git a/modules/module-mysql/src/replication/MySQLConnectionManager.ts b/modules/module-mysql/src/replication/MySQLConnectionManager.ts
new file mode 100644
index 000000000..3693b9ce2
--- /dev/null
+++ b/modules/module-mysql/src/replication/MySQLConnectionManager.ts
@@ -0,0 +1,107 @@
+import { NormalizedMySQLConnectionConfig } from '../types/types.js';
+import mysqlPromise from 'mysql2/promise';
+import mysql, { FieldPacket, RowDataPacket } from 'mysql2';
+import * as mysql_utils from '../utils/mysql-utils.js';
+import ZongJi from '@powersync/mysql-zongji';
+import { logger } from '@powersync/lib-services-framework';
+
+export class MySQLConnectionManager {
+ /**
+ * Pool that can create streamable connections
+ */
+ private readonly pool: mysql.Pool;
+ /**
+ * Pool that can create promise-based connections
+ */
+ private readonly promisePool: mysqlPromise.Pool;
+
+ private binlogListeners: ZongJi[] = [];
+
+ private isClosed = false;
+
+ constructor(
+ public options: NormalizedMySQLConnectionConfig,
+ public poolOptions: mysqlPromise.PoolOptions
+ ) {
+ // The pool is lazy - no connections are opened until a query is performed.
+ this.pool = mysql_utils.createPool(options, poolOptions);
+ this.promisePool = this.pool.promise();
+ }
+
+ public get connectionTag() {
+ return this.options.tag;
+ }
+
+ public get connectionId() {
+ return this.options.id;
+ }
+
+ public get databaseName() {
+ return this.options.database;
+ }
+
+ /**
+ * Create a new replication listener
+ */
+ createBinlogListener(): ZongJi {
+ const listener = new ZongJi({
+ host: this.options.hostname,
+ user: this.options.username,
+ password: this.options.password
+ });
+
+ this.binlogListeners.push(listener);
+
+ return listener;
+ }
+
+ /**
+ * Run a query using a connection from the pool
+ * A promise with the result is returned
+ * @param query
+ * @param params
+ */
+ async query(query: string, params?: any[]): Promise<[RowDataPacket[], FieldPacket[]]> {
+ return this.promisePool.query(query, params);
+ }
+
+ /**
+ * Get a streamable connection from this manager's pool
+ * The connection should be released when it is no longer needed
+ */
+ async getStreamingConnection(): Promise {
+ return new Promise((resolve, reject) => {
+ this.pool.getConnection((err, connection) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(connection);
+ }
+ });
+ });
+ }
+
+ /**
+ * Get a promise connection from this manager's pool
+ * The connection should be released when it is no longer needed
+ */
+ async getConnection(): Promise {
+ return this.promisePool.getConnection();
+ }
+
+ async end(): Promise {
+ if (!this.isClosed) {
+ for (const listener of this.binlogListeners) {
+ listener.stop();
+ }
+
+ try {
+ await this.promisePool.end();
+ this.isClosed = true;
+ } catch (error) {
+ // We don't particularly care if any errors are thrown when shutting down the pool
+ logger.warn('Error shutting down MySQL connection pool', error);
+ }
+ }
+ }
+}
diff --git a/modules/module-mysql/src/replication/MySQLConnectionManagerFactory.ts b/modules/module-mysql/src/replication/MySQLConnectionManagerFactory.ts
new file mode 100644
index 000000000..ea87f60ec
--- /dev/null
+++ b/modules/module-mysql/src/replication/MySQLConnectionManagerFactory.ts
@@ -0,0 +1,28 @@
+import { logger } from '@powersync/lib-services-framework';
+import mysql from 'mysql2/promise';
+import { MySQLConnectionManager } from './MySQLConnectionManager.js';
+import { ResolvedConnectionConfig } from '../types/types.js';
+
+export class MySQLConnectionManagerFactory {
+ private readonly connectionManagers: MySQLConnectionManager[];
+ private readonly connectionConfig: ResolvedConnectionConfig;
+
+ constructor(connectionConfig: ResolvedConnectionConfig) {
+ this.connectionConfig = connectionConfig;
+ this.connectionManagers = [];
+ }
+
+ create(poolOptions: mysql.PoolOptions) {
+ const manager = new MySQLConnectionManager(this.connectionConfig, poolOptions);
+ this.connectionManagers.push(manager);
+ return manager;
+ }
+
+ async shutdown() {
+ logger.info('Shutting down MySQL connection Managers...');
+ for (const manager of this.connectionManagers) {
+ await manager.end();
+ }
+ logger.info('MySQL connection Managers shutdown completed.');
+ }
+}
diff --git a/modules/module-mysql/src/replication/MySQLErrorRateLimiter.ts b/modules/module-mysql/src/replication/MySQLErrorRateLimiter.ts
new file mode 100644
index 000000000..8966cd201
--- /dev/null
+++ b/modules/module-mysql/src/replication/MySQLErrorRateLimiter.ts
@@ -0,0 +1,37 @@
+import { ErrorRateLimiter } from '@powersync/service-core';
+import { setTimeout } from 'timers/promises';
+
+export class MySQLErrorRateLimiter implements ErrorRateLimiter {
+ nextAllowed: number = Date.now();
+
+ async waitUntilAllowed(options?: { signal?: AbortSignal | undefined } | undefined): Promise {
+ const delay = Math.max(0, this.nextAllowed - Date.now());
+ // Minimum delay between connections, even without errors
+ this.setDelay(500);
+ await setTimeout(delay, undefined, { signal: options?.signal });
+ }
+
+ mayPing(): boolean {
+ return Date.now() >= this.nextAllowed;
+ }
+
+ reportError(e: any): void {
+ const message = (e.message as string) ?? '';
+ if (message.includes('password authentication failed')) {
+ // Wait 15 minutes, to avoid triggering Supabase's fail2ban
+ this.setDelay(900_000);
+ } else if (message.includes('ENOTFOUND')) {
+ // DNS lookup issue - incorrect URI or deleted instance
+ this.setDelay(120_000);
+ } else if (message.includes('ECONNREFUSED')) {
+ // Could be fail2ban or similar
+ this.setDelay(120_000);
+ } else {
+ this.setDelay(30_000);
+ }
+ }
+
+ private setDelay(delay: number) {
+ this.nextAllowed = Math.max(this.nextAllowed, Date.now() + delay);
+ }
+}
diff --git a/modules/module-mysql/src/replication/zongji/zongji-utils.ts b/modules/module-mysql/src/replication/zongji/zongji-utils.ts
new file mode 100644
index 000000000..36122b636
--- /dev/null
+++ b/modules/module-mysql/src/replication/zongji/zongji-utils.ts
@@ -0,0 +1,32 @@
+import {
+ BinLogEvent,
+ BinLogGTIDLogEvent,
+ BinLogMutationEvent,
+ BinLogRotationEvent,
+ BinLogUpdateEvent,
+ BinLogXidEvent
+} from '@powersync/mysql-zongji';
+
+export function eventIsGTIDLog(event: BinLogEvent): event is BinLogGTIDLogEvent {
+ return event.getEventName() == 'gtidlog';
+}
+
+export function eventIsXid(event: BinLogEvent): event is BinLogXidEvent {
+ return event.getEventName() == 'xid';
+}
+
+export function eventIsRotation(event: BinLogEvent): event is BinLogRotationEvent {
+ return event.getEventName() == 'rotate';
+}
+
+export function eventIsWriteMutation(event: BinLogEvent): event is BinLogMutationEvent {
+ return event.getEventName() == 'writerows';
+}
+
+export function eventIsDeleteMutation(event: BinLogEvent): event is BinLogMutationEvent {
+ return event.getEventName() == 'deleterows';
+}
+
+export function eventIsUpdateMutation(event: BinLogEvent): event is BinLogUpdateEvent {
+ return event.getEventName() == 'updaterows';
+}
diff --git a/modules/module-mysql/src/replication/zongji/zongji.d.ts b/modules/module-mysql/src/replication/zongji/zongji.d.ts
new file mode 100644
index 000000000..9a17f15e9
--- /dev/null
+++ b/modules/module-mysql/src/replication/zongji/zongji.d.ts
@@ -0,0 +1,119 @@
+declare module '@powersync/mysql-zongji' {
+ export type ZongjiOptions = {
+ host: string;
+ user: string;
+ password: string;
+ dateStrings?: boolean;
+ timeZone?: string;
+ };
+
+ interface DatabaseFilter {
+ [databaseName: string]: string[] | true;
+ }
+
+ export type StartOptions = {
+ includeEvents?: string[];
+ excludeEvents?: string[];
+ /**
+ * Describe which databases and tables to include (Only for row events). Use database names as the key and pass an array of table names or true (for the entire database).
+ * Example: { 'my_database': ['allow_table', 'another_table'], 'another_db': true }
+ */
+ includeSchema?: DatabaseFilter;
+ /**
+ * Object describing which databases and tables to exclude (Same format as includeSchema)
+ * Example: { 'other_db': ['disallowed_table'], 'ex_db': true }
+ */
+ excludeSchema?: DatabaseFilter;
+ /**
+ * BinLog position filename to start reading events from
+ */
+ filename?: string;
+ /**
+ * BinLog position offset to start reading events from in file specified
+ */
+ position?: number;
+
+ /**
+ * Unique server ID for this replication client.
+ */
+ serverId?: number;
+ };
+
+ export type ColumnSchema = {
+ COLUMN_NAME: string;
+ COLLATION_NAME: string;
+ CHARACTER_SET_NAME: string;
+ COLUMN_COMMENT: string;
+ COLUMN_TYPE: string;
+ };
+
+ export type ColumnDefinition = {
+ name: string;
+ charset: string;
+ type: number;
+ metadata: Record;
+ };
+
+ export type TableMapEntry = {
+ columnSchemas: ColumnSchema[];
+ parentSchema: string;
+ tableName: string;
+ columns: ColumnDefinition[];
+ };
+
+ export type BaseBinLogEvent = {
+ timestamp: number;
+ getEventName(): string;
+
+ /**
+ * Next position in BinLog file to read from after
+ * this event.
+ */
+ nextPosition: number;
+ /**
+ * Size of this event
+ */
+ size: number;
+ flags: number;
+ useChecksum: boolean;
+ };
+
+ export type BinLogRotationEvent = BaseBinLogEvent & {
+ binlogName: string;
+ position: number;
+ };
+
+ export type BinLogGTIDLogEvent = BaseBinLogEvent & {
+ serverId: Buffer;
+ transactionRange: number;
+ };
+
+ export type BinLogXidEvent = BaseBinLogEvent & {
+ xid: number;
+ };
+
+ export type BinLogMutationEvent = BaseBinLogEvent & {
+ tableId: number;
+ numberOfColumns: number;
+ tableMap: Record;
+ rows: Record[];
+ };
+
+ export type BinLogUpdateEvent = Omit & {
+ rows: {
+ before: Record;
+ after: Record;
+ }[];
+ };
+
+ export type BinLogEvent = BinLogRotationEvent | BinLogGTIDLogEvent | BinLogXidEvent | BinLogMutationEvent;
+
+ export default class ZongJi {
+ constructor(options: ZongjiOptions);
+
+ start(options: StartOptions): void;
+ stop(): void;
+
+ on(type: 'binlog' | string, callback: (event: BinLogEvent) => void);
+ }
+}
diff --git a/modules/module-mysql/src/types/types.ts b/modules/module-mysql/src/types/types.ts
new file mode 100644
index 000000000..43dd17696
--- /dev/null
+++ b/modules/module-mysql/src/types/types.ts
@@ -0,0 +1,106 @@
+import * as service_types from '@powersync/service-types';
+import * as t from 'ts-codec';
+import * as urijs from 'uri-js';
+
+export const MYSQL_CONNECTION_TYPE = 'mysql' as const;
+
+export interface NormalizedMySQLConnectionConfig {
+ id: string;
+ tag: string;
+
+ hostname: string;
+ port: number;
+ database: string;
+
+ username: string;
+ password: string;
+ server_id: number;
+
+ cacert?: string;
+ client_certificate?: string;
+ client_private_key?: string;
+}
+
+export const MySQLConnectionConfig = service_types.configFile.DataSourceConfig.and(
+ t.object({
+ type: t.literal(MYSQL_CONNECTION_TYPE),
+ uri: t.string.optional(),
+ hostname: t.string.optional(),
+ port: service_types.configFile.portCodec.optional(),
+ username: t.string.optional(),
+ password: t.string.optional(),
+ database: t.string.optional(),
+ server_id: t.number.optional(),
+
+ cacert: t.string.optional(),
+ client_certificate: t.string.optional(),
+ client_private_key: t.string.optional()
+ })
+);
+
+/**
+ * Config input specified when starting services
+ */
+export type MySQLConnectionConfig = t.Decoded;
+
+/**
+ * Resolved version of {@link MySQLConnectionConfig}
+ */
+export type ResolvedConnectionConfig = MySQLConnectionConfig & NormalizedMySQLConnectionConfig;
+
+/**
+ * Validate and normalize connection options.
+ *
+ * Returns destructured options.
+ */
+export function normalizeConnectionConfig(options: MySQLConnectionConfig): NormalizedMySQLConnectionConfig {
+ let uri: urijs.URIComponents;
+ if (options.uri) {
+ uri = urijs.parse(options.uri);
+ if (uri.scheme != 'mysql') {
+ throw new Error(`Invalid URI - protocol must be mysql, got ${uri.scheme}`);
+ }
+ } else {
+ uri = urijs.parse('mysql:///');
+ }
+
+ const hostname = options.hostname ?? uri.host ?? '';
+ const port = Number(options.port ?? uri.port ?? 3306);
+
+ const database = options.database ?? uri.path?.substring(1) ?? '';
+
+ const [uri_username, uri_password] = (uri.userinfo ?? '').split(':');
+
+ const username = options.username ?? uri_username ?? '';
+ const password = options.password ?? uri_password ?? '';
+
+ if (hostname == '') {
+ throw new Error(`hostname required`);
+ }
+
+ if (username == '') {
+ throw new Error(`username required`);
+ }
+
+ if (password == '') {
+ throw new Error(`password required`);
+ }
+
+ if (database == '') {
+ throw new Error(`database required`);
+ }
+
+ return {
+ id: options.id ?? 'default',
+ tag: options.tag ?? 'default',
+
+ hostname,
+ port,
+ database,
+
+ username,
+ password,
+
+ server_id: options.server_id ?? 1
+ };
+}
diff --git a/modules/module-mysql/src/utils/mysql-utils.ts b/modules/module-mysql/src/utils/mysql-utils.ts
new file mode 100644
index 000000000..a2279c234
--- /dev/null
+++ b/modules/module-mysql/src/utils/mysql-utils.ts
@@ -0,0 +1,84 @@
+import { logger } from '@powersync/lib-services-framework';
+import mysql from 'mysql2';
+import mysqlPromise from 'mysql2/promise';
+import * as types from '../types/types.js';
+import { coerce, gte } from 'semver';
+
+export type RetriedQueryOptions = {
+ connection: mysqlPromise.Connection;
+ query: string;
+ params?: any[];
+ retries?: number;
+};
+
+/**
+ * Retry a simple query - up to 2 attempts total.
+ */
+export async function retriedQuery(options: RetriedQueryOptions) {
+ const { connection, query, params = [], retries = 2 } = options;
+ for (let tries = retries; ; tries--) {
+ try {
+ logger.debug(`Executing query: ${query}`);
+ return connection.query(query, params);
+ } catch (e) {
+ if (tries == 1) {
+ throw e;
+ }
+ logger.warn('Query error, retrying', e);
+ }
+ }
+}
+
+export function createPool(config: types.NormalizedMySQLConnectionConfig, options?: mysql.PoolOptions): mysql.Pool {
+ const sslOptions = {
+ ca: config.cacert,
+ key: config.client_private_key,
+ cert: config.client_certificate
+ };
+ const hasSSLOptions = Object.values(sslOptions).some((v) => !!v);
+ return mysql.createPool({
+ host: config.hostname,
+ user: config.username,
+ password: config.password,
+ database: config.database,
+ ssl: hasSSLOptions ? sslOptions : undefined,
+ supportBigNumbers: true,
+ decimalNumbers: true,
+ timezone: 'Z', // Ensure no auto timezone manipulation of the dates occur
+ jsonStrings: true, // Return JSON columns as strings
+ ...(options || {})
+ });
+}
+
+/**
+ * Return a random server id for a given sync rule id.
+ * Expected format is: 00
+ * The max value for server id in MySQL is 2^32 - 1.
+ * We use the GTID format to keep track of our position in the binlog, no state is kept by the MySQL server, therefore
+ * it is ok to use a randomised server id every time.
+ * @param syncRuleId
+ */
+export function createRandomServerId(syncRuleId: number): number {
+ return Number.parseInt(`${syncRuleId}00${Math.floor(Math.random() * 10000)}`);
+}
+
+export async function getMySQLVersion(connection: mysqlPromise.Connection): Promise {
+ const [[versionResult]] = await retriedQuery({
+ connection,
+ query: `SELECT VERSION() as version`
+ });
+
+ return versionResult.version as string;
+}
+
+/**
+ * Check if the current MySQL version is newer or equal to the target version.
+ * @param version
+ * @param minimumVersion
+ */
+export function isVersionAtLeast(version: string, minimumVersion: string): boolean {
+ const coercedVersion = coerce(version);
+ const coercedMinimumVersion = coerce(minimumVersion);
+
+ return gte(coercedVersion!, coercedMinimumVersion!, { loose: true });
+}
diff --git a/modules/module-mysql/test/src/BinLogStream.test.ts b/modules/module-mysql/test/src/BinLogStream.test.ts
new file mode 100644
index 000000000..44240d461
--- /dev/null
+++ b/modules/module-mysql/test/src/BinLogStream.test.ts
@@ -0,0 +1,306 @@
+import { putOp, removeOp } from '@core-tests/stream_utils.js';
+import { MONGO_STORAGE_FACTORY } from '@core-tests/util.js';
+import { BucketStorageFactory, Metrics } from '@powersync/service-core';
+import { describe, expect, test } from 'vitest';
+import { binlogStreamTest } from './BinlogStreamUtils.js';
+import { v4 as uuid } from 'uuid';
+
+type StorageFactory = () => Promise;
+
+const BASIC_SYNC_RULES = `
+bucket_definitions:
+ global:
+ data:
+ - SELECT id, description FROM "test_data"
+`;
+
+describe(
+ ' Binlog stream - mongodb',
+ function () {
+ defineBinlogStreamTests(MONGO_STORAGE_FACTORY);
+ },
+ { timeout: 20_000 }
+);
+
+function defineBinlogStreamTests(factory: StorageFactory) {
+ test(
+ 'Replicate basic values',
+ binlogStreamTest(factory, async (context) => {
+ const { connectionManager } = context;
+ await context.updateSyncRules(`
+ bucket_definitions:
+ global:
+ data:
+ - SELECT id, description, num FROM "test_data"`);
+
+ await connectionManager.query(`CREATE TABLE test_data (id CHAR(36) PRIMARY KEY, description TEXT, num BIGINT)`);
+
+ await context.replicateSnapshot();
+
+ const startRowCount =
+ (await Metrics.getInstance().getMetricValueForTests('powersync_rows_replicated_total')) ?? 0;
+ const startTxCount =
+ (await Metrics.getInstance().getMetricValueForTests('powersync_transactions_replicated_total')) ?? 0;
+
+ context.startStreaming();
+ const testId = uuid();
+ await connectionManager.query(
+ `INSERT INTO test_data(id, description, num) VALUES('${testId}', 'test1', 1152921504606846976)`
+ );
+ const data = await context.getBucketData('global[]');
+
+ expect(data).toMatchObject([putOp('test_data', { id: testId, description: 'test1', num: 1152921504606846976n })]);
+ const endRowCount = (await Metrics.getInstance().getMetricValueForTests('powersync_rows_replicated_total')) ?? 0;
+ const endTxCount =
+ (await Metrics.getInstance().getMetricValueForTests('powersync_transactions_replicated_total')) ?? 0;
+ expect(endRowCount - startRowCount).toEqual(1);
+ expect(endTxCount - startTxCount).toEqual(1);
+ })
+ );
+
+ test(
+ 'replicating case sensitive table',
+ binlogStreamTest(factory, async (context) => {
+ const { connectionManager } = context;
+ await context.updateSyncRules(`
+ bucket_definitions:
+ global:
+ data:
+ - SELECT id, description FROM "test_DATA"
+ `);
+
+ await connectionManager.query(`CREATE TABLE test_DATA (id CHAR(36) PRIMARY KEY, description text)`);
+
+ await context.replicateSnapshot();
+
+ const startRowCount =
+ (await Metrics.getInstance().getMetricValueForTests('powersync_rows_replicated_total')) ?? 0;
+ const startTxCount =
+ (await Metrics.getInstance().getMetricValueForTests('powersync_transactions_replicated_total')) ?? 0;
+
+ context.startStreaming();
+
+ const testId = uuid();
+ await connectionManager.query(`INSERT INTO test_DATA(id, description) VALUES('${testId}','test1')`);
+
+ const data = await context.getBucketData('global[]');
+
+ expect(data).toMatchObject([putOp('test_DATA', { id: testId, description: 'test1' })]);
+ const endRowCount = (await Metrics.getInstance().getMetricValueForTests('powersync_rows_replicated_total')) ?? 0;
+ const endTxCount =
+ (await Metrics.getInstance().getMetricValueForTests('powersync_transactions_replicated_total')) ?? 0;
+ expect(endRowCount - startRowCount).toEqual(1);
+ expect(endTxCount - startTxCount).toEqual(1);
+ })
+ );
+
+ // TODO: Not supported yet
+ // test(
+ // 'replicating TRUNCATE',
+ // binlogStreamTest(factory, async (context) => {
+ // const { connectionManager } = context;
+ // const syncRuleContent = `
+ // bucket_definitions:
+ // global:
+ // data:
+ // - SELECT id, description FROM "test_data"
+ // by_test_data:
+ // parameters: SELECT id FROM test_data WHERE id = token_parameters.user_id
+ // data: []
+ // `;
+ // await context.updateSyncRules(syncRuleContent);
+ // await connectionManager.query(`DROP TABLE IF EXISTS test_data`);
+ // await connectionManager.query(
+ // `CREATE TABLE test_data(id uuid primary key default uuid_generate_v4(), description text)`
+ // );
+ //
+ // await context.replicateSnapshot();
+ // context.startStreaming();
+ //
+ // const [{ test_id }] = pgwireRows(
+ // await connectionManager.query(`INSERT INTO test_data(description) VALUES('test1') returning id as test_id`)
+ // );
+ // await connectionManager.query(`TRUNCATE test_data`);
+ //
+ // const data = await context.getBucketData('global[]');
+ //
+ // expect(data).toMatchObject([
+ // putOp('test_data', { id: test_id, description: 'test1' }),
+ // removeOp('test_data', test_id)
+ // ]);
+ // })
+ // );
+
+ test(
+ 'replicating changing primary key',
+ binlogStreamTest(factory, async (context) => {
+ const { connectionManager } = context;
+ await context.updateSyncRules(BASIC_SYNC_RULES);
+
+ await connectionManager.query(`CREATE TABLE test_data (id CHAR(36) PRIMARY KEY, description text)`);
+
+ await context.replicateSnapshot();
+ context.startStreaming();
+
+ const testId1 = uuid();
+ await connectionManager.query(`INSERT INTO test_data(id, description) VALUES('${testId1}','test1')`);
+
+ const testId2 = uuid();
+ await connectionManager.query(
+ `UPDATE test_data SET id = '${testId2}', description = 'test2a' WHERE id = '${testId1}'`
+ );
+
+ // This update may fail replicating with:
+ // Error: Update on missing record public.test_data:074a601e-fc78-4c33-a15d-f89fdd4af31d :: {"g":1,"t":"651e9fbe9fec6155895057ec","k":"1a0b34da-fb8c-5e6f-8421-d7a3c5d4df4f"}
+ await connectionManager.query(`UPDATE test_data SET description = 'test2b' WHERE id = '${testId2}'`);
+
+ // Re-use old id again
+ await connectionManager.query(`INSERT INTO test_data(id, description) VALUES('${testId1}', 'test1b')`);
+ await connectionManager.query(`UPDATE test_data SET description = 'test1c' WHERE id = '${testId1}'`);
+
+ const data = await context.getBucketData('global[]');
+ expect(data).toMatchObject([
+ // Initial insert
+ putOp('test_data', { id: testId1, description: 'test1' }),
+ // Update id, then description
+ removeOp('test_data', testId1),
+ putOp('test_data', { id: testId2, description: 'test2a' }),
+ putOp('test_data', { id: testId2, description: 'test2b' }),
+ // Re-use old id
+ putOp('test_data', { id: testId1, description: 'test1b' }),
+ putOp('test_data', { id: testId1, description: 'test1c' })
+ ]);
+ })
+ );
+
+ test(
+ 'initial sync',
+ binlogStreamTest(factory, async (context) => {
+ const { connectionManager } = context;
+ await context.updateSyncRules(BASIC_SYNC_RULES);
+
+ await connectionManager.query(`CREATE TABLE test_data (id CHAR(36) PRIMARY KEY, description text)`);
+
+ const testId = uuid();
+ await connectionManager.query(`INSERT INTO test_data(id, description) VALUES('${testId}','test1')`);
+
+ await context.replicateSnapshot();
+
+ const data = await context.getBucketData('global[]');
+ expect(data).toMatchObject([putOp('test_data', { id: testId, description: 'test1' })]);
+ })
+ );
+
+ test(
+ 'snapshot with date values',
+ binlogStreamTest(factory, async (context) => {
+ const { connectionManager } = context;
+ await context.updateSyncRules(`
+ bucket_definitions:
+ global:
+ data:
+ - SELECT * FROM "test_data"
+ `);
+
+ await connectionManager.query(
+ `CREATE TABLE test_data (id CHAR(36) PRIMARY KEY, description TEXT, date DATE, datetime DATETIME, timestamp TIMESTAMP)`
+ );
+
+ const testId = uuid();
+ await connectionManager.query(`
+ INSERT INTO test_data(id, description, date, datetime, timestamp) VALUES('${testId}','testDates', '2023-03-06', '2023-03-06 15:47', '2023-03-06 15:47')
+ `);
+
+ await context.replicateSnapshot();
+
+ const data = await context.getBucketData('global[]');
+ expect(data).toMatchObject([
+ putOp('test_data', {
+ id: testId,
+ description: 'testDates',
+ date: `2023-03-06`,
+ datetime: '2023-03-06T15:47:00.000Z',
+ timestamp: '2023-03-06T15:47:00.000Z'
+ })
+ ]);
+ })
+ );
+
+ test(
+ 'replication with date values',
+ binlogStreamTest(factory, async (context) => {
+ const { connectionManager } = context;
+ await context.updateSyncRules(`
+ bucket_definitions:
+ global:
+ data:
+ - SELECT * FROM "test_data"
+ `);
+
+ await connectionManager.query(
+ `CREATE TABLE test_data (id CHAR(36) PRIMARY KEY, description TEXT, date DATE, datetime DATETIME, timestamp TIMESTAMP)`
+ );
+
+ await context.replicateSnapshot();
+
+ const startRowCount =
+ (await Metrics.getInstance().getMetricValueForTests('powersync_rows_replicated_total')) ?? 0;
+ const startTxCount =
+ (await Metrics.getInstance().getMetricValueForTests('powersync_transactions_replicated_total')) ?? 0;
+
+ context.startStreaming();
+
+ const testId = uuid();
+ await connectionManager.query(`
+ INSERT INTO test_data(id, description, date, datetime, timestamp) VALUES('${testId}','testDates', '2023-03-06', '2023-03-06 15:47', '2023-03-06 15:47')
+ `);
+
+ const data = await context.getBucketData('global[]');
+ expect(data).toMatchObject([
+ putOp('test_data', {
+ id: testId,
+ description: 'testDates',
+ date: `2023-03-06`,
+ datetime: '2023-03-06T15:47:00.000Z',
+ timestamp: '2023-03-06T15:47:00.000Z'
+ })
+ ]);
+ const endRowCount = (await Metrics.getInstance().getMetricValueForTests('powersync_rows_replicated_total')) ?? 0;
+ const endTxCount =
+ (await Metrics.getInstance().getMetricValueForTests('powersync_transactions_replicated_total')) ?? 0;
+ expect(endRowCount - startRowCount).toEqual(1);
+ expect(endTxCount - startTxCount).toEqual(1);
+ })
+ );
+
+ test(
+ 'table not in sync rules',
+ binlogStreamTest(factory, async (context) => {
+ const { connectionManager } = context;
+ await context.updateSyncRules(BASIC_SYNC_RULES);
+
+ await connectionManager.query(`CREATE TABLE test_donotsync (id CHAR(36) PRIMARY KEY, description text)`);
+
+ await context.replicateSnapshot();
+
+ const startRowCount =
+ (await Metrics.getInstance().getMetricValueForTests('powersync_rows_replicated_total')) ?? 0;
+ const startTxCount =
+ (await Metrics.getInstance().getMetricValueForTests('powersync_transactions_replicated_total')) ?? 0;
+
+ context.startStreaming();
+
+ await connectionManager.query(`INSERT INTO test_donotsync(id, description) VALUES('${uuid()}','test1')`);
+ const data = await context.getBucketData('global[]');
+
+ expect(data).toMatchObject([]);
+ const endRowCount = (await Metrics.getInstance().getMetricValueForTests('powersync_rows_replicated_total')) ?? 0;
+ const endTxCount =
+ (await Metrics.getInstance().getMetricValueForTests('powersync_transactions_replicated_total')) ?? 0;
+
+ // There was a transaction, but we should not replicate any actual data
+ expect(endRowCount - startRowCount).toEqual(0);
+ expect(endTxCount - startTxCount).toEqual(1);
+ })
+ );
+}
diff --git a/modules/module-mysql/test/src/BinlogStreamUtils.ts b/modules/module-mysql/test/src/BinlogStreamUtils.ts
new file mode 100644
index 000000000..c08f22c60
--- /dev/null
+++ b/modules/module-mysql/test/src/BinlogStreamUtils.ts
@@ -0,0 +1,157 @@
+import {
+ ActiveCheckpoint,
+ BucketStorageFactory,
+ OpId,
+ OplogEntry,
+ SyncRulesBucketStorage
+} from '@powersync/service-core';
+import { TEST_CONNECTION_OPTIONS, clearTestDb } from './util.js';
+import { fromAsync } from '@core-tests/stream_utils.js';
+import { BinLogStream, BinLogStreamOptions } from '@module/replication/BinLogStream.js';
+import { MySQLConnectionManager } from '@module/replication/MySQLConnectionManager.js';
+import mysqlPromise from 'mysql2/promise';
+import { readExecutedGtid } from '@module/common/read-executed-gtid.js';
+import { logger } from '@powersync/lib-services-framework';
+
+/**
+ * Tests operating on the binlog stream need to configure the stream and manage asynchronous
+ * replication, which gets a little tricky.
+ *
+ * This wraps a test in a function that configures all the context, and tears it down afterward.
+ */
+export function binlogStreamTest(
+ factory: () => Promise,
+ test: (context: BinlogStreamTestContext) => Promise
+): () => Promise {
+ return async () => {
+ const f = await factory();
+ const connectionManager = new MySQLConnectionManager(TEST_CONNECTION_OPTIONS, {});
+
+ const connection = await connectionManager.getConnection();
+ await clearTestDb(connection);
+ connection.release();
+ const context = new BinlogStreamTestContext(f, connectionManager);
+ try {
+ await test(context);
+ } finally {
+ await context.dispose();
+ }
+ };
+}
+
+export class BinlogStreamTestContext {
+ private _binlogStream?: BinLogStream;
+ private abortController = new AbortController();
+ private streamPromise?: Promise;
+ public storage?: SyncRulesBucketStorage;
+ private replicationDone = false;
+
+ constructor(
+ public factory: BucketStorageFactory,
+ public connectionManager: MySQLConnectionManager
+ ) {}
+
+ async dispose() {
+ this.abortController.abort();
+ await this.streamPromise;
+ await this.connectionManager.end();
+ }
+
+ get connectionTag() {
+ return this.connectionManager.connectionTag;
+ }
+
+ async updateSyncRules(content: string): Promise {
+ const syncRules = await this.factory.updateSyncRules({ content: content });
+ this.storage = this.factory.getInstance(syncRules);
+ return this.storage!;
+ }
+
+ get binlogStream(): BinLogStream {
+ if (this.storage == null) {
+ throw new Error('updateSyncRules() first');
+ }
+ if (this._binlogStream) {
+ return this._binlogStream;
+ }
+ const options: BinLogStreamOptions = {
+ storage: this.storage,
+ connections: this.connectionManager,
+ abortSignal: this.abortController.signal
+ };
+ this._binlogStream = new BinLogStream(options);
+ return this._binlogStream!;
+ }
+
+ async replicateSnapshot() {
+ await this.binlogStream.initReplication();
+ this.replicationDone = true;
+ }
+
+ startStreaming() {
+ if (!this.replicationDone) {
+ throw new Error('Call replicateSnapshot() before startStreaming()');
+ }
+ this.streamPromise = this.binlogStream.streamChanges();
+ }
+
+ async getCheckpoint(options?: { timeout?: number }): Promise {
+ const connection = await this.connectionManager.getConnection();
+ let checkpoint = await Promise.race([
+ getClientCheckpoint(connection, this.factory, { timeout: options?.timeout ?? 60_000 }),
+ this.streamPromise
+ ]);
+ connection.release();
+ if (typeof checkpoint == undefined) {
+ // This indicates an issue with the test setup - streamingPromise completed instead
+ // of getClientCheckpoint()
+ throw new Error('Test failure - streamingPromise completed');
+ }
+ return checkpoint as string;
+ }
+
+ async getBucketsDataBatch(buckets: Record, options?: { timeout?: number }) {
+ const checkpoint = await this.getCheckpoint(options);
+ const map = new Map(Object.entries(buckets));
+ return fromAsync(this.storage!.getBucketDataBatch(checkpoint, map));
+ }
+
+ async getBucketData(bucket: string, start = '0', options?: { timeout?: number }): Promise {
+ const checkpoint = await this.getCheckpoint(options);
+ const map = new Map([[bucket, start]]);
+ const batch = this.storage!.getBucketDataBatch(checkpoint, map);
+ const batches = await fromAsync(batch);
+ return batches[0]?.batch.data ?? [];
+ }
+}
+
+export async function getClientCheckpoint(
+ connection: mysqlPromise.Connection,
+ bucketStorage: BucketStorageFactory,
+ options?: { timeout?: number }
+): Promise {
+ const start = Date.now();
+ const gtid = await readExecutedGtid(connection);
+ // This old API needs a persisted checkpoint id.
+ // Since we don't use LSNs anymore, the only way to get that is to wait.
+
+ const timeout = options?.timeout ?? 50_000;
+ let lastCp: ActiveCheckpoint | null = null;
+
+ logger.info('Expected Checkpoint: ' + gtid.comparable);
+ while (Date.now() - start < timeout) {
+ const cp = await bucketStorage.getActiveCheckpoint();
+ lastCp = cp;
+ //logger.info('Last Checkpoint: ' + lastCp.lsn);
+ if (!cp.hasSyncRules()) {
+ throw new Error('No sync rules available');
+ }
+ if (cp.lsn && cp.lsn >= gtid.comparable) {
+ return cp.checkpoint;
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ }
+
+ throw new Error(`Timeout while waiting for checkpoint ${gtid.comparable}. Last checkpoint: ${lastCp?.lsn}`);
+}
diff --git a/modules/module-mysql/test/src/env.ts b/modules/module-mysql/test/src/env.ts
new file mode 100644
index 000000000..05fc76c42
--- /dev/null
+++ b/modules/module-mysql/test/src/env.ts
@@ -0,0 +1,7 @@
+import { utils } from '@powersync/lib-services-framework';
+
+export const env = utils.collectEnvironmentVariables({
+ MYSQL_TEST_URI: utils.type.string.default('mysql://root:mypassword@localhost:3306/mydatabase'),
+ CI: utils.type.boolean.default('false'),
+ SLOW_TESTS: utils.type.boolean.default('false')
+});
diff --git a/modules/module-mysql/test/src/mysql-to-sqlite.test.ts b/modules/module-mysql/test/src/mysql-to-sqlite.test.ts
new file mode 100644
index 000000000..9cebdccd2
--- /dev/null
+++ b/modules/module-mysql/test/src/mysql-to-sqlite.test.ts
@@ -0,0 +1,322 @@
+import { SqliteRow } from '@powersync/service-sync-rules';
+import { afterAll, describe, expect, test } from 'vitest';
+import { clearTestDb, TEST_CONNECTION_OPTIONS } from './util.js';
+import { eventIsWriteMutation, eventIsXid } from '@module/replication/zongji/zongji-utils.js';
+import * as common from '@module/common/common-index.js';
+import ZongJi, { BinLogEvent } from '@powersync/mysql-zongji';
+import { MySQLConnectionManager } from '@module/replication/MySQLConnectionManager.js';
+import { toColumnDescriptors } from '@module/common/common-index.js';
+
+describe('MySQL Data Types', () => {
+ const connectionManager = new MySQLConnectionManager(TEST_CONNECTION_OPTIONS, {});
+
+ afterAll(async () => {
+ await connectionManager.end();
+ });
+
+ async function setupTable() {
+ const connection = await connectionManager.getConnection();
+ await clearTestDb(connection);
+ await connection.query(`CREATE TABLE test_data (
+ tinyint_col TINYINT,
+ smallint_col SMALLINT,
+ mediumint_col MEDIUMINT,
+ int_col INT,
+ integer_col INTEGER,
+ bigint_col BIGINT,
+ float_col FLOAT,
+ double_col DOUBLE,
+ decimal_col DECIMAL(10,2),
+ numeric_col NUMERIC(10,2),
+ bit_col BIT(8),
+ boolean_col BOOLEAN,
+ serial_col SERIAL,
+
+ date_col DATE,
+ datetime_col DATETIME(3),
+ timestamp_col TIMESTAMP(3),
+ time_col TIME,
+ year_col YEAR,
+
+ char_col CHAR(10),
+ varchar_col VARCHAR(255),
+ binary_col BINARY(16),
+ varbinary_col VARBINARY(256),
+ tinyblob_col TINYBLOB,
+ blob_col BLOB,
+ mediumblob_col MEDIUMBLOB,
+ longblob_col LONGBLOB,
+ tinytext_col TINYTEXT,
+ text_col TEXT,
+ mediumtext_col MEDIUMTEXT,
+ longtext_col LONGTEXT,
+ enum_col ENUM('value1', 'value2', 'value3'),
+ set_col SET('value1', 'value2', 'value3'),
+
+ json_col JSON,
+
+ geometry_col GEOMETRY,
+ point_col POINT,
+ linestring_col LINESTRING,
+ polygon_col POLYGON,
+ multipoint_col MULTIPOINT,
+ multilinestring_col MULTILINESTRING,
+ multipolygon_col MULTIPOLYGON,
+ geometrycollection_col GEOMETRYCOLLECTION
+ )`);
+
+ connection.release();
+ }
+
+ test('Number types mappings', async () => {
+ await setupTable();
+ await connectionManager.query(`
+INSERT INTO test_data (
+ tinyint_col,
+ smallint_col,
+ mediumint_col,
+ int_col,
+ integer_col,
+ bigint_col,
+ double_col,
+ decimal_col,
+ numeric_col,
+ bit_col,
+ boolean_col
+ -- serial_col is auto-incremented and can be left out
+) VALUES (
+ 127, -- TINYINT maximum value
+ 32767, -- SMALLINT maximum value
+ 8388607, -- MEDIUMINT maximum value
+ 2147483647, -- INT maximum value
+ 2147483647, -- INTEGER maximum value
+ 9223372036854775807, -- BIGINT maximum value
+ 3.1415926535, -- DOUBLE example
+ 12345.67, -- DECIMAL(10,2) example
+ 12345.67, -- NUMERIC(10,2) example
+ b'10101010', -- BIT(8) example in binary notation
+ TRUE -- BOOLEAN value (alias for TINYINT(1))
+ -- serial_col is auto-incremented
+)`);
+
+ const databaseRows = await getDatabaseRows(connectionManager, 'test_data');
+ const replicatedRows = await getReplicatedRows();
+
+ const expectedResult = {
+ tinyint_col: 127n,
+ smallint_col: 32767n,
+ mediumint_col: 8388607n,
+ int_col: 2147483647n,
+ integer_col: 2147483647n,
+ bigint_col: 9223372036854775807n,
+ double_col: 3.1415926535,
+ decimal_col: 12345.67,
+ numeric_col: 12345.67,
+ bit_col: new Uint8Array([0b10101010]).valueOf(),
+ boolean_col: 1n,
+ serial_col: 1n
+ };
+ expect(databaseRows[0]).toMatchObject(expectedResult);
+ expect(replicatedRows[0]).toMatchObject(expectedResult);
+ });
+
+ test('Float type mapping', async () => {
+ await setupTable();
+ const expectedFloatValue = 3.14;
+ await connectionManager.query(`INSERT INTO test_data (float_col) VALUES (${expectedFloatValue})`);
+
+ const databaseRows = await getDatabaseRows(connectionManager, 'test_data');
+ const replicatedRows = await getReplicatedRows();
+
+ const allowedPrecision = 0.0001;
+
+ const actualFloatValueDB = databaseRows[0].float_col;
+ let difference = Math.abs((actualFloatValueDB as number) - expectedFloatValue);
+ expect(difference).toBeLessThan(allowedPrecision);
+
+ const actualFloatValueReplicated = replicatedRows[0].float_col;
+ difference = Math.abs((actualFloatValueReplicated as number) - expectedFloatValue);
+ expect(difference).toBeLessThan(allowedPrecision);
+ });
+
+ test('Character types mappings', async () => {
+ await setupTable();
+ await connectionManager.query(`
+INSERT INTO test_data (
+ char_col,
+ varchar_col,
+ binary_col,
+ varbinary_col,
+ tinyblob_col,
+ blob_col,
+ mediumblob_col,
+ longblob_col,
+ tinytext_col,
+ text_col,
+ mediumtext_col,
+ longtext_col,
+ enum_col
+) VALUES (
+ 'CharData', -- CHAR(10) with padding spaces
+ 'Variable character data',-- VARCHAR(255)
+ 'ShortBin', -- BINARY(16)
+ 'VariableBinaryData', -- VARBINARY(256)
+ 'TinyBlobData', -- TINYBLOB
+ 'BlobData', -- BLOB
+ 'MediumBlobData', -- MEDIUMBLOB
+ 'LongBlobData', -- LONGBLOB
+ 'TinyTextData', -- TINYTEXT
+ 'TextData', -- TEXT
+ 'MediumTextData', -- MEDIUMTEXT
+ 'LongTextData', -- LONGTEXT
+ 'value1' -- ENUM('value1', 'value2', 'value3')
+);`);
+
+ const databaseRows = await getDatabaseRows(connectionManager, 'test_data');
+ const replicatedRows = await getReplicatedRows();
+ const expectedResult = {
+ char_col: 'CharData',
+ varchar_col: 'Variable character data',
+ binary_col: new Uint8Array([83, 104, 111, 114, 116, 66, 105, 110, 0, 0, 0, 0, 0, 0, 0, 0]), // Pad with 0
+ varbinary_col: new Uint8Array([
+ 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x42, 0x69, 0x6e, 0x61, 0x72, 0x79, 0x44, 0x61, 0x74, 0x61
+ ]),
+ tinyblob_col: new Uint8Array([0x54, 0x69, 0x6e, 0x79, 0x42, 0x6c, 0x6f, 0x62, 0x44, 0x61, 0x74, 0x61]),
+ blob_col: new Uint8Array([0x42, 0x6c, 0x6f, 0x62, 0x44, 0x61, 0x74, 0x61]),
+ mediumblob_col: new Uint8Array([
+ 0x4d, 0x65, 0x64, 0x69, 0x75, 0x6d, 0x42, 0x6c, 0x6f, 0x62, 0x44, 0x61, 0x74, 0x61
+ ]),
+ longblob_col: new Uint8Array([0x4c, 0x6f, 0x6e, 0x67, 0x42, 0x6c, 0x6f, 0x62, 0x44, 0x61, 0x74, 0x61]),
+ tinytext_col: 'TinyTextData',
+ text_col: 'TextData',
+ mediumtext_col: 'MediumTextData',
+ longtext_col: 'LongTextData',
+ enum_col: 'value1'
+ };
+
+ expect(databaseRows[0]).toMatchObject(expectedResult);
+ expect(replicatedRows[0]).toMatchObject(expectedResult);
+ });
+
+ test('Date types mappings', async () => {
+ await setupTable();
+ await connectionManager.query(`
+ INSERT INTO test_data(date_col, datetime_col, timestamp_col, time_col, year_col)
+ VALUES('2023-03-06', '2023-03-06 15:47', '2023-03-06 15:47', '15:47:00', '2023');
+ `);
+
+ const databaseRows = await getDatabaseRows(connectionManager, 'test_data');
+ const replicatedRows = await getReplicatedRows();
+ const expectedResult = {
+ date_col: '2023-03-06',
+ datetime_col: '2023-03-06T15:47:00.000Z',
+ timestamp_col: '2023-03-06T15:47:00.000Z',
+ time_col: '15:47:00',
+ year_col: 2023
+ };
+
+ expect(databaseRows[0]).toMatchObject(expectedResult);
+ expect(replicatedRows[0]).toMatchObject(expectedResult);
+ });
+
+ test('Date types edge cases mappings', async () => {
+ await setupTable();
+
+ await connectionManager.query(`INSERT INTO test_data(timestamp_col) VALUES('1970-01-01 00:00:01')`);
+ await connectionManager.query(`INSERT INTO test_data(timestamp_col) VALUES('2038-01-19 03:14:07.499')`);
+ await connectionManager.query(`INSERT INTO test_data(datetime_col) VALUES('1000-01-01 00:00:00')`);
+ await connectionManager.query(`INSERT INTO test_data(datetime_col) VALUES('9999-12-31 23:59:59.499')`);
+
+ const databaseRows = await getDatabaseRows(connectionManager, 'test_data');
+ const replicatedRows = await getReplicatedRows(4);
+ const expectedResults = [
+ { timestamp_col: '1970-01-01T00:00:01.000Z' },
+ { timestamp_col: '2038-01-19T03:14:07.499Z' },
+ { datetime_col: '1000-01-01T00:00:00.000Z' },
+ { datetime_col: '9999-12-31T23:59:59.499Z' }
+ ];
+
+ for (let i = 0; i < expectedResults.length; i++) {
+ expect(databaseRows[i]).toMatchObject(expectedResults[i]);
+ expect(replicatedRows[i]).toMatchObject(expectedResults[i]);
+ }
+ });
+
+ test('Json types mappings', async () => {
+ await setupTable();
+
+ const expectedJSON = { name: 'John Doe', age: 30, married: true };
+ const expectedSet = ['value1', 'value3'];
+
+ // For convenience, we map the SET data type to a JSON Array
+ await connectionManager.query(
+ `INSERT INTO test_data (json_col, set_col) VALUES ('${JSON.stringify(expectedJSON)}', '${expectedSet.join(',')}')`
+ );
+
+ const databaseRows = await getDatabaseRows(connectionManager, 'test_data');
+ const replicatedRows = await getReplicatedRows();
+
+ const actualDBJSONValue = JSON.parse(databaseRows[0].json_col as string);
+ const actualReplicatedJSONValue = JSON.parse(replicatedRows[0].json_col as string);
+ expect(actualDBJSONValue).toEqual(expectedJSON);
+ expect(actualReplicatedJSONValue).toEqual(expectedJSON);
+
+ const actualDBSetValue = JSON.parse(databaseRows[0].set_col as string);
+ const actualReplicatedSetValue = JSON.parse(replicatedRows[0].set_col as string);
+ expect(actualDBSetValue).toEqual(expectedSet);
+ expect(actualReplicatedSetValue).toEqual(expectedSet);
+ });
+});
+
+async function getDatabaseRows(connection: MySQLConnectionManager, tableName: string): Promise {
+ const [results, fields] = await connection.query(`SELECT * FROM ${tableName}`);
+ const columns = toColumnDescriptors(fields);
+ return results.map((row) => common.toSQLiteRow(row, columns));
+}
+
+/**
+ * Return all the inserts from the first transaction in the binlog stream.
+ */
+async function getReplicatedRows(expectedTransactionsCount?: number): Promise {
+ let transformed: SqliteRow[] = [];
+ const zongji = new ZongJi({
+ host: TEST_CONNECTION_OPTIONS.hostname,
+ user: TEST_CONNECTION_OPTIONS.username,
+ password: TEST_CONNECTION_OPTIONS.password,
+ timeZone: 'Z' // Ensure no auto timezone manipulation of the dates occur
+ });
+
+ const completionPromise = new Promise((resolve, reject) => {
+ zongji.on('binlog', (evt: BinLogEvent) => {
+ try {
+ if (eventIsWriteMutation(evt)) {
+ const tableMapEntry = evt.tableMap[evt.tableId];
+ const columns = toColumnDescriptors(tableMapEntry);
+ const records = evt.rows.map((row: Record) => common.toSQLiteRow(row, columns));
+ transformed.push(...records);
+ } else if (eventIsXid(evt)) {
+ if (expectedTransactionsCount !== undefined) {
+ expectedTransactionsCount--;
+ if (expectedTransactionsCount == 0) {
+ zongji.stop();
+ resolve(transformed);
+ }
+ } else {
+ zongji.stop();
+ resolve(transformed);
+ }
+ }
+ } catch (e) {
+ reject(e);
+ }
+ });
+ });
+
+ zongji.start({
+ includeEvents: ['tablemap', 'writerows', 'xid'],
+ filename: 'mysql-bin.000001',
+ position: 0
+ });
+
+ return completionPromise;
+}
diff --git a/modules/module-mysql/test/src/mysql-utils.test.ts b/modules/module-mysql/test/src/mysql-utils.test.ts
new file mode 100644
index 000000000..039756267
--- /dev/null
+++ b/modules/module-mysql/test/src/mysql-utils.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, test } from 'vitest';
+import { isVersionAtLeast } from '@module/utils/mysql-utils.js';
+
+describe('MySQL Utility Tests', () => {
+ test('Minimum version checking ', () => {
+ const newerVersion = '8.4.0';
+ const olderVersion = '5.7';
+ const sameVersion = '8.0';
+ // Improperly formatted semantic versions should be handled gracefully if possible
+ const improperSemver = '5.7.42-0ubuntu0.18.04.1-log';
+
+ expect(isVersionAtLeast(newerVersion, '8.0')).toBeTruthy();
+ expect(isVersionAtLeast(sameVersion, '8.0')).toBeTruthy();
+ expect(isVersionAtLeast(olderVersion, '8.0')).toBeFalsy();
+ expect(isVersionAtLeast(improperSemver, '5.7')).toBeTruthy();
+ });
+});
diff --git a/modules/module-mysql/test/src/setup.ts b/modules/module-mysql/test/src/setup.ts
new file mode 100644
index 000000000..b924cf736
--- /dev/null
+++ b/modules/module-mysql/test/src/setup.ts
@@ -0,0 +1,7 @@
+import { container } from '@powersync/lib-services-framework';
+import { beforeAll } from 'vitest';
+
+beforeAll(() => {
+ // Executes for every test file
+ container.registerDefaults();
+});
diff --git a/modules/module-mysql/test/src/util.ts b/modules/module-mysql/test/src/util.ts
new file mode 100644
index 000000000..f87f13e82
--- /dev/null
+++ b/modules/module-mysql/test/src/util.ts
@@ -0,0 +1,56 @@
+import * as types from '@module/types/types.js';
+import { BucketStorageFactory, Metrics, MongoBucketStorage } from '@powersync/service-core';
+import { env } from './env.js';
+import mysqlPromise from 'mysql2/promise';
+import { connectMongo } from '@core-tests/util.js';
+import { getMySQLVersion, isVersionAtLeast } from '@module/utils/mysql-utils.js';
+
+export const TEST_URI = env.MYSQL_TEST_URI;
+
+export const TEST_CONNECTION_OPTIONS = types.normalizeConnectionConfig({
+ type: 'mysql',
+ uri: TEST_URI
+});
+
+// The metrics need to be initialized before they can be used
+await Metrics.initialise({
+ disable_telemetry_sharing: true,
+ powersync_instance_id: 'test',
+ internal_metrics_endpoint: 'unused.for.tests.com'
+});
+Metrics.getInstance().resetCounters();
+
+export type StorageFactory = () => Promise;
+
+export const INITIALIZED_MONGO_STORAGE_FACTORY: StorageFactory = async () => {
+ const db = await connectMongo();
+
+ // None of the tests insert data into this collection, so it was never created
+ if (!(await db.db.listCollections({ name: db.bucket_parameters.collectionName }).hasNext())) {
+ await db.db.createCollection('bucket_parameters');
+ }
+
+ await db.clear();
+
+ return new MongoBucketStorage(db, { slot_name_prefix: 'test_' });
+};
+
+export async function clearTestDb(connection: mysqlPromise.Connection) {
+ const version = await getMySQLVersion(connection);
+ if (isVersionAtLeast(version, '8.4.0')) {
+ await connection.query('RESET BINARY LOGS AND GTIDS');
+ } else {
+ await connection.query('RESET MASTER');
+ }
+
+ const [result] = await connection.query(
+ `SELECT TABLE_NAME FROM information_schema.tables
+ WHERE TABLE_SCHEMA = '${TEST_CONNECTION_OPTIONS.database}'`
+ );
+ for (let row of result) {
+ const name = row.TABLE_NAME;
+ if (name.startsWith('test_')) {
+ await connection.query(`DROP TABLE ${name}`);
+ }
+ }
+}
diff --git a/modules/module-mysql/test/tsconfig.json b/modules/module-mysql/test/tsconfig.json
new file mode 100644
index 000000000..5257b2739
--- /dev/null
+++ b/modules/module-mysql/test/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "baseUrl": "./",
+ "noEmit": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "paths": {
+ "@/*": ["../../../packages/service-core/src/*"],
+ "@module/*": ["../src/*"],
+ "@core-tests/*": ["../../../packages/service-core/test/src/*"]
+ }
+ },
+ "include": ["src", "../src/replication/zongji/zongji.d.ts"],
+ "references": [
+ {
+ "path": "../"
+ },
+ {
+ "path": "../../../packages/service-core/test"
+ },
+ {
+ "path": "../../../packages/service-core/"
+ }
+ ]
+}
diff --git a/modules/module-mysql/tsconfig.json b/modules/module-mysql/tsconfig.json
new file mode 100644
index 000000000..a9d72169d
--- /dev/null
+++ b/modules/module-mysql/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "typeRoots": ["./node_modules/@types", "./src/replication/zongji.d.ts"]
+ },
+ "include": ["src"],
+ "references": [
+ {
+ "path": "../../packages/types"
+ },
+ {
+ "path": "../../packages/sync-rules"
+ },
+ {
+ "path": "../../packages/service-core"
+ },
+ {
+ "path": "../../libs/lib-services"
+ }
+ ]
+}
diff --git a/modules/module-mysql/vitest.config.ts b/modules/module-mysql/vitest.config.ts
new file mode 100644
index 000000000..7a39c1f71
--- /dev/null
+++ b/modules/module-mysql/vitest.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from 'vitest/config';
+import tsconfigPaths from 'vite-tsconfig-paths';
+
+export default defineConfig({
+ plugins: [tsconfigPaths()],
+ test: {
+ setupFiles: './test/src/setup.ts',
+ poolOptions: {
+ threads: {
+ singleThread: true
+ }
+ },
+ pool: 'threads'
+ }
+});
diff --git a/modules/module-postgres/CHANGELOG.md b/modules/module-postgres/CHANGELOG.md
new file mode 100644
index 000000000..01e900aa0
--- /dev/null
+++ b/modules/module-postgres/CHANGELOG.md
@@ -0,0 +1 @@
+# @powersync/service-module-postgres
diff --git a/modules/module-postgres/LICENSE b/modules/module-postgres/LICENSE
new file mode 100644
index 000000000..c8efd46cc
--- /dev/null
+++ b/modules/module-postgres/LICENSE
@@ -0,0 +1,67 @@
+# Functional Source License, Version 1.1, Apache 2.0 Future License
+
+## Abbreviation
+
+FSL-1.1-Apache-2.0
+
+## Notice
+
+Copyright 2023-2024 Journey Mobile, Inc.
+
+## Terms and Conditions
+
+### Licensor ("We")
+
+The party offering the Software under these Terms and Conditions.
+
+### The Software
+
+The "Software" is each version of the software that we make available under these Terms and Conditions, as indicated by our inclusion of these Terms and Conditions with the Software.
+
+### License Grant
+
+Subject to your compliance with this License Grant and the Patents, Redistribution and Trademark clauses below, we hereby grant you the right to use, copy, modify, create derivative works, publicly perform, publicly display and redistribute the Software for any Permitted Purpose identified below.
+
+### Permitted Purpose
+
+A Permitted Purpose is any purpose other than a Competing Use. A Competing Use means making the Software available to others in a commercial product or service that:
+
+1. substitutes for the Software;
+2. substitutes for any other product or service we offer using the Software that exists as of the date we make the Software available; or
+3. offers the same or substantially similar functionality as the Software.
+
+Permitted Purposes specifically include using the Software:
+
+1. for your internal use and access;
+2. for non-commercial education;
+3. for non-commercial research; and
+4. in connection with professional services that you provide to a licensee using the Software in accordance with these Terms and Conditions.
+
+### Patents
+
+To the extent your use for a Permitted Purpose would necessarily infringe our patents, the license grant above includes a license under our patents. If you make a claim against any party that the Software infringes or contributes to the infringement of any patent, then your patent license to the Software ends immediately.
+
+### Redistribution
+
+The Terms and Conditions apply to all copies, modifications and derivatives of the Software.
+If you redistribute any copies, modifications or derivatives of the Software, you must include a copy of or a link to these Terms and Conditions and not remove any copyright notices provided in or with the Software.
+
+### Disclaimer
+
+THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
+IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
+
+### Trademarks
+
+Except for displaying the License Details and identifying us as the origin of the Software, you have no right under these Terms and Conditions to use our trademarks, trade names, service marks or product names.
+
+## Grant of Future License
+
+We hereby irrevocably grant you an additional license to use the Software under the Apache License, Version 2.0 that is effective on the second anniversary of the date we make the Software available. On or after that date, you may use the Software under the Apache License, Version 2.0, in which case the following will apply:
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
diff --git a/modules/module-postgres/README.md b/modules/module-postgres/README.md
new file mode 100644
index 000000000..78377396a
--- /dev/null
+++ b/modules/module-postgres/README.md
@@ -0,0 +1,3 @@
+# PowerSync Service Module Postgres
+
+Postgres replication module for PowerSync
diff --git a/modules/module-postgres/package.json b/modules/module-postgres/package.json
new file mode 100644
index 000000000..b34b8dd80
--- /dev/null
+++ b/modules/module-postgres/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "@powersync/service-module-postgres",
+ "repository": "https://github.com/powersync-ja/powersync-service",
+ "types": "dist/index.d.ts",
+ "publishConfig": {
+ "access": "public"
+ },
+ "version": "0.0.1",
+ "main": "dist/index.js",
+ "license": "FSL-1.1-Apache-2.0",
+ "type": "module",
+ "scripts": {
+ "build": "tsc -b",
+ "build:tests": "tsc -b test/tsconfig.json",
+ "clean": "rm -rf ./dist && tsc -b --clean",
+ "test": "vitest"
+ },
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "require": "./dist/index.js",
+ "default": "./dist/index.js"
+ },
+ "./types": {
+ "import": "./dist/types/types.js",
+ "require": "./dist/types/types.js",
+ "default": "./dist/types/types.js"
+ }
+ },
+ "dependencies": {
+ "@powersync/lib-services-framework": "workspace:*",
+ "@powersync/service-core": "workspace:*",
+ "@powersync/service-jpgwire": "workspace:*",
+ "@powersync/service-jsonbig": "workspace:*",
+ "@powersync/service-sync-rules": "workspace:*",
+ "@powersync/service-types": "workspace:*",
+ "pgwire": "github:kagis/pgwire#f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87",
+ "jose": "^4.15.1",
+ "ts-codec": "^1.2.2",
+ "uuid": "^9.0.1",
+ "uri-js": "^4.4.1"
+ },
+ "devDependencies": {
+ "@types/uuid": "^9.0.4"
+ }
+}
diff --git a/modules/module-postgres/src/api/PostgresRouteAPIAdapter.ts b/modules/module-postgres/src/api/PostgresRouteAPIAdapter.ts
new file mode 100644
index 000000000..11cd1cdbd
--- /dev/null
+++ b/modules/module-postgres/src/api/PostgresRouteAPIAdapter.ts
@@ -0,0 +1,310 @@
+import { api, ParseSyncRulesOptions } from '@powersync/service-core';
+import * as pgwire from '@powersync/service-jpgwire';
+
+import * as sync_rules from '@powersync/service-sync-rules';
+import * as service_types from '@powersync/service-types';
+import * as replication_utils from '../replication/replication-utils.js';
+import * as types from '../types/types.js';
+import * as pg_utils from '../utils/pgwire_utils.js';
+import { getDebugTableInfo } from '../replication/replication-utils.js';
+import { PUBLICATION_NAME } from '../replication/WalStream.js';
+
+export class PostgresRouteAPIAdapter implements api.RouteAPI {
+ protected pool: pgwire.PgClient;
+
+ connectionTag: string;
+ // TODO this should probably be configurable one day
+ publicationName = PUBLICATION_NAME;
+
+ constructor(protected config: types.ResolvedConnectionConfig) {
+ this.pool = pgwire.connectPgWirePool(config, {
+ idleTimeout: 30_000
+ });
+ this.connectionTag = config.tag ?? sync_rules.DEFAULT_TAG;
+ }
+
+ getParseSyncRulesOptions(): ParseSyncRulesOptions {
+ return {
+ defaultSchema: 'public'
+ };
+ }
+
+ async shutdown(): Promise {
+ await this.pool.end();
+ }
+
+ async getSourceConfig(): Promise {
+ return this.config;
+ }
+
+ async getConnectionStatus(): Promise {
+ const base = {
+ id: this.config.id,
+ uri: types.baseUri(this.config)
+ };
+
+ try {
+ await pg_utils.retriedQuery(this.pool, `SELECT 'PowerSync connection test'`);
+ } catch (e) {
+ return {
+ ...base,
+ connected: false,
+ errors: [{ level: 'fatal', message: e.message }]
+ };
+ }
+
+ try {
+ await replication_utils.checkSourceConfiguration(this.pool, this.publicationName);
+ } catch (e) {
+ return {
+ ...base,
+ connected: true,
+ errors: [{ level: 'fatal', message: e.message }]
+ };
+ }
+
+ return {
+ ...base,
+ connected: true,
+ errors: []
+ };
+ }
+
+ async executeQuery(query: string, params: any[]): Promise {
+ if (!this.config.debug_api) {
+ return service_types.internal_routes.ExecuteSqlResponse.encode({
+ results: {
+ columns: [],
+ rows: []
+ },
+ success: false,
+ error: 'SQL querying is not enabled'
+ });
+ }
+
+ try {
+ const result = await this.pool.query({
+ statement: query,
+ params: params.map(pg_utils.autoParameter)
+ });
+
+ return service_types.internal_routes.ExecuteSqlResponse.encode({
+ success: true,
+ results: {
+ columns: result.columns.map((c) => c.name),
+ rows: result.rows.map((row) => {
+ return row.map((value) => {
+ const sqlValue = sync_rules.toSyncRulesValue(value);
+ if (typeof sqlValue == 'bigint') {
+ return Number(value);
+ } else if (sync_rules.isJsonValue(sqlValue)) {
+ return sqlValue;
+ } else {
+ return null;
+ }
+ });
+ })
+ }
+ });
+ } catch (e) {
+ return service_types.internal_routes.ExecuteSqlResponse.encode({
+ results: {
+ columns: [],
+ rows: []
+ },
+ success: false,
+ error: e.message
+ });
+ }
+ }
+
+ async getDebugTablesInfo(
+ tablePatterns: sync_rules.TablePattern[],
+ sqlSyncRules: sync_rules.SqlSyncRules
+ ): Promise {
+ let result: api.PatternResult[] = [];
+
+ for (let tablePattern of tablePatterns) {
+ const schema = tablePattern.schema;
+
+ let patternResult: api.PatternResult = {
+ schema: schema,
+ pattern: tablePattern.tablePattern,
+ wildcard: tablePattern.isWildcard
+ };
+ result.push(patternResult);
+
+ if (tablePattern.isWildcard) {
+ patternResult.tables = [];
+ const prefix = tablePattern.tablePrefix;
+ const results = await pg_utils.retriedQuery(this.pool, {
+ statement: `SELECT c.oid AS relid, c.relname AS table_name
+ FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = $1
+ AND c.relkind = 'r'
+ AND c.relname LIKE $2`,
+ params: [
+ { type: 'varchar', value: schema },
+ { type: 'varchar', value: tablePattern.tablePattern }
+ ]
+ });
+
+ for (let row of pgwire.pgwireRows(results)) {
+ const name = row.table_name as string;
+ const relationId = row.relid as number;
+ if (!name.startsWith(prefix)) {
+ continue;
+ }
+ const details = await this.getDebugTableInfo(tablePattern, name, relationId, sqlSyncRules);
+ patternResult.tables.push(details);
+ }
+ } else {
+ const results = await pg_utils.retriedQuery(this.pool, {
+ statement: `SELECT c.oid AS relid, c.relname AS table_name
+ FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = $1
+ AND c.relkind = 'r'
+ AND c.relname = $2`,
+ params: [
+ { type: 'varchar', value: schema },
+ { type: 'varchar', value: tablePattern.tablePattern }
+ ]
+ });
+ if (results.rows.length == 0) {
+ // Table not found
+ patternResult.table = await this.getDebugTableInfo(tablePattern, tablePattern.name, null, sqlSyncRules);
+ } else {
+ const row = pgwire.pgwireRows(results)[0];
+ const name = row.table_name as string;
+ const relationId = row.relid as number;
+ patternResult.table = await this.getDebugTableInfo(tablePattern, name, relationId, sqlSyncRules);
+ }
+ }
+ }
+ return result;
+ }
+
+ protected async getDebugTableInfo(
+ tablePattern: sync_rules.TablePattern,
+ name: string,
+ relationId: number | null,
+ syncRules: sync_rules.SqlSyncRules
+ ): Promise {
+ return getDebugTableInfo({
+ db: this.pool,
+ name: name,
+ publicationName: this.publicationName,
+ connectionTag: this.connectionTag,
+ tablePattern: tablePattern,
+ relationId: relationId,
+ syncRules: syncRules
+ });
+ }
+
+ async getReplicationLag(options: api.ReplicationLagOptions): Promise {
+ const { bucketStorage: slotName } = options;
+ const results = await pg_utils.retriedQuery(this.pool, {
+ statement: `SELECT
+ slot_name,
+ confirmed_flush_lsn,
+ pg_current_wal_lsn(),
+ (pg_current_wal_lsn() - confirmed_flush_lsn) AS lsn_distance
+FROM pg_replication_slots WHERE slot_name = $1 LIMIT 1;`,
+ params: [{ type: 'varchar', value: slotName }]
+ });
+ const [row] = pgwire.pgwireRows(results);
+ if (row) {
+ return Number(row.lsn_distance);
+ }
+
+ throw new Error(`Could not determine replication lag for slot ${slotName}`);
+ }
+
+ async getReplicationHead(): Promise {
+ const [{ lsn }] = pgwire.pgwireRows(
+ await pg_utils.retriedQuery(this.pool, `SELECT pg_logical_emit_message(false, 'powersync', 'ping') as lsn`)
+ );
+ return String(lsn);
+ }
+
+ async getConnectionSchema(): Promise {
+ // https://github.com/Borvik/vscode-postgres/blob/88ec5ed061a0c9bced6c5d4ec122d0759c3f3247/src/language/server.ts
+ const results = await pg_utils.retriedQuery(
+ this.pool,
+ `SELECT
+tbl.schemaname,
+tbl.tablename,
+tbl.quoted_name,
+json_agg(a ORDER BY attnum) as columns
+FROM
+(
+ SELECT
+ n.nspname as schemaname,
+ c.relname as tablename,
+ (quote_ident(n.nspname) || '.' || quote_ident(c.relname)) as quoted_name
+ FROM
+ pg_catalog.pg_class c
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
+ WHERE
+ c.relkind = 'r'
+ AND n.nspname not in ('information_schema', 'pg_catalog', 'pg_toast')
+ AND n.nspname not like 'pg_temp_%'
+ AND n.nspname not like 'pg_toast_temp_%'
+ AND c.relnatts > 0
+ AND has_schema_privilege(n.oid, 'USAGE') = true
+ AND has_table_privilege(quote_ident(n.nspname) || '.' || quote_ident(c.relname), 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER') = true
+) as tbl
+LEFT JOIN (
+ SELECT
+ attrelid,
+ attname,
+ format_type(atttypid, atttypmod) as data_type,
+ (SELECT typname FROM pg_catalog.pg_type WHERE oid = atttypid) as pg_type,
+ attnum,
+ attisdropped
+ FROM
+ pg_attribute
+) as a ON (
+ a.attrelid = tbl.quoted_name::regclass
+ AND a.attnum > 0
+ AND NOT a.attisdropped
+ AND has_column_privilege(tbl.quoted_name, a.attname, 'SELECT, INSERT, UPDATE, REFERENCES')
+)
+GROUP BY schemaname, tablename, quoted_name`
+ );
+ const rows = pgwire.pgwireRows(results);
+
+ let schemas: Record = {};
+
+ for (let row of rows) {
+ const schema = (schemas[row.schemaname] ??= {
+ name: row.schemaname,
+ tables: []
+ });
+ const table: service_types.TableSchema = {
+ name: row.tablename,
+ columns: [] as any[]
+ };
+ schema.tables.push(table);
+
+ const columnInfo = JSON.parse(row.columns);
+ for (let column of columnInfo) {
+ let pg_type = column.pg_type as string;
+ if (pg_type.startsWith('_')) {
+ pg_type = `${pg_type.substring(1)}[]`;
+ }
+ table.columns.push({
+ name: column.attname,
+ sqlite_type: sync_rules.expressionTypeFromPostgresType(pg_type).typeFlags,
+ type: column.data_type,
+ internal_type: column.data_type,
+ pg_type: pg_type
+ });
+ }
+ }
+
+ return Object.values(schemas);
+ }
+}
diff --git a/packages/service-core/src/auth/SupabaseKeyCollector.ts b/modules/module-postgres/src/auth/SupabaseKeyCollector.ts
similarity index 68%
rename from packages/service-core/src/auth/SupabaseKeyCollector.ts
rename to modules/module-postgres/src/auth/SupabaseKeyCollector.ts
index 559e0e7f9..c52a9abe2 100644
--- a/packages/service-core/src/auth/SupabaseKeyCollector.ts
+++ b/modules/module-postgres/src/auth/SupabaseKeyCollector.ts
@@ -1,10 +1,9 @@
-import * as jose from 'jose';
+import { auth } from '@powersync/service-core';
import * as pgwire from '@powersync/service-jpgwire';
-import { connectPgWirePool, pgwireRows } from '@powersync/service-jpgwire';
-import { KeyCollector } from './KeyCollector.js';
-import { KeyOptions, KeySpec } from './KeySpec.js';
-import { retriedQuery } from '../util/pgwire_utils.js';
-import { ResolvedConnection } from '../util/config/types.js';
+import * as jose from 'jose';
+
+import * as types from '../types/types.js';
+import * as pgwire_utils from '../utils/pgwire_utils.js';
/**
* Fetches key from the Supabase database.
@@ -12,16 +11,16 @@ import { ResolvedConnection } from '../util/config/types.js';
* Unfortunately, despite the JWTs containing a kid, we have no way to lookup that kid
* before receiving a valid token.
*/
-export class SupabaseKeyCollector implements KeyCollector {
+export class SupabaseKeyCollector implements auth.KeyCollector {
private pool: pgwire.PgClient;
- private keyOptions: KeyOptions = {
+ private keyOptions: auth.KeyOptions = {
requiresAudience: ['authenticated'],
maxLifetimeSeconds: 86400 * 7 + 1200 // 1 week + 20 minutes margin
};
- constructor(connection: ResolvedConnection) {
- this.pool = connectPgWirePool(connection, {
+ constructor(connectionConfig: types.ResolvedConnectionConfig) {
+ this.pool = pgwire.connectPgWirePool(connectionConfig, {
// To avoid overloading the source database with open connections,
// limit to a single connection, and close the connection shortly
// after using it.
@@ -30,11 +29,15 @@ export class SupabaseKeyCollector implements KeyCollector {
});
}
+ shutdown() {
+ return this.pool.end();
+ }
+
async getKeys() {
let row: { jwt_secret: string };
try {
- const rows = pgwireRows(
- await retriedQuery(this.pool, `SELECT current_setting('app.settings.jwt_secret') as jwt_secret`)
+ const rows = pgwire.pgwireRows(
+ await pgwire_utils.retriedQuery(this.pool, `SELECT current_setting('app.settings.jwt_secret') as jwt_secret`)
);
row = rows[0] as any;
} catch (e) {
@@ -57,7 +60,7 @@ export class SupabaseKeyCollector implements KeyCollector {
// While the secret is valid base64, the base64-encoded form is the secret value.
k: Buffer.from(secret, 'utf8').toString('base64url')
};
- const imported = await KeySpec.importKey(key, this.keyOptions);
+ const imported = await auth.KeySpec.importKey(key, this.keyOptions);
return {
keys: [imported],
errors: []
diff --git a/modules/module-postgres/src/index.ts b/modules/module-postgres/src/index.ts
new file mode 100644
index 000000000..3b0d87195
--- /dev/null
+++ b/modules/module-postgres/src/index.ts
@@ -0,0 +1 @@
+export * from './module/PostgresModule.js';
diff --git a/modules/module-postgres/src/module/PostgresModule.ts b/modules/module-postgres/src/module/PostgresModule.ts
new file mode 100644
index 000000000..5b61275e2
--- /dev/null
+++ b/modules/module-postgres/src/module/PostgresModule.ts
@@ -0,0 +1,132 @@
+import { api, auth, ConfigurationFileSyncRulesProvider, modules, replication, system } from '@powersync/service-core';
+import * as jpgwire from '@powersync/service-jpgwire';
+import { PostgresRouteAPIAdapter } from '../api/PostgresRouteAPIAdapter.js';
+import { SupabaseKeyCollector } from '../auth/SupabaseKeyCollector.js';
+import { ConnectionManagerFactory } from '../replication/ConnectionManagerFactory.js';
+import { PgManager } from '../replication/PgManager.js';
+import { PostgresErrorRateLimiter } from '../replication/PostgresErrorRateLimiter.js';
+import { checkSourceConfiguration, cleanUpReplicationSlot } from '../replication/replication-utils.js';
+import { WalStreamReplicator } from '../replication/WalStreamReplicator.js';
+import * as types from '../types/types.js';
+import { PostgresConnectionConfig } from '../types/types.js';
+import { PUBLICATION_NAME } from '../replication/WalStream.js';
+
+export class PostgresModule extends replication.ReplicationModule {
+ constructor() {
+ super({
+ name: 'Postgres',
+ type: types.POSTGRES_CONNECTION_TYPE,
+ configSchema: types.PostgresConnectionConfig
+ });
+ }
+
+ async initialize(context: system.ServiceContextContainer): Promise {
+ await super.initialize(context);
+
+ if (context.configuration.base_config.client_auth?.supabase) {
+ this.registerSupabaseAuth(context);
+ }
+
+ // Record replicated bytes using global jpgwire metrics.
+ if (context.metrics) {
+ jpgwire.setMetricsRecorder({
+ addBytesRead(bytes) {
+ context.metrics!.data_replicated_bytes.add(bytes);
+ }
+ });
+ }
+ }
+
+ protected createRouteAPIAdapter(): api.RouteAPI {
+ return new PostgresRouteAPIAdapter(this.resolveConfig(this.decodedConfig!));
+ }
+
+ protected createReplicator(context: system.ServiceContext): replication.AbstractReplicator {
+ const normalisedConfig = this.resolveConfig(this.decodedConfig!);
+ const syncRuleProvider = new ConfigurationFileSyncRulesProvider(context.configuration.sync_rules);
+ const connectionFactory = new ConnectionManagerFactory(normalisedConfig);
+
+ return new WalStreamReplicator({
+ id: this.getDefaultId(normalisedConfig.database),
+ syncRuleProvider: syncRuleProvider,
+ storageEngine: context.storageEngine,
+ connectionFactory: connectionFactory,
+ rateLimiter: new PostgresErrorRateLimiter()
+ });
+ }
+
+ /**
+ * Combines base config with normalized connection settings
+ */
+ private resolveConfig(config: types.PostgresConnectionConfig): types.ResolvedConnectionConfig {
+ return {
+ ...config,
+ ...types.normalizeConnectionConfig(config)
+ };
+ }
+
+ async teardown(options: modules.TearDownOptions): Promise {
+ const normalisedConfig = this.resolveConfig(this.decodedConfig!);
+ const connectionManager = new PgManager(normalisedConfig, {
+ idleTimeout: 30_000,
+ maxSize: 1
+ });
+
+ try {
+ if (options.syncRules) {
+ // TODO: In the future, once we have more replication types, we will need to check if these syncRules are for Postgres
+ for (let syncRules of options.syncRules) {
+ try {
+ await cleanUpReplicationSlot(syncRules.slot_name, connectionManager.pool);
+ } catch (e) {
+ // Not really much we can do here for failures, most likely the database is no longer accessible
+ this.logger.warn(`Failed to fully clean up Postgres replication slot: ${syncRules.slot_name}`, e);
+ }
+ }
+ }
+ } finally {
+ await connectionManager.end();
+ }
+ }
+
+ // TODO: This should rather be done by registering the key collector in some kind of auth engine
+ private registerSupabaseAuth(context: system.ServiceContextContainer) {
+ const { configuration } = context;
+ // Register the Supabase key collector(s)
+ configuration.connections
+ ?.map((baseConfig) => {
+ if (baseConfig.type != types.POSTGRES_CONNECTION_TYPE) {
+ return;
+ }
+ try {
+ return this.resolveConfig(types.PostgresConnectionConfig.decode(baseConfig as any));
+ } catch (ex) {
+ this.logger.warn('Failed to decode configuration.', ex);
+ }
+ })
+ .filter((c) => !!c)
+ .forEach((config) => {
+ const keyCollector = new SupabaseKeyCollector(config!);
+ context.lifeCycleEngine.withLifecycle(keyCollector, {
+ // Close the internal pool
+ stop: (collector) => collector.shutdown()
+ });
+ configuration.client_keystore.collector.add(new auth.CachedKeyCollector(keyCollector));
+ });
+ }
+
+ async testConnection(config: PostgresConnectionConfig): Promise {
+ this.decodeConfig(config);
+ const normalisedConfig = this.resolveConfig(this.decodedConfig!);
+ const connectionManager = new PgManager(normalisedConfig, {
+ idleTimeout: 30_000,
+ maxSize: 1
+ });
+ const connection = await connectionManager.snapshotConnection();
+ try {
+ return checkSourceConfiguration(connection, PUBLICATION_NAME);
+ } finally {
+ await connectionManager.end();
+ }
+ }
+}
diff --git a/modules/module-postgres/src/replication/ConnectionManagerFactory.ts b/modules/module-postgres/src/replication/ConnectionManagerFactory.ts
new file mode 100644
index 000000000..0c46b9f24
--- /dev/null
+++ b/modules/module-postgres/src/replication/ConnectionManagerFactory.ts
@@ -0,0 +1,28 @@
+import { PgManager } from './PgManager.js';
+import { NormalizedPostgresConnectionConfig } from '../types/types.js';
+import { PgPoolOptions } from '@powersync/service-jpgwire';
+import { logger } from '@powersync/lib-services-framework';
+
+export class ConnectionManagerFactory {
+ private readonly connectionManagers: PgManager[];
+ private readonly dbConnectionConfig: NormalizedPostgresConnectionConfig;
+
+ constructor(dbConnectionConfig: NormalizedPostgresConnectionConfig) {
+ this.dbConnectionConfig = dbConnectionConfig;
+ this.connectionManagers = [];
+ }
+
+ create(poolOptions: PgPoolOptions) {
+ const manager = new PgManager(this.dbConnectionConfig, poolOptions);
+ this.connectionManagers.push(manager);
+ return manager;
+ }
+
+ async shutdown() {
+ logger.info('Shutting down Postgres connection Managers...');
+ for (const manager of this.connectionManagers) {
+ await manager.end();
+ }
+ logger.info('Postgres connection Managers shutdown completed.');
+ }
+}
diff --git a/packages/service-core/src/util/PgManager.ts b/modules/module-postgres/src/replication/PgManager.ts
similarity index 72%
rename from packages/service-core/src/util/PgManager.ts
rename to modules/module-postgres/src/replication/PgManager.ts
index 3c499f5ac..f89e14496 100644
--- a/packages/service-core/src/util/PgManager.ts
+++ b/modules/module-postgres/src/replication/PgManager.ts
@@ -1,5 +1,5 @@
import * as pgwire from '@powersync/service-jpgwire';
-import { NormalizedPostgresConnection } from '@powersync/service-types';
+import { NormalizedPostgresConnectionConfig } from '../types/types.js';
export class PgManager {
/**
@@ -9,11 +9,18 @@ export class PgManager {
private connectionPromises: Promise[] = [];
- constructor(public options: NormalizedPostgresConnection, public poolOptions: pgwire.PgPoolOptions) {
+ constructor(
+ public options: NormalizedPostgresConnectionConfig,
+ public poolOptions: pgwire.PgPoolOptions
+ ) {
// The pool is lazy - no connections are opened until a query is performed.
this.pool = pgwire.connectPgWirePool(this.options, poolOptions);
}
+ public get connectionTag() {
+ return this.options.tag;
+ }
+
/**
* Create a new replication connection.
*/
@@ -34,11 +41,12 @@ export class PgManager {
return await p;
}
- async end() {
+ async end(): Promise {
for (let result of await Promise.allSettled([
this.pool.end(),
- ...this.connectionPromises.map((promise) => {
- return promise.then((connection) => connection.end());
+ ...this.connectionPromises.map(async (promise) => {
+ const connection = await promise;
+ return await connection.end();
})
])) {
// Throw the first error, if any
@@ -51,8 +59,9 @@ export class PgManager {
async destroy() {
this.pool.destroy();
for (let result of await Promise.allSettled([
- ...this.connectionPromises.map((promise) => {
- return promise.then((connection) => connection.destroy());
+ ...this.connectionPromises.map(async (promise) => {
+ const connection = await promise;
+ return connection.destroy();
})
])) {
// Throw the first error, if any
diff --git a/modules/module-postgres/src/replication/PgRelation.ts b/modules/module-postgres/src/replication/PgRelation.ts
new file mode 100644
index 000000000..f6d9ac900
--- /dev/null
+++ b/modules/module-postgres/src/replication/PgRelation.ts
@@ -0,0 +1,31 @@
+import { storage } from '@powersync/service-core';
+import { PgoutputRelation } from '@powersync/service-jpgwire';
+
+export type ReplicationIdentity = 'default' | 'nothing' | 'full' | 'index';
+
+export function getReplicaIdColumns(relation: PgoutputRelation): storage.ColumnDescriptor[] {
+ if (relation.replicaIdentity == 'nothing') {
+ return [];
+ } else {
+ return relation.columns
+ .filter((c) => (c.flags & 0b1) != 0)
+ .map((c) => ({ name: c.name, typeId: c.typeOid }) satisfies storage.ColumnDescriptor);
+ }
+}
+export function getRelId(source: PgoutputRelation): number {
+ // Source types are wrong here
+ const relId = (source as any).relationOid as number;
+ if (!relId) {
+ throw new Error(`No relation id!`);
+ }
+ return relId;
+}
+
+export function getPgOutputRelation(source: PgoutputRelation): storage.SourceEntityDescriptor {
+ return {
+ name: source.name,
+ schema: source.schema,
+ objectId: getRelId(source),
+ replicationColumns: getReplicaIdColumns(source)
+ } satisfies storage.SourceEntityDescriptor;
+}
diff --git a/modules/module-postgres/src/replication/PostgresErrorRateLimiter.ts b/modules/module-postgres/src/replication/PostgresErrorRateLimiter.ts
new file mode 100644
index 000000000..9a86a0704
--- /dev/null
+++ b/modules/module-postgres/src/replication/PostgresErrorRateLimiter.ts
@@ -0,0 +1,44 @@
+import { setTimeout } from 'timers/promises';
+import { ErrorRateLimiter } from '@powersync/service-core';
+
+export class PostgresErrorRateLimiter implements ErrorRateLimiter {
+ nextAllowed: number = Date.now();
+
+ async waitUntilAllowed(options?: { signal?: AbortSignal | undefined } | undefined): Promise {
+ const delay = Math.max(0, this.nextAllowed - Date.now());
+ // Minimum delay between connections, even without errors
+ this.setDelay(500);
+ await setTimeout(delay, undefined, { signal: options?.signal });
+ }
+
+ mayPing(): boolean {
+ return Date.now() >= this.nextAllowed;
+ }
+
+ reportError(e: any): void {
+ const message = (e.message as string) ?? '';
+ if (message.includes('password authentication failed')) {
+ // Wait 15 minutes, to avoid triggering Supabase's fail2ban
+ this.setDelay(900_000);
+ } else if (message.includes('ENOTFOUND')) {
+ // DNS lookup issue - incorrect URI or deleted instance
+ this.setDelay(120_000);
+ } else if (message.includes('ECONNREFUSED')) {
+ // Could be fail2ban or similar
+ this.setDelay(120_000);
+ } else if (
+ message.includes('Unable to do postgres query on ended pool') ||
+ message.includes('Postgres unexpectedly closed connection')
+ ) {
+ // Connection timed out - ignore / immediately retry
+ // We don't explicitly set the delay to 0, since there could have been another error that
+ // we need to respect.
+ } else {
+ this.setDelay(30_000);
+ }
+ }
+
+ private setDelay(delay: number) {
+ this.nextAllowed = Math.max(this.nextAllowed, Date.now() + delay);
+ }
+}
diff --git a/packages/service-core/src/replication/WalStream.ts b/modules/module-postgres/src/replication/WalStream.ts
similarity index 74%
rename from packages/service-core/src/replication/WalStream.ts
rename to modules/module-postgres/src/replication/WalStream.ts
index 46bb0ccc7..67436646d 100644
--- a/packages/service-core/src/replication/WalStream.ts
+++ b/modules/module-postgres/src/replication/WalStream.ts
@@ -1,20 +1,18 @@
-import * as pgwire from '@powersync/service-jpgwire';
import { container, errors, logger } from '@powersync/lib-services-framework';
-import { SqliteRow, SqlSyncRules, TablePattern, toSyncRulesRow } from '@powersync/service-sync-rules';
-
-import * as storage from '../storage/storage-index.js';
-import * as util from '../util/util-index.js';
-
-import { getPgOutputRelation, getRelId, PgRelation } from './PgRelation.js';
-import { getReplicationIdentityColumns } from './util.js';
-import { WalConnection } from './WalConnection.js';
-import { Metrics } from '../metrics/Metrics.js';
+import { getUuidReplicaIdentityBson, Metrics, SourceEntityDescriptor, storage } from '@powersync/service-core';
+import * as pgwire from '@powersync/service-jpgwire';
+import { DatabaseInputRow, SqliteRow, SqlSyncRules, TablePattern, toSyncRulesRow } from '@powersync/service-sync-rules';
+import * as pg_utils from '../utils/pgwire_utils.js';
+import { PgManager } from './PgManager.js';
+import { getPgOutputRelation, getRelId } from './PgRelation.js';
+import { checkSourceConfiguration, getReplicationIdentityColumns } from './replication-utils.js';
export const ZERO_LSN = '00000000/00000000';
+export const PUBLICATION_NAME = 'powersync';
+export const POSTGRES_DEFAULT_SCHEMA = 'public';
export interface WalStreamOptions {
- connections: util.PgManager;
- factory: storage.BucketStorageFactory;
+ connections: PgManager;
storage: storage.SyncRulesBucketStorage;
abort_signal: AbortSignal;
}
@@ -33,29 +31,27 @@ export class WalStream {
sync_rules: SqlSyncRules;
group_id: number;
- wal_connection: WalConnection;
connection_id = 1;
private readonly storage: storage.SyncRulesBucketStorage;
- private slot_name: string;
+ private readonly slot_name: string;
- private connections: util.PgManager;
+ private connections: PgManager;
private abort_signal: AbortSignal;
- private relation_cache = new Map();
+ private relation_cache = new Map();
private startedStreaming = false;
constructor(options: WalStreamOptions) {
this.storage = options.storage;
- this.sync_rules = options.storage.sync_rules;
+ this.sync_rules = options.storage.getParsedSyncRules({ defaultSchema: POSTGRES_DEFAULT_SCHEMA });
this.group_id = options.storage.group_id;
this.slot_name = options.storage.slot_name;
this.connections = options.connections;
- this.wal_connection = new WalConnection({ db: this.connections.pool, sync_rules: this.sync_rules });
this.abort_signal = options.abort_signal;
this.abort_signal.addEventListener(
'abort',
@@ -64,7 +60,7 @@ export class WalStream {
// Ping to speed up cancellation of streaming replication
// We're not using pg_snapshot here, since it could be in the middle of
// an initial replication transaction.
- const promise = util.retriedQuery(
+ const promise = pg_utils.retriedQuery(
this.connections.pool,
`SELECT * FROM pg_logical_emit_message(false, 'powersync', 'ping')`
);
@@ -81,14 +77,6 @@ export class WalStream {
);
}
- get publication_name() {
- return this.wal_connection.publication_name;
- }
-
- get connectionTag() {
- return this.wal_connection.connectionTag;
- }
-
get stopped() {
return this.abort_signal.aborted;
}
@@ -99,7 +87,7 @@ export class WalStream {
tablePattern: TablePattern
): Promise {
const schema = tablePattern.schema;
- if (tablePattern.connectionTag != this.connectionTag) {
+ if (tablePattern.connectionTag != this.connections.connectionTag) {
return [];
}
@@ -151,13 +139,13 @@ export class WalStream {
const rs = await db.query({
statement: `SELECT 1 FROM pg_publication_tables WHERE pubname = $1 AND schemaname = $2 AND tablename = $3`,
params: [
- { type: 'varchar', value: this.publication_name },
+ { type: 'varchar', value: PUBLICATION_NAME },
{ type: 'varchar', value: tablePattern.schema },
{ type: 'varchar', value: name }
]
});
if (rs.rows.length == 0) {
- logger.info(`Skipping ${tablePattern.schema}.${name} - not part of ${this.publication_name} publication`);
+ logger.info(`Skipping ${tablePattern.schema}.${name} - not part of ${PUBLICATION_NAME} publication`);
continue;
}
@@ -168,10 +156,9 @@ export class WalStream {
{
name,
schema,
- relationId: relid,
- replicaIdentity: cresult.replicationIdentity,
- replicationColumns: cresult.columns
- },
+ objectId: relid,
+ replicationColumns: cresult.replicationColumns
+ } as SourceEntityDescriptor,
false
);
@@ -181,7 +168,7 @@ export class WalStream {
}
async initSlot(): Promise {
- await this.wal_connection.checkSourceConfiguration();
+ await checkSourceConfiguration(this.connections.pool, PUBLICATION_NAME);
const slotName = this.slot_name;
@@ -217,7 +204,7 @@ export class WalStream {
statement: `SELECT 1 FROM pg_catalog.pg_logical_slot_peek_binary_changes($1, NULL, 1000, 'proto_version', '1', 'publication_names', $2)`,
params: [
{ type: 'varchar', value: slotName },
- { type: 'varchar', value: this.publication_name }
+ { type: 'varchar', value: PUBLICATION_NAME }
]
});
@@ -354,21 +341,23 @@ WHERE oid = $1::regclass`,
async initialReplication(db: pgwire.PgConnection, lsn: string) {
const sourceTables = this.sync_rules.getSourceTables();
- await this.storage.startBatch({}, async (batch) => {
- for (let tablePattern of sourceTables) {
- const tables = await this.getQualifiedTableNames(batch, db, tablePattern);
- for (let table of tables) {
- await this.snapshotTable(batch, db, table);
- await batch.markSnapshotDone([table], lsn);
-
- await touch();
+ await this.storage.startBatch(
+ { zeroLSN: ZERO_LSN, defaultSchema: POSTGRES_DEFAULT_SCHEMA, storeCurrentData: true },
+ async (batch) => {
+ for (let tablePattern of sourceTables) {
+ const tables = await this.getQualifiedTableNames(batch, db, tablePattern);
+ for (let table of tables) {
+ await this.snapshotTable(batch, db, table);
+ await batch.markSnapshotDone([table], lsn);
+ await touch();
+ }
}
+ await batch.commit(lsn);
}
- await batch.commit(lsn);
- });
+ );
}
- static *getQueryData(results: Iterable): Generator {
+ static *getQueryData(results: Iterable): Generator {
for (let row of results) {
yield toSyncRulesRow(row);
}
@@ -379,7 +368,7 @@ WHERE oid = $1::regclass`,
const estimatedCount = await this.estimatedCount(db, table);
let at = 0;
let lastLogIndex = 0;
- const cursor = await db.stream({ statement: `SELECT * FROM ${table.escapedIdentifier}` });
+ const cursor = db.stream({ statement: `SELECT * FROM ${table.escapedIdentifier}` });
let columns: { i: number; name: string }[] = [];
// pgwire streams rows in chunks.
// These chunks can be quite small (as little as 16KB), so we don't flush chunks automatically.
@@ -394,7 +383,7 @@ WHERE oid = $1::regclass`,
}
const rows = chunk.rows.map((row) => {
- let q: pgwire.DatabaseInputRow = {};
+ let q: DatabaseInputRow = {};
for (let c of columns) {
q[c.name] = row[c.i];
}
@@ -408,10 +397,18 @@ WHERE oid = $1::regclass`,
throw new Error(`Aborted initial replication of ${this.slot_name}`);
}
- for (let record of WalStream.getQueryData(rows)) {
+ for (const record of WalStream.getQueryData(rows)) {
// This auto-flushes when the batch reaches its size limit
- await batch.save({ tag: 'insert', sourceTable: table, before: undefined, after: record });
+ await batch.save({
+ tag: storage.SaveOperationTag.INSERT,
+ sourceTable: table,
+ before: undefined,
+ beforeReplicaId: undefined,
+ after: record,
+ afterReplicaId: getUuidReplicaIdentityBson(record, table.replicaIdColumns)
+ });
}
+
at += rows.length;
Metrics.getInstance().rows_replicated_total.add(rows.length);
@@ -421,18 +418,18 @@ WHERE oid = $1::regclass`,
await batch.flush();
}
- async handleRelation(batch: storage.BucketStorageBatch, relation: PgRelation, snapshot: boolean) {
- if (relation.relationId == null || typeof relation.relationId != 'number') {
- throw new Error('relationId expected');
+ async handleRelation(batch: storage.BucketStorageBatch, descriptor: SourceEntityDescriptor, snapshot: boolean) {
+ if (!descriptor.objectId && typeof descriptor.objectId != 'number') {
+ throw new Error('objectId expected');
}
const result = await this.storage.resolveTable({
group_id: this.group_id,
connection_id: this.connection_id,
- connection_tag: this.connectionTag,
- relation: relation,
+ connection_tag: this.connections.connectionTag,
+ entity_descriptor: descriptor,
sync_rules: this.sync_rules
});
- this.relation_cache.set(relation.relationId, result.table);
+ this.relation_cache.set(descriptor.objectId, result.table);
// Drop conflicting tables. This includes for example renamed tables.
await batch.drop(result.dropTables);
@@ -501,20 +498,41 @@ WHERE oid = $1::regclass`,
if (msg.tag == 'insert') {
Metrics.getInstance().rows_replicated_total.add(1);
- const baseRecord = util.constructAfterRecord(msg);
- return await batch.save({ tag: 'insert', sourceTable: table, before: undefined, after: baseRecord });
+ const baseRecord = pg_utils.constructAfterRecord(msg);
+ return await batch.save({
+ tag: storage.SaveOperationTag.INSERT,
+ sourceTable: table,
+ before: undefined,
+ beforeReplicaId: undefined,
+ after: baseRecord,
+ afterReplicaId: getUuidReplicaIdentityBson(baseRecord, table.replicaIdColumns)
+ });
} else if (msg.tag == 'update') {
Metrics.getInstance().rows_replicated_total.add(1);
// "before" may be null if the replica id columns are unchanged
// It's fine to treat that the same as an insert.
- const before = util.constructBeforeRecord(msg);
- const after = util.constructAfterRecord(msg);
- return await batch.save({ tag: 'update', sourceTable: table, before: before, after: after });
+ const before = pg_utils.constructBeforeRecord(msg);
+ const after = pg_utils.constructAfterRecord(msg);
+ return await batch.save({
+ tag: storage.SaveOperationTag.UPDATE,
+ sourceTable: table,
+ before: before,
+ beforeReplicaId: before ? getUuidReplicaIdentityBson(before, table.replicaIdColumns) : undefined,
+ after: after,
+ afterReplicaId: getUuidReplicaIdentityBson(after, table.replicaIdColumns)
+ });
} else if (msg.tag == 'delete') {
Metrics.getInstance().rows_replicated_total.add(1);
- const before = util.constructBeforeRecord(msg)!;
-
- return await batch.save({ tag: 'delete', sourceTable: table, before: before, after: undefined });
+ const before = pg_utils.constructBeforeRecord(msg)!;
+
+ return await batch.save({
+ tag: storage.SaveOperationTag.DELETE,
+ sourceTable: table,
+ before: before,
+ beforeReplicaId: getUuidReplicaIdentityBson(before, table.replicaIdColumns),
+ after: undefined,
+ afterReplicaId: undefined
+ });
}
} else if (msg.tag == 'truncate') {
let tables: storage.SourceTable[] = [];
@@ -554,7 +572,7 @@ WHERE oid = $1::regclass`,
slot: this.slot_name,
options: {
proto_version: '1',
- publication_names: this.publication_name
+ publication_names: PUBLICATION_NAME
}
});
this.startedStreaming = true;
@@ -562,56 +580,58 @@ WHERE oid = $1::regclass`,
// Auto-activate as soon as initial replication is done
await this.storage.autoActivate();
- await this.storage.startBatch({}, async (batch) => {
- // Replication never starts in the middle of a transaction
- let inTx = false;
- let count = 0;
+ await this.storage.startBatch(
+ { zeroLSN: ZERO_LSN, defaultSchema: POSTGRES_DEFAULT_SCHEMA, storeCurrentData: true },
+ async (batch) => {
+ // Replication never starts in the middle of a transaction
+ let inTx = false;
+ let count = 0;
- for await (const chunk of replicationStream.pgoutputDecode()) {
- await touch();
+ for await (const chunk of replicationStream.pgoutputDecode()) {
+ await touch();
- if (this.abort_signal.aborted) {
- break;
- }
+ if (this.abort_signal.aborted) {
+ break;
+ }
- // chunkLastLsn may come from normal messages in the chunk,
- // or from a PrimaryKeepalive message.
- const { messages, lastLsn: chunkLastLsn } = chunk;
-
- for (const msg of messages) {
- if (msg.tag == 'relation') {
- await this.handleRelation(batch, getPgOutputRelation(msg), true);
- } else if (msg.tag == 'begin') {
- inTx = true;
- } else if (msg.tag == 'commit') {
- Metrics.getInstance().transactions_replicated_total.add(1);
- inTx = false;
- await batch.commit(msg.lsn!);
- await this.ack(msg.lsn!, replicationStream);
- } else {
- if (count % 100 == 0) {
- logger.info(`${this.slot_name} replicating op ${count} ${msg.lsn}`);
- }
+ // chunkLastLsn may come from normal messages in the chunk,
+ // or from a PrimaryKeepalive message.
+ const { messages, lastLsn: chunkLastLsn } = chunk;
+ for (const msg of messages) {
+ if (msg.tag == 'relation') {
+ await this.handleRelation(batch, getPgOutputRelation(msg), true);
+ } else if (msg.tag == 'begin') {
+ inTx = true;
+ } else if (msg.tag == 'commit') {
+ Metrics.getInstance().transactions_replicated_total.add(1);
+ inTx = false;
+ await batch.commit(msg.lsn!);
+ await this.ack(msg.lsn!, replicationStream);
+ } else {
+ if (count % 100 == 0) {
+ logger.info(`${this.slot_name} replicating op ${count} ${msg.lsn}`);
+ }
- count += 1;
- const result = await this.writeChange(batch, msg);
+ count += 1;
+ await this.writeChange(batch, msg);
+ }
}
- }
- if (!inTx) {
- // In a transaction, we ack and commit according to the transaction progress.
- // Outside transactions, we use the PrimaryKeepalive messages to advance progress.
- // Big caveat: This _must not_ be used to skip individual messages, since this LSN
- // may be in the middle of the next transaction.
- // It must only be used to associate checkpoints with LSNs.
- if (await batch.keepalive(chunkLastLsn)) {
- await this.ack(chunkLastLsn, replicationStream);
+ if (!inTx) {
+ // In a transaction, we ack and commit according to the transaction progress.
+ // Outside transactions, we use the PrimaryKeepalive messages to advance progress.
+ // Big caveat: This _must not_ be used to skip individual messages, since this LSN
+ // may be in the middle of the next transaction.
+ // It must only be used to associate checkpoints with LSNs.
+ if (await batch.keepalive(chunkLastLsn)) {
+ await this.ack(chunkLastLsn, replicationStream);
+ }
}
- }
- Metrics.getInstance().chunks_replicated_total.add(1);
+ Metrics.getInstance().chunks_replicated_total.add(1);
+ }
}
- });
+ );
}
async ack(lsn: string, replicationStream: pgwire.ReplicationStream) {
diff --git a/packages/service-core/src/replication/WalStreamRunner.ts b/modules/module-postgres/src/replication/WalStreamReplicationJob.ts
similarity index 58%
rename from packages/service-core/src/replication/WalStreamRunner.ts
rename to modules/module-postgres/src/replication/WalStreamReplicationJob.ts
index ce3ff8759..40247452a 100644
--- a/packages/service-core/src/replication/WalStreamRunner.ts
+++ b/modules/module-postgres/src/replication/WalStreamReplicationJob.ts
@@ -1,73 +1,78 @@
-import * as pgwire from '@powersync/service-jpgwire';
-
-import * as storage from '../storage/storage-index.js';
-import * as util from '../util/util-index.js';
-
-import { ErrorRateLimiter } from './ErrorRateLimiter.js';
+import { container } from '@powersync/lib-services-framework';
+import { PgManager } from './PgManager.js';
import { MissingReplicationSlotError, WalStream } from './WalStream.js';
-import { ResolvedConnection } from '../util/config/types.js';
-import { container, logger } from '@powersync/lib-services-framework';
-
-export interface WalStreamRunnerOptions {
- factory: storage.BucketStorageFactory;
- storage: storage.SyncRulesBucketStorage;
- source_db: ResolvedConnection;
- lock: storage.ReplicationLock;
- rateLimiter?: ErrorRateLimiter;
-}
-
-export class WalStreamRunner {
- private abortController = new AbortController();
- private runPromise?: Promise;
+import { replication } from '@powersync/service-core';
+import { ConnectionManagerFactory } from './ConnectionManagerFactory.js';
- private connections: util.PgManager | null = null;
+export interface WalStreamReplicationJobOptions extends replication.AbstractReplicationJobOptions {
+ connectionFactory: ConnectionManagerFactory;
+}
- private rateLimiter?: ErrorRateLimiter;
+export class WalStreamReplicationJob extends replication.AbstractReplicationJob {
+ private connectionFactory: ConnectionManagerFactory;
+ private readonly connectionManager: PgManager;
- constructor(public options: WalStreamRunnerOptions) {
- this.rateLimiter = options.rateLimiter;
+ constructor(options: WalStreamReplicationJobOptions) {
+ super(options);
+ this.connectionFactory = options.connectionFactory;
+ this.connectionManager = this.connectionFactory.create({
+ // Pool connections are only used intermittently.
+ idleTimeout: 30_000,
+ maxSize: 2
+ });
}
- start() {
- this.runPromise = this.run();
+ /**
+ * Postgres on RDS writes performs a WAL checkpoint every 5 minutes by default, which creates a new 64MB file.
+ *
+ * The old WAL files are only deleted once no replication slot still references it.
+ *
+ * Unfortunately, when there are no changes to the db, the database creates new WAL files without the replication slot
+ * advancing**.
+ *
+ * As a workaround, we write a new message every couple of minutes, to make sure that the replication slot advances.
+ *
+ * **This may be a bug in pgwire or how we're using it.
+ */
+ async keepAlive() {
+ try {
+ await this.connectionManager.pool.query(`SELECT * FROM pg_logical_emit_message(false, 'powersync', 'ping')`);
+ } catch (e) {
+ this.logger.warn(`KeepAlive failed, unable to post to WAL`, e);
+ }
}
- get slot_name() {
+ get slotName() {
return this.options.storage.slot_name;
}
- get stopped() {
- return this.abortController.signal.aborted;
- }
-
- async run() {
+ async replicate() {
try {
await this.replicateLoop();
} catch (e) {
// Fatal exception
container.reporter.captureException(e, {
metadata: {
- replication_slot: this.slot_name
+ replication_slot: this.slotName
}
});
- logger.error(`Replication failed on ${this.slot_name}`, e);
+ this.logger.error(`Replication failed on ${this.slotName}`, e);
if (e instanceof MissingReplicationSlotError) {
// This stops replication on this slot, and creates a new slot
- await this.options.storage.factory.slotRemoved(this.slot_name);
+ await this.options.storage.factory.slotRemoved(this.slotName);
}
} finally {
this.abortController.abort();
}
- await this.options.lock.release();
}
async replicateLoop() {
- while (!this.stopped) {
+ while (!this.isStopped) {
await this.replicateOnce();
- if (!this.stopped) {
+ if (!this.isStopped) {
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
@@ -77,26 +82,24 @@ export class WalStreamRunner {
// New connections on every iteration (every error with retry),
// otherwise we risk repeating errors related to the connection,
// such as caused by cached PG schemas.
- let connections = new util.PgManager(this.options.source_db, {
+ const connectionManager = this.connectionFactory.create({
// Pool connections are only used intermittently.
idleTimeout: 30_000,
maxSize: 2
});
- this.connections = connections;
try {
await this.rateLimiter?.waitUntilAllowed({ signal: this.abortController.signal });
- if (this.stopped) {
+ if (this.isStopped) {
return;
}
const stream = new WalStream({
abort_signal: this.abortController.signal,
- factory: this.options.factory,
storage: this.options.storage,
- connections
+ connections: connectionManager
});
await stream.replicate();
} catch (e) {
- logger.error(`Replication error`, e);
+ this.logger.error(`Replication error`, e);
if (e.cause != null) {
// Example:
// PgError.conn_ended: Unable to do postgres query on ended connection
@@ -118,7 +121,7 @@ export class WalStreamRunner {
// [Symbol(pg.ErrorResponse)]: undefined
// }
// Without this additional log, the cause would not be visible in the logs.
- logger.error(`cause`, e.cause);
+ this.logger.error(`cause`, e.cause);
}
if (e instanceof MissingReplicationSlotError) {
throw e;
@@ -126,55 +129,14 @@ export class WalStreamRunner {
// Report the error if relevant, before retrying
container.reporter.captureException(e, {
metadata: {
- replication_slot: this.slot_name
+ replication_slot: this.slotName
}
});
// This sets the retry delay
this.rateLimiter?.reportError(e);
}
} finally {
- this.connections = null;
- if (connections != null) {
- await connections.end();
- }
- }
- }
-
- /**
- * This will also release the lock if start() was called earlier.
- */
- async stop(options?: { force?: boolean }) {
- logger.info(`${this.slot_name} Stopping replication`);
- // End gracefully
- this.abortController.abort();
-
- if (options?.force) {
- // destroy() is more forceful.
- await this.connections?.destroy();
+ await connectionManager.end();
}
- await this.runPromise;
- }
-
- /**
- * Terminate this replication stream. This drops the replication slot and deletes the replication data.
- *
- * Stops replication if needed.
- */
- async terminate(options?: { force?: boolean }) {
- logger.info(`${this.slot_name} Terminating replication`);
- await this.stop(options);
-
- const slotName = this.slot_name;
- const db = await pgwire.connectPgWire(this.options.source_db, { type: 'standard' });
- try {
- await db.query({
- statement: 'SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE slot_name = $1',
- params: [{ type: 'varchar', value: slotName }]
- });
- } finally {
- await db.end();
- }
-
- await this.options.storage.terminate();
}
}
diff --git a/modules/module-postgres/src/replication/WalStreamReplicator.ts b/modules/module-postgres/src/replication/WalStreamReplicator.ts
new file mode 100644
index 000000000..14a21725e
--- /dev/null
+++ b/modules/module-postgres/src/replication/WalStreamReplicator.ts
@@ -0,0 +1,45 @@
+import { replication, storage } from '@powersync/service-core';
+import { ConnectionManagerFactory } from './ConnectionManagerFactory.js';
+import { cleanUpReplicationSlot } from './replication-utils.js';
+import { WalStreamReplicationJob } from './WalStreamReplicationJob.js';
+
+export interface WalStreamReplicatorOptions extends replication.AbstractReplicatorOptions {
+ connectionFactory: ConnectionManagerFactory;
+}
+
+export class WalStreamReplicator extends replication.AbstractReplicator {
+ private readonly connectionFactory: ConnectionManagerFactory;
+
+ constructor(options: WalStreamReplicatorOptions) {
+ super(options);
+ this.connectionFactory = options.connectionFactory;
+ }
+
+ createJob(options: replication.CreateJobOptions): WalStreamReplicationJob {
+ return new WalStreamReplicationJob({
+ id: this.createJobId(options.storage.group_id),
+ storage: options.storage,
+ connectionFactory: this.connectionFactory,
+ lock: options.lock,
+ rateLimiter: this.rateLimiter
+ });
+ }
+
+ async cleanUp(syncRulesStorage: storage.SyncRulesBucketStorage): Promise {
+ const connectionManager = this.connectionFactory.create({
+ idleTimeout: 30_000,
+ maxSize: 1
+ });
+ try {
+ // TODO: Slot_name will likely have to come from a different source in the future
+ await cleanUpReplicationSlot(syncRulesStorage.slot_name, connectionManager.pool);
+ } finally {
+ await connectionManager.end();
+ }
+ }
+
+ async stop(): Promise {
+ await super.stop();
+ await this.connectionFactory.shutdown();
+ }
+}
diff --git a/modules/module-postgres/src/replication/replication-index.ts b/modules/module-postgres/src/replication/replication-index.ts
new file mode 100644
index 000000000..545553c1e
--- /dev/null
+++ b/modules/module-postgres/src/replication/replication-index.ts
@@ -0,0 +1,5 @@
+export * from './PgRelation.js';
+export * from './replication-utils.js';
+export * from './WalStream.js';
+export * from './WalStreamReplicator.js';
+export * from './WalStreamReplicationJob.js';
diff --git a/modules/module-postgres/src/replication/replication-utils.ts b/modules/module-postgres/src/replication/replication-utils.ts
new file mode 100644
index 000000000..c6b1e3fe1
--- /dev/null
+++ b/modules/module-postgres/src/replication/replication-utils.ts
@@ -0,0 +1,329 @@
+import * as pgwire from '@powersync/service-jpgwire';
+
+import { PatternResult, storage } from '@powersync/service-core';
+import * as pgwire_utils from '../utils/pgwire_utils.js';
+import { ReplicationIdentity } from './PgRelation.js';
+import * as sync_rules from '@powersync/service-sync-rules';
+import * as service_types from '@powersync/service-types';
+import * as pg_utils from '../utils/pgwire_utils.js';
+import * as util from '../utils/pgwire_utils.js';
+import { logger } from '@powersync/lib-services-framework';
+
+export interface ReplicaIdentityResult {
+ replicationColumns: storage.ColumnDescriptor[];
+ replicationIdentity: ReplicationIdentity;
+}
+
+export async function getPrimaryKeyColumns(
+ db: pgwire.PgClient,
+ relationId: number,
+ mode: 'primary' | 'replident'
+): Promise {
+ const indexFlag = mode == 'primary' ? `i.indisprimary` : `i.indisreplident`;
+ const attrRows = await pgwire_utils.retriedQuery(db, {
+ statement: `SELECT a.attname as name, a.atttypid as typeid, t.typname as type, a.attnum as attnum
+ FROM pg_index i
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY (i.indkey)
+ JOIN pg_type t ON a.atttypid = t.oid
+ WHERE i.indrelid = $1::oid
+ AND ${indexFlag}
+ AND a.attnum > 0
+ ORDER BY a.attnum`,
+ params: [{ value: relationId, type: 'int4' }]
+ });
+
+ return attrRows.rows.map((row) => {
+ return {
+ name: row[0] as string,
+ typeId: row[1] as number
+ } satisfies storage.ColumnDescriptor;
+ });
+}
+
+export async function getAllColumns(db: pgwire.PgClient, relationId: number): Promise {
+ const attrRows = await pgwire_utils.retriedQuery(db, {
+ statement: `SELECT a.attname as name, a.atttypid as typeid, t.typname as type, a.attnum as attnum
+ FROM pg_attribute a
+ JOIN pg_type t ON a.atttypid = t.oid
+ WHERE a.attrelid = $1::oid
+ AND attnum > 0
+ ORDER BY a.attnum`,
+ params: [{ type: 'varchar', value: relationId }]
+ });
+ return attrRows.rows.map((row) => {
+ return {
+ name: row[0] as string,
+ typeId: row[1] as number
+ } satisfies storage.ColumnDescriptor;
+ });
+}
+
+export async function getReplicationIdentityColumns(
+ db: pgwire.PgClient,
+ relationId: number
+): Promise {
+ const rows = await pgwire_utils.retriedQuery(db, {
+ statement: `SELECT CASE relreplident
+ WHEN 'd' THEN 'default'
+ WHEN 'n' THEN 'nothing'
+ WHEN 'f' THEN 'full'
+ WHEN 'i' THEN 'index'
+ END AS replica_identity
+FROM pg_class
+WHERE oid = $1::oid LIMIT 1`,
+ params: [{ type: 'int8', value: relationId }]
+ });
+ const idType: string = rows.rows[0]?.[0];
+ if (idType == 'nothing' || idType == null) {
+ return { replicationIdentity: 'nothing', replicationColumns: [] };
+ } else if (idType == 'full') {
+ return { replicationIdentity: 'full', replicationColumns: await getAllColumns(db, relationId) };
+ } else if (idType == 'default') {
+ return {
+ replicationIdentity: 'default',
+ replicationColumns: await getPrimaryKeyColumns(db, relationId, 'primary')
+ };
+ } else if (idType == 'index') {
+ return {
+ replicationIdentity: 'index',
+ replicationColumns: await getPrimaryKeyColumns(db, relationId, 'replident')
+ };
+ } else {
+ return { replicationIdentity: 'nothing', replicationColumns: [] };
+ }
+}
+
+export async function checkSourceConfiguration(db: pgwire.PgClient, publicationName: string): Promise {
+ // Check basic config
+ await pgwire_utils.retriedQuery(
+ db,
+ `DO $$
+BEGIN
+if current_setting('wal_level') is distinct from 'logical' then
+raise exception 'wal_level must be set to ''logical'', your database has it set to ''%''. Please edit your config file and restart PostgreSQL.', current_setting('wal_level');
+end if;
+if (current_setting('max_replication_slots')::int >= 1) is not true then
+raise exception 'Your max_replication_slots setting is too low, it must be greater than 1. Please edit your config file and restart PostgreSQL.';
+end if;
+if (current_setting('max_wal_senders')::int >= 1) is not true then
+raise exception 'Your max_wal_senders setting is too low, it must be greater than 1. Please edit your config file and restart PostgreSQL.';
+end if;
+end;
+$$ LANGUAGE plpgsql;`
+ );
+
+ // Check that publication exists
+ const rs = await pgwire_utils.retriedQuery(db, {
+ statement: `SELECT * FROM pg_publication WHERE pubname = $1`,
+ params: [{ type: 'varchar', value: publicationName }]
+ });
+ const row = pgwire.pgwireRows(rs)[0];
+ if (row == null) {
+ throw new Error(
+ `Publication '${publicationName}' does not exist. Run: \`CREATE PUBLICATION ${publicationName} FOR ALL TABLES\`, or read the documentation for details.`
+ );
+ }
+ if (row.pubinsert == false || row.pubupdate == false || row.pubdelete == false || row.pubtruncate == false) {
+ throw new Error(
+ `Publication '${publicationName}' does not publish all changes. Create a publication using \`WITH (publish = "insert, update, delete, truncate")\` (the default).`
+ );
+ }
+ if (row.pubviaroot) {
+ throw new Error(`'${publicationName}' uses publish_via_partition_root, which is not supported.`);
+ }
+}
+
+export interface GetDebugTablesInfoOptions {
+ db: pgwire.PgClient;
+ publicationName: string;
+ connectionTag: string;
+ tablePatterns: sync_rules.TablePattern[];
+ syncRules: sync_rules.SqlSyncRules;
+}
+
+export async function getDebugTablesInfo(options: GetDebugTablesInfoOptions): Promise {
+ const { db, publicationName, connectionTag, tablePatterns, syncRules } = options;
+ let result: PatternResult[] = [];
+
+ for (let tablePattern of tablePatterns) {
+ const schema = tablePattern.schema;
+
+ let patternResult: PatternResult = {
+ schema: schema,
+ pattern: tablePattern.tablePattern,
+ wildcard: tablePattern.isWildcard
+ };
+ result.push(patternResult);
+
+ if (tablePattern.isWildcard) {
+ patternResult.tables = [];
+ const prefix = tablePattern.tablePrefix;
+ const results = await util.retriedQuery(db, {
+ statement: `SELECT c.oid AS relid, c.relname AS table_name
+ FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = $1
+ AND c.relkind = 'r'
+ AND c.relname LIKE $2`,
+ params: [
+ { type: 'varchar', value: schema },
+ { type: 'varchar', value: tablePattern.tablePattern }
+ ]
+ });
+
+ for (let row of pgwire.pgwireRows(results)) {
+ const name = row.table_name as string;
+ const relationId = row.relid as number;
+ if (!name.startsWith(prefix)) {
+ continue;
+ }
+ const details = await getDebugTableInfo({
+ db,
+ name,
+ publicationName,
+ connectionTag,
+ tablePattern,
+ relationId,
+ syncRules: syncRules
+ });
+ patternResult.tables.push(details);
+ }
+ } else {
+ const results = await util.retriedQuery(db, {
+ statement: `SELECT c.oid AS relid, c.relname AS table_name
+ FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = $1
+ AND c.relkind = 'r'
+ AND c.relname = $2`,
+ params: [
+ { type: 'varchar', value: schema },
+ { type: 'varchar', value: tablePattern.tablePattern }
+ ]
+ });
+ if (results.rows.length == 0) {
+ // Table not found
+ patternResult.table = await getDebugTableInfo({
+ db,
+ name: tablePattern.name,
+ publicationName,
+ connectionTag,
+ tablePattern,
+ relationId: null,
+ syncRules: syncRules
+ });
+ } else {
+ const row = pgwire.pgwireRows(results)[0];
+ const name = row.table_name as string;
+ const relationId = row.relid as number;
+ patternResult.table = await getDebugTableInfo({
+ db,
+ name,
+ publicationName,
+ connectionTag,
+ tablePattern,
+ relationId,
+ syncRules: syncRules
+ });
+ }
+ }
+ }
+ return result;
+}
+
+export interface GetDebugTableInfoOptions {
+ db: pgwire.PgClient;
+ name: string;
+ publicationName: string;
+ connectionTag: string;
+ tablePattern: sync_rules.TablePattern;
+ relationId: number | null;
+ syncRules: sync_rules.SqlSyncRules;
+}
+
+export async function getDebugTableInfo(options: GetDebugTableInfoOptions): Promise {
+ const { db, name, publicationName, connectionTag, tablePattern, relationId, syncRules } = options;
+ const schema = tablePattern.schema;
+ let id_columns_result: ReplicaIdentityResult | undefined = undefined;
+ let id_columns_error = null;
+
+ if (relationId != null) {
+ try {
+ id_columns_result = await getReplicationIdentityColumns(db, relationId);
+ } catch (e) {
+ id_columns_error = { level: 'fatal', message: e.message };
+ }
+ }
+
+ const id_columns = id_columns_result?.replicationColumns ?? [];
+
+ const sourceTable = new storage.SourceTable(0, connectionTag, relationId ?? 0, schema, name, id_columns, true);
+
+ const syncData = syncRules.tableSyncsData(sourceTable);
+ const syncParameters = syncRules.tableSyncsParameters(sourceTable);
+
+ if (relationId == null) {
+ return {
+ schema: schema,
+ name: name,
+ pattern: tablePattern.isWildcard ? tablePattern.tablePattern : undefined,
+ replication_id: [],
+ data_queries: syncData,
+ parameter_queries: syncParameters,
+ // Also
+ errors: [{ level: 'warning', message: `Table ${sourceTable.qualifiedName} not found.` }]
+ };
+ }
+ if (id_columns.length == 0 && id_columns_error == null) {
+ let message = `No replication id found for ${sourceTable.qualifiedName}. Replica identity: ${id_columns_result?.replicationIdentity}.`;
+ if (id_columns_result?.replicationIdentity == 'default') {
+ message += ' Configure a primary key on the table.';
+ }
+ id_columns_error = { level: 'fatal', message };
+ }
+
+ let selectError = null;
+ try {
+ await pg_utils.retriedQuery(db, `SELECT * FROM ${sourceTable.escapedIdentifier} LIMIT 1`);
+ } catch (e) {
+ selectError = { level: 'fatal', message: e.message };
+ }
+
+ let replicateError = null;
+
+ const publications = await pg_utils.retriedQuery(db, {
+ statement: `SELECT tablename FROM pg_publication_tables WHERE pubname = $1 AND schemaname = $2 AND tablename = $3`,
+ params: [
+ { type: 'varchar', value: publicationName },
+ { type: 'varchar', value: tablePattern.schema },
+ { type: 'varchar', value: name }
+ ]
+ });
+ if (publications.rows.length == 0) {
+ replicateError = {
+ level: 'fatal',
+ message: `Table ${sourceTable.qualifiedName} is not part of publication '${publicationName}'. Run: \`ALTER PUBLICATION ${publicationName} ADD TABLE ${sourceTable.qualifiedName}\`.`
+ };
+ }
+
+ return {
+ schema: schema,
+ name: name,
+ pattern: tablePattern.isWildcard ? tablePattern.tablePattern : undefined,
+ replication_id: id_columns.map((c) => c.name),
+ data_queries: syncData,
+ parameter_queries: syncParameters,
+ errors: [id_columns_error, selectError, replicateError].filter(
+ (error) => error != null
+ ) as service_types.ReplicationError[]
+ };
+}
+
+export async function cleanUpReplicationSlot(slotName: string, db: pgwire.PgClient): Promise {
+ logger.info(`Cleaning up Postgres replication slot: ${slotName}...`);
+
+ await db.query({
+ statement: 'SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE slot_name = $1',
+ params: [{ type: 'varchar', value: slotName }]
+ });
+}
diff --git a/modules/module-postgres/src/types/types.ts b/modules/module-postgres/src/types/types.ts
new file mode 100644
index 000000000..6ab1c4196
--- /dev/null
+++ b/modules/module-postgres/src/types/types.ts
@@ -0,0 +1,158 @@
+import * as service_types from '@powersync/service-types';
+import * as t from 'ts-codec';
+import * as urijs from 'uri-js';
+
+export const POSTGRES_CONNECTION_TYPE = 'postgresql' as const;
+
+export interface NormalizedPostgresConnectionConfig {
+ id: string;
+ tag: string;
+
+ hostname: string;
+ port: number;
+ database: string;
+
+ username: string;
+ password: string;
+
+ sslmode: 'verify-full' | 'verify-ca' | 'disable';
+ cacert: string | undefined;
+
+ client_certificate: string | undefined;
+ client_private_key: string | undefined;
+}
+
+export const PostgresConnectionConfig = service_types.configFile.DataSourceConfig.and(
+ t.object({
+ type: t.literal(POSTGRES_CONNECTION_TYPE),
+ /** Unique identifier for the connection - optional when a single connection is present. */
+ id: t.string.optional(),
+ /** Tag used as reference in sync rules. Defaults to "default". Does not have to be unique. */
+ tag: t.string.optional(),
+ uri: t.string.optional(),
+ hostname: t.string.optional(),
+ port: service_types.configFile.portCodec.optional(),
+ username: t.string.optional(),
+ password: t.string.optional(),
+ database: t.string.optional(),
+
+ /** Defaults to verify-full */
+ sslmode: t.literal('verify-full').or(t.literal('verify-ca')).or(t.literal('disable')).optional(),
+ /** Required for verify-ca, optional for verify-full */
+ cacert: t.string.optional(),
+
+ client_certificate: t.string.optional(),
+ client_private_key: t.string.optional(),
+
+ /** Expose database credentials */
+ demo_database: t.boolean.optional(),
+
+ /**
+ * Prefix for the slot name. Defaults to "powersync_"
+ */
+ slot_name_prefix: t.string.optional()
+ })
+);
+
+/**
+ * Config input specified when starting services
+ */
+export type PostgresConnectionConfig = t.Decoded;
+
+/**
+ * Resolved version of {@link PostgresConnectionConfig}
+ */
+export type ResolvedConnectionConfig = PostgresConnectionConfig & NormalizedPostgresConnectionConfig;
+
+/**
+ * Validate and normalize connection options.
+ *
+ * Returns destructured options.
+ */
+export function normalizeConnectionConfig(options: PostgresConnectionConfig): NormalizedPostgresConnectionConfig {
+ let uri: urijs.URIComponents;
+ if (options.uri) {
+ uri = urijs.parse(options.uri);
+ if (uri.scheme != 'postgresql' && uri.scheme != 'postgres') {
+ `Invalid URI - protocol must be postgresql, got ${uri.scheme}`;
+ } else if (uri.scheme != 'postgresql') {
+ uri.scheme = 'postgresql';
+ }
+ } else {
+ uri = urijs.parse('postgresql:///');
+ }
+
+ const hostname = options.hostname ?? uri.host ?? '';
+ const port = validatePort(options.port ?? uri.port ?? 5432);
+
+ const database = options.database ?? uri.path?.substring(1) ?? '';
+
+ const [uri_username, uri_password] = (uri.userinfo ?? '').split(':');
+
+ const username = options.username ?? uri_username ?? '';
+ const password = options.password ?? uri_password ?? '';
+
+ const sslmode = options.sslmode ?? 'verify-full'; // Configuration not supported via URI
+ const cacert = options.cacert;
+
+ if (sslmode == 'verify-ca' && cacert == null) {
+ throw new Error('Explicit cacert is required for sslmode=verify-ca');
+ }
+
+ if (hostname == '') {
+ throw new Error(`hostname required`);
+ }
+
+ if (username == '') {
+ throw new Error(`username required`);
+ }
+
+ if (password == '') {
+ throw new Error(`password required`);
+ }
+
+ if (database == '') {
+ throw new Error(`database required`);
+ }
+
+ return {
+ id: options.id ?? 'default',
+ tag: options.tag ?? 'default',
+
+ hostname,
+ port,
+ database,
+
+ username,
+ password,
+ sslmode,
+ cacert,
+
+ client_certificate: options.client_certificate ?? undefined,
+ client_private_key: options.client_private_key ?? undefined
+ };
+}
+
+/**
+ * Check whether the port is in a "safe" range.
+ *
+ * We do not support connecting to "privileged" ports.
+ */
+export function validatePort(port: string | number): number {
+ if (typeof port == 'string') {
+ port = parseInt(port);
+ }
+ if (port < 1024) {
+ throw new Error(`Port ${port} not supported`);
+ }
+ return port;
+}
+
+/**
+ * Construct a postgres URI, without username, password or ssl options.
+ *
+ * Only contains hostname, port, database.
+ */
+export function baseUri(options: NormalizedPostgresConnectionConfig) {
+ return `postgresql://${options.hostname}:${options.port}/${options.database}`;
+}
diff --git a/packages/service-core/src/util/migration_lib.ts b/modules/module-postgres/src/utils/migration_lib.ts
similarity index 100%
rename from packages/service-core/src/util/migration_lib.ts
rename to modules/module-postgres/src/utils/migration_lib.ts
diff --git a/packages/service-core/src/util/pgwire_utils.ts b/modules/module-postgres/src/utils/pgwire_utils.ts
similarity index 51%
rename from packages/service-core/src/util/pgwire_utils.ts
rename to modules/module-postgres/src/utils/pgwire_utils.ts
index 9aa042980..9a349e06c 100644
--- a/packages/service-core/src/util/pgwire_utils.ts
+++ b/modules/module-postgres/src/utils/pgwire_utils.ts
@@ -1,11 +1,8 @@
// Adapted from https://github.com/kagis/pgwire/blob/0dc927f9f8990a903f238737326e53ba1c8d094f/mod.js#L2218
-import * as bson from 'bson';
-import * as uuid from 'uuid';
import * as pgwire from '@powersync/service-jpgwire';
-import { SqliteJsonValue, SqliteRow, ToastableSqliteRow, toSyncRulesRow } from '@powersync/service-sync-rules';
+import { SqliteJsonValue, SqliteRow, toSyncRulesRow } from '@powersync/service-sync-rules';
-import * as replication from '../replication/replication-index.js';
import { logger } from '@powersync/lib-services-framework';
/**
@@ -19,19 +16,6 @@ export function constructAfterRecord(message: pgwire.PgoutputInsert | pgwire.Pgo
return toSyncRulesRow(record);
}
-export function hasToastedValues(row: ToastableSqliteRow) {
- for (let key in row) {
- if (typeof row[key] == 'undefined') {
- return true;
- }
- }
- return false;
-}
-
-export function isCompleteRow(row: ToastableSqliteRow): row is SqliteRow {
- return !hasToastedValues(row);
-}
-
/**
* pgwire message -> SQLite row.
* @param message
@@ -45,56 +29,6 @@ export function constructBeforeRecord(message: pgwire.PgoutputDelete | pgwire.Pg
return toSyncRulesRow(record);
}
-function getRawReplicaIdentity(
- tuple: ToastableSqliteRow,
- columns: replication.ReplicationColumn[]
-): Record {
- let result: Record = {};
- for (let column of columns) {
- const name = column.name;
- result[name] = tuple[name];
- }
- return result;
-}
-const ID_NAMESPACE = 'a396dd91-09fc-4017-a28d-3df722f651e9';
-
-export function getUuidReplicaIdentityString(
- tuple: ToastableSqliteRow,
- columns: replication.ReplicationColumn[]
-): string {
- const rawIdentity = getRawReplicaIdentity(tuple, columns);
-
- return uuidForRow(rawIdentity);
-}
-
-export function uuidForRow(row: SqliteRow): string {
- // Important: This must not change, since it will affect how ids are generated.
- // Use BSON so that it's a well-defined format without encoding ambiguities.
- const repr = bson.serialize(row);
- return uuid.v5(repr, ID_NAMESPACE);
-}
-
-export function getUuidReplicaIdentityBson(
- tuple: ToastableSqliteRow,
- columns: replication.ReplicationColumn[]
-): bson.UUID {
- if (columns.length == 0) {
- // REPLICA IDENTITY NOTHING - generate random id
- return new bson.UUID(uuid.v4());
- }
- const rawIdentity = getRawReplicaIdentity(tuple, columns);
-
- return uuidForRowBson(rawIdentity);
-}
-
-export function uuidForRowBson(row: SqliteRow): bson.UUID {
- // Important: This must not change, since it will affect how ids are generated.
- // Use BSON so that it's a well-defined format without encoding ambiguities.
- const repr = bson.serialize(row);
- const buffer = Buffer.alloc(16);
- return new bson.UUID(uuid.v5(repr, ID_NAMESPACE, buffer));
-}
-
export function escapeIdentifier(identifier: string) {
return `"${identifier.replace(/"/g, '""').replace(/\./g, '"."')}"`;
}
diff --git a/modules/module-postgres/src/utils/populate_test_data.ts b/modules/module-postgres/src/utils/populate_test_data.ts
new file mode 100644
index 000000000..1d1c15de8
--- /dev/null
+++ b/modules/module-postgres/src/utils/populate_test_data.ts
@@ -0,0 +1,37 @@
+import { Worker } from 'node:worker_threads';
+
+import * as pgwire from '@powersync/service-jpgwire';
+
+// This util is actually for tests only, but we need it compiled to JS for the service to work, so it's placed in the service.
+
+export interface PopulateDataOptions {
+ connection: pgwire.NormalizedConnectionConfig;
+ num_transactions: number;
+ per_transaction: number;
+ size: number;
+}
+
+export async function populateData(options: PopulateDataOptions) {
+ const WORKER_TIMEOUT = 30_000;
+
+ const worker = new Worker(new URL('./populate_test_data_worker.js', import.meta.url), {
+ workerData: options
+ });
+ const timeout = setTimeout(() => {
+ // Exits with code 1 below
+ worker.terminate();
+ }, WORKER_TIMEOUT);
+ try {
+ return await new Promise((resolve, reject) => {
+ worker.on('message', resolve);
+ worker.on('error', reject);
+ worker.on('exit', (code) => {
+ if (code !== 0) {
+ reject(new Error(`Populating data failed with exit code ${code}`));
+ }
+ });
+ });
+ } finally {
+ clearTimeout(timeout);
+ }
+}
diff --git a/packages/service-core/src/util/populate_test_data.ts b/modules/module-postgres/src/utils/populate_test_data_worker.ts
similarity index 52%
rename from packages/service-core/src/util/populate_test_data.ts
rename to modules/module-postgres/src/utils/populate_test_data_worker.ts
index f53648831..5fd161103 100644
--- a/packages/service-core/src/util/populate_test_data.ts
+++ b/modules/module-postgres/src/utils/populate_test_data_worker.ts
@@ -1,23 +1,20 @@
import * as crypto from 'crypto';
-import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
+import { isMainThread, parentPort, workerData } from 'node:worker_threads';
-import { connectPgWire } from '@powersync/service-jpgwire';
-import { NormalizedPostgresConnection } from '@powersync/service-types';
+import * as pgwire from '@powersync/service-jpgwire';
+import type { PopulateDataOptions } from './populate_test_data.js';
// This util is actually for tests only, but we need it compiled to JS for the service to work, so it's placed in the service.
-export interface PopulateDataOptions {
- connection: NormalizedPostgresConnection;
- num_transactions: number;
- per_transaction: number;
- size: number;
-}
-
if (isMainThread || parentPort == null) {
- // Not a worker - ignore
+ // Must not be imported - only expected to run in a worker
+ throw new Error('Do not import this file');
} else {
try {
const options = workerData as PopulateDataOptions;
+ if (options == null) {
+ throw new Error('loaded worker without options');
+ }
const result = await populateDataInner(options);
parentPort.postMessage(result);
@@ -32,7 +29,7 @@ if (isMainThread || parentPort == null) {
async function populateDataInner(options: PopulateDataOptions) {
// Dedicated connection so we can release the memory easily
- const initialDb = await connectPgWire(options.connection, { type: 'standard' });
+ const initialDb = await pgwire.connectPgWire(options.connection, { type: 'standard' });
const largeDescription = crypto.randomBytes(options.size / 2).toString('hex');
let operation_count = 0;
for (let i = 0; i < options.num_transactions; i++) {
@@ -51,28 +48,3 @@ async function populateDataInner(options: PopulateDataOptions) {
await initialDb.end();
return operation_count;
}
-
-export async function populateData(options: PopulateDataOptions) {
- const WORKER_TIMEOUT = 30_000;
-
- const worker = new Worker(new URL('./populate_test_data.js', import.meta.url), {
- workerData: options
- });
- const timeout = setTimeout(() => {
- // Exits with code 1 below
- worker.terminate();
- }, WORKER_TIMEOUT);
- try {
- return await new Promise((resolve, reject) => {
- worker.on('message', resolve);
- worker.on('error', reject);
- worker.on('exit', (code) => {
- if (code !== 0) {
- reject(new Error(`Populating data failed with exit code ${code}`));
- }
- });
- });
- } finally {
- clearTimeout(timeout);
- }
-}
diff --git a/packages/service-core/test/src/__snapshots__/pg_test.test.ts.snap b/modules/module-postgres/test/src/__snapshots__/pg_test.test.ts.snap
similarity index 100%
rename from packages/service-core/test/src/__snapshots__/pg_test.test.ts.snap
rename to modules/module-postgres/test/src/__snapshots__/pg_test.test.ts.snap
diff --git a/modules/module-postgres/test/src/env.ts b/modules/module-postgres/test/src/env.ts
new file mode 100644
index 000000000..fa8f76ca1
--- /dev/null
+++ b/modules/module-postgres/test/src/env.ts
@@ -0,0 +1,7 @@
+import { utils } from '@powersync/lib-services-framework';
+
+export const env = utils.collectEnvironmentVariables({
+ PG_TEST_URL: utils.type.string.default('postgres://postgres:postgres@localhost:5432/powersync_test'),
+ CI: utils.type.boolean.default('false'),
+ SLOW_TESTS: utils.type.boolean.default('false')
+});
diff --git a/packages/service-core/test/src/large_batch.test.ts b/modules/module-postgres/test/src/large_batch.test.ts
similarity index 97%
rename from packages/service-core/test/src/large_batch.test.ts
rename to modules/module-postgres/test/src/large_batch.test.ts
index edbc28610..2d20534b3 100644
--- a/packages/service-core/test/src/large_batch.test.ts
+++ b/modules/module-postgres/test/src/large_batch.test.ts
@@ -1,8 +1,9 @@
+import { MONGO_STORAGE_FACTORY, StorageFactory } from '@core-tests/util.js';
import { describe, expect, test } from 'vitest';
import { env } from './env.js';
-import { MONGO_STORAGE_FACTORY, StorageFactory, TEST_CONNECTION_OPTIONS } from './util.js';
+import { TEST_CONNECTION_OPTIONS } from './util.js';
import { walStreamTest } from './wal_stream_utils.js';
-import { populateData } from '../../dist/util/populate_test_data.js';
+import { populateData } from '../../dist/utils/populate_test_data.js';
describe('batch replication tests - mongodb', function () {
// These are slow but consistent tests.
diff --git a/packages/service-core/test/src/pg_test.test.ts b/modules/module-postgres/test/src/pg_test.test.ts
similarity index 97%
rename from packages/service-core/test/src/pg_test.test.ts
rename to modules/module-postgres/test/src/pg_test.test.ts
index 5ea9eb041..866adb3de 100644
--- a/packages/service-core/test/src/pg_test.test.ts
+++ b/modules/module-postgres/test/src/pg_test.test.ts
@@ -1,10 +1,9 @@
-import { describe, expect, test } from 'vitest';
-import { WalStream } from '../../src/replication/WalStream.js';
+import { constructAfterRecord } from '@module/utils/pgwire_utils.js';
import * as pgwire from '@powersync/service-jpgwire';
-import { clearTestDb, connectPgPool, connectPgWire, TEST_URI } from './util.js';
-import { constructAfterRecord } from '../../src/util/pgwire_utils.js';
import { SqliteRow } from '@powersync/service-sync-rules';
-import { getConnectionSchema } from '../../src/api/schema.js';
+import { describe, expect, test } from 'vitest';
+import { clearTestDb, connectPgPool, connectPgWire, TEST_URI } from './util.js';
+import { WalStream } from '@module/replication/WalStream.js';
describe('pg data types', () => {
async function setupTable(db: pgwire.PgClient) {
@@ -427,8 +426,9 @@ VALUES(10, ARRAY['null']::TEXT[]);
await setupTable(db);
- const schema = await getConnectionSchema(db);
- expect(schema).toMatchSnapshot();
+ // TODO need a test for adapter
+ // const schema = await api.getConnectionsSchema(db);
+ // expect(schema).toMatchSnapshot();
});
});
diff --git a/packages/service-core/test/src/schema_changes.test.ts b/modules/module-postgres/test/src/schema_changes.test.ts
similarity index 98%
rename from packages/service-core/test/src/schema_changes.test.ts
rename to modules/module-postgres/test/src/schema_changes.test.ts
index d14272a77..5318f7f10 100644
--- a/packages/service-core/test/src/schema_changes.test.ts
+++ b/modules/module-postgres/test/src/schema_changes.test.ts
@@ -1,14 +1,12 @@
+import { compareIds, putOp, removeOp } from '@core-tests/stream_utils.js';
import { describe, expect, test } from 'vitest';
-import { BucketStorageFactory } from '../../src/storage/BucketStorage.js';
-import { MONGO_STORAGE_FACTORY } from './util.js';
-import { compareIds, putOp, removeOp, walStreamTest } from './wal_stream_utils.js';
-
-type StorageFactory = () => Promise;
+import { walStreamTest } from './wal_stream_utils.js';
+import { INITIALIZED_MONGO_STORAGE_FACTORY, StorageFactory } from './util.js';
describe(
'schema changes',
function () {
- defineTests(MONGO_STORAGE_FACTORY);
+ defineTests(INITIALIZED_MONGO_STORAGE_FACTORY);
},
{ timeout: 20_000 }
);
diff --git a/modules/module-postgres/test/src/setup.ts b/modules/module-postgres/test/src/setup.ts
new file mode 100644
index 000000000..b924cf736
--- /dev/null
+++ b/modules/module-postgres/test/src/setup.ts
@@ -0,0 +1,7 @@
+import { container } from '@powersync/lib-services-framework';
+import { beforeAll } from 'vitest';
+
+beforeAll(() => {
+ // Executes for every test file
+ container.registerDefaults();
+});
diff --git a/packages/service-core/test/src/slow_tests.test.ts b/modules/module-postgres/test/src/slow_tests.test.ts
similarity index 93%
rename from packages/service-core/test/src/slow_tests.test.ts
rename to modules/module-postgres/test/src/slow_tests.test.ts
index f3e1a3d5d..7c5bad017 100644
--- a/packages/service-core/test/src/slow_tests.test.ts
+++ b/modules/module-postgres/test/src/slow_tests.test.ts
@@ -1,17 +1,16 @@
import * as bson from 'bson';
-import * as mongo from 'mongodb';
import { afterEach, describe, expect, test } from 'vitest';
import { WalStream, WalStreamOptions } from '../../src/replication/WalStream.js';
-import { getClientCheckpoint } from '../../src/util/utils.js';
import { env } from './env.js';
-import { MONGO_STORAGE_FACTORY, StorageFactory, TEST_CONNECTION_OPTIONS, clearTestDb, connectPgPool } from './util.js';
+import { clearTestDb, connectPgPool, getClientCheckpoint, TEST_CONNECTION_OPTIONS } from './util.js';
import * as pgwire from '@powersync/service-jpgwire';
import { SqliteRow } from '@powersync/service-sync-rules';
-import { MongoBucketStorage } from '../../src/storage/MongoBucketStorage.js';
-import { PgManager } from '../../src/util/PgManager.js';
-import { mapOpEntry } from '@/storage/storage-index.js';
-import { reduceBucket, validateCompactedBucket, validateBucket } from './bucket_validation.js';
+
+import { mapOpEntry, MongoBucketStorage } from '@/storage/storage-index.js';
+import { reduceBucket, validateCompactedBucket } from '@core-tests/bucket_validation.js';
+import { MONGO_STORAGE_FACTORY, StorageFactory } from '@core-tests/util.js';
+import { PgManager } from '@module/replication/PgManager.js';
import * as timers from 'node:timers/promises';
describe('slow tests - mongodb', function () {
@@ -83,13 +82,12 @@ bucket_definitions:
- SELECT * FROM "test_data"
`;
const syncRules = await f.updateSyncRules({ content: syncRuleContent });
- const storage = f.getInstance(syncRules.parsed());
+ using storage = f.getInstance(syncRules);
abortController = new AbortController();
const options: WalStreamOptions = {
abort_signal: abortController.signal,
connections,
- storage: storage,
- factory: f
+ storage: storage
};
walStream = new WalStream(options);
@@ -195,7 +193,7 @@ bucket_definitions:
// Check that all inserts have been deleted again
const docs = await f.db.current_data.find().toArray();
const transformed = docs.map((doc) => {
- return bson.deserialize((doc.data as mongo.Binary).buffer) as SqliteRow;
+ return bson.deserialize(doc.data.buffer) as SqliteRow;
});
expect(transformed).toEqual([]);
@@ -236,7 +234,7 @@ bucket_definitions:
- SELECT id, description FROM "test_data"
`;
const syncRules = await f.updateSyncRules({ content: syncRuleContent });
- const storage = f.getInstance(syncRules.parsed());
+ using storage = f.getInstance(syncRules);
// 1. Setup some base data that will be replicated in initial replication
await pool.query(`CREATE TABLE test_data(id uuid primary key default uuid_generate_v4(), description text)`);
@@ -266,8 +264,7 @@ bucket_definitions:
const options: WalStreamOptions = {
abort_signal: abortController.signal,
connections,
- storage: storage,
- factory: f
+ storage: storage
};
walStream = new WalStream(options);
diff --git a/modules/module-postgres/test/src/util.ts b/modules/module-postgres/test/src/util.ts
new file mode 100644
index 000000000..c8142739d
--- /dev/null
+++ b/modules/module-postgres/test/src/util.ts
@@ -0,0 +1,107 @@
+import { connectMongo } from '@core-tests/util.js';
+import * as types from '@module/types/types.js';
+import * as pg_utils from '@module/utils/pgwire_utils.js';
+import { logger } from '@powersync/lib-services-framework';
+import { BucketStorageFactory, Metrics, MongoBucketStorage, OpId } from '@powersync/service-core';
+import * as pgwire from '@powersync/service-jpgwire';
+import { pgwireRows } from '@powersync/service-jpgwire';
+import { env } from './env.js';
+
+// The metrics need to be initialized before they can be used
+await Metrics.initialise({
+ disable_telemetry_sharing: true,
+ powersync_instance_id: 'test',
+ internal_metrics_endpoint: 'unused.for.tests.com'
+});
+Metrics.getInstance().resetCounters();
+
+export const TEST_URI = env.PG_TEST_URL;
+
+export const TEST_CONNECTION_OPTIONS = types.normalizeConnectionConfig({
+ type: 'postgresql',
+ uri: TEST_URI,
+ sslmode: 'disable'
+});
+
+export type StorageFactory = () => Promise;
+
+export const INITIALIZED_MONGO_STORAGE_FACTORY: StorageFactory = async () => {
+ const db = await connectMongo();
+
+ // None of the PG tests insert data into this collection, so it was never created
+ if (!(await db.db.listCollections({ name: db.bucket_parameters.collectionName }).hasNext())) {
+ await db.db.createCollection('bucket_parameters');
+ }
+
+ await db.clear();
+
+ return new MongoBucketStorage(db, {
+ slot_name_prefix: 'test_'
+ });
+};
+
+export async function clearTestDb(db: pgwire.PgClient) {
+ await db.query(
+ "select pg_drop_replication_slot(slot_name) from pg_replication_slots where active = false and slot_name like 'test_%'"
+ );
+
+ await db.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
+ try {
+ await db.query(`DROP PUBLICATION powersync`);
+ } catch (e) {
+ // Ignore
+ }
+
+ await db.query(`CREATE PUBLICATION powersync FOR ALL TABLES`);
+
+ const tableRows = pgwire.pgwireRows(
+ await db.query(`SELECT table_name FROM information_schema.tables where table_schema = 'public'`)
+ );
+ for (let row of tableRows) {
+ const name = row.table_name;
+ if (name.startsWith('test_')) {
+ await db.query(`DROP TABLE public.${pg_utils.escapeIdentifier(name)}`);
+ }
+ }
+}
+
+export async function connectPgWire(type?: 'replication' | 'standard') {
+ const db = await pgwire.connectPgWire(TEST_CONNECTION_OPTIONS, { type });
+ return db;
+}
+
+export function connectPgPool() {
+ const db = pgwire.connectPgWirePool(TEST_CONNECTION_OPTIONS);
+ return db;
+}
+
+export async function getClientCheckpoint(
+ db: pgwire.PgClient,
+ bucketStorage: BucketStorageFactory,
+ options?: { timeout?: number }
+): Promise {
+ const start = Date.now();
+
+ const [{ lsn }] = pgwireRows(await db.query(`SELECT pg_logical_emit_message(false, 'powersync', 'ping') as lsn`));
+
+ // This old API needs a persisted checkpoint id.
+ // Since we don't use LSNs anymore, the only way to get that is to wait.
+
+ const timeout = options?.timeout ?? 50_000;
+
+ logger.info(`Waiting for LSN checkpoint: ${lsn}`);
+ while (Date.now() - start < timeout) {
+ const cp = await bucketStorage.getActiveCheckpoint();
+ if (!cp.hasSyncRules()) {
+ throw new Error('No sync rules available');
+ }
+ if (cp.lsn && cp.lsn >= lsn) {
+ logger.info(`Got write checkpoint: ${lsn} : ${cp.checkpoint}`);
+ return cp.checkpoint;
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ }
+
+ throw new Error('Timeout while waiting for checkpoint');
+}
diff --git a/packages/service-core/test/src/validation.test.ts b/modules/module-postgres/test/src/validation.test.ts
similarity index 75%
rename from packages/service-core/test/src/validation.test.ts
rename to modules/module-postgres/test/src/validation.test.ts
index e9f914a12..b7b7b23f2 100644
--- a/packages/service-core/test/src/validation.test.ts
+++ b/modules/module-postgres/test/src/validation.test.ts
@@ -1,7 +1,7 @@
+import { MONGO_STORAGE_FACTORY } from '@core-tests/util.js';
import { expect, test } from 'vitest';
-import { MONGO_STORAGE_FACTORY } from './util.js';
import { walStreamTest } from './wal_stream_utils.js';
-import { WalConnection } from '../../src/replication/WalConnection.js';
+import { getDebugTablesInfo } from '@module/replication/replication-utils.js';
// Not quite a walStreamTest, but it helps to manage the connection
test(
@@ -22,13 +22,14 @@ bucket_definitions:
const syncRules = await context.factory.updateSyncRules({ content: syncRuleContent });
- const walConnection = new WalConnection({
+ const tablePatterns = syncRules.parsed({ defaultSchema: 'public' }).sync_rules.getSourceTables();
+ const tableInfo = await getDebugTablesInfo({
db: pool,
- sync_rules: syncRules.parsed().sync_rules
+ publicationName: context.publicationName,
+ connectionTag: context.connectionTag,
+ tablePatterns: tablePatterns,
+ syncRules: syncRules.parsed({ defaultSchema: 'public' }).sync_rules
});
-
- const tablePatterns = syncRules.parsed().sync_rules.getSourceTables();
- const tableInfo = await walConnection.getDebugTablesInfo(tablePatterns);
expect(tableInfo).toEqual([
{
schema: 'public',
diff --git a/packages/service-core/test/src/wal_stream.test.ts b/modules/module-postgres/test/src/wal_stream.test.ts
similarity index 97%
rename from packages/service-core/test/src/wal_stream.test.ts
rename to modules/module-postgres/test/src/wal_stream.test.ts
index a6cb83fa1..a55454654 100644
--- a/packages/service-core/test/src/wal_stream.test.ts
+++ b/modules/module-postgres/test/src/wal_stream.test.ts
@@ -1,10 +1,10 @@
+import { putOp, removeOp } from '@core-tests/stream_utils.js';
+import { MONGO_STORAGE_FACTORY } from '@core-tests/util.js';
+import { BucketStorageFactory, Metrics } from '@powersync/service-core';
+import { pgwireRows } from '@powersync/service-jpgwire';
import * as crypto from 'crypto';
import { describe, expect, test } from 'vitest';
-import { BucketStorageFactory } from '@/storage/BucketStorage.js';
-import { MONGO_STORAGE_FACTORY } from './util.js';
-import { putOp, removeOp, walStreamTest } from './wal_stream_utils.js';
-import { pgwireRows } from '@powersync/service-jpgwire';
-import { Metrics } from '@/metrics/Metrics.js';
+import { walStreamTest } from './wal_stream_utils.js';
type StorageFactory = () => Promise;
diff --git a/packages/service-core/test/src/wal_stream_utils.ts b/modules/module-postgres/test/src/wal_stream_utils.ts
similarity index 54%
rename from packages/service-core/test/src/wal_stream_utils.ts
rename to modules/module-postgres/test/src/wal_stream_utils.ts
index 7c639a6e5..23eced2e7 100644
--- a/packages/service-core/test/src/wal_stream_utils.ts
+++ b/modules/module-postgres/test/src/wal_stream_utils.ts
@@ -1,11 +1,9 @@
+import { fromAsync } from '@core-tests/stream_utils.js';
+import { PgManager } from '@module/replication/PgManager.js';
+import { PUBLICATION_NAME, WalStream, WalStreamOptions } from '@module/replication/WalStream.js';
+import { BucketStorageFactory, SyncRulesBucketStorage } from '@powersync/service-core';
import * as pgwire from '@powersync/service-jpgwire';
-import { WalStream, WalStreamOptions } from '../../src/replication/WalStream.js';
-import { BucketStorageFactory, SyncRulesBucketStorage } from '../../src/storage/BucketStorage.js';
-import { OplogEntry } from '../../src/util/protocol-types.js';
-import { getClientCheckpoint } from '../../src/util/utils.js';
-import { TEST_CONNECTION_OPTIONS, clearTestDb } from './util.js';
-import { PgManager } from '../../src/util/PgManager.js';
-import { JSONBig } from '@powersync/service-jsonbig';
+import { clearTestDb, getClientCheckpoint, TEST_CONNECTION_OPTIONS } from './util.js';
/**
* Tests operating on the wal stream need to configure the stream and manage asynchronous
@@ -19,40 +17,48 @@ export function walStreamTest(
): () => Promise {
return async () => {
const f = await factory();
- const connections = new PgManager(TEST_CONNECTION_OPTIONS, {});
+ const connectionManager = new PgManager(TEST_CONNECTION_OPTIONS, {});
- await clearTestDb(connections.pool);
- const context = new WalStreamTestContext(f, connections);
- try {
- await test(context);
- } finally {
- await context.dispose();
- }
+ await clearTestDb(connectionManager.pool);
+ await using context = new WalStreamTestContext(f, connectionManager);
+ await test(context);
};
}
-export class WalStreamTestContext {
+export class WalStreamTestContext implements AsyncDisposable {
private _walStream?: WalStream;
private abortController = new AbortController();
private streamPromise?: Promise;
public storage?: SyncRulesBucketStorage;
private replicationConnection?: pgwire.PgConnection;
- constructor(public factory: BucketStorageFactory, public connections: PgManager) {}
+ constructor(
+ public factory: BucketStorageFactory,
+ public connectionManager: PgManager
+ ) {}
- async dispose() {
+ async [Symbol.asyncDispose]() {
this.abortController.abort();
await this.streamPromise;
- this.connections.destroy();
+ await this.connectionManager.destroy();
+ this.storage?.[Symbol.dispose]();
}
get pool() {
- return this.connections.pool;
+ return this.connectionManager.pool;
+ }
+
+ get connectionTag() {
+ return this.connectionManager.connectionTag;
+ }
+
+ get publicationName() {
+ return PUBLICATION_NAME;
}
async updateSyncRules(content: string) {
const syncRules = await this.factory.updateSyncRules({ content: content });
- this.storage = this.factory.getInstance(syncRules.parsed());
+ this.storage = this.factory.getInstance(syncRules);
return this.storage!;
}
@@ -65,8 +71,7 @@ export class WalStreamTestContext {
}
const options: WalStreamOptions = {
storage: this.storage,
- factory: this.factory,
- connections: this.connections,
+ connections: this.connectionManager,
abort_signal: this.abortController.signal
};
this._walStream = new WalStream(options);
@@ -74,7 +79,7 @@ export class WalStreamTestContext {
}
async replicateSnapshot() {
- this.replicationConnection = await this.connections.replicationConnection();
+ this.replicationConnection = await this.connectionManager.replicationConnection();
await this.walStream.initReplication(this.replicationConnection);
await this.storage!.autoActivate();
}
@@ -88,7 +93,7 @@ export class WalStreamTestContext {
async getCheckpoint(options?: { timeout?: number }) {
let checkpoint = await Promise.race([
- getClientCheckpoint(this.connections.pool, this.factory, { timeout: options?.timeout ?? 15_000 }),
+ getClientCheckpoint(this.pool, this.factory, { timeout: options?.timeout ?? 15_000 }),
this.streamPromise
]);
if (typeof checkpoint == undefined) {
@@ -109,48 +114,8 @@ export class WalStreamTestContext {
start ??= '0';
let checkpoint = await this.getCheckpoint(options);
const map = new Map([[bucket, start]]);
- const batch = await this.storage!.getBucketDataBatch(checkpoint, map);
+ const batch = this.storage!.getBucketDataBatch(checkpoint, map);
const batches = await fromAsync(batch);
return batches[0]?.batch.data ?? [];
}
}
-
-export function putOp(table: string, data: Record): Partial {
- return {
- op: 'PUT',
- object_type: table,
- object_id: data.id,
- data: JSONBig.stringify(data)
- };
-}
-
-export function removeOp(table: string, id: string): Partial {
- return {
- op: 'REMOVE',
- object_type: table,
- object_id: id
- };
-}
-
-export function compareIds(a: OplogEntry, b: OplogEntry) {
- return a.object_id!.localeCompare(b.object_id!);
-}
-
-export async function fromAsync(source: Iterable | AsyncIterable): Promise {
- const items: T[] = [];
- for await (const item of source) {
- items.push(item);
- }
- return items;
-}
-
-export async function oneFromAsync(source: Iterable | AsyncIterable): Promise {
- const items: T[] = [];
- for await (const item of source) {
- items.push(item);
- }
- if (items.length != 1) {
- throw new Error(`One item expected, got: ${items.length}`);
- }
- return items[0];
-}
diff --git a/modules/module-postgres/test/tsconfig.json b/modules/module-postgres/test/tsconfig.json
new file mode 100644
index 000000000..18898c4ee
--- /dev/null
+++ b/modules/module-postgres/test/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "baseUrl": "./",
+ "noEmit": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "paths": {
+ "@/*": ["../../../packages/service-core/src/*"],
+ "@module/*": ["../src/*"],
+ "@core-tests/*": ["../../../packages/service-core/test/src/*"]
+ }
+ },
+ "include": ["src"],
+ "references": [
+ {
+ "path": "../"
+ },
+ {
+ "path": "../../../packages/service-core/test"
+ },
+ {
+ "path": "../../../packages/service-core/"
+ }
+ ]
+}
diff --git a/modules/module-postgres/tsconfig.json b/modules/module-postgres/tsconfig.json
new file mode 100644
index 000000000..9ceadec40
--- /dev/null
+++ b/modules/module-postgres/tsconfig.json
@@ -0,0 +1,31 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "sourceMap": true
+ },
+ "include": ["src"],
+ "references": [
+ {
+ "path": "../../packages/types"
+ },
+ {
+ "path": "../../packages/jsonbig"
+ },
+ {
+ "path": "../../packages/jpgwire"
+ },
+ {
+ "path": "../../packages/sync-rules"
+ },
+ {
+ "path": "../../packages/service-core"
+ },
+ {
+ "path": "../../libs/lib-services"
+ }
+ ]
+}
diff --git a/modules/module-postgres/vitest.config.ts b/modules/module-postgres/vitest.config.ts
new file mode 100644
index 000000000..7a39c1f71
--- /dev/null
+++ b/modules/module-postgres/vitest.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from 'vitest/config';
+import tsconfigPaths from 'vite-tsconfig-paths';
+
+export default defineConfig({
+ plugins: [tsconfigPaths()],
+ test: {
+ setupFiles: './test/src/setup.ts',
+ poolOptions: {
+ threads: {
+ singleThread: true
+ }
+ },
+ pool: 'threads'
+ }
+});
diff --git a/package.json b/package.json
index 54bae275f..1846299d9 100644
--- a/package.json
+++ b/package.json
@@ -21,20 +21,22 @@
"test": "pnpm run -r test"
},
"devDependencies": {
- "@changesets/cli": "^2.27.3",
- "@types/node": "18.11.11",
+ "@changesets/cli": "^2.27.8",
+ "@types/node": "^22.5.5",
"async": "^3.2.4",
"bson": "^6.6.0",
"concurrently": "^8.2.2",
"inquirer": "^9.2.7",
- "npm-check-updates": "^16.10.15",
- "prettier": "^2.8.8",
+ "npm-check-updates": "^17.1.2",
+ "prettier": "^3.3.3",
"rsocket-core": "1.0.0-alpha.3",
"rsocket-websocket-client": "1.0.0-alpha.3",
"semver": "^7.5.4",
"tsc-watch": "^6.2.0",
"ts-node-dev": "^2.0.0",
- "typescript": "~5.2.2",
+ "typescript": "^5.6.2",
+ "vite-tsconfig-paths": "^4.3.2",
+ "vitest": "^2.1.1",
"ws": "^8.2.3"
}
}
diff --git a/packages/jpgwire/ca/README.md b/packages/jpgwire/ca/README.md
index 6e406e6e2..6bd7a532b 100644
--- a/packages/jpgwire/ca/README.md
+++ b/packages/jpgwire/ca/README.md
@@ -1,4 +1,3 @@
-
## AWS RDS
https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html#UsingWithRDS.SSL.CertificatesAllRegions
@@ -11,12 +10,13 @@ https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/how-to-connec
https://learn.microsoft.com/en-us/azure/postgresql/single-server/concepts-certificate-rotation
Includes:
- * BaltimoreCyberTrustRoot
- * DigiCertGlobalRootG2 Root CA
- * Microsoft RSA Root Certificate Authority 2017
- * Microsoft ECC Root Certificate Authority 2017
- * DigiCert Global Root G3
- * DigiCert Global Root CA
+
+- BaltimoreCyberTrustRoot
+- DigiCertGlobalRootG2 Root CA
+- Microsoft RSA Root Certificate Authority 2017
+- Microsoft ECC Root Certificate Authority 2017
+- DigiCert Global Root G3
+- DigiCert Global Root CA
## Supabase
diff --git a/packages/jpgwire/package.json b/packages/jpgwire/package.json
index c5df87c70..d0b47430e 100644
--- a/packages/jpgwire/package.json
+++ b/packages/jpgwire/package.json
@@ -20,7 +20,8 @@
"dependencies": {
"@powersync/service-jsonbig": "workspace:^",
"@powersync/service-types": "workspace:^",
- "date-fns": "^3.6.0",
+ "@powersync/service-sync-rules": "workspace:^",
+ "date-fns": "^4.1.0",
"pgwire": "github:kagis/pgwire#f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87"
}
}
diff --git a/packages/jpgwire/src/pgwire_types.ts b/packages/jpgwire/src/pgwire_types.ts
index aa21d7fd5..a93aeba66 100644
--- a/packages/jpgwire/src/pgwire_types.ts
+++ b/packages/jpgwire/src/pgwire_types.ts
@@ -3,6 +3,7 @@
import type { PgoutputRelation } from 'pgwire/mod.js';
import { dateToSqlite, lsnMakeComparable, timestampToSqlite, timestamptzToSqlite } from './util.js';
import { JsonContainer } from '@powersync/service-jsonbig';
+import { DatabaseInputRow } from '@powersync/service-sync-rules';
export class PgType {
static decode(text: string, typeOid: number) {
@@ -253,23 +254,3 @@ export function decodeTuple(relation: PgoutputRelation, tupleRaw: Record = {
-readonly [P in keyof T]: T[P];
};
-export interface PgWireConnectionOptions extends NormalizedPostgresConnection {
+export interface PgWireConnectionOptions extends NormalizedConnectionConfig {
resolved_ip?: string;
}
diff --git a/packages/jpgwire/tsconfig.json b/packages/jpgwire/tsconfig.json
index f84b49829..7da72934d 100644
--- a/packages/jpgwire/tsconfig.json
+++ b/packages/jpgwire/tsconfig.json
@@ -15,6 +15,9 @@
},
{
"path": "../jsonbig"
+ },
+ {
+ "path": "../sync-rules"
}
]
}
diff --git a/packages/jsonbig/README.md b/packages/jsonbig/README.md
index f18b78151..3490fb45a 100644
--- a/packages/jsonbig/README.md
+++ b/packages/jsonbig/README.md
@@ -1,6 +1,7 @@
# powersync-jsonbig
JSON is used everywhere, including:
+
1. PostgreSQL (json/jsonb types)
2. Sync rules input (values are normalized to JSON text).
3. Sync rule transformations (extracting values, constructing objects in the future)
@@ -9,10 +10,12 @@ JSON is used everywhere, including:
Where we can, JSON data is kept as strings and not parsed.
This is so that:
+
1. We don't add parsing / serializing overhead.
2. We don't change the data.
Specifically:
+
1. The SQLite type system makes a distinction between INTEGER and REAL values. We try to preserve this.
2. Integers in SQLite can be up to 64-bit.
diff --git a/packages/rsocket-router/package.json b/packages/rsocket-router/package.json
index 43114d2c9..039abc5d3 100644
--- a/packages/rsocket-router/package.json
+++ b/packages/rsocket-router/package.json
@@ -28,8 +28,6 @@
"@types/uuid": "^9.0.4",
"@types/ws": "~8.2.0",
"bson": "^6.6.0",
- "rsocket-websocket-client": "1.0.0-alpha.3",
- "typescript": "~5.2.2",
- "vitest": "^0.34.6"
+ "rsocket-websocket-client": "1.0.0-alpha.3"
}
}
diff --git a/packages/service-core/package.json b/packages/service-core/package.json
index 2efb16ac5..df1e5976a 100644
--- a/packages/service-core/package.json
+++ b/packages/service-core/package.json
@@ -12,7 +12,7 @@
"scripts": {
"build": "tsc -b",
"build:tests": "tsc -b test/tsconfig.json",
- "test": "vitest --no-threads",
+ "test": "vitest",
"clean": "rm -rf ./dist && tsc -b --clean"
},
"dependencies": {
@@ -23,7 +23,6 @@
"@opentelemetry/resources": "^1.24.1",
"@opentelemetry/sdk-metrics": "1.24.1",
"@powersync/lib-services-framework": "workspace:*",
- "@powersync/service-jpgwire": "workspace:*",
"@powersync/service-jsonbig": "workspace:*",
"@powersync/service-rsocket-router": "workspace:*",
"@powersync/service-sync-rules": "workspace:*",
@@ -40,8 +39,8 @@
"lru-cache": "^10.2.2",
"mongodb": "^6.7.0",
"node-fetch": "^3.3.2",
- "pgwire": "github:kagis/pgwire#f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87",
"ts-codec": "^1.2.2",
+ "uri-js": "^4.4.1",
"uuid": "^9.0.1",
"winston": "^3.13.0",
"yaml": "^2.3.2"
@@ -51,9 +50,6 @@
"@types/lodash": "^4.17.5",
"@types/uuid": "^9.0.4",
"fastify": "4.23.2",
- "fastify-plugin": "^4.5.1",
- "typescript": "^5.2.2",
- "vite-tsconfig-paths": "^4.3.2",
- "vitest": "^0.34.6"
+ "fastify-plugin": "^4.5.1"
}
}
diff --git a/packages/service-core/src/api/RouteAPI.ts b/packages/service-core/src/api/RouteAPI.ts
new file mode 100644
index 000000000..c4212aa2b
--- /dev/null
+++ b/packages/service-core/src/api/RouteAPI.ts
@@ -0,0 +1,78 @@
+import { SqlSyncRules, TablePattern } from '@powersync/service-sync-rules';
+import * as types from '@powersync/service-types';
+import { ParseSyncRulesOptions, SyncRulesBucketStorage } from '../storage/BucketStorage.js';
+
+export interface PatternResult {
+ schema: string;
+ pattern: string;
+ wildcard: boolean;
+ tables?: types.TableInfo[];
+ table?: types.TableInfo;
+}
+
+export interface ReplicationLagOptions {
+ bucketStorage: SyncRulesBucketStorage;
+}
+
+/**
+ * Describes all the methods currently required to service the sync API endpoints.
+ */
+export interface RouteAPI {
+ /**
+ * @returns basic identification of the connection
+ */
+ getSourceConfig(): Promise;
+
+ /**
+ * Checks the current connection status of the data source.
+ * This is usually some test query to verify the source can be reached.
+ */
+ getConnectionStatus(): Promise;
+
+ /**
+ * Generates replication table information from a given pattern of tables.
+ *
+ * @param tablePatterns A set of table patterns which typically come from
+ * the tables listed in sync rules definitions.
+ *
+ * @param sqlSyncRules
+ * @returns A result of all the tables and columns which should be replicated
+ * based off the input patterns. Certain tests are executed on the
+ * tables to ensure syncing should function according to the input
+ * pattern. Debug errors and warnings are reported per table.
+ */
+ getDebugTablesInfo(tablePatterns: TablePattern[], sqlSyncRules: SqlSyncRules): Promise;
+
+ /**
+ * @returns The replication lag: that is the amount of data which has not been
+ * replicated yet, in bytes.
+ */
+ getReplicationLag(options: ReplicationLagOptions): Promise;
+
+ /**
+ * Get the current LSN or equivalent replication HEAD position identifier
+ */
+ getReplicationHead(): Promise;
+
+ /**
+ * @returns The schema for tables inside the connected database. This is typically
+ * used to validate sync rules.
+ */
+ getConnectionSchema(): Promise;
+
+ /**
+ * Executes a query and return the result from the data source. This is currently used in the
+ * admin API which is exposed in Collide.
+ */
+ executeQuery(query: string, params: any[]): Promise;
+
+ /**
+ * Close any resources that need graceful termination.
+ */
+ shutdown(): Promise;
+
+ /**
+ * Get the default schema (or database) when only a table name is specified in sync rules.
+ */
+ getParseSyncRulesOptions(): ParseSyncRulesOptions;
+}
diff --git a/packages/service-core/src/api/api-index.ts b/packages/service-core/src/api/api-index.ts
index f6063e867..0f90b1738 100644
--- a/packages/service-core/src/api/api-index.ts
+++ b/packages/service-core/src/api/api-index.ts
@@ -1,2 +1,3 @@
export * from './diagnostics.js';
+export * from './RouteAPI.js';
export * from './schema.js';
diff --git a/packages/service-core/src/api/diagnostics.ts b/packages/service-core/src/api/diagnostics.ts
index 46a7cde98..72231c9ce 100644
--- a/packages/service-core/src/api/diagnostics.ts
+++ b/packages/service-core/src/api/diagnostics.ts
@@ -1,51 +1,9 @@
+import { logger } from '@powersync/lib-services-framework';
import { DEFAULT_TAG, SourceTableInterface, SqlSyncRules } from '@powersync/service-sync-rules';
-import { pgwireRows } from '@powersync/service-jpgwire';
-import { ConnectionStatus, SyncRulesStatus, TableInfo, baseUri } from '@powersync/service-types';
+import { SyncRulesStatus, TableInfo } from '@powersync/service-types';
-import * as replication from '../replication/replication-index.js';
import * as storage from '../storage/storage-index.js';
-import * as util from '../util/util-index.js';
-
-import { CorePowerSyncSystem } from '../system/CorePowerSyncSystem.js';
-import { logger } from '@powersync/lib-services-framework';
-
-export async function getConnectionStatus(system: CorePowerSyncSystem): Promise {
- if (system.pgwire_pool == null) {
- return null;
- }
-
- const pool = system.requirePgPool();
-
- const base = {
- id: system.config.connection!.id,
- postgres_uri: baseUri(system.config.connection!)
- };
- try {
- await util.retriedQuery(pool, `SELECT 'PowerSync connection test'`);
- } catch (e) {
- return {
- ...base,
- connected: false,
- errors: [{ level: 'fatal', message: e.message }]
- };
- }
-
- try {
- await replication.checkSourceConfiguration(pool);
- } catch (e) {
- return {
- ...base,
- connected: true,
- errors: [{ level: 'fatal', message: e.message }]
- };
- }
-
- return {
- ...base,
- connected: true,
- errors: []
- };
-}
+import { RouteAPI } from './RouteAPI.js';
export interface DiagnosticsOptions {
/**
@@ -66,9 +24,12 @@ export interface DiagnosticsOptions {
check_connection: boolean;
}
+export const DEFAULT_DATASOURCE_ID = 'default';
+
export async function getSyncRulesStatus(
+ bucketStorage: storage.BucketStorageFactory,
+ apiHandler: RouteAPI,
sync_rules: storage.PersistedSyncRulesContent | null,
- system: CorePowerSyncSystem,
options: DiagnosticsOptions
): Promise {
if (sync_rules == null) {
@@ -82,7 +43,7 @@ export async function getSyncRulesStatus(
let rules: SqlSyncRules;
let persisted: storage.PersistedSyncRules;
try {
- persisted = sync_rules.parsed();
+ persisted = sync_rules.parsed(apiHandler.getParseSyncRulesOptions());
rules = persisted.sync_rules;
} catch (e) {
return {
@@ -92,21 +53,19 @@ export async function getSyncRulesStatus(
};
}
- const systemStorage = live_status ? await system.storage.getInstance(persisted) : undefined;
+ const sourceConfig = await apiHandler.getSourceConfig();
+ // This method can run under some situations if no connection is configured yet.
+ // It will return a default tag in such a case. This default tag is not module specific.
+ const tag = sourceConfig.tag ?? DEFAULT_TAG;
+ using systemStorage = live_status ? bucketStorage.getInstance(sync_rules) : undefined;
const status = await systemStorage?.getStatus();
let replication_lag_bytes: number | undefined = undefined;
let tables_flat: TableInfo[] = [];
if (check_connection) {
- const pool = system.requirePgPool();
-
const source_table_patterns = rules.getSourceTables();
- const wc = new replication.WalConnection({
- db: pool,
- sync_rules: rules
- });
- const resolved_tables = await wc.getDebugTablesInfo(source_table_patterns);
+ const resolved_tables = await apiHandler.getDebugTablesInfo(source_table_patterns, rules);
tables_flat = resolved_tables.flatMap((info) => {
if (info.table) {
return [info.table];
@@ -119,19 +78,9 @@ export async function getSyncRulesStatus(
if (systemStorage) {
try {
- const results = await util.retriedQuery(pool, {
- statement: `SELECT
- slot_name,
- confirmed_flush_lsn,
- pg_current_wal_lsn(),
- (pg_current_wal_lsn() - confirmed_flush_lsn) AS lsn_distance
- FROM pg_replication_slots WHERE slot_name = $1 LIMIT 1;`,
- params: [{ type: 'varchar', value: systemStorage!.slot_name }]
+ replication_lag_bytes = await apiHandler.getReplicationLag({
+ bucketStorage: systemStorage
});
- const [row] = pgwireRows(results);
- if (row) {
- replication_lag_bytes = Number(row.lsn_distance);
- }
} catch (e) {
// Ignore
logger.warn(`Unable to get replication lag`, e);
@@ -139,7 +88,6 @@ export async function getSyncRulesStatus(
}
} else {
const source_table_patterns = rules.getSourceTables();
- const tag = system.config.connection!.tag ?? DEFAULT_TAG;
tables_flat = source_table_patterns.map((pattern): TableInfo => {
if (pattern.isWildcard) {
@@ -190,8 +138,8 @@ export async function getSyncRulesStatus(
content: include_content ? sync_rules.sync_rules_content : undefined,
connections: [
{
- id: system.config.connection!.id,
- tag: system.config.connection!.tag ?? DEFAULT_TAG,
+ id: sourceConfig.id ?? DEFAULT_DATASOURCE_ID,
+ tag: tag,
slot_name: sync_rules.slot_name,
initial_replication_done: status?.snapshot_done ?? false,
// TODO: Rename?
diff --git a/packages/service-core/src/api/schema.ts b/packages/service-core/src/api/schema.ts
index e3ffbb744..5469973b2 100644
--- a/packages/service-core/src/api/schema.ts
+++ b/packages/service-core/src/api/schema.ts
@@ -1,99 +1,27 @@
-import type * as pgwire from '@powersync/service-jpgwire';
-import { pgwireRows } from '@powersync/service-jpgwire';
-import { DatabaseSchema, internal_routes } from '@powersync/service-types';
+import { internal_routes } from '@powersync/service-types';
-import * as util from '../util/util-index.js';
-import { CorePowerSyncSystem } from '../system/CorePowerSyncSystem.js';
+import * as api from '../api/api-index.js';
-export async function getConnectionsSchema(system: CorePowerSyncSystem): Promise {
- if (system.config.connection == null) {
- return { connections: [] };
+export async function getConnectionsSchema(api: api.RouteAPI): Promise {
+ if (!api) {
+ return {
+ connections: [],
+ defaultConnectionTag: 'default',
+ defaultSchema: ''
+ };
}
- const schemas = await getConnectionSchema(system.requirePgPool());
+
+ const baseConfig = await api.getSourceConfig();
+
return {
connections: [
{
- schemas,
- tag: system.config.connection!.tag,
- id: system.config.connection!.id
+ id: baseConfig.id,
+ tag: baseConfig.tag,
+ schemas: await api.getConnectionSchema()
}
- ]
+ ],
+ defaultConnectionTag: baseConfig.tag!,
+ defaultSchema: api.getParseSyncRulesOptions().defaultSchema
};
}
-
-export async function getConnectionSchema(db: pgwire.PgClient): Promise {
- // https://github.com/Borvik/vscode-postgres/blob/88ec5ed061a0c9bced6c5d4ec122d0759c3f3247/src/language/server.ts
- const results = await util.retriedQuery(
- db,
- `SELECT
- tbl.schemaname,
- tbl.tablename,
- tbl.quoted_name,
- json_agg(a ORDER BY attnum) as columns
-FROM
- (
- SELECT
- n.nspname as schemaname,
- c.relname as tablename,
- (quote_ident(n.nspname) || '.' || quote_ident(c.relname)) as quoted_name
- FROM
- pg_catalog.pg_class c
- JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
- WHERE
- c.relkind = 'r'
- AND n.nspname not in ('information_schema', 'pg_catalog', 'pg_toast')
- AND n.nspname not like 'pg_temp_%'
- AND n.nspname not like 'pg_toast_temp_%'
- AND c.relnatts > 0
- AND has_schema_privilege(n.oid, 'USAGE') = true
- AND has_table_privilege(quote_ident(n.nspname) || '.' || quote_ident(c.relname), 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER') = true
- ) as tbl
- LEFT JOIN (
- SELECT
- attrelid,
- attname,
- format_type(atttypid, atttypmod) as data_type,
- (SELECT typname FROM pg_catalog.pg_type WHERE oid = atttypid) as pg_type,
- attnum,
- attisdropped
- FROM
- pg_attribute
- ) as a ON (
- a.attrelid = tbl.quoted_name::regclass
- AND a.attnum > 0
- AND NOT a.attisdropped
- AND has_column_privilege(tbl.quoted_name, a.attname, 'SELECT, INSERT, UPDATE, REFERENCES')
- )
-GROUP BY schemaname, tablename, quoted_name`
- );
- const rows = pgwireRows(results);
-
- let schemas: Record = {};
-
- for (let row of rows) {
- const schema = (schemas[row.schemaname] ??= {
- name: row.schemaname,
- tables: []
- });
- const table = {
- name: row.tablename,
- columns: [] as any[]
- };
- schema.tables.push(table);
-
- const columnInfo = JSON.parse(row.columns);
- for (let column of columnInfo) {
- let pg_type = column.pg_type as string;
- if (pg_type.startsWith('_')) {
- pg_type = `${pg_type.substring(1)}[]`;
- }
- table.columns.push({
- name: column.attname,
- type: column.data_type,
- pg_type: pg_type
- });
- }
- }
-
- return Object.values(schemas);
-}
diff --git a/packages/service-core/src/auth/KeyStore.ts b/packages/service-core/src/auth/KeyStore.ts
index 9392d1cd3..aed671a13 100644
--- a/packages/service-core/src/auth/KeyStore.ts
+++ b/packages/service-core/src/auth/KeyStore.ts
@@ -1,9 +1,9 @@
+import { logger } from '@powersync/lib-services-framework';
import * as jose from 'jose';
import secs from '../util/secs.js';
-import { KeyOptions, KeySpec, SUPPORTED_ALGORITHMS } from './KeySpec.js';
-import { KeyCollector } from './KeyCollector.js';
import { JwtPayload } from './JwtPayload.js';
-import { logger } from '@powersync/lib-services-framework';
+import { KeyCollector } from './KeyCollector.js';
+import { KeyOptions, KeySpec, SUPPORTED_ALGORITHMS } from './KeySpec.js';
/**
* KeyStore to get keys and verify tokens.
@@ -32,10 +32,13 @@ import { logger } from '@powersync/lib-services-framework';
* If we have a matching kid, we can generally get a detailed error (e.g. signature verification failed, invalid algorithm, etc).
* If we don't have a matching kid, we'll generally just get an error "Could not find an appropriate key...".
*/
-export class KeyStore {
- private collector: KeyCollector;
+export class KeyStore