From 6a245efb8bf118aebd4cfb78c5d9bf20e80806cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 15:08:19 +0000 Subject: [PATCH 1/5] Initial plan From f1d3bd4dc0e8fa3b44561064a536d8aba65de7c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 15:12:22 +0000 Subject: [PATCH 2/5] Add schemaName support, docs, migration, and test for Supabase custom schema replication Co-authored-by: pubkey <8926560+pubkey@users.noreply.github.com> --- .../20250307000000_humans-private-schema.sql | 47 ++++++++++ docs-src/docs/replication-supabase.md | 44 +++++++++ src/plugins/replication-supabase/index.ts | 11 ++- src/plugins/replication-supabase/types.ts | 5 + test/replication-supabase.test.ts | 94 +++++++++++++++++++ 5 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 config/supabase/migrations/20250307000000_humans-private-schema.sql diff --git a/config/supabase/migrations/20250307000000_humans-private-schema.sql b/config/supabase/migrations/20250307000000_humans-private-schema.sql new file mode 100644 index 00000000000..8ea6fa98eb1 --- /dev/null +++ b/config/supabase/migrations/20250307000000_humans-private-schema.sql @@ -0,0 +1,47 @@ +-- Create a custom schema to test schema-aware replication +create schema if not exists "private"; + +create table "private"."humans" ( + "passportId" text primary key, + "firstName" text not null, + "lastName" text not null, + "age" integer, + + "_deleted" boolean DEFAULT false NOT NULL, + "_modified" timestamp with time zone DEFAULT now() NOT NULL +); + +-- auto-update the _modified timestamp +CREATE TRIGGER update_modified_datetime BEFORE UPDATE ON private.humans FOR EACH ROW +EXECUTE FUNCTION extensions.moddatetime('_modified'); + +-- add a table to the publication so we can subscribe to changes +alter publication supabase_realtime add table "private"."humans"; + +grant usage on schema "private" to "anon"; +grant usage on schema "private" to "authenticated"; +grant usage on schema "private" to "service_role"; + +grant delete on table "private"."humans" to "anon"; +grant insert on table "private"."humans" to "anon"; +grant references on table "private"."humans" to "anon"; +grant select on table "private"."humans" to "anon"; +grant trigger on table "private"."humans" to "anon"; +grant truncate on table "private"."humans" to "anon"; +grant update on table "private"."humans" to "anon"; + +grant delete on table "private"."humans" to "authenticated"; +grant insert on table "private"."humans" to "authenticated"; +grant references on table "private"."humans" to "authenticated"; +grant select on table "private"."humans" to "authenticated"; +grant trigger on table "private"."humans" to "authenticated"; +grant truncate on table "private"."humans" to "authenticated"; +grant update on table "private"."humans" to "authenticated"; + +grant delete on table "private"."humans" to "service_role"; +grant insert on table "private"."humans" to "service_role"; +grant references on table "private"."humans" to "service_role"; +grant select on table "private"."humans" to "service_role"; +grant trigger on table "private"."humans" to "service_role"; +grant truncate on table "private"."humans" to "service_role"; +grant update on table "private"."humans" to "service_role"; diff --git a/docs-src/docs/replication-supabase.md b/docs-src/docs/replication-supabase.md index b1fc3c918b7..f7601a6c78e 100644 --- a/docs-src/docs/replication-supabase.md +++ b/docs-src/docs/replication-supabase.md @@ -223,6 +223,50 @@ Supabase returns `null` for nullable columns, but in RxDB you often model those ::: +## Using a Custom Postgres Schema + +By default, the plugin targets the `public` schema. If your tables live in a different Postgres schema, pass `schemaName` to `replicateSupabase`: + +```ts +const replication = replicateSupabase({ + tableName: 'humans', + schemaName: 'private', // default is "public" + client: supabase, + collection: db.humans, + replicationIdentifier: 'humans-private-schema', + pull: { batchSize: 50 }, + push: { batchSize: 50 }, +}); +``` + +You also need to: +- Create the table inside that schema and grant the required roles access to both the schema and the table. +- Add the table to the `supabase_realtime` publication if you use live replication. + +Example SQL to set up a `private` schema with a `humans` table: + +```sql +create schema if not exists "private"; + +create table "private"."humans" ( + "passportId" text primary key, + "firstName" text not null, + "lastName" text not null, + "age" integer, + "_deleted" boolean DEFAULT false NOT NULL, + "_modified" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TRIGGER update_modified_datetime BEFORE UPDATE ON private.humans FOR EACH ROW +EXECUTE FUNCTION extensions.moddatetime('_modified'); + +alter publication supabase_realtime add table "private"."humans"; + +grant usage on schema "private" to "anon"; +grant select, insert, update, delete on table "private"."humans" to "anon"; +``` + + ## Using Joins You can use the `pull.queryBuilder` to use joins and also pull data from related tables. diff --git a/src/plugins/replication-supabase/index.ts b/src/plugins/replication-supabase/index.ts index 95fed8d5513..9784a049ac7 100644 --- a/src/plugins/replication-supabase/index.ts +++ b/src/plugins/replication-supabase/index.ts @@ -58,6 +58,7 @@ export function replicateSupabase( // set defaults options.waitForLeadership = typeof options.waitForLeadership === 'undefined' ? true : options.waitForLeadership; options.live = typeof options.live === 'undefined' ? true : options.live; + const schemaName = typeof options.schemaName === 'undefined' ? 'public' : options.schemaName; const modifiedField = options.modifiedField ? options.modifiedField : DEFAULT_MODIFIED_FIELD; const deletedField = options.deletedField ? options.deletedField : DEFAULT_DELETED_FIELD; @@ -86,7 +87,7 @@ export function replicateSupabase( return doc; } async function fetchById(id: string): Promise> { - const { data, error } = await options.client + const { data, error } = await options.client.schema(schemaName) .from(options.tableName) .select() .eq(primaryPath, id) @@ -103,7 +104,7 @@ export function replicateSupabase( lastPulledCheckpoint: SupabaseCheckpoint | undefined, batchSize: number ) { - let query = options.client + let query = options.client.schema(schemaName) .from(options.tableName) .select('*'); @@ -169,7 +170,7 @@ export function replicateSupabase( ) { async function insertOrReturnConflict(doc: WithDeleted): Promise | undefined> { const id = (doc as any)[primaryPath]; - const { error } = await options.client.from(options.tableName).insert(doc) + const { error } = await options.client.schema(schemaName).from(options.tableName).insert(doc) if (!error) { return; } else if (error.code == POSTGRES_INSERT_CONFLICT_CODE) { @@ -197,7 +198,7 @@ export function replicateSupabase( // modified field will be set server-side delete toRow[modifiedField]; - let query = options.client + let query = options.client.schema(schemaName) .from(options.tableName) .update(toRow); @@ -262,7 +263,7 @@ export function replicateSupabase( .channel('realtime:' + options.tableName) .on( 'postgres_changes', - { event: '*', schema: 'public', table: options.tableName }, + { event: '*', schema: schemaName, table: options.tableName }, (payload) => { /** * We assume soft-deletes in supabase diff --git a/src/plugins/replication-supabase/types.ts b/src/plugins/replication-supabase/types.ts index c1c9a5c859f..9a7c8c11f47 100644 --- a/src/plugins/replication-supabase/types.ts +++ b/src/plugins/replication-supabase/types.ts @@ -26,6 +26,11 @@ export type SyncOptionsSupabase = Omit< client: SupabaseClient; tableName: string; + /** + * The Postgres schema to use. Default: "public" + */ + schemaName?: string; + /** * Modified field, default "_modified" */ diff --git a/test/replication-supabase.test.ts b/test/replication-supabase.test.ts index 68c93577f7f..77d1b18ca88 100644 --- a/test/replication-supabase.test.ts +++ b/test/replication-supabase.test.ts @@ -535,6 +535,100 @@ describe('replication-supabase.test.ts', function () { }); }); + describe('schemaName', () => { + const privateSchemaTableName = 'humans'; + const privateSchemaName = 'private'; + + async function getPrivateSchemaServerState(): Promise[]> { + const { data, error } = await supabase + .schema(privateSchemaName) + .from(privateSchemaTableName) + .select('*'); + if (error) { + throw error; + } + return data; + } + async function cleanUpPrivateSchemaServer() { + const { error } = await supabase + .schema(privateSchemaName) + .from(privateSchemaTableName) + .delete() + .neq(primaryPath, 0); + if (error) { + throw error; + } + } + it('should push and pull documents using a custom schemaName', async () => { + await cleanUpPrivateSchemaServer(); + + const collection = await humansCollection.createPrimary(5, undefined, false); + + const replicationState = replicateSupabase({ + tableName: privateSchemaTableName, + schemaName: privateSchemaName, + client: supabase, + replicationIdentifier: randomToken(10), + collection, + live: false, + pull: { + batchSize, + modifier: d => { + if (!d.age) { + delete d.age; + } + return d; + } + }, + push: { + batchSize + } + }); + ensureReplicationHasNoErrors(replicationState); + await replicationState.awaitInitialReplication(); + await replicationState.awaitInSync(); + await replicationState.cancel(); + + const serverState = await getPrivateSchemaServerState(); + assert.strictEqual(serverState.length, 5, 'must have pushed all docs to the custom schema'); + + // also verify that the public schema was not touched + const publicServerState = await getServerState(); + assert.strictEqual(publicServerState.length, 0, 'public schema must be empty'); + + // pull from the custom schema into a fresh collection + const collection2 = await humansCollection.createPrimary(0, undefined, false); + const replicationState2 = replicateSupabase({ + tableName: privateSchemaTableName, + schemaName: privateSchemaName, + client: supabase, + replicationIdentifier: randomToken(10), + collection: collection2, + live: false, + pull: { + batchSize, + modifier: d => { + if (!d.age) { + delete d.age; + } + return d; + } + } + }); + ensureReplicationHasNoErrors(replicationState2); + await replicationState2.awaitInitialReplication(); + await replicationState2.awaitInSync(); + await replicationState2.cancel(); + + const docs2 = await collection2.find().exec(); + assert.strictEqual(docs2.length, 5, 'must have pulled all docs from the custom schema'); + + await collection.database.close(); + await collection2.database.close(); + await cleanUpPrivateSchemaServer(); + }); + }); + describe('issues', () => { it('#7513 push.modifier is never applied', async () => { await cleanUpServer(); From 40d30f5b1862175614c1f945335b7d62059c7bfd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 16:05:03 +0000 Subject: [PATCH 3/5] Add schemaName feature entry to 17.0.0.md release notes Co-authored-by: pubkey <8926560+pubkey@users.noreply.github.com> --- docs-src/docs/releases/17.0.0.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs-src/docs/releases/17.0.0.md b/docs-src/docs/releases/17.0.0.md index 74b4445e75f..e7978063052 100644 --- a/docs-src/docs/releases/17.0.0.md +++ b/docs-src/docs/releases/17.0.0.md @@ -101,6 +101,7 @@ To improve vibe-coding when working with RxDB directly we: - **FIX** add event guard to count and findByIds in _execOverDatabase [#7864](https://github.com/pubkey/rxdb/pull/7864) - **FIX**: memory leak in migratePromise() [#7787](https://github.com/pubkey/rxdb/pull/7787) - **FIX** `exclusiveMinimum` and `exclusiveMaximum` TypeScript types corrected from `boolean` to `number` to match JSON Schema Draft 6+ [#7962](https://github.com/pubkey/rxdb/pull/7962) +- **ADD** `schemaName` option to the [Supabase Replication Plugin](../replication-supabase.md) to replicate tables from non-public Postgres schemas [#7963](https://github.com/pubkey/rxdb/pull/7963) - **ADD** `waitBeforePersist` option to `ReplicationPushOptions` to delay upstream persistence cycles, enabling write batching across collections and CPU-idle deferral [#7872](https://github.com/pubkey/rxdb/issues/7872) - **ADD** enforce maximum length for indexes and primary keys (`maxLength: 2048`) - **CHANGE** `final` schema fields no longer need to be marked as `required` From 056dc69673cf40c74b869dba134bbcd0f6b54c83 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 19:48:41 +0000 Subject: [PATCH 4/5] Fix: avoid client.schema('public') to prevent operation-canceled errors in tests Co-authored-by: pubkey <8926560+pubkey@users.noreply.github.com> --- src/plugins/replication-supabase/index.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/plugins/replication-supabase/index.ts b/src/plugins/replication-supabase/index.ts index 9784a049ac7..edec72fa0b7 100644 --- a/src/plugins/replication-supabase/index.ts +++ b/src/plugins/replication-supabase/index.ts @@ -62,6 +62,16 @@ export function replicateSupabase( const modifiedField = options.modifiedField ? options.modifiedField : DEFAULT_MODIFIED_FIELD; const deletedField = options.deletedField ? options.deletedField : DEFAULT_DELETED_FIELD; + /** + * For the default 'public' schema we use the client directly + * to avoid sending an explicit Accept-Profile header, which is + * not needed and can cause "operation was canceled" errors in + * some PostgREST setups. + */ + const schemaClient = schemaName === 'public' + ? options.client + : options.client.schema(schemaName); + const pullStream$: Subject> = new Subject(); let replicationPrimitivesPull: ReplicationPullOptions | undefined; @@ -87,7 +97,7 @@ export function replicateSupabase( return doc; } async function fetchById(id: string): Promise> { - const { data, error } = await options.client.schema(schemaName) + const { data, error } = await schemaClient .from(options.tableName) .select() .eq(primaryPath, id) @@ -104,7 +114,7 @@ export function replicateSupabase( lastPulledCheckpoint: SupabaseCheckpoint | undefined, batchSize: number ) { - let query = options.client.schema(schemaName) + let query = schemaClient .from(options.tableName) .select('*'); @@ -170,7 +180,7 @@ export function replicateSupabase( ) { async function insertOrReturnConflict(doc: WithDeleted): Promise | undefined> { const id = (doc as any)[primaryPath]; - const { error } = await options.client.schema(schemaName).from(options.tableName).insert(doc) + const { error } = await schemaClient.from(options.tableName).insert(doc) if (!error) { return; } else if (error.code == POSTGRES_INSERT_CONFLICT_CODE) { @@ -198,7 +208,7 @@ export function replicateSupabase( // modified field will be set server-side delete toRow[modifiedField]; - let query = options.client.schema(schemaName) + let query = schemaClient .from(options.tableName) .update(toRow); From 80c4b0519183718900e5db30d2129af191e0cc35 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:01:23 +0000 Subject: [PATCH 5/5] Changes before error encountered Co-authored-by: pubkey <8926560+pubkey@users.noreply.github.com> --- config/supabase/.branches/_current_branch | 1 - config/supabase/.temp/cli-latest | 1 - config/supabase/config.toml | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 config/supabase/.branches/_current_branch delete mode 100644 config/supabase/.temp/cli-latest diff --git a/config/supabase/.branches/_current_branch b/config/supabase/.branches/_current_branch deleted file mode 100644 index 88d050b1908..00000000000 --- a/config/supabase/.branches/_current_branch +++ /dev/null @@ -1 +0,0 @@ -main \ No newline at end of file diff --git a/config/supabase/.temp/cli-latest b/config/supabase/.temp/cli-latest deleted file mode 100644 index 80a44124881..00000000000 --- a/config/supabase/.temp/cli-latest +++ /dev/null @@ -1 +0,0 @@ -v2.39.2 \ No newline at end of file diff --git a/config/supabase/config.toml b/config/supabase/config.toml index 1dc53eaf138..0adc285b0f2 100644 --- a/config/supabase/config.toml +++ b/config/supabase/config.toml @@ -8,7 +8,7 @@ enabled = true port = 54321 # Schemas to expose in your API. Tables, views and stored procedures in this schema will get API # endpoints. public and storage are always included. -schemas = ["public", "storage", "graphql_public"] +schemas = ["public", "private", "storage", "graphql_public"] # Extra schemas to add to the search_path of every request. public is always included. extra_search_path = ["public", "extensions"] # The maximum number of rows returns from a view, table, or stored procedure. Limits payload size