@powersync/service-module-postgres 0.16.2 → 0.16.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/api/PostgresRouteAPIAdapter.d.ts +1 -0
  3. package/dist/api/PostgresRouteAPIAdapter.js +8 -1
  4. package/dist/api/PostgresRouteAPIAdapter.js.map +1 -1
  5. package/dist/index.d.ts +0 -1
  6. package/dist/index.js +0 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/module/PostgresModule.d.ts +1 -0
  9. package/dist/module/PostgresModule.js +9 -4
  10. package/dist/module/PostgresModule.js.map +1 -1
  11. package/dist/replication/ConnectionManagerFactory.d.ts +3 -1
  12. package/dist/replication/ConnectionManagerFactory.js +4 -2
  13. package/dist/replication/ConnectionManagerFactory.js.map +1 -1
  14. package/dist/replication/PgManager.d.ts +8 -2
  15. package/dist/replication/PgManager.js +5 -4
  16. package/dist/replication/PgManager.js.map +1 -1
  17. package/dist/replication/PgRelation.d.ts +1 -0
  18. package/dist/replication/PgRelation.js +7 -0
  19. package/dist/replication/PgRelation.js.map +1 -1
  20. package/dist/replication/WalStream.d.ts +6 -1
  21. package/dist/replication/WalStream.js +45 -33
  22. package/dist/replication/WalStream.js.map +1 -1
  23. package/dist/types/registry.d.ts +69 -0
  24. package/dist/types/registry.js +196 -0
  25. package/dist/types/registry.js.map +1 -0
  26. package/dist/types/resolver.d.ts +47 -0
  27. package/dist/types/resolver.js +191 -0
  28. package/dist/types/resolver.js.map +1 -0
  29. package/dist/types/types.d.ts +4 -1
  30. package/dist/types/types.js.map +1 -1
  31. package/dist/utils/postgres_version.d.ts +3 -0
  32. package/dist/utils/postgres_version.js +7 -0
  33. package/dist/utils/postgres_version.js.map +1 -0
  34. package/package.json +10 -10
  35. package/src/api/PostgresRouteAPIAdapter.ts +9 -1
  36. package/src/index.ts +0 -2
  37. package/src/module/PostgresModule.ts +10 -4
  38. package/src/replication/ConnectionManagerFactory.ts +6 -2
  39. package/src/replication/PgManager.ts +12 -4
  40. package/src/replication/PgRelation.ts +9 -0
  41. package/src/replication/WalStream.ts +49 -30
  42. package/src/types/registry.ts +278 -0
  43. package/src/types/resolver.ts +210 -0
  44. package/src/types/types.ts +5 -1
  45. package/src/utils/postgres_version.ts +8 -0
  46. package/test/src/pg_test.test.ts +152 -5
  47. package/test/src/route_api_adapter.test.ts +60 -0
  48. package/test/src/schema_changes.test.ts +32 -0
  49. package/test/src/slow_tests.test.ts +3 -2
  50. package/test/src/types/registry.test.ts +149 -0
  51. package/test/src/util.ts +16 -0
  52. package/test/src/wal_stream.test.ts +24 -0
  53. package/test/src/wal_stream_utils.ts +2 -1
  54. package/tsconfig.tsbuildinfo +1 -1
  55. package/dist/utils/pgwire_utils.d.ts +0 -17
  56. package/dist/utils/pgwire_utils.js +0 -43
  57. package/dist/utils/pgwire_utils.js.map +0 -1
  58. package/src/utils/pgwire_utils.ts +0 -48
@@ -1,4 +1,3 @@
1
- import { constructAfterRecord } from '@module/utils/pgwire_utils.js';
2
1
  import * as pgwire from '@powersync/service-jpgwire';
