@dbos-inc/drizzle-datasource 3.0.7-preview → 3.0.8-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,266 @@
1
+ import { Client, ClientConfig, Pool, PoolConfig } from 'pg';
2
+ import { DBOS, DBOSWorkflowConflictError } from '@dbos-inc/dbos-sdk';
3
+ import {
4
+ type DataSourceTransactionHandler,
5
+ createTransactionCompletionSchemaPG,
6
+ createTransactionCompletionTablePG,
7
+ isPGRetriableTransactionError,
8
+ isPGKeyConflictError,
9
+ registerTransaction,
10
+ runTransaction,
11
+ DBOSDataSource,
12
+ registerDataSource,
13
+ } from '@dbos-inc/dbos-sdk/datasource';
14
+ import { drizzle, NodePgDatabase } from 'drizzle-orm/node-postgres';
15
+ import { AsyncLocalStorage } from 'async_hooks';
16
+ import { SuperJSON } from 'superjson';
17
+ import { PgTransactionConfig } from 'drizzle-orm/pg-core';
18
+ import { sql } from 'drizzle-orm';
19
+
20
+ interface DrizzleLocalCtx {
21
+ client: NodePgDatabase<{ [key: string]: object }>;
22
+ }
23
+
24
+ export type TransactionConfig = Pick<PgTransactionConfig, 'isolationLevel' | 'accessMode'>;
25
+
26
+ const asyncLocalCtx = new AsyncLocalStorage<DrizzleLocalCtx>();
27
+
28
+ export interface transaction_completion {
29
+ workflow_id: string;
30
+ function_num: number;
31
+ output: string | null;
32
+ error: string | null;
33
+ }
34
+
35
+ interface DrizzleConnection {
36
+ readonly db: NodePgDatabase<{ [key: string]: object }>;
37
+ end(): Promise<void>;
38
+ }
39
+
40
+ class DrizzleTransactionHandler implements DataSourceTransactionHandler {
41
+ readonly dsType = 'drizzle';
42
+ #connection: DrizzleConnection | undefined;
43
+
44
+ constructor(
45
+ readonly name: string,
46
+ private readonly config: PoolConfig,
47
+ private readonly entities: { [key: string]: object } = {},
48
+ ) {}
49
+
50
+ async initialize(): Promise<void> {
51
+ const conn = this.#connection;
52
+
53
+ const driver = new Pool(this.config);
54
+ const db = drizzle(driver, { schema: this.entities });
55
+ this.#connection = { db, end: () => driver.end() };
56
+
57
+ await conn?.end();
58
+ }
59
+
60
+ async destroy(): Promise<void> {
61
+ const conn = this.#connection;
62
+
63
+ this.#connection = undefined;
64
+
65
+ await conn?.end();
66
+ }
67
+
68
+ get #drizzle(): NodePgDatabase<{ [key: string]: object }> {
69
+ if (!this.#connection) {
70
+ throw new Error(`DataSource ${this.name} is not initialized.`);
71
+ }
72
+ return this.#connection.db;
73
+ }
74
+
75
+ async #checkExecution(
76
+ workflowID: string,
77
+ stepID: number,
78
+ ): Promise<{ output: string | null } | { error: string } | undefined> {
79
+ type Result = { output: string | null; error: string | null };
80
+
81
+ const statement = sql`
82
+ SELECT output, error FROM dbos.transaction_completion
83
+ WHERE workflow_id = ${workflowID} AND function_num = ${stepID}`;
84
+ const result = await this.#drizzle.execute<Result>(statement);
85
+
86
+ if (result.rows.length !== 1) {
87
+ return undefined;
88
+ }
89
+
90
+ const { output, error } = result.rows[0];
91
+ return error !== null ? { error } : { output };
92
+ }
93
+
94
+ static async #recordOutput(
95
+ client: NodePgDatabase<{ [key: string]: object }>,
96
+ workflowID: string,
97
+ stepID: number,
98
+ output: string,
99
+ ): Promise<void> {
100
+ try {
101
+ const statement = sql`
102
+ INSERT INTO dbos.transaction_completion (workflow_id, function_num, output)
103
+ VALUES (${workflowID}, ${stepID}, ${output})`;
104
+ await client.execute(statement);
105
+ } catch (error) {
106
+ if (isPGKeyConflictError(error)) {
107
+ throw new DBOSWorkflowConflictError(workflowID);
108
+ } else {
109
+ throw error;
110
+ }
111
+ }
112
+ }
113
+
114
+ async #recordError(workflowID: string, stepID: number, error: string): Promise<void> {
115
+ try {
116
+ const statement = sql`
117
+ INSERT INTO dbos.transaction_completion (workflow_id, function_num, error)
118
+ VALUES (${workflowID}, ${stepID}, ${error})`;
119
+ await this.#drizzle.execute(statement);
120
+ } catch (error) {
121
+ if (isPGKeyConflictError(error)) {
122
+ throw new DBOSWorkflowConflictError(workflowID);
123
+ } else {
124
+ throw error;
125
+ }
126
+ }
127
+ }
128
+
129
+ /* Invoke a transaction function, called by the framework */
130
+ async invokeTransactionFunction<This, Args extends unknown[], Return>(
131
+ config: TransactionConfig | undefined,
132
+ target: This,
133
+ func: (this: This, ...args: Args) => Promise<Return>,
134
+ ...args: Args
135
+ ): Promise<Return> {
136
+ const workflowID = DBOS.workflowID;
137
+ const stepID = DBOS.stepID;
138
+ if (workflowID !== undefined && stepID === undefined) {
139
+ throw new Error('DBOS.stepID is undefined inside a workflow.');
140
+ }
141
+
142
+ const readOnly = config?.accessMode === 'read only' ? true : false;
143
+ const saveResults = !readOnly && workflowID !== undefined;
144
+
145
+ // Retry loop if appropriate
146
+ let retryWaitMS = 1;
147
+ const backoffFactor = 1.5;
148
+ const maxRetryWaitMS = 2000; // Maximum wait 2 seconds.
149
+
150
+ while (true) {
151
+ // Check to see if this tx has already been executed
152
+ const previousResult = saveResults ? await this.#checkExecution(workflowID, stepID!) : undefined;
153
+ if (previousResult) {
154
+ DBOS.span?.setAttribute('cached', true);
155
+
156
+ if ('error' in previousResult) {
157
+ throw SuperJSON.parse(previousResult.error);
158
+ }
159
+ return (previousResult.output ? SuperJSON.parse(previousResult.output) : null) as Return;
160
+ }
161
+
162
+ try {
163
+ const result = await this.#drizzle.transaction(
164
+ async (client) => {
165
+ // execute user's transaction function
166
+ const result = await asyncLocalCtx.run({ client }, async () => {
167
+ return await func.call(target, ...args);
168
+ });
169
+
170
+ // save the output of read/write transactions
171
+ if (saveResults) {
172
+ await DrizzleTransactionHandler.#recordOutput(client, workflowID, stepID!, SuperJSON.stringify(result));
173
+ }
174
+
175
+ return result;
176
+ },
177
+ { accessMode: config?.accessMode, isolationLevel: config?.isolationLevel },
178
+ );
179
+
180
+ return result;
181
+ } catch (error) {
182
+ if (isPGRetriableTransactionError(error)) {
183
+ DBOS.span?.addEvent('TXN SERIALIZATION FAILURE', { retryWaitMillis: retryWaitMS }, performance.now());
184
+ // Retry serialization failures.
185
+ await new Promise((resolve) => setTimeout(resolve, retryWaitMS));
186
+ retryWaitMS = Math.min(retryWaitMS * backoffFactor, maxRetryWaitMS);
187
+ continue;
188
+ } else {
189
+ if (saveResults) {
190
+ const message = SuperJSON.stringify(error);
191
+ await this.#recordError(workflowID, stepID!, message);
192
+ }
193
+
194
+ throw error;
195
+ }
196
+ }
197
+ }
198
+ }
199
+ }
200
+
201
+ export class DrizzleDataSource implements DBOSDataSource<TransactionConfig> {
202
+ // User calls this... DBOS not directly involved...
203
+ static get client(): NodePgDatabase<{ [key: string]: object }> {
204
+ if (!DBOS.isInTransaction()) {
205
+ throw new Error('Invalid use of DrizzleDataSource.client outside of a DBOS transaction');
206
+ }
207
+ const ctx = asyncLocalCtx.getStore();
208
+ if (!ctx) {
209
+ throw new Error('Invalid use of DrizzleDataSource.client outside of a DBOS transaction');
210
+ }
211
+ return ctx.client;
212
+ }
213
+
214
+ static async initializeInternalSchema(config: ClientConfig): Promise<void> {
215
+ const client = new Client(config);
216
+ try {
217
+ await client.connect();
218
+ await client.query(createTransactionCompletionSchemaPG);
219
+ await client.query(createTransactionCompletionTablePG);
220
+ } finally {
221
+ await client.end();
222
+ }
223
+ }
224
+
225
+ #provider: DrizzleTransactionHandler;
226
+
227
+ constructor(
228
+ readonly name: string,
229
+ config: PoolConfig,
230
+ entities: { [key: string]: object } = {},
231
+ ) {
232
+ this.#provider = new DrizzleTransactionHandler(name, config, entities);
233
+ registerDataSource(this.#provider);
234
+ }
235
+
236
+ async runTransaction<T>(callback: () => Promise<T>, funcName: string, config?: TransactionConfig) {
237
+ return await runTransaction(callback, funcName, { dsName: this.name, config });
238
+ }
239
+
240
+ registerTransaction<This, Args extends unknown[], Return>(
241
+ func: (this: This, ...args: Args) => Promise<Return>,
242
+ config?: TransactionConfig,
243
+ name?: string,
244
+ ): (this: This, ...args: Args) => Promise<Return> {
245
+ return registerTransaction(this.name, func, { name: name ?? func.name }, config);
246
+ }
247
+
248
+ // decorator
249
+ transaction(config?: TransactionConfig) {
250
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
251
+ const ds = this;
252
+ return function decorator<This, Args extends unknown[], Return>(
253
+ _target: object,
254
+ propertyKey: PropertyKey,
255
+ descriptor: TypedPropertyDescriptor<(this: This, ...args: Args) => Promise<Return>>,
256
+ ) {
257
+ if (!descriptor.value) {
258
+ throw new Error('Use of decorator when original method is undefined');
259
+ }
260
+
261
+ descriptor.value = ds.registerTransaction(descriptor.value, config, String(propertyKey));
262
+
263
+ return descriptor;
264
+ };
265
+ }
266
+ }
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@dbos-inc/drizzle-datasource",
3
- "version": "3.0.7-preview",
4
- "main": "dist/src/index.js",
5
- "types": "dist/src/index.d.ts",
3
+ "version": "3.0.8-preview",
4
+ "description": "DBOS DataSource library for Drizzle ORM with PostgreSQL support",
5
+ "license": "MIT",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "homepage": "https://docs.dbos.dev/",
6
9
  "repository": {
7
10
  "type": "git",
8
11
  "url": "https://github.com/dbos-inc/dbos-transact-ts",
@@ -10,14 +13,9 @@
10
13
  },
11
14
  "scripts": {
12
15
  "build": "tsc --project tsconfig.json",
13
- "test": "npm run build && jest --detectOpenHandles"
16
+ "test": "jest --detectOpenHandles"
14
17
  },
15
- "keywords": [],
16
- "author": "",
17
- "license": "MIT",
18
- "description": "",
19
18
  "dependencies": {
20
- "drizzle-kit": "^0.31.1",
21
19
  "drizzle-orm": "^0.40.1",
22
20
  "pg": "^8.11.3",
23
21
  "superjson": "^1.13"
@@ -26,12 +24,10 @@
26
24
  "@dbos-inc/dbos-sdk": "*"
27
25
  },
28
26
  "devDependencies": {
29
- "@types/jest": "^29.5.12",
30
- "@types/node": "^20.11.25",
31
- "@types/supertest": "^6.0.2",
27
+ "@types/jest": "^29.5.14",
28
+ "@types/pg": "^8.15.2",
29
+ "drizzle-kit": "^0.31.1",
32
30
  "jest": "^29.7.0",
33
- "supertest": "^7.0.0",
34
- "ts-jest": "^29.1.4",
35
- "typescript": "^5.3.3"
31
+ "typescript": "^5.4.5"
36
32
  }
37
33
  }
@@ -0,0 +1,31 @@
1
+ import { Client } from 'pg';
2
+ import { DrizzleDataSource } from '../index';
3
+ import { dropDB, ensureDB } from './test-helpers';
4
+
5
+ describe('DrizzleDataSource.configure', () => {
6
+ const config = { user: 'postgres', database: 'drizzle_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 DrizzleDataSource.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
+ });