@dbos-inc/knex-datasource 3.0.11-preview.gc9233b8190 → 3.0.13-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,6 +20,7 @@ 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 {
@@ -28,50 +31,79 @@ export type TransactionConfig = Pick<Knex.TransactionConfig, 'isolationLevel' |
28
31
 
29
32
  const asyncLocalCtx = new AsyncLocalStorage<KnexDataSourceContext>();
30
33
 
31
- class KnexDSTH implements DataSourceTransactionHandler {
32
- readonly name: string;
34
+ class KnexTransactionHandler implements DataSourceTransactionHandler {
33
35
  readonly dsType = 'KnexDataSource';
34
- readonly #knexDB: Knex;
36
+ #knexDBField: Knex | undefined;
35
37
 
36
- constructor(name: string, config: Knex.Config) {
37
- this.name = name;
38
- this.#knexDB = knex(config);
38
+ constructor(
39
+ readonly name: string,
40
+ private readonly config: Knex.Config,
41
+ ) {}
42
+
43
+ async initialize(): Promise<void> {
44
+ const knexDB = this.#knexDBField;
45
+ this.#knexDBField = knex(this.config);
46
+ await knexDB?.destroy();
39
47
  }
40
48
 
41
- initialize(): Promise<void> {
42
- return Promise.resolve();
49
+ async destroy(): Promise<void> {
50
+ const knexDB = this.#knexDBField;
51
+ this.#knexDBField = undefined;
52
+ await knexDB?.destroy();
43
53
  }
44
54
 
45
- destroy(): Promise<void> {
46
- return this.#knexDB.destroy();
55
+ get #knexDB() {
56
+ if (!this.#knexDBField) {
57
+ throw new Error(`DataSource ${this.name} is not initialized.`);
58
+ }
59
+ return this.#knexDBField;
47
60
  }
48
61
 
49
- static async #checkExecution(
50
- client: Knex.Transaction,
62
+ async #checkExecution(
51
63
  workflowID: string,
52
- functionNum: number,
53
- ): Promise<{ output: string | null } | undefined> {
54
- const result = await client<transaction_completion>('transaction_completion')
64
+ stepID: number,
65
+ ): Promise<{ output: string | null } | { error: string } | undefined> {
66
+ const result = await this.#knexDB<transaction_completion>('transaction_completion')
55
67
  .withSchema('dbos')
56
- .select('output')
68
+ .select('output', 'error')
57
69
  .where({
58
70
  workflow_id: workflowID,
59
- function_num: functionNum,
71
+ function_num: stepID,
60
72
  })
61
73
  .first();
62
- return result === undefined ? undefined : { output: result.output };
74
+ if (result === undefined) {
75
+ return undefined;
76
+ }
77
+ const { output, error } = result;
78
+ return error !== null ? { error } : { output };
79
+ }
80
+
81
+ async #recordError(workflowID: string, stepID: number, error: string): Promise<void> {
82
+ try {
83
+ await this.#knexDB<transaction_completion>('transaction_completion').withSchema('dbos').insert({
84
+ workflow_id: workflowID,
85
+ function_num: stepID,
86
+ error,
87
+ });
88
+ } catch (error) {
89
+ if (isPGKeyConflictError(error)) {
90
+ throw new DBOSWorkflowConflictError(workflowID);
91
+ } else {
92
+ throw error;
93
+ }
94
+ }
63
95
  }
64
96
 
65
97
  static async #recordOutput(
66
98
  client: Knex.Transaction,
67
99
  workflowID: string,
68
- functionNum: number,
100
+ stepID: number,
69
101
  output: string | null,
70
102
  ): Promise<void> {
71
103
  try {
72
104
  await client<transaction_completion>('transaction_completion').withSchema('dbos').insert({
73
105
  workflow_id: workflowID,
74
- function_num: functionNum,
106
+ function_num: stepID,
75
107
  output,
76
108
  });
77
109
  } catch (error) {
@@ -90,67 +122,61 @@ class KnexDSTH implements DataSourceTransactionHandler {
90
122
  ...args: Args
91
123
  ): Promise<Return> {
92
124
  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.');
125
+ const stepID = DBOS.stepID;
126
+ if (workflowID !== undefined && stepID === undefined) {
127
+ throw new Error('DBOS.stepID is undefined inside a workflow.');
99
128
  }
100
129
 
101
130
  const readOnly = config?.readOnly ?? false;
131
+ const saveResults = !readOnly && workflowID !== undefined;
132
+
133
+ // Retry loop if appropriate
102
134
  let retryWaitMS = 1;
103
135
  const backoffFactor = 1.5;
104
- const maxRetryWaitMS = 2000;
136
+ const maxRetryWaitMS = 2000; // Maximum wait 2 seconds.
105
137
 
106
138
  while (true) {
139
+ // Check to see if this tx has already been executed
140
+ const previousResult = saveResults ? await this.#checkExecution(workflowID, stepID!) : undefined;
141
+ if (previousResult) {
142
+ DBOS.span?.setAttribute('cached', true);
143
+
144
+ if ('error' in previousResult) {
145
+ throw SuperJSON.parse(previousResult.error);
146
+ }
147
+ return (previousResult.output ? SuperJSON.parse(previousResult.output) : null) as Return;
148
+ }
149
+
107
150
  try {
108
151
  const result = await this.#knexDB.transaction<Return>(
109
152
  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
153
  // execute user's transaction function
118
154
  const result = await asyncLocalCtx.run({ client }, async () => {
119
155
  return (await func.call(target, ...args)) as Return;
120
156
  });
121
157
 
122
158
  // 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
159
+ if (saveResults) {
160
+ await KnexTransactionHandler.#recordOutput(client, workflowID, stepID!, SuperJSON.stringify(result));
130
161
  }
131
162
 
132
163
  return result;
133
164
  },
134
165
  { isolationLevel: config?.isolationLevel, readOnly: config?.readOnly },
135
166
  );
136
- // TODO: span.setStatus({ code: SpanStatusCode.OK });
137
- // TODO: this.tracer.endSpan(span);
138
167
 
139
168
  return result;
140
169
  } catch (error) {
141
170
  if (isPGRetriableTransactionError(error)) {
142
- // TODO: span.addEvent('TXN SERIALIZATION FAILURE', { retryWaitMillis: retryWaitMillis }, performance.now());
171
+ DBOS.span?.addEvent('TXN SERIALIZATION FAILURE', { retryWaitMillis: retryWaitMS }, performance.now());
143
172
  await new Promise((resolve) => setTimeout(resolve, retryWaitMS));
144
173
  retryWaitMS = Math.min(retryWaitMS * backoffFactor, maxRetryWaitMS);
145
174
  continue;
146
175
  } 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.
176
+ if (saveResults) {
177
+ const message = SuperJSON.stringify(error);
178
+ await this.#recordError(workflowID, stepID!, message);
179
+ }
154
180
 
155
181
  throw error;
156
182
  }
@@ -159,6 +185,10 @@ class KnexDSTH implements DataSourceTransactionHandler {
159
185
  }
160
186
  }
161
187
 
188
+ function isKnex(value: Knex | Knex.Config): value is Knex {
189
+ return 'raw' in value;
190
+ }
191
+
162
192
  export class KnexDataSource implements DBOSDataSource<TransactionConfig> {
163
193
  static get client(): Knex.Transaction {
164
194
  if (!DBOS.isInTransaction()) {
@@ -166,35 +196,53 @@ export class KnexDataSource implements DBOSDataSource<TransactionConfig> {
166
196
  }
167
197
  const ctx = asyncLocalCtx.getStore();
168
198
  if (!ctx) {
169
- throw new Error('No async local context found.');
199
+ throw new Error('invalid use of KnexDataSource.client outside of a DBOS transaction.');
170
200
  }
171
201
  return ctx.client;
172
202
  }
173
203
 
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
- });
204
+ static async initializeSchema(knexOrConfig: Knex.Config) {
205
+ if (isKnex(knexOrConfig)) {
206
+ await $initSchema(knexOrConfig);
207
+ } else {
208
+ const knexDB = knex(knexOrConfig);
209
+ try {
210
+ await $initSchema(knexDB);
211
+ } finally {
212
+ await knexDB.destroy();
213
+ }
214
+ }
215
+
216
+ async function $initSchema(knexDB: Knex) {
217
+ await knexDB.raw(createTransactionCompletionSchemaPG);
218
+ await knexDB.raw(createTransactionCompletionTablePG);
219
+ }
220
+ }
221
+
222
+ static async uninitializeSchema(knexOrConfig: Knex.Config) {
223
+ if (isKnex(knexOrConfig)) {
224
+ await $uninitSchema(knexOrConfig);
225
+ } else {
226
+ const knexDB = knex(knexOrConfig);
227
+ try {
228
+ await $uninitSchema(knexDB);
229
+ } finally {
230
+ await knexDB.destroy();
186
231
  }
187
- } finally {
188
- await knexDB.destroy();
232
+ }
233
+
234
+ async function $uninitSchema(knexDB: Knex) {
235
+ await knexDB.raw('DROP TABLE IF EXISTS dbos.transaction_completion; DROP SCHEMA IF EXISTS dbos;');
189
236
  }
190
237
  }
191
238
 
192
- readonly name: string;
193
- #provider: KnexDSTH;
239
+ #provider: KnexTransactionHandler;
194
240
 
195
- constructor(name: string, config: Knex.Config) {
196
- this.name = name;
197
- this.#provider = new KnexDSTH(name, config);
241
+ constructor(
242
+ readonly name: string,
243
+ config: Knex.Config,
244
+ ) {
245
+ this.#provider = new KnexTransactionHandler(name, config);
198
246
  registerDataSource(this.#provider);
199
247
  }
200
248
 
@@ -204,10 +252,10 @@ export class KnexDataSource implements DBOSDataSource<TransactionConfig> {
204
252
 
205
253
  registerTransaction<This, Args extends unknown[], Return>(
206
254
  func: (this: This, ...args: Args) => Promise<Return>,
207
- name: string,
208
255
  config?: TransactionConfig,
256
+ name?: string,
209
257
  ): (this: This, ...args: Args) => Promise<Return> {
210
- return registerTransaction(this.name, func, { name }, config);
258
+ return registerTransaction(this.name, func, { name: name ?? func.name }, config);
211
259
  }
212
260
 
213
261
  transaction(config?: TransactionConfig) {
@@ -215,14 +263,14 @@ export class KnexDataSource implements DBOSDataSource<TransactionConfig> {
215
263
  const ds = this;
216
264
  return function decorator<This, Args extends unknown[], Return>(
217
265
  _target: object,
218
- propertyKey: string,
266
+ propertyKey: PropertyKey,
219
267
  descriptor: TypedPropertyDescriptor<(this: This, ...args: Args) => Promise<Return>>,
220
268
  ) {
221
269
  if (!descriptor.value) {
222
270
  throw Error('Use of decorator when original method is undefined');
223
271
  }
224
272
 
225
- descriptor.value = ds.registerTransaction(descriptor.value, propertyKey.toString(), config);
273
+ descriptor.value = ds.registerTransaction(descriptor.value, config, String(propertyKey));
226
274
 
227
275
  return descriptor;
228
276
  };
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.13-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.initializeSchema', () => {
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('initializeSchema-with-config', async () => {
36
+ const knexConfig = { client: 'pg', connection: config };
37
+ await KnexDataSource.initializeSchema(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.uninitializeSchema(knexConfig);
46
+
47
+ await expect(txCompletionTableExists(client)).resolves.toBe(false);
48
+ } finally {
49
+ await client.end();
50
+ }
51
+ });
52
+
53
+ test('initializeSchema-with-knex', async () => {
54
+ const knexDB = knex({ client: 'pg', connection: config });
55
+ const client = new Client(config);
56
+ try {
57
+ await KnexDataSource.initializeSchema(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.uninitializeSchema(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
  });