@dbos-inc/knex-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/index.ts CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  runTransaction,
10
10
  DBOSDataSource,
11
11
  registerDataSource,
12
+ createTransactionCompletionSchemaPG,
13
+ createTransactionCompletionTablePG,
12
14
  } from '@dbos-inc/dbos-sdk/datasource';
13
15
  import { AsyncLocalStorage } from 'async_hooks';
14
16
  import knex, { type Knex } from 'knex';
@@ -18,60 +20,91 @@ interface transaction_completion {
18
20
  workflow_id: string;
19
21
  function_num: number;
20
22
  output: string | null;
23
+ error: string | null;
21
24
  }
22
25
 
23
26
  interface KnexDataSourceContext {
24
27
  client: Knex.Transaction;
28
+ owner: KnexTransactionHandler;
25
29
  }
26
30
 
27
- export type TransactionConfig = Pick<Knex.TransactionConfig, 'isolationLevel' | 'readOnly'>;
31
+ export type TransactionConfig = Pick<Knex.TransactionConfig, 'isolationLevel' | 'readOnly'> & { name?: string };
28
32
 
29
33
  const asyncLocalCtx = new AsyncLocalStorage<KnexDataSourceContext>();
30
34
 
31
- class KnexDSTH implements DataSourceTransactionHandler {
32
- readonly name: string;
35
+ class KnexTransactionHandler implements DataSourceTransactionHandler {
33
36
  readonly dsType = 'KnexDataSource';
34
- readonly #knexDB: Knex;
37
+ #knexDBField: Knex | undefined;
35
38
 
36
- constructor(name: string, config: Knex.Config) {
37
- this.name = name;
38
- this.#knexDB = knex(config);
39
+ constructor(
40
+ readonly name: string,
41
+ private readonly config: Knex.Config,
42
+ ) {}
43
+
44
+ async initialize(): Promise<void> {
45
+ const knexDB = this.#knexDBField;
46
+ this.#knexDBField = knex(this.config);
47
+ await knexDB?.destroy();
39
48
  }
40
49
 
41
- initialize(): Promise<void> {
42
- return Promise.resolve();
50
+ async destroy(): Promise<void> {
51
+ const knexDB = this.#knexDBField;
52
+ this.#knexDBField = undefined;
53
+ await knexDB?.destroy();
43
54
  }
44
55
 
45
- destroy(): Promise<void> {
46
- return this.#knexDB.destroy();
56
+ get #knexDB() {
57
+ if (!this.#knexDBField) {
58
+ throw new Error(`DataSource ${this.name} is not initialized.`);
59
+ }
60
+ return this.#knexDBField;
47
61
  }
48
62
 
49
- static async #checkExecution(
50
- client: Knex.Transaction,
63
+ async #checkExecution(
51
64
  workflowID: string,
52
- functionNum: number,
53
- ): Promise<{ output: string | null } | undefined> {
54
- const result = await client<transaction_completion>('transaction_completion')
65
+ stepID: number,
66
+ ): Promise<{ output: string | null } | { error: string } | undefined> {
67
+ const result = await this.#knexDB<transaction_completion>('transaction_completion')
55
68
  .withSchema('dbos')
56
- .select('output')
69
+ .select('output', 'error')
57
70
  .where({
58
71
  workflow_id: workflowID,
59
- function_num: functionNum,
72
+ function_num: stepID,
60
73
  })
61
74
  .first();
62
- return result === undefined ? undefined : { output: result.output };
75
+ if (result === undefined) {
76
+ return undefined;
77
+ }
78
+ const { output, error } = result;
79
+ return error !== null ? { error } : { output };
80
+ }
81
+
82
+ async #recordError(workflowID: string, stepID: number, error: string): Promise<void> {
83
+ try {
84
+ await this.#knexDB<transaction_completion>('transaction_completion').withSchema('dbos').insert({
85
+ workflow_id: workflowID,
86
+ function_num: stepID,
87
+ error,
88
+ });
89
+ } catch (error) {
90
+ if (isPGKeyConflictError(error)) {
91
+ throw new DBOSWorkflowConflictError(workflowID);
92
+ } else {
93
+ throw error;
94
+ }
95
+ }
63
96
  }
