@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.
- package/CHANGELOG.md +12 -0
- package/LICENSE +67 -0
- package/README.md +3 -0
- package/dist/db/connection/AbstractPostgresConnection.d.ts +29 -0
- package/dist/db/connection/AbstractPostgresConnection.js +82 -0
- package/dist/db/connection/AbstractPostgresConnection.js.map +1 -0
- package/dist/db/connection/ConnectionSlot.d.ts +41 -0
- package/dist/db/connection/ConnectionSlot.js +122 -0
- package/dist/db/connection/ConnectionSlot.js.map +1 -0
- package/dist/db/connection/DatabaseClient.d.ts +62 -0
- package/dist/db/connection/DatabaseClient.js +209 -0
- package/dist/db/connection/DatabaseClient.js.map +1 -0
- package/dist/db/connection/WrappedConnection.d.ts +9 -0
- package/dist/db/connection/WrappedConnection.js +11 -0
- package/dist/db/connection/WrappedConnection.js.map +1 -0
- package/dist/db/db-index.d.ts +4 -0
- package/dist/db/db-index.js +5 -0
- package/dist/db/db-index.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/locks/PostgresLockManager.d.ts +17 -0
- package/dist/locks/PostgresLockManager.js +112 -0
- package/dist/locks/PostgresLockManager.js.map +1 -0
- package/dist/locks/locks-index.d.ts +1 -0
- package/dist/locks/locks-index.js +2 -0
- package/dist/locks/locks-index.js.map +1 -0
- package/dist/types/types.d.ts +72 -0
- package/dist/types/types.js +108 -0
- package/dist/types/types.js.map +1 -0
- package/dist/utils/pgwire_utils.d.ts +6 -0
- package/dist/utils/pgwire_utils.js +47 -0
- package/dist/utils/pgwire_utils.js.map +1 -0
- package/dist/utils/utils-index.d.ts +1 -0
- package/dist/utils/utils-index.js +2 -0
- package/dist/utils/utils-index.js.map +1 -0
- package/package.json +43 -0
- package/src/db/connection/AbstractPostgresConnection.ts +109 -0
- package/src/db/connection/ConnectionSlot.ts +165 -0
- package/src/db/connection/DatabaseClient.ts +261 -0
- package/src/db/connection/WrappedConnection.ts +11 -0
- package/src/db/db-index.ts +4 -0
- package/src/index.ts +11 -0
- package/src/locks/PostgresLockManager.ts +128 -0
- package/src/locks/locks-index.ts +1 -0
- package/src/types/types.ts +149 -0
- package/src/utils/pgwire_utils.ts +49 -0
- package/src/utils/utils-index.ts +1 -0
- package/test/src/config.test.ts +12 -0
- package/test/tsconfig.json +18 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- 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
|
+
}
|
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';
|