@dbos-inc/postgres-datasource 3.0.6-preview

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/index.ts ADDED
@@ -0,0 +1,238 @@
1
+ // using https://github.com/porsager/postgres
2
+
3
+ import postgres, { type Sql } from 'postgres';
4
+ import { DBOS, DBOSWorkflowConflictError } from '@dbos-inc/dbos-sdk';
5
+ import {
6
+ createTransactionCompletionSchemaPG,
7
+ createTransactionCompletionTablePG,
8
+ type DataSourceTransactionHandler,
9
+ isPGRetriableTransactionError,
10
+ isPGKeyConflictError,
11
+ registerTransaction,
12
+ runTransaction,
13
+ PGIsolationLevel as IsolationLevel,
14
+ PGTransactionConfig as PostgresTransactionOptions,
15
+ DBOSDataSource,
16
+ registerDataSource,
17
+ } from '@dbos-inc/dbos-sdk/datasource';
18
+ import { AsyncLocalStorage } from 'node:async_hooks';
19
+ import { SuperJSON } from 'superjson';
20
+
21
+ export { IsolationLevel, PostgresTransactionOptions };
22
+
23
+ interface PostgresDataSourceContext {
24
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
25
+ client: postgres.TransactionSql<{}>;
26
+ }
27
+
28
+ const asyncLocalCtx = new AsyncLocalStorage<PostgresDataSourceContext>();
29
+
30
+ class PostgresTransactionHandler implements DataSourceTransactionHandler {
31
+ readonly dsType = 'PostgresDataSource';
32
+ readonly #db: Sql;
33
+
34
+ constructor(
35
+ readonly name: string,
36
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
37
+ options: postgres.Options<{}> = {},
38
+ ) {
39
+ this.#db = postgres(options);
40
+ }
41
+
42
+ initialize(): Promise<void> {
43
+ return Promise.resolve();
44
+ }
45
+
46
+ destroy(): Promise<void> {
47
+ return this.#db.end();
48
+ }
49
+
50
+ async #checkExecution(
51
+ workflowID: string,
52
+ functionNum: number,
53
+ ): Promise<{ output: string | null } | { error: string } | undefined> {
54
+ type Result = { output: string | null; error: string | null };
55
+ const result = await this.#db<Result[]>/*sql*/ `
56
+ SELECT output, error FROM dbos.transaction_completion
57
+ WHERE workflow_id = ${workflowID} AND function_num = ${functionNum}`;
58
+ if (result.length === 0) {
59
+ return undefined;
60
+ }
61
+ const { output, error } = result[0];
62
+ return error !== null ? { error } : { output };
63
+ }
64
+
65
+ static async #recordOutput(
66
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
67
+ client: postgres.TransactionSql<{}>,
68
+ workflowID: string,
69
+ functionNum: number,
70
+ output: string | null,
71
+ ): Promise<void> {
72
+ try {
73
+ await client/*sql*/ `
74
+ INSERT INTO dbos.transaction_completion (workflow_id, function_num, output)
75
+ VALUES (${workflowID}, ${functionNum}, ${output})`;
76
+ } catch (error) {
77
+ if (isPGKeyConflictError(error)) {
78
+ throw new DBOSWorkflowConflictError(workflowID);
79
+ } else {
80
+ throw error;
81
+ }
82
+ }
83
+ }
84
+
85
+ async #recordError(workflowID: string, functionNum: number, error: string): Promise<void> {
86
+ try {
87
+ await this.#db/*sql*/ `
88
+ INSERT INTO dbos.transaction_completion (workflow_id, function_num, error)
89
+ VALUES (${workflowID}, ${functionNum}, ${error})`;
90
+ } catch (error) {
91
+ if (isPGKeyConflictError(error)) {
92
+ throw new DBOSWorkflowConflictError(workflowID);
93
+ } else {
94
+ throw error;
95
+ }
96
+ }
97
+ }
98
+
99
+ async invokeTransactionFunction<This, Args extends unknown[], Return>(
100
+ config: PostgresTransactionOptions | undefined,
101
+ target: This,
102
+ func: (this: This, ...args: Args) => Promise<Return>,
103
+ ...args: Args
104
+ ): Promise<Return> {
105
+ const workflowID = DBOS.workflowID;
106
+ if (workflowID === undefined) {
107
+ throw new Error('Workflow ID is not set.');
108
+ }
109
+ const functionNum = DBOS.stepID;
110
+ if (functionNum === undefined) {
111
+ throw new Error('Function Number is not set.');
112
+ }
113
+
114
+ const isolationLevel = config?.isolationLevel ? `ISOLATION LEVEL ${config.isolationLevel}` : '';
115
+ const readOnly = config?.readOnly ?? false;
116
+ const accessMode = config?.readOnly === undefined ? '' : readOnly ? 'READ ONLY' : 'READ WRITE';
117
+ const saveResults = !readOnly && workflowID;
118
+
119
+ let retryWaitMS = 1;
120
+ const backoffFactor = 1.5;
121
+ const maxRetryWaitMS = 2000;
122
+
123
+ while (true) {
124
+ // Check to see if this tx has already been executed
125
+ const previousResult = saveResults ? await this.#checkExecution(workflowID, functionNum) : undefined;
126
+ if (previousResult) {
127
+ DBOS.span?.setAttribute('cached', true);
128
+
129
+ if ('error' in previousResult) {
130
+ throw SuperJSON.parse(previousResult.error);
131
+ }
132
+ return (previousResult.output ? SuperJSON.parse(previousResult.output) : null) as Return;
133
+ }
134
+
135
+ try {
136
+ const result = await this.#db.begin<Return>(`${isolationLevel} ${accessMode}`, async (client) => {
137
+ // execute user's transaction function
138
+ const result = await asyncLocalCtx.run({ client }, async () => {
139
+ return (await func.call(target, ...args)) as Return;
140
+ });
141
+
142
+ // save the output of read/write transactions
143
+ if (saveResults) {
144
+ await PostgresTransactionHandler.#recordOutput(
145
+ client,
146
+ workflowID,
147
+ functionNum,
148
+ SuperJSON.stringify(result),
149
+ );
150
+ }
151
+
152
+ return result;
153
+ });
154
+ return result as Return;
155
+ } catch (error) {
156
+ if (isPGRetriableTransactionError(error)) {
157
+ // 400001 is a serialization failure in PostgreSQL
158
+ DBOS.span?.addEvent('TXN SERIALIZATION FAILURE', { retryWaitMillis: retryWaitMS }, performance.now());
159
+ await new Promise((resolve) => setTimeout(resolve, retryWaitMS));
160
+ retryWaitMS = Math.min(retryWaitMS * backoffFactor, maxRetryWaitMS);
161
+ continue;
162
+ } else {
163
+ if (saveResults) {
164
+ const message = SuperJSON.stringify(error);
165
+ await this.#recordError(workflowID, functionNum, message);
166
+ }
167
+
168
+ throw error;
169
+ }
170
+ }
171
+ }
172
+ }
173
+ }
174
+
175
+ export class PostgresDataSource implements DBOSDataSource<PostgresTransactionOptions> {
176
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
177
+ static get client(): postgres.TransactionSql<{}> {
178
+ if (!DBOS.isInTransaction()) {
179
+ throw new Error('invalid use of PostgresDataSource.client outside of a DBOS transaction.');
180
+ }
181
+ const ctx = asyncLocalCtx.getStore();
182
+ if (!ctx) {
183
+ throw new Error('No async local context found.');
184
+ }
185
+ return ctx.client;
186
+ }
187
+
188
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
189
+ static async initializeInternalSchema(options: postgres.Options<{}> = {}): Promise<void> {
190
+ const pg = postgres({ ...options, onnotice: () => {} });
191
+ try {
192
+ await pg.unsafe(createTransactionCompletionSchemaPG);
193
+ await pg.unsafe(createTransactionCompletionTablePG);
194
+ } finally {
195
+ await pg.end();
196
+ }
197
+ }
198
+
199
+ readonly name: string;
200
+ #provider: PostgresTransactionHandler;
201
+
202
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
203
+ constructor(name: string, options: postgres.Options<{}> = {}) {
204
+ this.name = name;
205
+ this.#provider = new PostgresTransactionHandler(name, options);
206
+ registerDataSource(this.#provider);
207
+ }
208
+
209
+ async runTransaction<T>(callback: () => Promise<T>, name: string, config?: PostgresTransactionOptions) {
210
+ return await runTransaction(callback, name, { dsName: this.name, config });
211
+ }
212
+
213
+ registerTransaction<This, Args extends unknown[], Return>(
214
+ func: (this: This, ...args: Args) => Promise<Return>,
215
+ name: string,
216
+ config?: PostgresTransactionOptions,
217
+ ): (this: This, ...args: Args) => Promise<Return> {
218
+ return registerTransaction(this.name, func, { name }, config);
219
+ }
220
+
221
+ transaction(config?: PostgresTransactionOptions) {
222
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
223
+ const ds = this;
224
+ return function decorator<This, Args extends unknown[], Return>(
225
+ _target: object,
226
+ propertyKey: string,
227
+ descriptor: TypedPropertyDescriptor<(this: This, ...args: Args) => Promise<Return>>,
228
+ ) {
229
+ if (!descriptor.value) {
230
+ throw Error('Use of decorator when original method is undefined');
231
+ }
232
+
233
+ descriptor.value = ds.registerTransaction(descriptor.value, propertyKey.toString(), config);
234
+
235
+ return descriptor;
236
+ };
237
+ }
238
+ }
package/jest.config.js ADDED
@@ -0,0 +1,8 @@
1
+ /** @type {import('ts-jest').JestConfigWithTsJest} */
2
+ module.exports = {
3
+ preset: 'ts-jest',
4
+ testEnvironment: 'node',
5
+ testRegex: '((\\.|/)(test|spec))\\.ts?$',
6
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
7
+ modulePaths: ['./'],
8
+ };
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@dbos-inc/postgres-datasource",
3
+ "version": "3.0.6-preview",
4
+ "description": "",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/dbos-inc/dbos-transact-ts",
9
+ "directory": "packages/knex-datasource"
10
+ },
11
+ "homepage": "https://docs.dbos.dev/",
12
+ "main": "index.js",
13
+ "scripts": {
14
+ "build": "tsc --project tsconfig.json",
15
+ "test": "jest --detectOpenHandles"
16
+ },
17
+ "dependencies": {
18
+ "postgres": "^3.4.7",
19
+ "superjson": "^1.13"
20
+ },
21
+ "peerDependencies": {
22
+ "@dbos-inc/dbos-sdk": "*"
23
+ },
24
+ "devDependencies": {
25
+ "@types/jest": "^29.5.14",
26
+ "@types/node": "^20.11.25",
27
+ "jest": "^29.7.0",
28
+ "typescript": "^5.4.5"
29
+ }
30
+ }
@@ -0,0 +1,31 @@
1
+ import { Client } from 'pg';
2
+ import { PostgresDataSource } from '../index';
3
+ import { dropDB, ensureDB } from './test-helpers';
4
+
5
+ describe('PostgresDataSource.configure', () => {
6
+ const config = { user: 'postgres', database: 'pg_ds_config_test' };
7
+
8
+ beforeAll(async () => {
9
+ const client = new Client({ ...config, database: 'postgres' });
10
+ try {
11
+ await client.connect();
12
+ await dropDB(client, config.database, true);
13
+ await ensureDB(client, config.database);
14
+ } finally {
15
+ await client.end();
16
+ }
17
+ });
18
+
19
+ test('configure creates tx outputs table', async () => {
20
+ await PostgresDataSource.initializeInternalSchema(config);
21
+
22
+ const client = new Client(config);
23
+ try {
24
+ await client.connect();
25
+ const result = await client.query('SELECT workflow_id, function_num, output FROM dbos.transaction_completion');
26
+ expect(result.rows.length).toBe(0);
27
+ } finally {
28
+ await client.end();
29
+ }
30
+ });
31
+ });