64
97
 
65
98
  static async #recordOutput(
66
99
  client: Knex.Transaction,
67
100
  workflowID: string,
68
- functionNum: number,
101
+ stepID: number,
69
102
  output: string | null,
70
103
  ): Promise<void> {
71
104
  try {
72
105
  await client<transaction_completion>('transaction_completion').withSchema('dbos').insert({
73
106
  workflow_id: workflowID,
74
- function_num: functionNum,
107
+ function_num: stepID,
75
108
  output,
76
109
  });
77
110
  } catch (error) {
@@ -90,67 +123,61 @@ class KnexDSTH implements DataSourceTransactionHandler {
90
123
  ...args: Args
91
124
  ): Promise<Return> {
92
125
  const workflowID = DBOS.workflowID;
93
- if (workflowID === undefined) {
94
- throw new Error('Workflow ID is not set.');
95
- }
96
- const functionNum = DBOS.stepID;
97
- if (functionNum === undefined) {
98
- throw new Error('Function Number is not set.');
126
+ const stepID = DBOS.stepID;
127
+ if (workflowID !== undefined && stepID === undefined) {
128
+ throw new Error('DBOS.stepID is undefined inside a workflow.');
99
129
  }
100
130
 
101
131
  const readOnly = config?.readOnly ?? false;
132
+ const saveResults = !readOnly && workflowID !== undefined;
133
+
134
+ // Retry loop if appropriate
102
135
  let retryWaitMS = 1;
103
136
  const backoffFactor = 1.5;
104
- const maxRetryWaitMS = 2000;
137
+ const maxRetryWaitMS = 2000; // Maximum wait 2 seconds.
105
138
 
106
139
  while (true) {
140
+ // Check to see if this tx has already been executed
141
+ const previousResult = saveResults ? await this.#checkExecution(workflowID, stepID!) : undefined;
142
+ if (previousResult) {
143
+ DBOS.span?.setAttribute('cached', true);
144
+
145
+ if ('error' in previousResult) {
146
+ throw SuperJSON.parse(previousResult.error);
147
+ }
148
+ return (previousResult.output ? SuperJSON.parse(previousResult.output) : null) as Return;
149
+ }
150
+
107
151
  try {
108
152
  const result = await this.#knexDB.transaction<Return>(
109
153
  async (client) => {
110
- // Check to see if this tx has already been executed
111
- const previousResult =
112
- readOnly || !workflowID ? undefined : await KnexDSTH.#checkExecution(client, workflowID, functionNum);
113
- if (previousResult) {
114
- return (previousResult.output ? SuperJSON.parse(previousResult.output) : null) as Return;
115
- }
116
-
117
154
  // execute user's transaction function
118
- const result = await asyncLocalCtx.run({ client }, async () => {
155
+ const result = await asyncLocalCtx.run({ client, owner: this }, async () => {
119
156
  return (await func.call(target, ...args)) as Return;
120
157
  });
121
158
 
122
159
  // save the output of read/write transactions
123
- if (!readOnly && workflowID) {
124
- await KnexDSTH.#recordOutput(client, workflowID, functionNum, SuperJSON.stringify(result));
125
-
126
- // Note, existing code wraps #recordOutput call in a try/catch block that
127
- // converts DB error with code 25P02 to DBOSFailedSqlTransactionError.
128
- // However, existing code doesn't make any logic decisions based on that error type.
129
- // DBOSFailedSqlTransactionError does stored WF ID and function name, so I assume that info is logged out somewhere
160
+ if (saveResults) {
161
+ await KnexTransactionHandler.#recordOutput(client, workflowID, stepID!, SuperJSON.stringify(result));
130
162
  }
131
163
 
132
164
  return result;
133
165
  },
134
166
  { isolationLevel: config?.isolationLevel, readOnly: config?.readOnly },
135
167
  );
136
- // TODO: span.setStatus({ code: SpanStatusCode.OK });
137
- // TODO: this.tracer.endSpan(span);
138
168
 
139
169
  return result;
140
170
  } catch (error) {
141
171
  if (isPGRetriableTransactionError(error)) {
142
- // TODO: span.addEvent('TXN SERIALIZATION FAILURE', { retryWaitMillis: retryWaitMillis }, performance.now());
172
+ DBOS.span?.addEvent('TXN SERIALIZATION FAILURE', { retryWaitMillis: retryWaitMS }, performance.now());
143
173
  await new Promise((resolve) => setTimeout(resolve, retryWaitMS));
144
174
  retryWaitMS = Math.min(retryWaitMS * backoffFactor, maxRetryWaitMS);
145
175
  continue;
146
176
  } else {
147
- // TODO: span.setStatus({ code: SpanStatusCode.ERROR, message: e.message });
148
- // TODO: this.tracer.endSpan(span);
149
-
150
- // TODO: currently, we are *not* recording errors in the txOutput table.
151
- // For normal execution, this is fine because we also store tx step results (output and error) in the sysdb operation output table.
152
- // However, I'm concerned that we have a dueling execution hole where one tx fails while another succeeds.
153
- // This implies that we can end up in a situation where the step output records an error but the txOutput table records success.
177
+ if (saveResults) {
178
+ const message = SuperJSON.stringify(error);
179
+ await this.#recordError(workflowID, stepID!, message);
180
+ }
154
181
 
155
182
  throw error;
156
183
  }
@@ -159,55 +186,85 @@ class KnexDSTH implements DataSourceTransactionHandler {
159
186
  }
160
187
  }
161
188
 
189
+ function isKnex(value: Knex | Knex.Config): value is Knex {
190
+ return 'raw' in value;
191
+ }
192
+
162
193
  export class KnexDataSource implements DBOSDataSource<TransactionConfig> {
163
- static get client(): Knex.Transaction {
194
+ static #getClient(p?: KnexTransactionHandler) {
164
195
  if (!DBOS.isInTransaction()) {
165
196
  throw new Error('invalid use of KnexDataSource.client outside of a DBOS transaction.');
166
197
  }
167
198
  const ctx = asyncLocalCtx.getStore();
168
199
  if (!ctx) {
169
- throw new Error('No async local context found.');
200
+ throw new Error('invalid use of KnexDataSource.client outside of a DBOS transaction.');
170
201
  }
202
+ if (p && p !== ctx.owner) throw new Error('Request of `KnexDataSource.client` from the wrong object.');
171
203
  return ctx.client;
172
204
  }
173
205
 
174
- static async initializeInternalSchema(config: Knex.Config) {
175
- const knexDB = knex(config);
176
- try {
177
- await knexDB.schema.createSchemaIfNotExists('dbos');
178
- const exists = await knexDB.schema.withSchema('dbos').hasTable('transaction_completion');
179
- if (!exists) {
180
- await knexDB.schema.withSchema('dbos').createTable('transaction_completion', (table) => {
181
- table.string('workflow_id').notNullable();
182
- table.integer('function_num').notNullable();
183
- table.string('output').nullable();
184
- table.primary(['workflow_id', 'function_num']);
185
- });
206
+ static get client(): Knex.Transaction {
207
+ return KnexDataSource.#getClient(undefined);
208
+ }
209
+
210
+ get client(): Knex.Transaction {
211
+ return KnexDataSource.#getClient(this.#provider);
212
+ }
213
+
214
+ static async initializeDBOSSchema(knexOrConfig: Knex.Config) {
215
+ if (isKnex(knexOrConfig)) {
216
+ await $initSchema(knexOrConfig);
217
+ } else {
218
+ const knexDB = knex(knexOrConfig);
219
+ try {
220
+ await $initSchema(knexDB);
221
+ } finally {
222
+ await knexDB.destroy();
186
223
  }
187
- } finally {
188
- await knexDB.destroy();
224
+ }
225
+
226
+ async function $initSchema(knexDB: Knex) {
227
+ await knexDB.raw(createTransactionCompletionSchemaPG);
228
+ await knexDB.raw(createTransactionCompletionTablePG);
189
229
  }
190
230
  }
191
231
 
192
- readonly name: string;
193
- #provider: KnexDSTH;
232
+ static async uninitializeDBOSSchema(knexOrConfig: Knex.Config) {
233
+ if (isKnex(knexOrConfig)) {
234
+ await $uninitSchema(knexOrConfig);
235
+ } else {
236
+ const knexDB = knex(knexOrConfig);
237
+ try {
238
+ await $uninitSchema(knexDB);
239
+ } finally {
240
+ await knexDB.destroy();
241
+ }
242
+ }
194
243
 
195
- constructor(name: string, config: Knex.Config) {
196
- this.name = name;
197
- this.#provider = new KnexDSTH(name, config);
244
+ async function $uninitSchema(knexDB: Knex) {
245
+ await knexDB.raw('DROP TABLE IF EXISTS dbos.transaction_completion; DROP SCHEMA IF EXISTS dbos;');
246
+ }
247
+ }
248
+
249
+ #provider: KnexTransactionHandler;
250
+
251
+ constructor(
252
+ readonly name: string,
253
+ config: Knex.Config,
254
+ ) {
255
+ this.#provider = new KnexTransactionHandler(name, config);
198
256
  registerDataSource(this.#provider);
199
257
  }
200
258
 
201
- async runTransaction<T>(callback: () => Promise<T>, funcName: string, config?: TransactionConfig) {
202
- return await runTransaction(callback, funcName, { dsName: this.name, config });
259
+ async runTransaction<T>(func: () => Promise<T>, config?: TransactionConfig) {
260
+ return await runTransaction(func, config?.name ?? func.name, { dsName: this.name, config });
203
261
  }
204
262
 
205
263
  registerTransaction<This, Args extends unknown[], Return>(
206
264
  func: (this: This, ...args: Args) => Promise<Return>,
207
- name: string,
208
265
  config?: TransactionConfig,
209
266
  ): (this: This, ...args: Args) => Promise<Return> {
210
- return registerTransaction(this.name, func, { name }, config);
267
+ return registerTransaction(this.name, func, { name: config?.name ?? func.name }, config);
211
268
  }
212
269
 
213
270
  transaction(config?: TransactionConfig) {
@@ -215,14 +272,17 @@ export class KnexDataSource implements DBOSDataSource<TransactionConfig> {
215
272
  const ds = this;
216
273
  return function decorator<This, Args extends unknown[], Return>(
217
274
  _target: object,
218
- propertyKey: string,
275
+ propertyKey: PropertyKey,
219
276
  descriptor: TypedPropertyDescriptor<(this: This, ...args: Args) => Promise<Return>>,
220
277
  ) {
221
278
  if (!descriptor.value) {
222
279
  throw Error('Use of decorator when original method is undefined');
223
280
  }
224
281
 
225
- descriptor.value = ds.registerTransaction(descriptor.value, propertyKey.toString(), config);
282
+ descriptor.value = ds.registerTransaction(descriptor.value, {
283
+ ...config,
284
+ name: config?.name ?? String(propertyKey),
285
+ });
226
286
 
227
287
  return descriptor;
228
288
  };
package/package.json CHANGED
@@ -1,21 +1,23 @@
1
1
  {
2
2
  "name": "@dbos-inc/knex-datasource",
3
- "version": "3.0.11-preview.gc9233b8190",
4
- "description": "",
3
+ "version": "3.0.16-preview",
4
+ "description": "DBOS DataSource library for Knex ORM with PostgreSQL support",
5
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",
9
12
  "directory": "packages/knex-datasource"
10
13
  },
11
- "homepage": "https://docs.dbos.dev/",
12
- "main": "index.js",
13
14
  "scripts": {
14
15
  "build": "tsc --project tsconfig.json",
15
16
  "test": "jest --detectOpenHandles"
16
17
  },
17
18
  "dependencies": {
18
19
  "knex": "^3.1.0",
20
+ "pg": "^8.11.3",
19
21
  "superjson": "^1.13"
20
22
  },
21
23
  "peerDependencies": {
@@ -25,7 +27,6 @@
25
27
  "@types/jest": "^29.5.14",
26
28
  "@types/pg": "^8.15.2",
27
29
  "jest": "^29.7.0",
28
- "pg": "^8.11.3",
29
30
  "typescript": "^5.4.5"
30
31
  }
31
32
  }
@@ -1,31 +1,71 @@
1
1
  import { Client } from 'pg';
2
2
  import { KnexDataSource } from '../index';
3
3
  import { dropDB, ensureDB } from './test-helpers';
4
+ import knex from 'knex';
4
5
 
5
- describe('KnexDataSource.configure', () => {
6
+ describe('KnexDataSource.initializeDBOSSchema', () => {
6
7
  const config = { user: 'postgres', database: 'knex_ds_config_test' };
7
8
 
8
- beforeAll(async () => {
9
+ beforeEach(async () => {
9
10
  const client = new Client({ ...config, database: 'postgres' });
10
11
  try {
11
12
  await client.connect();
12
- await dropDB(client, config.database);
13
+ await dropDB(client, config.database, true);
13
14
  await ensureDB(client, config.database);
14
15
  } finally {
15
16
  await client.end();
16
17
  }
17
18
  });
18
19
 
19
- test('configure creates tx outputs table', async () => {
20
- await KnexDataSource.initializeInternalSchema({ client: 'pg', connection: config });
20
+ async function queryTxCompletionTable(client: Client) {
21
+ const result = await client.query(
22
+ 'SELECT workflow_id, function_num, output, error FROM dbos.transaction_completion',
23
+ );
24
+ return result.rowCount;
25
+ }
26
+
27
+ async function txCompletionTableExists(client: Client) {
28
+ const result = await client.query<{ exists: boolean }>(
29
+ "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'dbos' AND table_name = 'transaction_completion');",
30
+ );
31
+ if (result.rowCount !== 1) throw new Error(`unexpected rowcount ${result.rowCount}`);
32
+ return result.rows[0].exists;
33
+ }
34
+
35
+ test('initializeDBOSSchema-with-config', async () => {
36
+ const knexConfig = { client: 'pg', connection: config };
37
+ await KnexDataSource.initializeDBOSSchema(knexConfig);
21
38
 
22
39
  const client = new Client(config);
23
40
  try {
24
41
  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);
42
+ await expect(txCompletionTableExists(client)).resolves.toBe(true);
43
+ await expect(queryTxCompletionTable(client)).resolves.toEqual(0);
44
+
45
+ await KnexDataSource.uninitializeDBOSSchema(knexConfig);
46
+
47
+ await expect(txCompletionTableExists(client)).resolves.toBe(false);
48
+ } finally {
49
+ await client.end();
50
+ }
51
+ });
52
+
53
+ test('initializeDBOSSchema-with-knex', async () => {
54
+ const knexDB = knex({ client: 'pg', connection: config });
55
+ const client = new Client(config);
56
+ try {
57
+ await KnexDataSource.initializeDBOSSchema(knexDB);
58
+ await client.connect();
59
+
60
+ await expect(txCompletionTableExists(client)).resolves.toBe(true);
61
+ await expect(queryTxCompletionTable(client)).resolves.toEqual(0);
62
+
63
+ await KnexDataSource.uninitializeDBOSSchema(knexDB);
64
+
65
+ await expect(txCompletionTableExists(client)).resolves.toBe(false);
27
66
  } finally {
28
67
  await client.end();
68
+ await knexDB.destroy();
29
69
  }
30
70
  });
31
71
  });