@powersync/lib-service-postgres 0.1.0

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 (53) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +67 -0
  3. package/README.md +3 -0
  4. package/dist/db/connection/AbstractPostgresConnection.d.ts +29 -0
  5. package/dist/db/connection/AbstractPostgresConnection.js +82 -0
  6. package/dist/db/connection/AbstractPostgresConnection.js.map +1 -0
  7. package/dist/db/connection/ConnectionSlot.d.ts +41 -0
  8. package/dist/db/connection/ConnectionSlot.js +122 -0
  9. package/dist/db/connection/ConnectionSlot.js.map +1 -0
  10. package/dist/db/connection/DatabaseClient.d.ts +62 -0
  11. package/dist/db/connection/DatabaseClient.js +209 -0
  12. package/dist/db/connection/DatabaseClient.js.map +1 -0
  13. package/dist/db/connection/WrappedConnection.d.ts +9 -0
  14. package/dist/db/connection/WrappedConnection.js +11 -0
  15. package/dist/db/connection/WrappedConnection.js.map +1 -0
  16. package/dist/db/db-index.d.ts +4 -0
  17. package/dist/db/db-index.js +5 -0
  18. package/dist/db/db-index.js.map +1 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.js +9 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/locks/PostgresLockManager.d.ts +17 -0
  23. package/dist/locks/PostgresLockManager.js +112 -0
  24. package/dist/locks/PostgresLockManager.js.map +1 -0
  25. package/dist/locks/locks-index.d.ts +1 -0
  26. package/dist/locks/locks-index.js +2 -0
  27. package/dist/locks/locks-index.js.map +1 -0
  28. package/dist/types/types.d.ts +72 -0
  29. package/dist/types/types.js +108 -0
  30. package/dist/types/types.js.map +1 -0
  31. package/dist/utils/pgwire_utils.d.ts +6 -0
  32. package/dist/utils/pgwire_utils.js +47 -0
  33. package/dist/utils/pgwire_utils.js.map +1 -0
  34. package/dist/utils/utils-index.d.ts +1 -0
  35. package/dist/utils/utils-index.js +2 -0
  36. package/dist/utils/utils-index.js.map +1 -0
  37. package/package.json +43 -0
  38. package/src/db/connection/AbstractPostgresConnection.ts +109 -0
  39. package/src/db/connection/ConnectionSlot.ts +165 -0
  40. package/src/db/connection/DatabaseClient.ts +261 -0
  41. package/src/db/connection/WrappedConnection.ts +11 -0
  42. package/src/db/db-index.ts +4 -0
  43. package/src/index.ts +11 -0
  44. package/src/locks/PostgresLockManager.ts +128 -0
  45. package/src/locks/locks-index.ts +1 -0
  46. package/src/types/types.ts +149 -0
  47. package/src/utils/pgwire_utils.ts +49 -0
  48. package/src/utils/utils-index.ts +1 -0
  49. package/test/src/config.test.ts +12 -0
  50. package/test/tsconfig.json +18 -0
  51. package/tsconfig.json +12 -0
  52. package/tsconfig.tsbuildinfo +1 -0
  53. package/vitest.config.ts +3 -0