3
2
  import {
4
3
  applyRowContext,
@@ -11,6 +10,8 @@ import {
11
10
  import { describe, expect, test } from 'vitest';
12
11
  import { clearTestDb, connectPgPool, connectPgWire, TEST_URI } from './util.js';
13
12
  import { WalStream } from '@module/replication/WalStream.js';
13
+ import { PostgresTypeResolver } from '@module/types/resolver.js';
14
+ import { CustomTypeRegistry } from '@module/types/registry.js';
14
15
 
15
16
  describe('pg data types', () => {
16
17
  async function setupTable(db: pgwire.PgClient) {
@@ -382,7 +383,7 @@ VALUES(10, ARRAY['null']::TEXT[]);
382
383
  }
383
384
  });
384
385
 
385
- const transformed = await getReplicationTx(replicationStream);
386
+ const transformed = await getReplicationTx(db, replicationStream);
386
387
  await pg.end();
387
388
 
388
389
  checkResults(transformed);
@@ -419,7 +420,7 @@ VALUES(10, ARRAY['null']::TEXT[]);
419
420
  }
420
421
  });
421
422
 
422
- const transformed = await getReplicationTx(replicationStream);
423
+ const transformed = await getReplicationTx(db, replicationStream);
423
424
  await pg.end();
424
425
 
425
426
  checkResultArrays(transformed.map((e) => applyRowContext(e, CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY)));
@@ -470,17 +471,163 @@ INSERT INTO test_data(id, time, timestamp, timestamptz) VALUES (1, '17:42:01.12'
470
471
  await db.end();
471
472
  }
472
473
  });
