@powersync/service-module-postgres 0.16.1 → 0.16.3
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.
- package/CHANGELOG.md +21 -0
- package/dist/api/PostgresRouteAPIAdapter.d.ts +1 -0
- package/dist/api/PostgresRouteAPIAdapter.js +8 -1
- package/dist/api/PostgresRouteAPIAdapter.js.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/module/PostgresModule.d.ts +1 -0
- package/dist/module/PostgresModule.js +9 -4
- package/dist/module/PostgresModule.js.map +1 -1
- package/dist/replication/ConnectionManagerFactory.d.ts +3 -1
- package/dist/replication/ConnectionManagerFactory.js +4 -2
- package/dist/replication/ConnectionManagerFactory.js.map +1 -1
- package/dist/replication/PgManager.d.ts +8 -2
- package/dist/replication/PgManager.js +5 -4
- package/dist/replication/PgManager.js.map +1 -1
- package/dist/replication/PgRelation.d.ts +1 -0
- package/dist/replication/PgRelation.js +7 -0
- package/dist/replication/PgRelation.js.map +1 -1
- package/dist/replication/WalStream.d.ts +6 -1
- package/dist/replication/WalStream.js +45 -33
- package/dist/replication/WalStream.js.map +1 -1
- package/dist/types/registry.d.ts +69 -0
- package/dist/types/registry.js +196 -0
- package/dist/types/registry.js.map +1 -0
- package/dist/types/resolver.d.ts +47 -0
- package/dist/types/resolver.js +191 -0
- package/dist/types/resolver.js.map +1 -0
- package/dist/types/types.d.ts +4 -1
- package/dist/types/types.js.map +1 -1
- package/dist/utils/postgres_version.d.ts +3 -0
- package/dist/utils/postgres_version.js +7 -0
- package/dist/utils/postgres_version.js.map +1 -0
- package/package.json +10 -10
- package/src/api/PostgresRouteAPIAdapter.ts +9 -1
- package/src/index.ts +0 -2
- package/src/module/PostgresModule.ts +10 -4
- package/src/replication/ConnectionManagerFactory.ts +6 -2
- package/src/replication/PgManager.ts +12 -4
- package/src/replication/PgRelation.ts +9 -0
- package/src/replication/WalStream.ts +49 -30
- package/src/types/registry.ts +278 -0
- package/src/types/resolver.ts +210 -0
- package/src/types/types.ts +5 -1
- package/src/utils/postgres_version.ts +8 -0
- package/test/src/checkpoints.test.ts +4 -2
- package/test/src/pg_test.test.ts +152 -5
- package/test/src/route_api_adapter.test.ts +60 -0
- package/test/src/schema_changes.test.ts +32 -0
- package/test/src/slow_tests.test.ts +3 -2
- package/test/src/types/registry.test.ts +149 -0
- package/test/src/util.ts +16 -0
- package/test/src/wal_stream.test.ts +24 -0
- package/test/src/wal_stream_utils.ts +2 -1
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/utils/pgwire_utils.d.ts +0 -17
- package/dist/utils/pgwire_utils.js +0 -43
- package/dist/utils/pgwire_utils.js.map +0 -1
- package/src/utils/pgwire_utils.ts +0 -48
package/test/src/pg_test.test.ts
CHANGED
|
@@ -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);
|