@@ -0,0 +1,165 @@
1
+ import * as framework from '@powersync/lib-services-framework';
2
+ import * as pgwire from '@powersync/service-jpgwire';
3
+
4
+ export interface NotificationListener extends framework.DisposableListener {
5
+ notification?: (payload: pgwire.PgNotification) => void;
6
+ }
7
+
8
+ export interface ConnectionSlotListener extends NotificationListener {
9
+ connectionAvailable?: () => void;
10
+ connectionError?: (exception: any) => void;
11
+ connectionCreated?: (connection: pgwire.PgConnection) => Promise<void>;
12
+ }
13
+
14
+ export type ConnectionLease = {
15
+ connection: pgwire.PgConnection;
16
+ release: () => void;
17
+ };
18
+
19
+ export type ConnectionSlotOptions = {
20
+ config: pgwire.NormalizedConnectionConfig;
21
+ notificationChannels?: string[];
22
+ };
23
+
24
+ export const MAX_CONNECTION_ATTEMPTS = 5;
25
+
26
+ export class ConnectionSlot extends framework.DisposableObserver<ConnectionSlotListener> {
27
+ isAvailable: boolean;
28
+ isPoking: boolean;
29
+
30
+ closed: boolean;
31
+
32
+ protected connection: pgwire.PgConnection | null;
33
+ protected connectingPromise: Promise<pgwire.PgConnection> | null;
34
+
35
+ constructor(protected options: ConnectionSlotOptions) {
36
+ super();
37
+ this.isAvailable = false;
38
+ this.connection = null;
39
+ this.isPoking = false;
40
+ this.connectingPromise = null;
41
+ this.closed = false;
42
+ }
43
+
44
+ get isConnected() {
45
+ return !!this.connection;
46
+ }
47
+
48
+ protected async connect() {
49
+ this.connectingPromise = pgwire.connectPgWire(this.options.config, { type: 'standard' });
50
+ const connection = await this.connectingPromise;
51
+ this.connectingPromise = null;
52
+ await this.iterateAsyncListeners(async (l) => l.connectionCreated?.(connection));
53
+ if (this.hasNotificationListener()) {
54
+ await this.configureConnectionNotifications(connection);
55
+ }
56
+ return connection;
57
+ }
58
+
59
+ async [Symbol.asyncDispose]() {
60
+ this.closed = true;
61
+ const connection = this.connection ?? (await this.connectingPromise);
62
+ await connection?.end();
63
+ return super[Symbol.dispose]();
64
+ }
65
+
66
+ protected async configureConnectionNotifications(connection: pgwire.PgConnection) {
67
+ if (connection.onnotification == this.handleNotification || this.closed == true) {
68
+ // Already configured
69
+ return;
70
+ }
71
+
72
+ connection.onnotification = this.handleNotification;
73
+
74
+ for (const channelName of this.options.notificationChannels ?? []) {
75
+ await connection.query({
76
+ statement: `LISTEN ${channelName}`
77
+ });
78
+ }
79
+ }
80
+
81
+ registerListener(listener: Partial<ConnectionSlotListener>): () => void {
82
+ const dispose = super.registerListener(listener);
83
+ if (this.connection && this.hasNotificationListener()) {
84
+ this.configureConnectionNotifications(this.connection);
85
+ }
86
+ return () => {
87
+ dispose();
88
+ if (this.connection && !this.hasNotificationListener()) {
89
+ this.connection.onnotification = () => {};
90
+ }
91
+ };
92
+ }
93
+
94
+ protected handleNotification = (payload: pgwire.PgNotification) => {
95
+ if (!this.options.notificationChannels?.includes(payload.channel)) {
96
+ return;
97
+ }
98
+ this.iterateListeners((l) => l.notification?.(payload));
99
+ };
100
+
101
+ protected hasNotificationListener() {
102
+ return !!Object.values(this.listeners).find((l) => !!l.notification);
103
+ }
104
+
105
+ /**
106
+ * Test the connection if it can be reached.
107
+ */
108
+ async poke() {
109
+ if (this.isPoking || (this.isConnected && this.isAvailable == false) || this.closed) {
110
+ return;
111
+ }
112
+ this.isPoking = true;
113
+ for (let retryCounter = 0; retryCounter <= MAX_CONNECTION_ATTEMPTS; retryCounter++) {
114
+ try {
115
+ const connection = this.connection ?? (await this.connect());
116
+
117
+ await connection.query({
118
+ statement: 'SELECT 1'
119
+ });
120
+
121
+ if (!this.connection) {
122
+ this.connection = connection;
123
+ this.setAvailable();
124
+ } else if (this.isAvailable) {
125
+ this.iterateListeners((cb) => cb.connectionAvailable?.());
126
+ }
127
+
128
+ // Connection is alive and healthy
129
+ break;
130
+ } catch (ex) {
131
+ // Should be valid for all cases
132
+ this.isAvailable = false;
133
+ if (this.connection) {
134
+ this.connection.onnotification = () => {};
135
+ this.connection.destroy();
136
+ this.connection = null;
137
+ }
138
+ if (retryCounter >= MAX_CONNECTION_ATTEMPTS) {
139
+ this.iterateListeners((cb) => cb.connectionError?.(ex));
140
+ }
141
+ }
142
+ }
143
+ this.isPoking = false;
144
+ }
145
+
146
+ protected setAvailable() {
147
+ this.isAvailable = true;
148
+ this.iterateListeners((l) => l.connectionAvailable?.());
149
+ }
150
+
151
+ lock(): ConnectionLease | null {
152
+ if (!this.isAvailable || !this.connection || this.closed) {
153
+ return null;
154
+ }
155
+
156
+ this.isAvailable = false;
157
+
158
+ return {
159
+ connection: this.connection,
160
+ release: () => {
161
+ this.setAvailable();
162
+ }
163
+ };
164
+ }
165
+ }
@@ -0,0 +1,261 @@
1
+ import * as lib_postgres from '@powersync/lib-service-postgres';
2
+ import * as pgwire from '@powersync/service-jpgwire';
3
+ import pDefer, { DeferredPromise } from 'p-defer';
4
+ import { AbstractPostgresConnection, sql } from './AbstractPostgresConnection.js';
5
+ import { ConnectionLease, ConnectionSlot, NotificationListener } from './ConnectionSlot.js';
6
+ import { WrappedConnection } from './WrappedConnection.js';
7
+
8
+ export type DatabaseClientOptions = {
9
+ config: lib_postgres.NormalizedBasePostgresConnectionConfig;
10
+ /**
11
+ * Optional schema which will be used as the default search path
12
+ */
13
+ schema?: string;
14
+ /**
15
+ * Notification channels to listen to.
16
+ */
17
+ notificationChannels?: string[];
18
+ };
19
+
20
+ export type DatabaseClientListener = NotificationListener & {
21
+ connectionCreated?: (connection: pgwire.PgConnection) => Promise<void>;
22
+ };
23
+
24
+ export const TRANSACTION_CONNECTION_COUNT = 5;
25
+
26
+ /**
27
+ * This provides access to Postgres via the PGWire library.
28
+ * A connection pool is used for individual query executions while
29
+ * a custom pool of connections is available for transactions or other operations
30
+ * which require being executed on the same connection.
31
+ */
32
+ export class DatabaseClient extends AbstractPostgresConnection<DatabaseClientListener> {
33
+ closed: boolean;
34
+
35
+ protected pool: pgwire.PgClient;
36
+ protected connections: ConnectionSlot[];
37
+
38
+ protected initialized: Promise<void>;
39
+ protected queue: DeferredPromise<ConnectionLease>[];
40
+
41
+ constructor(protected options: DatabaseClientOptions) {
42
+ super();
43
+ this.closed = false;
44
+ this.pool = pgwire.connectPgWirePool(options.config);
45
+ this.connections = Array.from({ length: TRANSACTION_CONNECTION_COUNT }, () => {
46
+ const slot = new ConnectionSlot({ config: options.config, notificationChannels: options.notificationChannels });
47
+ slot.registerListener({
48
+ connectionAvailable: () => this.processConnectionQueue(),
49
+ connectionError: (ex) => this.handleConnectionError(ex),
50
+ connectionCreated: (connection) => this.iterateAsyncListeners(async (l) => l.connectionCreated?.(connection))
51
+ });
52
+ return slot;
53
+ });
54
+ this.queue = [];
55
+ this.initialized = this.initialize();
56
+ }
57
+
58
+ protected get baseConnection() {
59
+ return this.pool;
60
+ }
61
+
62
+ protected get schemaStatement() {
63
+ const { schema } = this.options;
64
+ if (!schema) {
65
+ return;
66
+ }
67
+ return {
68
+ statement: `SET search_path TO ${schema};`
69
+ };
70
+ }
71
+
72
+ registerListener(listener: Partial<DatabaseClientListener>): () => void {
73
+ let disposeNotification: (() => void) | null = null;
74
+ if ('notification' in listener) {
75
+ // Pass this on to the first connection slot
76
+ // It will only actively listen on the connection once a listener has been registered
77
+ disposeNotification = this.connections[0].registerListener({
78
+ notification: listener.notification
79
+ });
80
+ this.pokeSlots();
81
+
82
+ delete listener['notification'];
83
+ }
84
+
85
+ const superDispose = super.registerListener(listener);
86
+ return () => {
87
+ disposeNotification?.();
88
+ superDispose();
89
+ };
90
+ }
91
+
92
+ query(script: string, options?: pgwire.PgSimpleQueryOptions): Promise<pgwire.PgResult>;
93
+ query(...args: pgwire.Statement[]): Promise<pgwire.PgResult>;
94
+ async query(...args: any[]): Promise<pgwire.PgResult> {
95
+ await this.initialized;
96
+ /**
97
+ * There is no direct way to set the default schema with pgwire.
98
+ * This hack uses multiple statements in order to always ensure the
99
+ * appropriate connection (in the pool) uses the correct schema.
100
+ */
101
+ const { schemaStatement } = this;
102
+ if (typeof args[0] == 'object' && schemaStatement) {
103
+ args.unshift(schemaStatement);
104
+ } else if (typeof args[0] == 'string' && schemaStatement) {
105
+ args[0] = `${schemaStatement.statement}; ${args[0]}`;
106
+ }
107
+
108
+ // Retry pool queries. Note that we can't retry queries in a transaction
109
+ // since a failed query will end the transaction.
110
+ return lib_postgres.retriedQuery(this.pool, ...args);
111
+ }
112
+
113
+ async *stream(...args: pgwire.Statement[]): AsyncIterableIterator<pgwire.PgChunk> {
114
+ await this.initialized;
115
+ const { schemaStatement } = this;
116
+ if (schemaStatement) {
117
+ args.unshift(schemaStatement);
118
+ }
119
+ yield* super.stream(...args);
120
+ }
121
+
122
+ async lockConnection<T>(callback: (db: WrappedConnection) => Promise<T>): Promise<T> {
123
+ const { connection, release } = await this.requestConnection();
124
+
125
+ await this.setSchema(connection);
126
+
127
+ try {
128
+ return await callback(new WrappedConnection(connection));
129
+ } finally {
130
+ release();
131
+ }
132
+ }
133
+
134
+ async transaction<T>(tx: (db: WrappedConnection) => Promise<T>): Promise<T> {
135
+ return this.lockConnection(async (db) => {
136
+ try {
137
+ await db.query(sql`BEGIN`);
138
+ const result = await tx(db);
139
+ await db.query(sql`COMMIT`);
140
+ return result;
141
+ } catch (ex) {
142
+ await db.query(sql`ROLLBACK`);
143
+ throw ex;
144
+ }
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Use the `powersync` schema as the default when resolving table names
150
+ */
151
+ protected async setSchema(client: pgwire.PgClient) {
152
+ const { schemaStatement } = this;
153
+ if (!schemaStatement) {
154
+ return;
155
+ }
156
+ await client.query(schemaStatement);
157
+ }
158
+
159
+ protected async initialize() {
160
+ const { schema } = this.options;
161
+ if (schema) {
162
+ // First check if it exists
163
+ const exists = await this.pool.query(sql`
164
+ SELECT
165
+ schema_name
166
+ FROM
167
+ information_schema.schemata
168
+ WHERE
169
+ schema_name = ${{ type: 'varchar', value: schema }};
170
+ `);
171
+
172
+ if (exists.rows.length) {
173
+ return;
174
+ }
175
+ // Create the schema if it doesn't exist
176
+ await this.pool.query({ statement: `CREATE SCHEMA IF NOT EXISTS ${this.options.schema}` });
177
+ }
178
+ }
179
+
180
+ protected async requestConnection(): Promise<ConnectionLease> {
181
+ if (this.closed) {
182
+ throw new Error('Database client is closed');
183
+ }
184
+
185
+ await this.initialized;
186
+
187
+ // Queue the operation
188
+ const deferred = pDefer<ConnectionLease>();
189
+ this.queue.push(deferred);
190
+
191
+ this.pokeSlots();
192
+
193
+ return deferred.promise;
194
+ }
195
+
196
+ protected pokeSlots() {
197
+ // Poke the slots to check if they are alive
198
+ for (const slot of this.connections) {
199
+ // No need to await this. Errors are reported asynchronously
200
+ slot.poke();
201
+ }
202
+ }
203
+
204
+ protected leaseConnectionSlot(): ConnectionLease | null {
205
+ const availableSlots = this.connections.filter((s) => s.isAvailable);
206
+ for (const slot of availableSlots) {
207
+ const lease = slot.lock();
208
+ if (lease) {
209
+ return lease;
210
+ }
211
+ // Possibly some contention detected, keep trying
212
+ }
213
+ return null;
214
+ }
215
+
216
+ protected processConnectionQueue() {
217
+ if (this.closed && this.queue.length) {
218
+ for (const q of this.queue) {
219
+ q.reject(new Error('Database has closed while waiting for a connection'));
220
+ }
221
+ this.queue = [];
222
+ }
223
+
224
+ if (this.queue.length) {
225
+ const lease = this.leaseConnectionSlot();
226
+ if (lease) {
227
+ const deferred = this.queue.shift()!;
228
+ deferred.resolve(lease);
229
+ }
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Reports connection errors which might occur from bad configuration or
235
+ * a server which is no longer available.
236
+ * This fails all pending requests.
237
+ */
238
+ protected handleConnectionError(exception: any) {
239
+ for (const q of this.queue) {
240
+ q.reject(exception);
241
+ }
242
+ this.queue = [];
243
+ }
244
+
245
+ async [Symbol.asyncDispose]() {
246
+ await this.initialized;
247
+ this.closed = true;
248
+
249
+ for (const c of this.connections) {
250
+ await c[Symbol.asyncDispose]();
251
+ }
252
+
253
+ await this.pool.end();
254
+
255
+ // Reject all remaining items
256
+ for (const q of this.queue) {
257
+ q.reject(new Error(`Database is disposed`));
258
+ }
259
+ this.queue = [];
260
+ }
261
+ }
@@ -0,0 +1,11 @@
1
+ import * as pgwire from '@powersync/service-jpgwire';
2
+ import { AbstractPostgresConnection } from './AbstractPostgresConnection.js';
3
+
4
+ /**
5
+ * Provides helper functionality to transaction contexts given an existing PGWire connection
6
+ */
7
+ export class WrappedConnection extends AbstractPostgresConnection {
8
+ constructor(protected baseConnection: pgwire.PgConnection) {
9
+ super();
10
+ }
11
+ }
@@ -0,0 +1,4 @@
1
+ export * from './connection/AbstractPostgresConnection.js';
2
+ export * from './connection/ConnectionSlot.js';
3
+ export * from './connection/DatabaseClient.js';
4
+ export * from './connection/WrappedConnection.js';
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export * from './db/db-index.js';
2
+ export * as db from './db/db-index.js';
3
+
4
+ export * from './locks/locks-index.js';
5
+ export * as locks from './locks/locks-index.js';
6
+
7
+ export * from './types/types.js';
8
+ export * as types from './types/types.js';
9
+
10
+ export * from './utils/utils-index.js';
11
+ export * as utils from './utils/utils-index.js';
@@ -0,0 +1,128 @@
1
+ import * as framework from '@powersync/lib-services-framework';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+ import { DatabaseClient, sql } from '../db/db-index.js';
4
+
5
+ const DEFAULT_LOCK_TIMEOUT = 60_000; // 1 minute
6
+
7
+ export interface PostgresLockManagerParams extends framework.locks.LockManagerParams {
8
+ db: DatabaseClient;
9
+ }
10
+
11
+ export class PostgresLockManager extends framework.locks.AbstractLockManager {
12
+ constructor(protected params: PostgresLockManagerParams) {
13
+ super(params);
14
+ }
15
+
16
+ protected get db() {
17
+ return this.params.db;
18
+ }
19
+
20
+ get timeout() {
21
+ return this.params.timeout ?? DEFAULT_LOCK_TIMEOUT;
22
+ }
23
+
24
+ get name() {
25
+ return this.params.name;
26
+ }
27
+
28
+ async init() {
29
+ /**
30
+ * Locks are required for migrations, which means this table can't be
31
+ * created inside a migration. This ensures the locks table is present.
32
+ */
33
+ await this.db.query(sql`
34
+ CREATE TABLE IF NOT EXISTS locks (
35
+ name TEXT PRIMARY KEY,
36
+ lock_id UUID NOT NULL,
37
+ ts TIMESTAMPTZ NOT NULL
38
+ );
39
+ `);
40
+ }
41
+
42
+ protected async acquireHandle(): Promise<framework.LockHandle | null> {
43
+ const id = await this._acquireId();
44
+ if (!id) {
45
+ return null;
46
+ }
47
+ return {
48
+ refresh: () => this.refreshHandle(id),
49
+ release: () => this.releaseHandle(id)
50
+ };
51
+ }
52
+
53
+ protected async _acquireId(): Promise<string | null> {
54
+ const now = new Date();
55
+ const nowISO = now.toISOString();
56
+ const expiredTs = new Date(now.getTime() - this.timeout).toISOString();
57
+ const lockId = uuidv4();
58
+
59
+ try {
60
+ // Attempt to acquire or refresh the lock
61
+ const res = await this.db.queryRows<{ lock_id: string }>(sql`
62
+ INSERT INTO
63
+ locks (name, lock_id, ts)
64
+ VALUES
65
+ (
66
+ ${{ type: 'varchar', value: this.name }},
67
+ ${{ type: 'uuid', value: lockId }},
68
+ ${{ type: 1184, value: nowISO }}
69
+ )
70
+ ON CONFLICT (name) DO UPDATE
71
+ SET
72
+ lock_id = CASE
73
+ WHEN locks.ts <= ${{ type: 1184, value: expiredTs }} THEN ${{ type: 'uuid', value: lockId }}
74
+ ELSE locks.lock_id
75
+ END,
76
+ ts = CASE
77
+ WHEN locks.ts <= ${{ type: 1184, value: expiredTs }} THEN ${{
78
+ type: 1184,
79
+ value: nowISO
80
+ }}
81
+ ELSE locks.ts
82
+ END
83
+ RETURNING
84
+ lock_id;
85
+ `);
86
+
87
+ if (res.length == 0 || res[0].lock_id !== lockId) {
88
+ // Lock is active and could not be acquired
89
+ return null;
90
+ }
91
+
92
+ return lockId;
93
+ } catch (err) {
94
+ console.error('Error acquiring lock:', err);
95
+ throw err;
96
+ }
97
+ }
98
+
99
+ protected async refreshHandle(lockId: string) {
100
+ const res = await this.db.query(sql`
101
+ UPDATE locks
102
+ SET
103
+ ts = ${{ type: 1184, value: new Date().toISOString() }}
104
+ WHERE
105
+ lock_id = ${{ type: 'uuid', value: lockId }}
106
+ RETURNING
107
+ lock_id;
108
+ `);
109
+
110
+ if (res.rows.length === 0) {
111
+ throw new Error('Lock not found, could not refresh');
112
+ }
113
+ }
114
+
115
+ protected async releaseHandle(lockId: string) {
116
+ const res = await this.db.query(sql`
117
+ DELETE FROM locks
118
+ WHERE
119
+ lock_id = ${{ type: 'uuid', value: lockId }}
120
+ RETURNING
121
+ lock_id;
122
+ `);
123
+
124
+ if (res.rows.length == 0) {
125
+ throw new Error('Lock not found, could not release');
126
+ }
127
+ }
128
+ }
@@ -0,0 +1 @@
1
+ export * from './PostgresLockManager.js';