474
+
475
+ test('test replication - custom types', async () => {
476
+ const db = await connectPgPool();
477
+ try {
478
+ await clearTestDb(db);
479
+ await db.query(`CREATE DOMAIN rating_value AS FLOAT CHECK (VALUE BETWEEN 0 AND 5);`);
480
+ await db.query(`CREATE TYPE composite AS (foo rating_value[], bar TEXT);`);
481
+ await db.query(`CREATE TYPE nested_composite AS (a BOOLEAN, b composite);`);
482
+ await db.query(`CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy')`);
483
+
484
+ await db.query(`CREATE TABLE test_custom(
485
+ id serial primary key,
486
+ rating rating_value,
487
+ composite composite,
488
+ nested_composite nested_composite,
489
+ boxes box[],
490
+ mood mood
491
+ );`);
492
+
493
+ const slotName = 'test_slot';
494
+
495
+ await db.query({
496
+ statement: 'SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE slot_name = $1',
497
+ params: [{ type: 'varchar', value: slotName }]
498
+ });
499
+
500
+ await db.query({
501
+ statement: `SELECT slot_name, lsn FROM pg_catalog.pg_create_logical_replication_slot($1, 'pgoutput')`,
502
+ params: [{ type: 'varchar', value: slotName }]
503
+ });
504
+
505
+ await db.query(`
506
+ INSERT INTO test_custom
507
+ (rating, composite, nested_composite, boxes, mood)
508
+ VALUES (
509
+ 1,
510
+ (ARRAY[2,3], 'bar'),
511
+ (TRUE, (ARRAY[2,3], 'bar')),
512
+ ARRAY[box(point '(1,2)', point '(3,4)'), box(point '(5, 6)', point '(7,8)')],
513
+ 'happy'
514
+ );
515
+ `);
516
+
517
+ const pg: pgwire.PgConnection = await pgwire.pgconnect({ replication: 'database' }, TEST_URI);
518
+ const replicationStream = await pg.logicalReplication({
519
+ slot: slotName,
520
+ options: {
521
+ proto_version: '1',
522
+ publication_names: 'powersync'
523
+ }
524
+ });
525
+
526
+ const [transformed] = await getReplicationTx(db, replicationStream);
527
+ await pg.end();
528
+
529
+ const oldFormat = applyRowContext(transformed, CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY);
530
+ expect(oldFormat).toMatchObject({
531
+ rating: '1',
532
+ composite: '("{2,3}",bar)',
533
+ nested_composite: '(t,"(""{2,3}"",bar)")',
534
+ boxes: '["(3","4)","(1","2);(7","8)","(5","6)"]',
535
+ mood: 'happy'
536
+ });
537
+
538
+ const newFormat = applyRowContext(transformed, new CompatibilityContext(CompatibilityEdition.SYNC_STREAMS));
539
+ expect(newFormat).toMatchObject({
540
+ rating: 1,
541
+ composite: '{"foo":[2.0,3.0],"bar":"bar"}',
542
+ nested_composite: '{"a":1,"b":{"foo":[2.0,3.0],"bar":"bar"}}',
543
+ boxes: JSON.stringify(['(3,4),(1,2)', '(7,8),(5,6)']),
544
+ mood: 'happy'
545
+ });
546
+ } finally {
547
+ await db.end();
548
+ }
549
+ });
550
+
551
+ test('test replication - multiranges', async () => {
552
+ const db = await connectPgPool();
553
+
554
+ if (!(await new PostgresTypeResolver(new CustomTypeRegistry(), db).supportsMultiRanges())) {
555
+ // This test requires Postgres 14 or later.
556
+ return;
557
+ }
558
+
559
+ try {
560
+ await clearTestDb(db);
561
+
562
+ await db.query(`CREATE TABLE test_custom(
563
+ id serial primary key,
564
+ ranges int4multirange[]
565
+ );`);
566
+
567
+ const slotName = 'test_slot';
568
+
569
+ await db.query({
570
+ statement: 'SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE slot_name = $1',
571
+ params: [{ type: 'varchar', value: slotName }]
572
+ });
573
+
574
+ await db.query({
575
+ statement: `SELECT slot_name, lsn FROM pg_catalog.pg_create_logical_replication_slot($1, 'pgoutput')`,
576
+ params: [{ type: 'varchar', value: slotName }]
577
+ });
578
+
579
+ await db.query(`
580
+ INSERT INTO test_custom
581
+ (ranges)
582
+ VALUES (
583
+ ARRAY[int4multirange(int4range(2, 4), int4range(5, 7, '(]'))]::int4multirange[]
584
+ );
585
+ `);
586
+
587
+ const pg: pgwire.PgConnection = await pgwire.pgconnect({ replication: 'database' }, TEST_URI);
588
+ const replicationStream = await pg.logicalReplication({
589
+ slot: slotName,
590
+ options: {
591
+ proto_version: '1',
592
+ publication_names: 'powersync'
593
+ }
594
+ });
595
+
596
+ const [transformed] = await getReplicationTx(db, replicationStream);
597
+ await pg.end();
598
+
599
+ const oldFormat = applyRowContext(transformed, CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY);
600
+ expect(oldFormat).toMatchObject({
601
+ ranges: '{"{[2,4),[6,8)}"}'
602
+ });
603
+
604
+ const newFormat = applyRowContext(transformed, new CompatibilityContext(CompatibilityEdition.SYNC_STREAMS));
605
+ expect(newFormat).toMatchObject({
606
+ ranges: JSON.stringify([
607
+ [
608
+ { lower: 2, upper: 4, lower_exclusive: 0, upper_exclusive: 1 },
609
+ { lower: 6, upper: 8, lower_exclusive: 0, upper_exclusive: 1 }
610
+ ]
611
+ ])
612
+ });
613
+ } finally {
614
+ await db.end();
615
+ }
616
+ });
473
617
  });
474
618
 
475
619
  /**
476
620
  * Return all the inserts from the first transaction in the replication stream.
477
621
  */
