Skip to content

Commit ed336fd

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix-checksums-2
2 parents 52168c2 + 9681b4c commit ed336fd

31 files changed

+1591
-141
lines changed

.changeset/empty-spiders-carry.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@powersync/service-module-postgres': patch
3+
'@powersync/service-sync-rules': patch
4+
'@powersync/service-image': patch
5+
---
6+
7+
Add the `custom_postgres_types` compatibility option. When enabled, domain, composite, enum, range, multirange and custom array types will get synced in a JSON representation instead of the raw postgres wire format.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@powersync/service-jpgwire': minor
3+
---
4+
5+
Add utilities for parsing serialized structures like compound types and arrays.

modules/module-postgres/src/api/PostgresRouteAPIAdapter.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ import { getDebugTableInfo } from '../replication/replication-utils.js';
99
import { KEEPALIVE_STATEMENT, PUBLICATION_NAME } from '../replication/WalStream.js';
1010
import * as types from '../types/types.js';
1111
import { getApplicationName } from '../utils/application-name.js';
12+
import { CustomTypeRegistry } from '../types/registry.js';
13+
import { PostgresTypeResolver } from '../types/resolver.js';
1214

1315
export class PostgresRouteAPIAdapter implements api.RouteAPI {
16+
private typeCache: PostgresTypeResolver;
1417
connectionTag: string;
1518
// TODO this should probably be configurable one day
1619
publicationName = PUBLICATION_NAME;
@@ -31,6 +34,7 @@ export class PostgresRouteAPIAdapter implements api.RouteAPI {
3134
connectionTag?: string,
3235
private config?: types.ResolvedConnectionConfig
3336
) {
37+
this.typeCache = new PostgresTypeResolver(config?.typeRegistry ?? new CustomTypeRegistry(), pool);
3438
this.connectionTag = connectionTag ?? sync_rules.DEFAULT_TAG;
3539
}
3640

@@ -297,6 +301,7 @@ LEFT JOIN (
297301
SELECT
298302
attrelid,
299303
attname,
304+
atttypid,
300305
format_type(atttypid, atttypmod) as data_type,
301306
(SELECT typname FROM pg_catalog.pg_type WHERE oid = atttypid) as pg_type,
302307
attnum,
@@ -311,6 +316,7 @@ LEFT JOIN (
311316
)
312317
GROUP BY schemaname, tablename, quoted_name`
313318
);
319+
await this.typeCache.fetchTypesForSchema();
314320
const rows = pgwire.pgwireRows(results);
315321

316322
let schemas: Record<string, service_types.DatabaseSchema> = {};
@@ -332,9 +338,11 @@ GROUP BY schemaname, tablename, quoted_name`
332338
if (pg_type.startsWith('_')) {
333339
pg_type = `${pg_type.substring(1)}[]`;
334340
}
341+
342+
const knownType = this.typeCache.registry.lookupType(Number(column.atttypid));
335343
table.columns.push({
336344
name: column.attname,
337-
sqlite_type: sync_rules.expressionTypeFromPostgresType(pg_type).typeFlags,
345+
sqlite_type: sync_rules.ExpressionType.fromTypeText(knownType.sqliteType()).typeFlags,
338346
type: column.data_type,
339347
internal_type: column.data_type,
340348
pg_type: pg_type
Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1 @@
11
export * from './module/PostgresModule.js';
2-
3-
export * as pg_utils from './utils/pgwire_utils.js';

modules/module-postgres/src/module/PostgresModule.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ import { WalStreamReplicator } from '../replication/WalStreamReplicator.js';
1919
import * as types from '../types/types.js';
2020
import { PostgresConnectionConfig } from '../types/types.js';
2121
import { getApplicationName } from '../utils/application-name.js';
22+
import { CustomTypeRegistry } from '../types/registry.js';
2223

2324
export class PostgresModule extends replication.ReplicationModule<types.PostgresConnectionConfig> {
25+
private customTypes: CustomTypeRegistry = new CustomTypeRegistry();
26+
2427
constructor() {
2528
super({
2629
name: 'Postgres',
@@ -48,7 +51,7 @@ export class PostgresModule extends replication.ReplicationModule<types.Postgres
4851
protected createReplicator(context: system.ServiceContext): replication.AbstractReplicator {
4952
const normalisedConfig = this.resolveConfig(this.decodedConfig!);
5053
const syncRuleProvider = new ConfigurationFileSyncRulesProvider(context.configuration.sync_rules);
51-
const connectionFactory = new ConnectionManagerFactory(normalisedConfig);
54+
const connectionFactory = new ConnectionManagerFactory(normalisedConfig, this.customTypes);
5255

5356
return new WalStreamReplicator({
5457
id: this.getDefaultId(normalisedConfig.database),
@@ -66,7 +69,8 @@ export class PostgresModule extends replication.ReplicationModule<types.Postgres
6669
private resolveConfig(config: types.PostgresConnectionConfig): types.ResolvedConnectionConfig {
6770
return {
6871
...config,
69-
...types.normalizeConnectionConfig(config)
72+
...types.normalizeConnectionConfig(config),
73+
typeRegistry: this.customTypes
7074
};
7175
}
7276

@@ -75,7 +79,8 @@ export class PostgresModule extends replication.ReplicationModule<types.Postgres
7579
const connectionManager = new PgManager(normalisedConfig, {
7680
idleTimeout: 30_000,
7781
maxSize: 1,
78-
applicationName: getApplicationName()
82+
applicationName: getApplicationName(),
83+
registry: this.customTypes
7984
});
8085

8186
try {
@@ -106,7 +111,8 @@ export class PostgresModule extends replication.ReplicationModule<types.Postgres
106111
const connectionManager = new PgManager(normalizedConfig, {
107112
idleTimeout: 30_000,
108113
maxSize: 1,
109-
applicationName: getApplicationName()
114+
applicationName: getApplicationName(),
115+
registry: new CustomTypeRegistry()
110116
});
111117
const connection = await connectionManager.snapshotConnection();
112118
try {

modules/module-postgres/src/replication/ConnectionManagerFactory.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,22 @@ import { PgManager } from './PgManager.js';
22
import { NormalizedPostgresConnectionConfig } from '../types/types.js';
33
import { PgPoolOptions } from '@powersync/service-jpgwire';
44
import { logger } from '@powersync/lib-services-framework';
5+
import { CustomTypeRegistry } from '../types/registry.js';
56

67
export class ConnectionManagerFactory {
78
private readonly connectionManagers: PgManager[];
89
public readonly dbConnectionConfig: NormalizedPostgresConnectionConfig;
910

10-
constructor(dbConnectionConfig: NormalizedPostgresConnectionConfig) {
11+
constructor(
12+
dbConnectionConfig: NormalizedPostgresConnectionConfig,
13+
private readonly registry: CustomTypeRegistry
14+
) {
1115
this.dbConnectionConfig = dbConnectionConfig;
1216
this.connectionManagers = [];
1317
}
1418

1519
create(poolOptions: PgPoolOptions) {
16-
const manager = new PgManager(this.dbConnectionConfig, poolOptions);
20+
const manager = new PgManager(this.dbConnectionConfig, { ...poolOptions, registry: this.registry });
1721
this.connectionManagers.push(manager);
1822
return manager;
1923
}

modules/module-postgres/src/replication/PgManager.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ import * as pgwire from '@powersync/service-jpgwire';
22
import semver from 'semver';
33
import { NormalizedPostgresConnectionConfig } from '../types/types.js';
44
import { getApplicationName } from '../utils/application-name.js';
5+
import { PostgresTypeResolver } from '../types/resolver.js';
6+
import { getServerVersion } from '../utils/postgres_version.js';
7+
import { CustomTypeRegistry } from '../types/registry.js';
8+
9+
export interface PgManagerOptions extends pgwire.PgPoolOptions {
10+
registry: CustomTypeRegistry;
11+
}
512

613
/**
714
* Shorter timeout for snapshot connections than for replication connections.
@@ -14,14 +21,17 @@ export class PgManager {
1421
*/
1522
public readonly pool: pgwire.PgClient;
1623

24+
public readonly types: PostgresTypeResolver;
25+
1726
private connectionPromises: Promise<pgwire.PgConnection>[] = [];
1827

1928
constructor(
2029
public options: NormalizedPostgresConnectionConfig,
21-
public poolOptions: pgwire.PgPoolOptions
30+
public poolOptions: PgManagerOptions
2231
) {
2332
// The pool is lazy - no connections are opened until a query is performed.
2433
this.pool = pgwire.connectPgWirePool(this.options, poolOptions);
34+
this.types = new PostgresTypeResolver(poolOptions.registry, this.pool);
2535
}
2636

2737
public get connectionTag() {
@@ -41,9 +51,7 @@ export class PgManager {
4151
* @returns The Postgres server version in a parsed Semver instance
4252
*/
4353
async getServerVersion(): Promise<semver.SemVer | null> {
44-
const result = await this.pool.query(`SHOW server_version;`);
45-
// The result is usually of the form "16.2 (Debian 16.2-1.pgdg120+2)"
46-
return semver.coerce(result.rows[0][0].split(' ')[0]);
54+
return await getServerVersion(this.pool);
4755
}
4856

4957
/**

modules/module-postgres/src/replication/PgRelation.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,12 @@ export function getPgOutputRelation(source: PgoutputRelation): storage.SourceEnt
3030
replicaIdColumns: getReplicaIdColumns(source)
3131
} satisfies storage.SourceEntityDescriptor;
3232
}
33+
34+
export function referencedColumnTypeIds(source: PgoutputRelation): number[] {
35+
const oids = new Set<number>();
36+
for (const column of source.columns) {
37+
oids.add(column.typeOid);
38+
}
39+
40+
return [...oids];
41+
}

modules/module-postgres/src/replication/WalStream.ts

Lines changed: 49 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,9 @@ import {
2929
TablePattern,
3030
toSyncRulesRow
3131
} from '@powersync/service-sync-rules';
32-
import * as pg_utils from '../utils/pgwire_utils.js';
3332

3433
import { PgManager } from './PgManager.js';
35-
import { getPgOutputRelation, getRelId } from './PgRelation.js';
34+
import { getPgOutputRelation, getRelId, referencedColumnTypeIds } from './PgRelation.js';
3635
import { checkSourceConfiguration, checkTableRls, getReplicationIdentityColumns } from './replication-utils.js';
3736
import { ReplicationMetric } from '@powersync/service-types';
3837
import {
@@ -189,28 +188,30 @@ export class WalStream {
189188

190189
let tableRows: any[];
191190
const prefix = tablePattern.isWildcard ? tablePattern.tablePrefix : undefined;
192-
if (tablePattern.isWildcard) {
193-
const result = await db.query({
194-
statement: `SELECT c.oid AS relid, c.relname AS table_name
191+
192+
{
193+
let query = `
194+
SELECT
195+
c.oid AS relid,
196+
c.relname AS table_name,
197+
(SELECT
198+
json_agg(DISTINCT a.atttypid)
199+
FROM pg_attribute a
200+
WHERE a.attnum > 0 AND NOT a.attisdropped AND a.attrelid = c.oid)
201+
AS column_types
195202
FROM pg_class c
196203
JOIN pg_namespace n ON n.oid = c.relnamespace
197204
WHERE n.nspname = $1
198-
AND c.relkind = 'r'
199-
AND c.relname LIKE $2`,
200-
params: [
201-
{ type: 'varchar', value: schema },
202-
{ type: 'varchar', value: tablePattern.tablePattern }
203-
]
204-
});
205-
tableRows = pgwire.pgwireRows(result);
206-
} else {
205+
AND c.relkind = 'r'`;
206+
207+
if (tablePattern.isWildcard) {
208+
query += ' AND c.relname LIKE $2';
209+
} else {
210+
query += ' AND c.relname = $2';
211+
}
212+
207213
const result = await db.query({
208-
statement: `SELECT c.oid AS relid, c.relname AS table_name
209-
FROM pg_class c
210-
JOIN pg_namespace n ON n.oid = c.relnamespace
211-
WHERE n.nspname = $1
212-
AND c.relkind = 'r'
213-
AND c.relname = $2`,
214+
statement: query,
214215
params: [
215216
{ type: 'varchar', value: schema },
216217
{ type: 'varchar', value: tablePattern.tablePattern }
@@ -219,6 +220,7 @@ export class WalStream {
219220

220221
tableRows = pgwire.pgwireRows(result);
221222
}
223+
222224
let result: storage.SourceTable[] = [];
223225

224226
for (let row of tableRows) {
@@ -258,16 +260,18 @@ export class WalStream {
258260

259261
const cresult = await getReplicationIdentityColumns(db, relid);
260262

261-
const table = await this.handleRelation(
263+
const columnTypes = (JSON.parse(row.column_types) as string[]).map((e) => Number(e));
264+
const table = await this.handleRelation({
262265
batch,
263-
{
266+
descriptor: {
264267
name,
265268
schema,
266269
objectId: relid,
267270
replicaIdColumns: cresult.replicationColumns
268271
} as SourceEntityDescriptor,
269-
false
270-
);
272+
snapshot: false,
273+
referencedTypeIds: columnTypes
274+
});
271275

272276
result.push(table);
273277
}
@@ -683,7 +687,14 @@ WHERE oid = $1::regclass`,
683687
}
684688
}
685689

686-
async handleRelation(batch: storage.BucketStorageBatch, descriptor: SourceEntityDescriptor, snapshot: boolean) {
690+
async handleRelation(options: {
691+
batch: storage.BucketStorageBatch;
692+
descriptor: SourceEntityDescriptor;
693+
snapshot: boolean;
694+
referencedTypeIds: number[];
695+
}) {
696+
const { batch, descriptor, snapshot, referencedTypeIds } = options;
697+
687698
if (!descriptor.objectId && typeof descriptor.objectId != 'number') {
688699
throw new ReplicationAssertionError(`objectId expected, got ${typeof descriptor.objectId}`);
689700
}
@@ -699,6 +710,9 @@ WHERE oid = $1::regclass`,
699710
// Drop conflicting tables. This includes for example renamed tables.
700711
await batch.drop(result.dropTables);
701712

713+
// Ensure we have a description for custom types referenced in the table.
714+
await this.connections.types.fetchTypes(referencedTypeIds);
715+
702716
// Snapshot if:
703717
// 1. Snapshot is requested (false for initial snapshot, since that process handles it elsewhere)
704718
// 2. Snapshot is not already done, AND:
@@ -789,7 +803,7 @@ WHERE oid = $1::regclass`,
789803

790804
if (msg.tag == 'insert') {
791805
this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(1);
792-
const baseRecord = pg_utils.constructAfterRecord(msg);
806+
const baseRecord = this.connections.types.constructAfterRecord(msg);
793807
return await batch.save({
794808
tag: storage.SaveOperationTag.INSERT,
795809
sourceTable: table,
@@ -802,8 +816,8 @@ WHERE oid = $1::regclass`,
802816
this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(1);
803817
// "before" may be null if the replica id columns are unchanged
804818
// It's fine to treat that the same as an insert.
805-
const before = pg_utils.constructBeforeRecord(msg);
806-
const after = pg_utils.constructAfterRecord(msg);
819+
const before = this.connections.types.constructBeforeRecord(msg);
820+
const after = this.connections.types.constructAfterRecord(msg);
807821
return await batch.save({
808822
tag: storage.SaveOperationTag.UPDATE,
809823
sourceTable: table,
@@ -814,7 +828,7 @@ WHERE oid = $1::regclass`,
814828
});
815829
} else if (msg.tag == 'delete') {
816830
this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(1);
817-
const before = pg_utils.constructBeforeRecord(msg)!;
831+
const before = this.connections.types.constructBeforeRecord(msg)!;
818832

819833
return await batch.save({
820834
tag: storage.SaveOperationTag.DELETE,
@@ -955,7 +969,12 @@ WHERE oid = $1::regclass`,
955969

956970
for (const msg of messages) {
957971
if (msg.tag == 'relation') {
958-
await this.handleRelation(batch, getPgOutputRelation(msg), true);
972+
await this.handleRelation({
973+
batch,
974+
descriptor: getPgOutputRelation(msg),
975+
snapshot: true,
976+
referencedTypeIds: referencedColumnTypeIds(msg)
977+
});
959978
} else if (msg.tag == 'begin') {
960979
// This may span multiple transactions in the same chunk, or even across chunks.
961980
skipKeepalive = true;

0 commit comments

Comments
 (0)