@dbos-inc/knex-datasource 3.0.7-preview.gfe77addda7 → 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 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
  }
@@ -166,7 +192,7 @@ export class KnexDataSource implements DBOSDataSource<TransactionConfig> {
166
192
  }
167
193
  const ctx = asyncLocalCtx.getStore();
168
194
  if (!ctx) {
169
- throw new Error('No async local context found.');
195
+ throw new Error('invalid use of KnexDataSource.client outside of a DBOS transaction.');
170
196
  }
171
197
  return ctx.client;
172
198
  }
@@ -174,27 +200,20 @@ export class KnexDataSource implements DBOSDataSource<TransactionConfig> {
174
200
  static async initializeInternalSchema(config: Knex.Config) {
175
201
  const knexDB = knex(config);
176
202
  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
- });
186
- }
203
+ await knexDB.raw(createTransactionCompletionSchemaPG);
204
+ await knexDB.raw(createTransactionCompletionTablePG);
187
205
  } finally {
188
206
  await knexDB.destroy();
189
207
  }
190
208
  }
191
209
 
192
- readonly name: string;
193
- #provider: KnexDSTH;
210
+ #provider: KnexTransactionHandler;
194
211
 
195
- constructor(name: string, config: Knex.Config) {
196
- this.name = name;
197
- this.#provider = new KnexDSTH(name, config);
212
+ constructor(
213
+ readonly name: string,
214
+ config: Knex.Config,
215
+ ) {
216
+ this.#provider = new KnexTransactionHandler(name, config);
198
217
  registerDataSource(this.#provider);
199
218
  }
200
219
 
@@ -204,10 +223,10 @@ export class KnexDataSource implements DBOSDataSource<TransactionConfig> {
204
223
 
205
224
  registerTransaction<This, Args extends unknown[], Return>(
206
225
  func: (this: This, ...args: Args) => Promise<Return>,
207
- name: string,
208
226
  config?: TransactionConfig,
227
+ name?: string,
209
228
  ): (this: This, ...args: Args) => Promise<Return> {
210
- return registerTransaction(this.name, func, { name }, config);
229
+ return registerTransaction(this.name, func, { name: name ?? func.name }, config);
211
230
  }
212
231
 
213
232
  transaction(config?: TransactionConfig) {
@@ -215,14 +234,14 @@ export class KnexDataSource implements DBOSDataSource<TransactionConfig> {
215
234
  const ds = this;
216
235
  return function decorator<This, Args extends unknown[], Return>(
217
236
  _target: object,
218
- propertyKey: string,
237
+ propertyKey: PropertyKey,
219
238
  descriptor: TypedPropertyDescriptor<(this: This, ...args: Args) => Promise<Return>>,
220
239
  ) {
221
240
  if (!descriptor.value) {
222
241
  throw Error('Use of decorator when original method is undefined');
223
242
  }
224
243
 
225
- descriptor.value = ds.registerTransaction(descriptor.value, propertyKey.toString(), config);
244
+ descriptor.value = ds.registerTransaction(descriptor.value, config, String(propertyKey));
226
245
 
227
246
  return descriptor;
228
247
  };
package/package.json CHANGED
@@ -1,21 +1,23 @@
1
1
  {
2
2
  "name": "@dbos-inc/knex-datasource",
3
- "version": "3.0.7-preview.gfe77addda7",
4
- "description": "",
3
+ "version": "3.0.8-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
  }
@@ -9,7 +9,7 @@ describe('KnexDataSource.configure', () => {
9
9
  const client = new Client({ ...config, database: 'postgres' });
10
10
  try {
11
11
  await client.connect();
12
- await dropDB(client, config.database);
12
+ await dropDB(client, config.database, true);
13
13
  await ensureDB(client, config.database);
14
14
  } finally {
15
15
  await client.end();