@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/dist/index.d.ts +10 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +123 -65
- package/dist/index.js.map +1 -1
- package/index.ts +140 -80
- package/package.json +6 -5
- package/tests/config.test.ts +47 -7
- package/tests/datasource.test.ts +171 -47
- package/tests/test-helpers.ts +3 -2
- package/dist/tsconfig.tsbuildinfo +0 -1
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
|
|
32
|
-
readonly name: string;
|
|
35
|
+
class KnexTransactionHandler implements DataSourceTransactionHandler {
|
|
33
36
|
readonly dsType = 'KnexDataSource';
|
|
34
|
-
|
|
37
|
+
#knexDBField: Knex | undefined;
|
|
35
38
|
|
|
36
|
-
constructor(
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
50
|
+
async destroy(): Promise<void> {
|
|
51
|
+
const knexDB = this.#knexDBField;
|
|
52
|
+
this.#knexDBField = undefined;
|
|
53
|
+
await knexDB?.destroy();
|
|
43
54
|
}
|
|
44
55
|
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
client: Knex.Transaction,
|
|
63
|
+
async #checkExecution(
|
|
51
64
|
workflowID: string,
|
|
52
|
-
|
|
53
|
-
): Promise<{ output: string | null } | undefined> {
|
|
54
|
-
const result = await
|
|
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:
|
|
72
|
+
function_num: stepID,
|
|
60
73
|
})
|
|
61
74
|
.first();
|
|
62
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
94
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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('
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
}
|
|
188
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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>(
|
|
202
|
-
return await runTransaction(
|
|
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:
|
|
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,
|
|
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.
|
|
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
|
}
|
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.initializeDBOSSchema', () => {
|
|
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('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
|
-
|
|
26
|
-
expect(
|
|
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
|
});
|