@dbos-inc/typeorm-datasource 3.0.11-preview.gc9233b8190 → 3.0.16-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/src/index.ts DELETED
@@ -1 +0,0 @@
1
- export { IsolationLevel, TypeOrmDataSource, TypeOrmTransactionConfig } from './typeorm_datasource';
@@ -1,332 +0,0 @@
1
- import { PoolConfig } from 'pg';
2
- import { DBOS, Error } from '@dbos-inc/dbos-sdk';
3
- import {
4
- type DataSourceTransactionHandler,
5
- createTransactionCompletionSchemaPG,
6
- createTransactionCompletionTablePG,
7
- isPGRetriableTransactionError,
8
- isPGKeyConflictError,
9
- isPGFailedSqlTransactionError,
10
- registerTransaction,
11
- runTransaction,
12
- PGIsolationLevel as IsolationLevel,
13
- PGTransactionConfig as TypeOrmTransactionConfig,
14
- DBOSDataSource,
15
- registerDataSource,
16
- } from '@dbos-inc/dbos-sdk/datasource';
17
- import { DataSource, EntityManager } from 'typeorm';
18
- import { AsyncLocalStorage } from 'async_hooks';
19
- import { SuperJSON } from 'superjson';
20
-
21
- interface DBOSTypeOrmLocalCtx {
22
- typeOrmEntityManager: EntityManager;
23
- }
24
- const asyncLocalCtx = new AsyncLocalStorage<DBOSTypeOrmLocalCtx>();
25
-
26
- function getCurrentDSContextStore(): DBOSTypeOrmLocalCtx | undefined {
27
- return asyncLocalCtx.getStore();
28
- }
29
-
30
- function assertCurrentDSContextStore(): DBOSTypeOrmLocalCtx {
31
- const ctx = getCurrentDSContextStore();
32
- if (!ctx) throw new Error.DBOSInvalidWorkflowTransitionError('Invalid use of TypeOrmDs outside of a `transaction`');
33
- return ctx;
34
- }
35
-
36
- interface transaction_completion {
37
- workflow_id: string;
38
- function_num: number;
39
- output: string | null;
40
- error: string | null;
41
- }
42
-
43
- export { IsolationLevel, TypeOrmTransactionConfig };
44
-
45
- class TypeOrmDSTH implements DataSourceTransactionHandler {
46
- readonly dsType = 'TypeOrm';
47
- dataSource: DataSource | undefined;
48
-
49
- constructor(
50
- readonly name: string,
51
- readonly config: PoolConfig,
52
- // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
53
- readonly entities: Function[],
54
- ) {}
55
-
56
- async createInstance(): Promise<DataSource> {
57
- const ds = new DataSource({
58
- type: 'postgres',
59
- url: this.config.connectionString,
60
- connectTimeoutMS: this.config.connectionTimeoutMillis,
61
- entities: this.entities,
62
- poolSize: this.config.max,
63
- });
64
- await ds.initialize();
65
- return ds;
66
- }
67
-
68
- async initialize(): Promise<void> {
69
- this.dataSource = await this.createInstance();
70
-
71
- return Promise.resolve();
72
- }
73
-
74
- async destroy(): Promise<void> {
75
- await this.dataSource?.destroy();
76
- }
77
-
78
- async #checkExecution<R>(
79
- client: DataSource,
80
- workflowID: string,
81
- funcNum: number,
82
- ): Promise<
83
- | {
84
- res: R;
85
- }
86
- | undefined
87
- > {
88
- type TxOutputRow = Pick<transaction_completion, 'output'> & {
89
- recorded: boolean;
90
- };
91
-
92
- const { rows } = await client.query<{ rows: TxOutputRow[] }>(
93
- `SELECT output
94
- FROM dbos.transaction_completion
95
- WHERE workflow_id=$1 AND function_num=$2;`,
96
- [workflowID, funcNum],
97
- );
98
-
99
- if (rows.length !== 1) {
100
- return undefined;
101
- }
102
-
103
- if (rows[0].output === null) {
104
- return undefined;
105
- }
106
-
107
- return { res: SuperJSON.parse(rows[0].output) };
108
- }
109
-
110
- async #recordOutput<R>(client: DataSource, workflowID: string, funcNum: number, output: R): Promise<void> {
111
- const serialOutput = SuperJSON.stringify(output);
112
- await client.query<{ rows: transaction_completion[] }>(
113
- `INSERT INTO dbos.transaction_completion (
114
- workflow_id,
115
- function_num,
116
- output,
117
- created_at
118
- ) VALUES ($1, $2, $3, $4)`,
119
- [workflowID, funcNum, serialOutput, Date.now()],
120
- );
121
- }
122
-
123
- async #recordError<R>(client: DataSource, workflowID: string, funcNum: number, error: R): Promise<void> {
124
- const serialError = SuperJSON.stringify(error);
125
- await client.query<{ rows: transaction_completion[] }>(
126
- `INSERT INTO dbos.transaction_completion (
127
- workflow_id,
128
- function_num,
129
- error,
130
- created_at
131
- ) VALUES ($1, $2, $3, $4)`,
132
- [workflowID, funcNum, serialError, Date.now()],
133
- );
134
- }
135
-
136
- /* Required by base class */
137
- async invokeTransactionFunction<This, Args extends unknown[], Return>(
138
- config: TypeOrmTransactionConfig,
139
- target: This,
140
- func: (this: This, ...args: Args) => Promise<Return>,
141
- ...args: Args
142
- ): Promise<Return> {
143
- const isolationLevel = config?.isolationLevel ?? 'SERIALIZABLE';
144
-
145
- const readOnly = config?.readOnly ? true : false;
146
-
147
- const wfid = DBOS.workflowID!;
148
- const funcnum = DBOS.stepID!;
149
- const funcname = func.name;
150
-
151
- // Retry loop if appropriate
152
- let retryWaitMillis = 1;
153
- const backoffFactor = 1.5;
154
- const maxRetryWaitMs = 2000; // Maximum wait 2 seconds.
155
- const shouldCheckOutput = false;
156
-
157
- if (this.dataSource === undefined) {
158
- throw new Error.DBOSInvalidWorkflowTransitionError('Invalid use of Datasource');
159
- }
160
-
161
- while (true) {
162
- let failedForRetriableReasons = false;
163
-
164
- try {
165
- const result = this.dataSource.transaction(isolationLevel, async (transactionEntityManager: EntityManager) => {
166
- if (this.dataSource === undefined) {
167
- throw new Error.DBOSInvalidWorkflowTransitionError('Invalid use of Datasource');
168
- }
169
-
170
- if (shouldCheckOutput && !readOnly && wfid) {
171
- const executionResult = await this.#checkExecution<Return>(this.dataSource, wfid, funcnum);
172
-
173
- if (executionResult) {
174
- DBOS.span?.setAttribute('cached', true);
175
- return executionResult.res;
176
- }
177
- }
178
-
179
- const result = await asyncLocalCtx.run({ typeOrmEntityManager: transactionEntityManager }, async () => {
180
- return await func.call(target, ...args);
181
- });
182
-
183
- // Save result
184
- try {
185
- if (!readOnly && wfid) {
186
- await this.#recordOutput(this.dataSource, wfid, funcnum, result);
187
- }
188
- } catch (e) {
189
- const error = e as Error;
190
- await this.#recordError(this.dataSource, wfid, funcnum, error);
191
-
192
- // Aside from a connectivity error, two kinds of error are anticipated here:
193
- // 1. The transaction is marked failed, but the user code did not throw.
194
- // Bad on them. We will throw an error (this will get recorded) and not retry.
195
- // 2. There was a key conflict in the statement, and we need to use the fetched output
196
- if (isPGFailedSqlTransactionError(error)) {
197
- DBOS.logger.error(
198
- `In workflow ${wfid}, Postgres aborted a transaction but the function '${funcname}' did not raise an exception. Please ensure that the transaction method raises an exception if the database transaction is aborted.`,
199
- );
200
- failedForRetriableReasons = false;
201
- throw new Error.DBOSFailedSqlTransactionError(wfid, funcname);
202
- } else if (isPGKeyConflictError(error)) {
203
- throw new Error.DBOSWorkflowConflictError(
204
- `In workflow ${wfid}, Postgres raised a key conflict error in transaction '${funcname}'. This is not retriable, but the output will be fetched from the database.`,
205
- );
206
- } else {
207
- DBOS.logger.error(`Unexpected error raised in transaction '${funcname}: ${error}`);
208
- failedForRetriableReasons = false;
209
- throw error;
210
- }
211
- }
212
-
213
- return result;
214
- });
215
-
216
- return result;
217
- } catch (e) {
218
- const err = e as Error;
219
- if (failedForRetriableReasons || isPGRetriableTransactionError(err)) {
220
- DBOS.span?.addEvent('TXN SERIALIZATION FAILURE', { retryWaitMillis: retryWaitMillis }, performance.now());
221
- // Retry serialization failures.
222
- await DBOS.sleepms(retryWaitMillis);
223
- retryWaitMillis *= backoffFactor;
224
- retryWaitMillis = retryWaitMillis < maxRetryWaitMs ? retryWaitMillis : maxRetryWaitMs;
225
- continue;
226
- } else {
227
- throw err;
228
- }
229
- }
230
- }
231
- }
232
- }
233
-
234
- export class TypeOrmDataSource implements DBOSDataSource<TypeOrmTransactionConfig> {
235
- #provider: TypeOrmDSTH;
236
- constructor(
237
- readonly name: string,
238
- readonly config: PoolConfig,
239
- // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
240
- readonly entities: Function[],
241
- ) {
242
- this.#provider = new TypeOrmDSTH(name, config, entities);
243
- registerDataSource(this.#provider);
244
- }
245
-
246
- // User calls this... DBOS not directly involved...
247
- static get entityManager(): EntityManager {
248
- const ctx = assertCurrentDSContextStore();
249
- if (!DBOS.isInTransaction())
250
- throw new Error.DBOSInvalidWorkflowTransitionError(
251
- 'Invalid use of `TypeOrmDataSource.entityManager` outside of a `transaction`',
252
- );
253
- return ctx.typeOrmEntityManager;
254
- }
255
-
256
- async initializeInternalSchema(): Promise<void> {
257
- const ds = await this.#provider.createInstance();
258
-
259
- try {
260
- await ds.query(createTransactionCompletionSchemaPG);
261
- await ds.query(createTransactionCompletionTablePG);
262
- } catch (e) {
263
- const error = e as Error;
264
- throw new Error.DBOSError(`Unexpected error initializing schema: ${error.message}`);
265
- } finally {
266
- try {
267
- await ds.destroy();
268
- } catch (e) {}
269
- }
270
- }
271
-
272
- /**
273
- * Run `callback` as a transaction against this DataSource
274
- * @param callback Function to run within a transactional context
275
- * @param funcName Name to record for the transaction
276
- * @param config Transaction configuration (isolation, etc)
277
- * @returns Return value from `callback`
278
- */
279
- async runTransaction<T>(callback: () => Promise<T>, funcName: string, config?: TypeOrmTransactionConfig) {
280
- return await runTransaction(callback, funcName, { dsName: this.name, config });
281
- }
282
-
283
- /**
284
- * Register function as DBOS transaction, to be called within the context
285
- * of a transaction on this data source.
286
- *
287
- * @param func Function to wrap
288
- * @param target Name of function
289
- * @param config Transaction settings
290
- * @returns Wrapped function, to be called instead of `func`
291
- */
292
- registerTransaction<This, Args extends unknown[], Return>(
293
- func: (this: This, ...args: Args) => Promise<Return>,
294
- name: string,
295
- config?: TypeOrmTransactionConfig,
296
- ): (this: This, ...args: Args) => Promise<Return> {
297
- return registerTransaction(this.name, func, { name }, config);
298
- }
299
-
300
- /**
301
- * Decorator establishing function as a transaction
302
- */
303
- transaction(config?: TypeOrmTransactionConfig) {
304
- // eslint-disable-next-line @typescript-eslint/no-this-alias
305
- const ds = this;
306
- return function decorator<This, Args extends unknown[], Return>(
307
- _target: object,
308
- propertyKey: string,
309
- descriptor: TypedPropertyDescriptor<(this: This, ...args: Args) => Promise<Return>>,
310
- ) {
311
- if (!descriptor.value) {
312
- throw new Error.DBOSError('Use of decorator when original method is undefined');
313
- }
314
-
315
- descriptor.value = ds.registerTransaction(descriptor.value, propertyKey.toString(), config);
316
-
317
- return descriptor;
318
- };
319
- }
320
-
321
- /**
322
- * For testing: Use DataSource.syncronize to install the user schema
323
- */
324
- async createSchema() {
325
- const ds = await this.#provider.createInstance();
326
- try {
327
- await ds.synchronize();
328
- } finally {
329
- await ds.destroy();
330
- }
331
- }
332
- }
@@ -1,30 +0,0 @@
1
- import { Client } from 'pg';
2
- import { DBOSConfig } from '@dbos-inc/dbos-sdk';
3
-
4
- export async function setUpDBOSTestDb(config: DBOSConfig) {
5
- const pgSystemClient = new Client({
6
- user: config.poolConfig?.user,
7
- port: config.poolConfig?.port,
8
- host: config.poolConfig?.host,
9
- password: config.poolConfig?.password,
10
- database: 'postgres',
11
- });
12
-
13
- try {
14
- await pgSystemClient.connect();
15
- await pgSystemClient.query(`DROP DATABASE IF EXISTS ${config.poolConfig?.database};`);
16
- await pgSystemClient.query(`CREATE DATABASE ${config.poolConfig?.database};`);
17
- await pgSystemClient.query(`DROP DATABASE IF EXISTS ${config.system_database};`);
18
- await pgSystemClient.end();
19
- } catch (e) {
20
- if (e instanceof AggregateError) {
21
- console.error(`Test database setup failed: AggregateError containing ${e.errors.length} errors:`);
22
- e.errors.forEach((err, index) => {
23
- console.error(` Error ${index + 1}:`, err);
24
- });
25
- } else {
26
- console.error(`Test database setup failed:`, e);
27
- }
28
- throw e;
29
- }
30
- }
@@ -1,203 +0,0 @@
1
- /* eslint-disable */
2
- import { DBOS } from '@dbos-inc/dbos-sdk';
3
- import { TypeOrmDataSource } from '../src';
4
- import { Entity, Column, PrimaryColumn, PrimaryGeneratedColumn } from 'typeorm';
5
- import { randomUUID } from 'node:crypto';
6
- import { setUpDBOSTestDb } from './testutils';
7
-
8
- @Entity()
9
- class KV {
10
- @PrimaryColumn()
11
- id: string = 't';
12
-
13
- @Column()
14
- value: string = 'v';
15
- }
16
-
17
- @Entity()
18
- class User {
19
- @PrimaryGeneratedColumn('uuid')
20
- id: string = '';
21
-
22
- @Column()
23
- birthday: Date = new Date();
24
-
25
- @Column('varchar')
26
- name: string = '';
27
-
28
- @Column('money')
29
- salary: number = 0;
30
- }
31
-
32
- const dbPassword: string | undefined = process.env.DB_PASSWORD || process.env.PGPASSWORD;
33
- if (!dbPassword) {
34
- throw new Error('DB_PASSWORD or PGPASSWORD environment variable not set');
35
- }
36
-
37
- const databaseUrl = `postgresql://postgres:${dbPassword}@localhost:5432/typeorm_testdb?sslmode=disable`;
38
-
39
- const poolconfig = {
40
- connectionString: databaseUrl,
41
- user: 'postgres',
42
- password: dbPassword,
43
- database: 'typeorm_testdb',
44
-
45
- host: 'localhost',
46
- port: 5432,
47
- };
48
-
49
- const typeOrmDS = new TypeOrmDataSource('app-db', poolconfig, [KV, User]);
50
-
51
- const dbosConfig = {
52
- databaseUrl: databaseUrl,
53
- poolConfig: poolconfig,
54
- system_database: 'typeorm_testdb_dbos_sys',
55
- telemetry: {
56
- logs: {
57
- silent: true,
58
- },
59
- },
60
- };
61
-
62
- async function txFunctionGuts() {
63
- expect(DBOS.isInTransaction()).toBe(true);
64
- expect(DBOS.isWithinWorkflow()).toBe(true);
65
- const res = await TypeOrmDataSource.entityManager.query("SELECT 'Tx2 result' as a");
66
- return res[0].a;
67
- }
68
-
69
- const txFunc = typeOrmDS.registerTransaction(txFunctionGuts, 'MySecondTx', { readOnly: true });
70
-
71
- async function wfFunctionGuts() {
72
- // Transaction variant 2: Let DBOS run a code snippet as a step
73
- const p1 = await typeOrmDS.runTransaction(
74
- async () => {
75
- return (await TypeOrmDataSource.entityManager.query("SELECT 'My first tx result' as a"))[0].a;
76
- },
77
- 'MyFirstTx',
78
- { readOnly: true },
79
- );
80
-
81
- // Transaction variant 1: Use a registered DBOS transaction function
82
- const p2 = await txFunc();
83
-
84
- return p1 + '|' + p2;
85
- }
86
-
87
- // Workflow functions must always be registered before launch; this
88
- // allows recovery to occur.
89
- const wfFunction = DBOS.registerWorkflow(wfFunctionGuts, 'workflow');
90
-
91
- class DBWFI {
92
- @typeOrmDS.transaction({ readOnly: true })
93
- static async tx(): Promise<number> {
94
- return await TypeOrmDataSource.entityManager.count(User);
95
- }
96
-
97
- @DBOS.workflow()
98
- static async wf(): Promise<string> {
99
- return `${await DBWFI.tx()}`;
100
- }
101
- }
102
-
103
- describe('decoratorless-api-tests', () => {
104
- beforeAll(() => {
105
- DBOS.setConfig(dbosConfig);
106
- });
107
-
108
- beforeEach(async () => {
109
- await setUpDBOSTestDb(dbosConfig);
110
- await typeOrmDS.initializeInternalSchema();
111
- await typeOrmDS.createSchema();
112
- await DBOS.launch();
113
- });
114
-
115
- afterEach(async () => {
116
- await DBOS.shutdown();
117
- });
118
-
119
- test('bare-tx-wf-functions', async () => {
120
- const wfid = randomUUID();
121
-
122
- await DBOS.withNextWorkflowID(wfid, async () => {
123
- const res = await wfFunction();
124
- expect(res).toBe('My first tx result|Tx2 result');
125
- });
126
-
127
- const wfsteps = (await DBOS.listWorkflowSteps(wfid))!;
128
- expect(wfsteps.length).toBe(2);
129
- expect(wfsteps[0].functionID).toBe(0);
130
- expect(wfsteps[0].name).toBe('MyFirstTx');
131
- expect(wfsteps[1].functionID).toBe(1);
132
- expect(wfsteps[1].name).toBe('MySecondTx');
133
- });
134
-
135
- test('decorated-tx-wf-functions', async () => {
136
- const wfid = randomUUID();
137
-
138
- await DBOS.withNextWorkflowID(wfid, async () => {
139
- const res = await DBWFI.wf();
140
- expect(res).toBe('0');
141
- });
142
-
143
- const wfsteps = (await DBOS.listWorkflowSteps(wfid))!;
144
- expect(wfsteps.length).toBe(1);
145
- expect(wfsteps[0].functionID).toBe(0);
146
- expect(wfsteps[0].name).toBe('tx');
147
- });
148
- });
149
-
150
- class KVController {
151
- @typeOrmDS.transaction()
152
- static async testTxn(id: string, value: string) {
153
- const kv: KV = new KV();
154
- kv.id = id;
155
- kv.value = value;
156
- const res = await TypeOrmDataSource.entityManager.save(kv);
157
-
158
- return res.id;
159
- }
160
-
161
- static async readTxn(id: string): Promise<string> {
162
- const kvp = await TypeOrmDataSource.entityManager.findOneBy(KV, { id: id });
163
- return Promise.resolve(kvp?.value || '<Not Found>');
164
- }
165
-
166
- @DBOS.workflow()
167
- static async wf(id: string, value: string) {
168
- return await KVController.testTxn(id, value);
169
- }
170
- }
171
-
172
- const txFunc2 = typeOrmDS.registerTransaction(KVController.readTxn, 'explicitRegister', {});
173
- async function explicitWf(id: string): Promise<string> {
174
- return await txFunc2(id);
175
- }
176
- const wfFunction2 = DBOS.registerWorkflow(explicitWf, 'explicitworkflow');
177
-
178
- describe('typeorm-tests', () => {
179
- beforeAll(() => {
180
- DBOS.setConfig(dbosConfig);
181
- });
182
-
183
- beforeEach(async () => {
184
- await setUpDBOSTestDb(dbosConfig);
185
- await typeOrmDS.initializeInternalSchema();
186
- await DBOS.launch();
187
- await typeOrmDS.createSchema();
188
- });
189
-
190
- afterEach(async () => {
191
- await DBOS.shutdown();
192
- });
193
-
194
- test('simple-typeorm', async () => {
195
- await expect(KVController.wf('test', 'value')).resolves.toBe('test');
196
- });
197
-
198
- test('typeorm-register', async () => {
199
- await expect(wfFunction2('test')).resolves.toBe('<Not Found>');
200
- await KVController.wf('test', 'value');
201
- await expect(wfFunction2('test')).resolves.toBe('value');
202
- });
203
- });