478
- async function getReplicationTx(replicationStream: pgwire.ReplicationStream) {
622
+ async function getReplicationTx(db: pgwire.PgClient, replicationStream: pgwire.ReplicationStream) {
623
+ const typeCache = new PostgresTypeResolver(new CustomTypeRegistry(), db);
624
+ await typeCache.fetchTypesForSchema();
625
+
479
626
  let transformed: SqliteInputRow[] = [];
480
627
  for await (const batch of replicationStream.pgoutputDecode()) {
481
628
  for (const msg of batch.messages) {
482
629
  if (msg.tag == 'insert') {
483
- transformed.push(constructAfterRecord(msg));
630
+ transformed.push(typeCache.constructAfterRecord(msg));
484
631
  } else if (msg.tag == 'commit') {
485
632
  return transformed;
486
633
  }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { clearTestDb, connectPgPool } from './util.js';
3
+ import { PostgresRouteAPIAdapter } from '@module/api/PostgresRouteAPIAdapter.js';
4
+ import { TYPE_INTEGER, TYPE_REAL, TYPE_TEXT } from '@powersync/service-sync-rules';
5
+
6
+ describe('PostgresRouteAPIAdapter tests', () => {
7
+ test('infers connection schema', async () => {
8
+ const db = await connectPgPool();
9
+ try {
10
+ await clearTestDb(db);
11
+ const api = new PostgresRouteAPIAdapter(db);
12
+
13
+ await db.query(`CREATE DOMAIN rating_value AS FLOAT CHECK (VALUE BETWEEN 0 AND 5)`);
14
+ await db.query(`
15
+ CREATE TABLE test_users (
16
+ id TEXT NOT NULL PRIMARY KEY,
17
+ is_admin BOOLEAN,
18
+ rating RATING_VALUE
19
+ );
20
+ `);
21
+
22
+ const schema = await api.getConnectionSchema();
23
+ expect(schema).toStrictEqual([
24
+ {
25
+ name: 'public',
26
+ tables: [
27
+ {
28
+ name: 'test_users',
29
+ columns: [
30
+ {
31
+ internal_type: 'text',
32
+ name: 'id',
33
+ pg_type: 'text',
34
+ sqlite_type: TYPE_TEXT,
35
+ type: 'text'
36
+ },
37
+ {
38
+ internal_type: 'boolean',
39
+ name: 'is_admin',
40
+ pg_type: 'bool',
41
+ sqlite_type: TYPE_INTEGER,
42
+ type: 'boolean'
43
+ },
44
+ {
45
+ internal_type: 'rating_value',
46
+ name: 'rating',
47
+ pg_type: 'rating_value',
48
+ sqlite_type: TYPE_REAL,
49
+ type: 'rating_value'
50
+ }
51
+ ]
52
+ }
53
+ ]
54
+ }
55
+ ]);
56
+ } finally {
57
+ await db.end();
58
+ }
59
+ });
60
+ });
@@ -590,4 +590,36 @@ function defineTests(factory: storage.TestStorageFactory) {
590
590
 
591
591
  expect(failures).toEqual([]);
592
592
  });
593
+
594
+ test('custom types', async () => {
595
+ await using context = await WalStreamTestContext.open(factory);
596
+
597
+ await context.updateSyncRules(`
598
+ streams:
599
+ stream:
600
+ query: SELECT * FROM "test_data"
601
+
602
+ config:
603
+ edition: 2
604
+ `);
605
+
606
+ const { pool } = context;
607
+ await pool.query(`CREATE TABLE test_data(id text primary key);`);
608
+ await pool.query(`INSERT INTO test_data(id) VALUES ('t1')`);
609
+
610
+ await context.replicateSnapshot();
611
+ context.startStreaming();
612
+
613
+ await pool.query(
614
+ { statement: `CREATE TYPE composite AS (foo bool, bar int4);` },
615
+ { statement: `ALTER TABLE test_data ADD COLUMN other composite;` },
616
+ { statement: `UPDATE test_data SET other = ROW(TRUE, 2)::composite;` }
617
+ );
618
+
619
+ const data = await context.getBucketData('1#stream|0[]');
620
+ expect(data).toMatchObject([
621
+ putOp('test_data', { id: 't1' }),
622
+ putOp('test_data', { id: 't1', other: '{"foo":1,"bar":2}' })
623
+ ]);
624
+ });
593
625
  }
@@ -19,6 +19,7 @@ import { METRICS_HELPER, test_utils } from '@powersync/service-core-tests';
19
19
  import * as mongo_storage from '@powersync/service-module-mongodb-storage';
20
20
  import * as postgres_storage from '@powersync/service-module-postgres-storage';
21
21
  import * as timers from 'node:timers/promises';
22
+ import { CustomTypeRegistry } from '@module/types/registry.js';
22
23
 
23
24
  describe.skipIf(!(env.CI || env.SLOW_TESTS))('slow tests', function () {
24
25
  describeWithStorage({ timeout: 120_000 }, function (factory) {
@@ -68,7 +69,7 @@ function defineSlowTests(factory: storage.TestStorageFactory) {
68
69
  });
69
70
 
70
71
  async function testRepeatedReplication(testOptions: { compact: boolean; maxBatchSize: number; numBatches: number }) {
71
- const connections = new PgManager(TEST_CONNECTION_OPTIONS, {});
72
+ const connections = new PgManager(TEST_CONNECTION_OPTIONS, { registry: new CustomTypeRegistry() });
72
73
  const replicationConnection = await connections.replicationConnection();
73
74
  const pool = connections.pool;
74
75
  await clearTestDb(pool);
@@ -329,7 +330,7 @@ bucket_definitions:
329
330
  await pool.query(`SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE active = FALSE`);
330
331
  i += 1;
331
332
 
332
- const connections = new PgManager(TEST_CONNECTION_OPTIONS, {});
333
+ const connections = new PgManager(TEST_CONNECTION_OPTIONS, { registry: new CustomTypeRegistry() });
333
334
  const replicationConnection = await connections.replicationConnection();
334
335
 
335
336
  abortController = new AbortController();
@@ -0,0 +1,149 @@
1
+ import { describe, expect, test, beforeEach } from 'vitest';
2
+ import { CustomTypeRegistry } from '@module/types/registry.js';
3
+ import { CHAR_CODE_COMMA, PgTypeOid } from '@powersync/service-jpgwire';
4
+ import {
5
+ applyValueContext,
6
+ CompatibilityContext,
7
+ CompatibilityEdition,
8
+ toSyncRulesValue
9
+ } from '@powersync/service-sync-rules';
10
+
11
+ describe('custom type registry', () => {
12
+ let registry: CustomTypeRegistry;
13
+
14
+ beforeEach(() => {
15
+ registry = new CustomTypeRegistry();
16
+ });
17
+
18
+ function checkResult(raw: string, type: number, old: any, fixed: any) {
19
+ const input = registry.decodeDatabaseValue(raw, type);
20
+ const syncRulesValue = toSyncRulesValue(input);
21
+
22
+ expect(applyValueContext(syncRulesValue, CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY)).toStrictEqual(old);
23
+ expect(
24
+ applyValueContext(syncRulesValue, new CompatibilityContext(CompatibilityEdition.SYNC_STREAMS))
25
+ ).toStrictEqual(fixed);
26
+ }
27
+
28
+ test('domain types', () => {
29
+ registry.setDomainType(1337, PgTypeOid.INT4); // create domain wrapping integer
30
+ checkResult('12', 1337, '12', 12n); // Should be raw text value without fix, parsed as inner type if enabled
31
+ });
32
+
33
+ test('array of domain types', () => {
34
+ registry.setDomainType(1337, PgTypeOid.INT4);
35
+ registry.set(1338, { type: 'array', separatorCharCode: CHAR_CODE_COMMA, innerId: 1337, sqliteType: () => 'text' });
36
+
37
+ checkResult('{1,2,3}', 1338, '{1,2,3}', '[1,2,3]');
38
+ });
39
+
40
+ test('nested array through domain type', () => {
41
+ registry.setDomainType(1337, PgTypeOid.INT4);
42
+ registry.set(1338, { type: 'array', separatorCharCode: CHAR_CODE_COMMA, innerId: 1337, sqliteType: () => 'text' });
43
+ registry.setDomainType(1339, 1338);
44
+
45
+ checkResult('{1,2,3}', 1339, '{1,2,3}', '[1,2,3]');
46
+
47
+ registry.set(1400, { type: 'array', separatorCharCode: CHAR_CODE_COMMA, innerId: 1339, sqliteType: () => 'text' });
48
+ checkResult('{{1,2,3}}', 1400, '{{1,2,3}}', '[[1,2,3]]');
49
+ });
50
+
51
+ test('structure', () => {
52
+ // create type c1 AS (a bool, b integer, c text[]);
53
+ registry.set(1337, {
54
+ type: 'composite',
55
+ sqliteType: () => 'text',
56
+ members: [
57
+ { name: 'a', typeId: PgTypeOid.BOOL },
58
+ { name: 'b', typeId: PgTypeOid.INT4 },
59
+ { name: 'c', typeId: 1009 } // text array
60
+ ]
61
+ });
62
+
63
+ // SELECT (TRUE, 123, ARRAY['foo', 'bar'])::c1;
64
+ checkResult('(t,123,"{foo,bar}")', 1337, '(t,123,"{foo,bar}")', '{"a":1,"b":123,"c":["foo","bar"]}');
65
+ });
66
+
67
+ test('array of structure', () => {
68
+ // create type c1 AS (a bool, b integer, c text[]);
69
+ registry.set(1337, {
70
+ type: 'composite',
71
+ sqliteType: () => 'text',
72
+ members: [
73
+ { name: 'a', typeId: PgTypeOid.BOOL },
74
+ { name: 'b', typeId: PgTypeOid.INT4 },
75
+ { name: 'c', typeId: 1009 } // text array
76
+ ]
77
+ });
78
+ registry.set(1338, { type: 'array', separatorCharCode: CHAR_CODE_COMMA, innerId: 1337, sqliteType: () => 'text' });
79
+
80
+ // SELECT ARRAY[(TRUE, 123, ARRAY['foo', 'bar']),(FALSE, NULL, ARRAY[]::text[])]::c1[];
81
+ checkResult(
82
+ '{"(t,123,\\"{foo,bar}\\")","(f,,{})"}',
83
+ 1338,
84
+ '{"(t,123,\\"{foo,bar}\\")","(f,,{})"}',
85
+ '[{"a":1,"b":123,"c":["foo","bar"]},{"a":0,"b":null,"c":[]}]'
86
+ );
87
+ });
88
+
89
+ test('domain type of structure', () => {
90
+ registry.set(1337, {
91
+ type: 'composite',
92
+ sqliteType: () => 'text',
93
+ members: [
94
+ { name: 'a', typeId: PgTypeOid.BOOL },
95
+ { name: 'b', typeId: PgTypeOid.INT4 }
96
+ ]
97
+ });
98
+ registry.setDomainType(1338, 1337);
99
+
100
+ checkResult('(t,123)', 1337, '(t,123)', '{"a":1,"b":123}');
101
+ });
102
+
103
+ test('structure of another structure', () => {
104
+ // CREATE TYPE c2 AS (a BOOLEAN, b INTEGER);
105
+ registry.set(1337, {
106
+ type: 'composite',
107
+ sqliteType: () => 'text',
108
+ members: [
109
+ { name: 'a', typeId: PgTypeOid.BOOL },
110
+ { name: 'b', typeId: PgTypeOid.INT4 }
111
+ ]
112
+ });
113
+ registry.set(1338, { type: 'array', separatorCharCode: CHAR_CODE_COMMA, innerId: 1337, sqliteType: () => 'text' });
114
+ // CREATE TYPE c3 (c c2[]);
115
+ registry.set(1339, {
116
+ type: 'composite',
117
+ sqliteType: () => 'text',
118
+ members: [{ name: 'c', typeId: 1338 }]
119
+ });
120
+
121
+ // SELECT ROW(ARRAY[(FALSE,2)]::c2[])::c3;
122
+ checkResult('("{""(f,2)""}")', 1339, '("{""(f,2)""}")', '{"c":[{"a":0,"b":2}]}');
123
+ });
124
+
125
+ test('range', () => {
126
+ registry.set(1337, {
127
+ type: 'range',
128
+ sqliteType: () => 'text',
129
+ innerId: PgTypeOid.INT2
130
+ });
131
+
132
+ checkResult('[1,2]', 1337, '[1,2]', '{"lower":1,"upper":2,"lower_exclusive":0,"upper_exclusive":0}');
133
+ });
134
+
135
+ test('multirange', () => {
136
+ registry.set(1337, {
137
+ type: 'multirange',
138
+ sqliteType: () => 'text',
139
+ innerId: PgTypeOid.INT2
140
+ });
141
+
142
+ checkResult(
143
+ '{[1,2),[3,4)}',
144
+ 1337,
145
+ '{[1,2),[3,4)}',
146
+ '[{"lower":1,"upper":2,"lower_exclusive":0,"upper_exclusive":1},{"lower":3,"upper":4,"lower_exclusive":0,"upper_exclusive":1}]'
147
+ );
148
+ });
149
+ });
package/test/src/util.ts CHANGED
@@ -59,6 +59,22 @@ export async function clearTestDb(db: pgwire.PgClient) {
59
59
  await db.query(`DROP TABLE public.${lib_postgres.escapeIdentifier(name)}`);
60
60
  }
61
61
  }
62
+
63
+ const domainRows = pgwire.pgwireRows(
64
+ await db.query(`
65
+ SELECT typname,typtype
66
+ FROM pg_type t
67
+ LEFT JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
68
+ WHERE n.nspname = 'public' AND typarray != 0
69
+ `)
70
+ );
71
+ for (let row of domainRows) {
72
+ if (row.typtype == 'd') {
73
+ await db.query(`DROP DOMAIN public.${lib_postgres.escapeIdentifier(row.typname)} CASCADE`);
74
+ } else {
75
+ await db.query(`DROP TYPE public.${lib_postgres.escapeIdentifier(row.typname)} CASCADE`);
76
+ }
77
+ }
62
78
  }
63
79
 
64
80
  export async function connectPgWire(type?: 'replication' | 'standard') {
@@ -324,4 +324,28 @@ bucket_definitions:
324
324
  // creating a new replication slot.
325
325
  }
326
326
  });
327
+
328
+ test('custom types', async () => {
329
+ await using context = await WalStreamTestContext.open(factory);
330
+
331
+ await context.updateSyncRules(`
332
+ streams:
333
+ stream:
334
+ query: SELECT id, * FROM "test_data"
335
+
336
+ config:
337
+ edition: 2
338
+ `);
339
+
340
+ const { pool } = context;
341
+ await pool.query(`DROP TABLE IF EXISTS test_data`);
342
+ await pool.query(`CREATE TYPE composite AS (foo bool, bar int4);`);
343
+ await pool.query(`CREATE TABLE test_data(id text primary key, description composite);`);
344
+
345
+ await context.initializeReplication();
346
+ await pool.query(`INSERT INTO test_data(id, description) VALUES ('t1', ROW(TRUE, 2)::composite)`);
347
+
348
+ const data = await context.getBucketData('1#stream|0[]');
349
+ expect(data).toMatchObject([putOp('test_data', { id: 't1', description: '{"foo":1,"bar":2}' })]);
350
+ });
327
351
  }
@@ -12,6 +12,7 @@ import {
12
12
  import { METRICS_HELPER, test_utils } from '@powersync/service-core-tests';
13
13
  import * as pgwire from '@powersync/service-jpgwire';
14
14
  import { clearTestDb, getClientCheckpoint, TEST_CONNECTION_OPTIONS } from './util.js';
15
+ import { CustomTypeRegistry } from '@module/types/registry.js';
15
16
 
16
17
  export class WalStreamTestContext implements AsyncDisposable {
17
18
  private _walStream?: WalStream;
@@ -32,7 +33,7 @@ export class WalStreamTestContext implements AsyncDisposable {
32
33
  options?: { doNotClear?: boolean; walStreamOptions?: Partial<WalStreamOptions> }
33
34
  ) {
34
35
  const f = await factory({ doNotClear: options?.doNotClear });
35
- const connectionManager = new PgManager(TEST_CONNECTION_OPTIONS, {});
36
+ const connectionManager = new PgManager(TEST_CONNECTION_OPTIONS, { registry: new CustomTypeRegistry() });
36
37
 
37
38
  if (!options?.doNotClear) {
38
39
  await clearTestDb(connectionManager.pool);