@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/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +108 -61
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/index.ts +123 -75
- package/package.json +6 -5
- package/tests/config.test.ts +47 -7
- package/tests/datasource.test.ts +165 -42
- package/tests/test-helpers.ts +3 -2
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
|
|
32
|
-
readonly name: string;
|
|
34
|
+
class KnexTransactionHandler implements DataSourceTransactionHandler {
|
|
33
35
|
readonly dsType = 'KnexDataSource';
|
|
34
|
-
|
|
36
|
+
#knexDBField: Knex | undefined;
|
|
35
37
|
|
|
36
|
-
constructor(
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
49
|
+
async destroy(): Promise<void> {
|
|
50
|
+
const knexDB = this.#knexDBField;
|
|
51
|
+
this.#knexDBField = undefined;
|
|
52
|
+
await knexDB?.destroy();
|
|
43
53
|
}
|
|
44
54
|
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
client: Knex.Transaction,
|
|
62
|
+
async #checkExecution(
|
|
51
63
|
workflowID: string,
|
|
52
|
-
|
|
53
|
-
): Promise<{ output: string | null } | undefined> {
|
|
54
|
-
const result = await
|
|
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:
|
|
71
|
+
function_num: stepID,
|
|
60
72
|
})
|
|
61
73
|
.first();
|
|
62
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
94
|
-
|
|
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 (
|
|
124
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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('
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
await knexDB
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
}
|
|
188
|
-
|
|
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
|
-
|
|
193
|
-
#provider: KnexDSTH;
|
|
239
|
+
#provider: KnexTransactionHandler;
|
|
194
240
|
|
|
195
|
-
constructor(
|
|
196
|
-
|
|
197
|
-
|
|
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:
|
|
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,
|
|
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.
|
|
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
|
}
|
package/tests/config.test.ts
CHANGED
|
@@ -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.
|
|
6
|
+
describe('KnexDataSource.initializeSchema', () => {
|
|
6
7
|
const config = { user: 'postgres', database: 'knex_ds_config_test' };
|
|
7
8
|
|
|
8
|
-
|
|
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
|
-
|
|
20
|
-
await
|
|
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
|
-
|
|
26
|
-
expect(
|
|
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
|
});
|