@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +21 -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/checkpoints.test.ts +4 -2
  47. package/test/src/pg_test.test.ts +152 -5
  48. package/test/src/route_api_adapter.test.ts +60 -0
  49. package/test/src/schema_changes.test.ts +32 -0
  50. package/test/src/slow_tests.test.ts +3 -2
  51. package/test/src/types/registry.test.ts +149 -0
  52. package/test/src/util.ts +16 -0
  53. package/test/src/wal_stream.test.ts +24 -0
  54. package/test/src/wal_stream_utils.ts +2 -1
  55. package/tsconfig.tsbuildinfo +1 -1
  56. package/dist/utils/pgwire_utils.d.ts +0 -17
  57. package/dist/utils/pgwire_utils.js +0 -43
  58. package/dist/utils/pgwire_utils.js.map +0 -1
  59. package/src/utils/pgwire_utils.ts +0 -48
@@ -0,0 +1,278 @@
1
+ import {
2
+ applyValueContext,
3
+ CompatibilityContext,
4
+ CompatibilityOption,
5
+ CustomSqliteValue,
6
+ DatabaseInputValue,
7
+ SqliteValue,
8
+ SqliteValueType,
9
+ toSyncRulesValue
10
+ } from '@powersync/service-sync-rules';
11
+ import * as pgwire from '@powersync/service-jpgwire';
12
+
13
+ interface BaseType {
14
+ sqliteType: () => SqliteValueType;
15
+ }
16
+
17
+ /** A type natively supported by {@link pgwire.PgType.decode}. */
18
+ interface BuiltinType extends BaseType {
19
+ type: 'builtin';
20
+ oid: number;
21
+ }
22
+
23
+ /**
24
+ * An array type.
25
+ */
26
+ interface ArrayType extends BaseType {
27
+ type: 'array';
28
+ innerId: number;
29
+ separatorCharCode: number;
30
+ }
31
+
32
+ /**
33
+ * A domain type, like `CREATE DOMAIN api.rating_value AS FLOAT CHECK (VALUE BETWEEN 0 AND 5);`
34
+ *
35
+ * This type gets decoded and synced as the inner type (`FLOAT` in the example above).
36
+ */
37
+ interface DomainType extends BaseType {
38
+ type: 'domain';
39
+ innerId: number;
40
+ }
41
+
42
+ /**
43
+ * A composite type as created by `CREATE TYPE AS`.
44
+ *
45
+ * These types are encoded as a tuple of values, so we recover attribute names to restore them as a JSON object.
46
+ */
47
+ interface CompositeType extends BaseType {
48
+ type: 'composite';
49
+ members: { name: string; typeId: number }[];
50
+ }
51
+
52
+ /**
53
+ * A type created with `CREATE TYPE AS RANGE`.
54
+ *
55
+ * Ranges are represented as {@link pgwire.Range}. Multiranges are represented as arrays thereof.
56
+ */
57
+ interface RangeType extends BaseType {
58
+ type: 'range' | 'multirange';
59
+ innerId: number;
60
+ }
61
+
62
+ type KnownType = BuiltinType | ArrayType | DomainType | DomainType | CompositeType | RangeType;
63
+
64
+ interface UnknownType extends BaseType {
65
+ type: 'unknown';
66
+ }
67
+
68
+ type MaybeKnownType = KnownType | UnknownType;
69
+
70
+ const UNKNOWN_TYPE: UnknownType = {
71
+ type: 'unknown',
72
+ sqliteType: () => 'text'
73
+ };
74
+
75
+ class CustomTypeValue extends CustomSqliteValue {
76
+ constructor(
77
+ readonly oid: number,
78
+ readonly cache: CustomTypeRegistry,
79
+ readonly rawValue: string
80
+ ) {
81
+ super();
82
+ }
83
+
84
+ private lookup(): KnownType | UnknownType {
85
+ return this.cache.lookupType(this.oid);
86
+ }
87
+
88
+ private decodeToDatabaseInputValue(context: CompatibilityContext): DatabaseInputValue {
89
+ if (context.isEnabled(CompatibilityOption.customTypes)) {
90
+ try {
91
+ return this.cache.decodeWithCustomTypes(this.rawValue, this.oid);
92
+ } catch (_e) {
93
+ return this.rawValue;
94
+ }
95
+ } else {
96
+ return pgwire.PgType.decode(this.rawValue, this.oid);
97
+ }
98
+ }
99
+
100
+ toSqliteValue(context: CompatibilityContext): SqliteValue {
101
+ const value = toSyncRulesValue(this.decodeToDatabaseInputValue(context));
102
+ return applyValueContext(value, context);
103
+ }
104
+
105
+ get sqliteType(): SqliteValueType {
106
+ return this.lookup().sqliteType();
107
+ }
108
+ }
109
+
110
+ /**
111
+ * A registry of custom types.
112
+ *
113
+ * These extend the builtin decoding behavior in {@link pgwire.PgType.decode} for user-defined types like `DOMAIN`s or
114
+ * composite types.
115
+ */
116
+ export class CustomTypeRegistry {
117
+ private readonly byOid: Map<number, KnownType>;
118
+
119
+ constructor() {
120
+ this.byOid = new Map();
121
+
122
+ for (const builtin of Object.values(pgwire.PgTypeOid)) {
123
+ if (typeof builtin == 'number') {
124
+ // We need to know the SQLite type of builtins to implement CustomSqliteValue.sqliteType for DOMAIN types.
125
+ let sqliteType: SqliteValueType;
126
+ switch (builtin) {
127
+ case pgwire.PgTypeOid.TEXT:
128
+ case pgwire.PgTypeOid.UUID:
129
+ case pgwire.PgTypeOid.VARCHAR:
130
+ case pgwire.PgTypeOid.DATE:
131
+ case pgwire.PgTypeOid.TIMESTAMP:
132
+ case pgwire.PgTypeOid.TIMESTAMPTZ:
133
+ case pgwire.PgTypeOid.TIME:
134
+ case pgwire.PgTypeOid.JSON:
135
+ case pgwire.PgTypeOid.JSONB:
136
+ case pgwire.PgTypeOid.PG_LSN:
137
+ sqliteType = 'text';
138
+ break;
139
+ case pgwire.PgTypeOid.BYTEA:
140
+ sqliteType = 'blob';
141
+ break;
142
+ case pgwire.PgTypeOid.BOOL:
143
+ case pgwire.PgTypeOid.INT2:
144
+ case pgwire.PgTypeOid.INT4:
145
+ case pgwire.PgTypeOid.OID:
146
+ case pgwire.PgTypeOid.INT8:
147
+ sqliteType = 'integer';
148
+ break;
149
+ case pgwire.PgTypeOid.FLOAT4:
150
+ case pgwire.PgTypeOid.FLOAT8:
151
+ sqliteType = 'real';
152
+ break;
153
+ default:
154
+ sqliteType = 'text';
155
+ }
156
+
157
+ this.byOid.set(builtin, {
158
+ type: 'builtin',
159
+ oid: builtin,
160
+ sqliteType: () => sqliteType
161
+ });
162
+ }
163
+ }
164
+
165
+ for (const [arrayId, innerId] of pgwire.ARRAY_TO_ELEM_OID.entries()) {
166
+ // We can just use the default decoder, except for box[] because those use a different delimiter. We don't fix
167
+ // this in PgType._decodeArray for backwards-compatibility.
168
+ if (innerId == 603) {
169
+ this.byOid.set(arrayId, {
170
+ type: 'array',
171
+ innerId,
172
+ sqliteType: () => 'text', // these get encoded as JSON arrays
173
+ separatorCharCode: 0x3b // ";"
174
+ });
175
+ } else {
176
+ this.byOid.set(arrayId, {
177
+ type: 'builtin',
178
+ oid: arrayId,
179
+ sqliteType: () => 'text' // these get encoded as JSON arrays
180
+ });
181
+ }
182
+ }
183
+ }
184
+
185
+ knows(oid: number): boolean {
186
+ return this.byOid.has(oid);
187
+ }
188
+
189
+ set(oid: number, value: KnownType) {
190
+ this.byOid.set(oid, value);
191
+ }
192
+
193
+ setDomainType(oid: number, inner: number) {
194
+ this.set(oid, {
195
+ type: 'domain',
196
+ innerId: inner,
197
+ sqliteType: () => this.lookupType(inner).sqliteType()
198
+ });
199
+ }
200
+
201
+ decodeWithCustomTypes(raw: string, oid: number): DatabaseInputValue {
202
+ const resolved = this.lookupType(oid);
203
+ switch (resolved.type) {
204
+ case 'builtin':
205
+ case 'unknown':
206
+ return pgwire.PgType.decode(raw, oid);
207
+ case 'domain':
208
+ return this.decodeWithCustomTypes(raw, resolved.innerId);
209
+ case 'composite': {
210
+ const parsed: [string, any][] = [];
211
+
212
+ new pgwire.StructureParser(raw).parseComposite((raw) => {
213
+ const nextMember = resolved.members[parsed.length];
214
+ if (nextMember) {
215
+ const value = raw == null ? null : this.decodeWithCustomTypes(raw, nextMember.typeId);
216
+ parsed.push([nextMember.name, value]);
217
+ }
218
+ });
219
+ return Object.fromEntries(parsed);
220
+ }
221
+ case 'array': {
222
+ // Nornalize "array of array of T" types into just "array of T", because Postgres arrays are natively multi-
223
+ // dimensional. This may be required when we have a DOMAIN wrapper around an array followed by another array
224
+ // around that domain.
225
+ let innerId = resolved.innerId;
226
+ while (true) {
227
+ const resolvedInner = this.lookupType(innerId);
228
+ if (resolvedInner.type == 'domain') {
229
+ innerId = resolvedInner.innerId;
230
+ } else if (resolvedInner.type == 'array') {
231
+ innerId = resolvedInner.innerId;
232
+ } else {
233
+ break;
234
+ }
235
+ }
236
+
237
+ return new pgwire.StructureParser(raw).parseArray(
238
+ (source) => this.decodeWithCustomTypes(source, innerId),
239
+ resolved.separatorCharCode
240
+ );
241
+ }
242
+ case 'range':
243
+ return new pgwire.StructureParser(raw).parseRange((s) => this.decodeWithCustomTypes(s, resolved.innerId));
244
+ case 'multirange':
245
+ return new pgwire.StructureParser(raw).parseMultiRange((s) => this.decodeWithCustomTypes(s, resolved.innerId));
246
+ }
247
+ }
248
+
249
+ lookupType(type: number): KnownType | UnknownType {
250
+ return this.byOid.get(type) ?? UNKNOWN_TYPE;
251
+ }
252
+
253
+ private isParsedWithoutCustomTypesSupport(type: MaybeKnownType): boolean {
254
+ switch (type.type) {
255
+ case 'builtin':
256
+ case 'unknown':
257
+ return true;
258
+ case 'array':
259
+ return (
260
+ type.separatorCharCode == pgwire.CHAR_CODE_COMMA &&
261
+ this.isParsedWithoutCustomTypesSupport(this.lookupType(type.innerId))
262
+ );
263
+ default:
264
+ return false;
265
+ }
266
+ }
267
+
268
+ decodeDatabaseValue(value: string, oid: number): DatabaseInputValue {
269
+ const resolved = this.lookupType(oid);
270
+ // For backwards-compatibility, some types are only properly parsed with a compatibility option. Others are synced
271
+ // in the raw text representation by default, and are only parsed as JSON values when necessary.
272
+ if (this.isParsedWithoutCustomTypesSupport(resolved)) {
273
+ return pgwire.PgType.decode(value, oid);
274
+ } else {
275
+ return new CustomTypeValue(oid, this, value);
276
+ }
277
+ }
278
+ }
@@ -0,0 +1,210 @@
1
+ import { DatabaseInputRow, SqliteInputRow, toSyncRulesRow } from '@powersync/service-sync-rules';
2
+ import * as pgwire from '@powersync/service-jpgwire';
3
+ import { CustomTypeRegistry } from './registry.js';
4
+ import semver from 'semver';
5
+ import { getServerVersion } from '../utils/postgres_version.js';
6
+
7
+ /**
8
+ * Resolves descriptions used to decode values for custom postgres types.
9
+ *
10
+ * Custom types are resolved from the source database, which also involves crawling inner types (e.g. for composites).
11
+ */
12
+ export class PostgresTypeResolver {
13
+ private cachedVersion: semver.SemVer | null = null;
14
+
15
+ constructor(
16
+ readonly registry: CustomTypeRegistry,
17
+ private readonly pool: pgwire.PgClient
18
+ ) {
19
+ this.registry = new CustomTypeRegistry();
20
+ }
21
+
22
+ private async fetchVersion(): Promise<semver.SemVer> {
23
+ if (this.cachedVersion == null) {
24
+ this.cachedVersion = (await getServerVersion(this.pool)) ?? semver.parse('0.0.1');
25
+ }
26
+
27
+ return this.cachedVersion!;
28
+ }
29
+
30
+ /**
31
+ * @returns Whether the Postgres instance this type cache is connected to has support for the multirange type (which
32
+ * is the case for Postgres 14 and later).
33
+ */
34
+ async supportsMultiRanges() {
35
+ const version = await this.fetchVersion();
36
+ return version.compare(PostgresTypeResolver.minVersionForMultirange) >= 0;
37
+ }
38
+
39
+ /**
40
+ * Fetches information about indicated types.
41
+ *
42
+ * If a type references another custom type (e.g. because it's a composite type with a custom field), these are
43
+ * automatically crawled as well.
44
+ */
45
+ public async fetchTypes(oids: number[]) {
46
+ const multiRangeSupport = await this.supportsMultiRanges();
47
+
48
+ let pending = oids.filter((id) => !this.registry.knows(id));
49
+ // For details on columns, see https://www.postgresql.org/docs/current/catalog-pg-type.html
50
+ const multiRangeDesc = `WHEN 'm' THEN json_build_object('inner', (SELECT rngsubtype FROM pg_range WHERE rngmultitypid = t.oid))`;
51
+ const statement = `
52
+ SELECT oid, t.typtype,
53
+ CASE t.typtype
54
+ WHEN 'b' THEN json_build_object('element_type', t.typelem, 'delim', (SELECT typdelim FROM pg_type i WHERE i.oid = t.typelem))
55
+ WHEN 'd' THEN json_build_object('type', t.typbasetype)
56
+ WHEN 'c' THEN json_build_object(
57
+ 'elements',
58
+ (SELECT json_agg(json_build_object('name', a.attname, 'type', a.atttypid))
59
+ FROM pg_attribute a
60
+ WHERE a.attrelid = t.typrelid)
61
+ )
62
+ WHEN 'r' THEN json_build_object('inner', (SELECT rngsubtype FROM pg_range WHERE rngtypid = t.oid))
63
+ ${multiRangeSupport ? multiRangeDesc : ''}
64
+ ELSE NULL
65
+ END AS desc
66
+ FROM pg_type t
67
+ WHERE t.oid = ANY($1)
68
+ `;
69
+
70
+ while (pending.length != 0) {
71
+ // 1016: int8 array
72
+ const query = await this.pool.query({ statement, params: [{ type: 1016, value: pending }] });
73
+ const stillPending: number[] = [];
74
+
75
+ const requireType = (oid: number) => {
76
+ if (!this.registry.knows(oid) && !pending.includes(oid) && !stillPending.includes(oid)) {
77
+ stillPending.push(oid);
78
+ }
79
+ };
80
+
81
+ for (const row of pgwire.pgwireRows(query)) {
82
+ const oid = Number(row.oid);
83
+ const desc = JSON.parse(row.desc);
84
+
85
+ switch (row.typtype) {
86
+ case 'b':
87
+ const { element_type, delim } = desc;
88
+
89
+ if (!this.registry.knows(oid)) {
90
+ // This type is an array of another custom type.
91
+ const inner = Number(element_type);
92
+ if (inner != 0) {
93
+ // Some array types like macaddr[] don't seem to have their inner type set properly - skip!
94
+ requireType(inner);
95
+ this.registry.set(oid, {
96
+ type: 'array',
97
+ innerId: inner,
98
+ separatorCharCode: (delim as string).charCodeAt(0),
99
+ sqliteType: () => 'text' // Since it's JSON
100
+ });
101
+ }
102
+ }
103
+ break;
104
+ case 'c':
105
+ // For composite types, we sync the JSON representation.
106
+ const elements: { name: string; typeId: number }[] = [];
107
+ for (const { name, type } of desc.elements) {
108
+ const typeId = Number(type);
109
+ elements.push({ name, typeId });
110
+ requireType(typeId);
111
+ }
112
+
113
+ this.registry.set(oid, {
114
+ type: 'composite',
115
+ members: elements,
116
+ sqliteType: () => 'text' // Since it's JSON
117
+ });
118
+ break;
119
+ case 'd':
120
+ // For domain values like CREATE DOMAIN api.rating_value AS FLOAT CHECK (VALUE BETWEEN 0 AND 5), we sync
121
+ // the inner type (pg_type.typbasetype).
122
+ const inner = Number(desc.type);
123
+ this.registry.setDomainType(oid, inner);
124
+ requireType(inner);
125
+ break;
126
+ case 'r':
127
+ case 'm': {
128
+ const inner = Number(desc.inner);
129
+ this.registry.set(oid, {
130
+ type: row.typtype == 'r' ? 'range' : 'multirange',
131
+ innerId: inner,
132
+ sqliteType: () => 'text' // Since it's JSON
133
+ });
134
+ }
135
+ }
136
+ }
137
+
138
+ pending = stillPending;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Crawls all custom types referenced by table columns in the current database.
144
+ */
145
+ public async fetchTypesForSchema() {
146
+ const sql = `
147
+ SELECT DISTINCT a.atttypid AS type_oid
148
+ FROM pg_attribute a
149
+ JOIN pg_class c ON c.oid = a.attrelid
150
+ JOIN pg_namespace cn ON cn.oid = c.relnamespace
151
+ JOIN pg_type t ON t.oid = a.atttypid
152
+ JOIN pg_namespace tn ON tn.oid = t.typnamespace
153
+ WHERE a.attnum > 0
154
+ AND NOT a.attisdropped
155
+ AND cn.nspname not in ('information_schema', 'pg_catalog', 'pg_toast')
156
+ `;
157
+
158
+ const query = await this.pool.query({ statement: sql });
159
+ let ids: number[] = [];
160
+ for (const row of pgwire.pgwireRows(query)) {
161
+ ids.push(Number(row.type_oid));
162
+ }
163
+
164
+ await this.fetchTypes(ids);
165
+ }
166
+
167
+ /**
168
+ * pgwire message -> SQLite row.
169
+ * @param message
170
+ */
171
+ constructAfterRecord(message: pgwire.PgoutputInsert | pgwire.PgoutputUpdate): SqliteInputRow {
172
+ const rawData = (message as any).afterRaw;
173
+
174
+ const record = this.decodeTuple(message.relation, rawData);
175
+ return toSyncRulesRow(record);
176
+ }
177
+
178
+ /**
179
+ * pgwire message -> SQLite row.
180
+ * @param message
181
+ */
182
+ constructBeforeRecord(message: pgwire.PgoutputDelete | pgwire.PgoutputUpdate): SqliteInputRow | undefined {
183
+ const rawData = (message as any).beforeRaw;
184
+ if (rawData == null) {
185
+ return undefined;
186
+ }
187
+ const record = this.decodeTuple(message.relation, rawData);
188
+ return toSyncRulesRow(record);
189
+ }
190
+
191
+ /**
192
+ * We need a high level of control over how values are decoded, to make sure there is no loss
193
+ * of precision in the process.
194
+ */
195
+ decodeTuple(relation: pgwire.PgoutputRelation, tupleRaw: Record<string, any>): DatabaseInputRow {
196
+ let result: Record<string, any> = {};
197
+ for (let columnName in tupleRaw) {
198
+ const rawval = tupleRaw[columnName];
199
+ const typeOid = (relation as any)._tupleDecoder._typeOids.get(columnName);
200
+ if (typeof rawval == 'string' && typeOid) {
201
+ result[columnName] = this.registry.decodeDatabaseValue(rawval, typeOid);
202
+ } else {
203
+ result[columnName] = rawval;
204
+ }
205
+ }
206
+ return result;
207
+ }
208
+
209
+ private static minVersionForMultirange: semver.SemVer = semver.parse('14.0.0')!;
210
+ }
@@ -1,6 +1,7 @@
1
1
  import * as lib_postgres from '@powersync/lib-service-postgres';
2
2
  import * as service_types from '@powersync/service-types';
3
3
  import * as t from 'ts-codec';
4
+ import { CustomTypeRegistry } from './registry.js';
4
5
 
5
6
  // Maintain backwards compatibility by exporting these
6
7
  export const validatePort = lib_postgres.validatePort;
@@ -24,7 +25,10 @@ export type PostgresConnectionConfig = t.Decoded<typeof PostgresConnectionConfig
24
25
  /**
25
26
  * Resolved version of {@link PostgresConnectionConfig}
26
27
  */
27
- export type ResolvedConnectionConfig = PostgresConnectionConfig & NormalizedPostgresConnectionConfig;
28
+ export type ResolvedConnectionConfig = PostgresConnectionConfig &
29
+ NormalizedPostgresConnectionConfig & {
30
+ typeRegistry: CustomTypeRegistry;
31
+ };
28
32
 
29
33
  export function isPostgresConfig(
30
34
  config: service_types.configFile.DataSourceConfig
@@ -0,0 +1,8 @@
1
+ import * as pgwire from '@powersync/service-jpgwire';
2
+ import semver, { type SemVer } from 'semver';
3
+
4
+ export async function getServerVersion(db: pgwire.PgClient): Promise<SemVer | null> {
5
+ const result = await db.query(`SHOW server_version;`);
6
+ // The result is usually of the form "16.2 (Debian 16.2-1.pgdg120+2)"
7
+ return semver.coerce(result.rows[0][0].split(' ')[0]);
8
+ }
@@ -23,11 +23,13 @@ const checkpointTests = (factory: TestStorageFactory) => {
23
23
  const { pool } = context;
24
24
  const api = new PostgresRouteAPIAdapter(pool);
25
25
  const serverVersion = await context.connectionManager.getServerVersion();
26
- if (serverVersion!.compareMain('13.0.0') < 0) {
26
+ if (serverVersion!.compareMain('14.0.0') < 0) {
27
27
  // The test is not stable on Postgres 11 or 12. See the notes on
28
28
  // PostgresRouteAPIAdapter.createReplicationHead() for details.
29
29
  // Postgres 12 is already EOL, so not worth finding a fix - just skip the tests.
30
- console.log('Skipping write checkpoint test on Postgres < 13.0.0');
30
+ // Postgres 13 fares a little better, but even there the test is still unstable.
31
+ // Postgres 14+ appears to have no issue.
32
+ console.log('Skipping write checkpoint test on Postgres < 14.0.0');
31
33
  return;
32
34
  }
33
35