@dbos-inc/kysely-datasource 5.3.4-preview → 5.3.6-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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dbos-inc/kysely-datasource",
3
- "version": "5.3.4-preview",
3
+ "version": "5.3.6-preview",
4
4
  "description": "DBOS DataSource library for Kysely Query Builder with PostgreSQL support",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
package/index.ts DELETED
@@ -1,379 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- // using https://kysely.dev/
3
- import { DBOS, FunctionName } from '@dbos-inc/dbos-sdk';
4
- import {
5
- type DataSourceTransactionHandler,
6
- isPGRetriableTransactionError,
7
- DBOSError,
8
- DBOSStepAlreadyRecordedError,
9
- DBOSWorkflowConflictError,
10
- assertStillOwnsWorkflow,
11
- replayRecordedStep,
12
- registerTransaction,
13
- runTransaction,
14
- DBOSDataSource,
15
- registerDataSource,
16
- DataSourceMigrationOptions,
17
- DataSourceSQLExecutor,
18
- initializeDataSourceSchemaPG,
19
- migrateDataSourcePG,
20
- verifyDataSourcePG,
21
- } from '@dbos-inc/dbos-sdk/datasource';
22
- import { AsyncLocalStorage } from 'async_hooks';
23
- import { Kysely, sql, Transaction, IsolationLevel, PostgresDialect } from 'kysely';
24
- import { Pool, PoolConfig } from 'pg';
25
- import { SuperJSON } from 'superjson';
26
-
27
- export interface transaction_completion {
28
- workflow_id: string;
29
- function_num: number;
30
- output: string | null;
31
- error: string | null;
32
- }
33
-
34
- // Define a database interface for tables used in this datasource
35
- interface DBOSKyselyTables {
36
- 'dbos.transaction_completion': transaction_completion;
37
- }
38
-
39
- export interface TransactionConfig {
40
- name?: string;
41
- isolationLevel?: IsolationLevel;
42
- readOnly?: boolean;
43
- }
44
-
45
- interface KyselyDataSourceContext<DB> {
46
- client: Transaction<DB>;
47
- owner: KyselyTransactionHandler;
48
- }
49
-
50
- const asyncLocalCtx = new AsyncLocalStorage();
51
-
52
- function kyselyExecutor(db: Kysely<any>): DataSourceSQLExecutor {
53
- return async (text) => (await sql.raw<Record<string, unknown>>(text).execute(db)).rows;
54
- }
55
-
56
- class KyselyTransactionHandler implements DataSourceTransactionHandler {
57
- #kyselyDBField: Kysely<DBOSKyselyTables>;
58
- readonly schemaName: string;
59
- readonly poolConfig: PoolConfig | undefined;
60
-
61
- constructor(
62
- readonly name: string,
63
- poolConfigOrKysely: PoolConfig | Kysely<any>,
64
- schemaName: string = 'dbos',
65
- private readonly options: DataSourceMigrationOptions = {},
66
- ) {
67
- this.schemaName = schemaName;
68
-
69
- if (poolConfigOrKysely instanceof Kysely) {
70
- this.#kyselyDBField = poolConfigOrKysely as Kysely<DBOSKyselyTables>;
71
- } else {
72
- this.poolConfig = poolConfigOrKysely;
73
- this.#kyselyDBField = new Kysely<DBOSKyselyTables>({
74
- dialect: new PostgresDialect({
75
- pool: new Pool(poolConfigOrKysely),
76
- }),
77
- });
78
- }
79
- }
80
-
81
- async initialize(): Promise<void> {
82
- if (this.poolConfig) {
83
- const kyselyDB = this.#kyselyDBField;
84
- this.#kyselyDBField = new Kysely<DBOSKyselyTables>({
85
- dialect: new PostgresDialect({
86
- pool: new Pool(this.poolConfig),
87
- }),
88
- });
89
- await kyselyDB?.destroy();
90
- }
91
-
92
- if (this.options.runMigrations === false) {
93
- await verifyDataSourcePG(kyselyExecutor(this.#kyselyDBField), this.schemaName, this.name);
94
- return;
95
- }
96
-
97
- try {
98
- await this.#kyselyDBField
99
- .transaction()
100
- .execute((trx) => migrateDataSourcePG(kyselyExecutor(trx), this.schemaName));
101
- } catch (err) {
102
- throw new Error(
103
- `In initialization of 'KyselyDataSource' ${this.name}: The '${this.schemaName}' transaction schema could not be migrated: ${(err as Error).message}. This should be added to your database migrations.
104
- See: https://docs.dbos.dev/typescript/tutorials/transaction-tutorial#installing-the-dbos-schema`,
105
- );
106
- }
107
- }
108
-
109
- async destroy(): Promise<void> {
110
- await this.#kyselyDBField.destroy();
111
- }
112
-
113
- get #kyselyDB() {
114
- if (!this.#kyselyDBField) {
115
- throw new Error(`DataSource ${this.name} is not initialized.`);
116
- }
117
- return this.#kyselyDBField;
118
- }
119
-
120
- async deleteCheckpoints(workflowID: string, startStep: number, beforeCommit?: () => Promise<void>): Promise<void> {
121
- await this.#kyselyDB.transaction().execute(async (client) => {
122
- await client
123
- .deleteFrom('dbos.transaction_completion')
124
- .where('workflow_id', '=', workflowID)
125
- .where('function_num', '>=', startStep)
126
- .execute();
127
- await beforeCommit?.();
128
- });
129
- }
130
-
131
- async #checkExecution(
132
- workflowID: string,
133
- stepID: number,
134
- ): Promise<{ output: string | null } | { error: string } | undefined> {
135
- const result = await this.#kyselyDB
136
- .selectFrom('dbos.transaction_completion')
137
- .select(['output', 'error'])
138
- .where('workflow_id', '=', workflowID)
139
- .where('function_num', '=', stepID)
140
- .executeTakeFirst();
141
- if (result === undefined) {
142
- return undefined;
143
- }
144
- const { output, error } = result;
145
- return error !== null ? { error } : { output };
146
- }
147
-
148
- async #recordError(workflowID: string, stepID: number, error: string): Promise<void> {
149
- const inserted = await this.#kyselyDB
150
- .insertInto('dbos.transaction_completion')
151
- .values({
152
- workflow_id: workflowID,
153
- function_num: stepID,
154
- error,
155
- output: null,
156
- })
157
- .onConflict((oc) => oc.columns(['workflow_id', 'function_num']).doNothing())
158
- .returning('workflow_id')
159
- .executeTakeFirst();
160
- if (inserted === undefined) {
161
- throw new DBOSStepAlreadyRecordedError(workflowID, stepID);
162
- }
163
- }
164
-
165
- // A duplicate execution won the race, so its recorded outcome is the durable one.
166
- async #replayConflictingStep<Return>(workflowID: string, stepID: number): Promise<Return> {
167
- const recorded = await this.#checkExecution(workflowID, stepID);
168
- if (recorded === undefined) {
169
- throw new DBOSError(
170
- `Step ${stepID} of workflow ${workflowID} conflicted with a concurrent execution, but no recorded outcome was found`,
171
- );
172
- }
173
- return replayRecordedStep<Return>(recorded);
174
- }
175
-
176
- static async #recordOutput(
177
- client: Transaction<DBOSKyselyTables>,
178
- workflowID: string,
179
- stepID: number,
180
- output: string | null,
181
- ): Promise<void> {
182
- const inserted = await client
183
- .insertInto('dbos.transaction_completion')
184
- .values({
185
- workflow_id: workflowID,
186
- function_num: stepID,
187
- output,
188
- error: null,
189
- })
190
- .onConflict((oc) => oc.columns(['workflow_id', 'function_num']).doNothing())
191
- .returning('workflow_id')
192
- .executeTakeFirst();
193
- if (inserted === undefined) {
194
- throw new DBOSStepAlreadyRecordedError(workflowID, stepID);
195
- }
196
- // Holding this step's row, so a later owner's insert waits on our commit.
197
- await assertStillOwnsWorkflow(workflowID);
198
- }
199
-
200
- async invokeTransactionFunction<This, Args extends unknown[], Return>(
201
- config: TransactionConfig | undefined,
202
- target: This,
203
- func: (this: This, ...args: Args) => Promise<Return>,
204
- ...args: Args
205
- ): Promise<Return> {
206
- const workflowID = DBOS.workflowID;
207
- const stepID = DBOS.stepID;
208
- if (workflowID !== undefined && stepID === undefined) {
209
- throw new Error('DBOS.stepID is undefined inside a workflow.');
210
- }
211
-
212
- const readOnly = config?.readOnly ?? false;
213
- const saveResults = !readOnly && workflowID !== undefined;
214
-
215
- // Retry loop if appropriate
216
- let retryWaitMS = 1;
217
- const backoffFactor = 1.5;
218
- const maxRetryWaitMS = 2000; // Maximum wait 2 seconds.
219
-
220
- while (true) {
221
- // Check to see if this tx has already been executed
222
- const previousResult = saveResults ? await this.#checkExecution(workflowID, stepID!) : undefined;
223
- if (previousResult) {
224
- return replayRecordedStep<Return>(previousResult);
225
- }
226
-
227
- try {
228
- let trx = this.#kyselyDB.transaction();
229
- if (config?.readOnly) {
230
- trx = trx.setAccessMode('read only');
231
- }
232
- if (config?.isolationLevel) {
233
- trx = trx.setIsolationLevel(config.isolationLevel);
234
- }
235
- const result = await trx.execute(async (client) => {
236
- // execute user's transaction function
237
- const result = await asyncLocalCtx.run({ client, owner: this }, async () => {
238
- return (await func.call(target, ...args)) as Return;
239
- });
240
-
241
- // save the output of read/write transactions
242
- if (saveResults) {
243
- await KyselyTransactionHandler.#recordOutput(client, workflowID, stepID!, SuperJSON.stringify(result));
244
- }
245
-
246
- return result;
247
- });
248
-
249
- return result;
250
- } catch (error) {
251
- if (saveResults && error instanceof DBOSStepAlreadyRecordedError) {
252
- return await this.#replayConflictingStep<Return>(workflowID, stepID!);
253
- }
254
- // The new owner wins; recording an error here would replay it as this step's outcome.
255
- if (error instanceof DBOSWorkflowConflictError) throw error;
256
- if (isPGRetriableTransactionError(error)) {
257
- DBOS.span?.addEvent('TXN SERIALIZATION FAILURE', { retryWaitMillis: retryWaitMS }, performance.now());
258
- await new Promise((resolve) => setTimeout(resolve, retryWaitMS));
259
- retryWaitMS = Math.min(retryWaitMS * backoffFactor, maxRetryWaitMS);
260
- continue;
261
- } else {
262
- if (saveResults) {
263
- const message = SuperJSON.stringify(error);
264
- try {
265
- await this.#recordError(workflowID, stepID!, message);
266
- } catch (recordError) {
267
- if (recordError instanceof DBOSStepAlreadyRecordedError) {
268
- return await this.#replayConflictingStep<Return>(workflowID, stepID!);
269
- }
270
- throw recordError;
271
- }
272
- }
273
-
274
- throw error;
275
- }
276
- }
277
- }
278
- }
279
- }
280
-
281
- export class KyselyDataSource<DB> implements DBOSDataSource<TransactionConfig> {
282
- static #getClient<DB>(p?: KyselyTransactionHandler): Kysely<DB> {
283
- if (!DBOS.isInTransaction()) {
284
- throw new Error('invalid use of KyselyDataSource.client outside of a DBOS transaction.');
285
- }
286
- const ctx = asyncLocalCtx.getStore() as KyselyDataSourceContext<DB>;
287
- if (!ctx) {
288
- throw new Error('invalid use of KyselyDataSource.client outside of a DBOS transaction.');
289
- }
290
- if (p && p !== ctx.owner) throw new Error('Request of `KyselyDataSource.client` from the wrong object.');
291
- return ctx.client;
292
- }
293
-
294
- get client(): Kysely<DB> {
295
- return KyselyDataSource.#getClient(this.#provider);
296
- }
297
-
298
- /**
299
- * Create or migrate the DBOS transaction schema, typically with a privileged role,
300
- * optionally granting `applicationRole` the minimal permissions to use it.
301
- */
302
- static async initializeDBOSSchema(
303
- poolConfig: PoolConfig,
304
- schemaName: string = 'dbos',
305
- options: { applicationRole?: string } = {},
306
- ) {
307
- const client = new Kysely({
308
- dialect: new PostgresDialect({
309
- pool: new Pool(poolConfig),
310
- }),
311
- });
312
- try {
313
- await client
314
- .transaction()
315
- .execute((trx) => initializeDataSourceSchemaPG(kyselyExecutor(trx), schemaName, options.applicationRole));
316
- } finally {
317
- await client.destroy();
318
- }
319
- }
320
-
321
- static async uninitializeDBOSSchema(poolConfig: PoolConfig, schemaName: string = 'dbos') {
322
- const client = new Kysely({
323
- dialect: new PostgresDialect({
324
- pool: new Pool(poolConfig),
325
- }),
326
- });
327
- await sql
328
- .raw(
329
- `DROP TABLE IF EXISTS "${schemaName}".transaction_completion; DROP SCHEMA IF EXISTS "${schemaName}" CASCADE;`,
330
- )
331
- .execute(client);
332
- await client.destroy();
333
- }
334
-
335
- #provider: KyselyTransactionHandler;
336
-
337
- constructor(
338
- readonly name: string,
339
- poolConfigOrKysely: PoolConfig | Kysely<any>,
340
- schemaName: string = 'dbos',
341
- options: DataSourceMigrationOptions = {},
342
- ) {
343
- this.#provider = new KyselyTransactionHandler(name, poolConfigOrKysely, schemaName, options);
344
- registerDataSource(this.#provider);
345
- }
346
-
347
- async runTransaction<R>(func: () => Promise<R>, config?: TransactionConfig) {
348
- return await runTransaction(func, config?.name ?? func.name, { dsName: this.name, config });
349
- }
350
-
351
- registerTransaction<This, Args extends unknown[], Return>(
352
- func: (this: This, ...args: Args) => Promise<Return>,
353
- config?: TransactionConfig & FunctionName,
354
- ): (this: This, ...args: Args) => Promise<Return> {
355
- return registerTransaction(this.name, func, config);
356
- }
357
-
358
- transaction(config?: TransactionConfig) {
359
- // eslint-disable-next-line @typescript-eslint/no-this-alias
360
- const ds = this;
361
- return function decorator<This, Args extends unknown[], Return>(
362
- target: object,
363
- propertyKey: PropertyKey,
364
- descriptor: TypedPropertyDescriptor<(this: This, ...args: Args) => Promise<Return>>,
365
- ) {
366
- if (!descriptor.value) {
367
- throw Error('Use of decorator when original method is undefined');
368
- }
369
-
370
- descriptor.value = ds.registerTransaction(descriptor.value, {
371
- ...config,
372
- name: config?.name ?? String(propertyKey),
373
- ctorOrProto: target,
374
- });
375
-
376
- return descriptor;
377
- };
378
- }
379
- }
package/jest.config.js DELETED
@@ -1,8 +0,0 @@
1
- /** @type {import('ts-jest').JestConfigWithTsJest} */
2
- module.exports = {
3
- preset: 'ts-jest',
4
- testEnvironment: 'node',
5
- testRegex: '((\\.|/)(test|spec))\\.ts?$',
6
- moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
7
- modulePaths: ['./'],
8
- };
@@ -1,51 +0,0 @@
1
- import { Client, Pool, PoolClient } from 'pg';
2
- import { KyselyDataSource } from '..';
3
- import { dropDB, ensureDB } from './test-helpers';
4
-
5
- const config = { user: 'postgres', database: 'kysely_ds_config_test' };
6
-
7
- describe('KyselyDataSource.initializeDBOSSchema', () => {
8
- beforeEach(async () => {
9
- const client = new Client({ ...config, database: 'postgres' });
10
- try {
11
- await client.connect();
12
- await dropDB(client, config.database, true);
13
- await ensureDB(client, config.database);
14
- } finally {
15
- await client.end();
16
- }
17
- });
18
-
19
- async function queryTxCompletionTable(client: PoolClient) {
20
- const result = await client.query(
21
- 'SELECT workflow_id, function_num, output, error FROM dbos.transaction_completion',
22
- );
23
- return result.rowCount;
24
- }
25
-
26
- async function txCompletionTableExists(client: PoolClient) {
27
- const result = await client.query<{ exists: boolean }>(
28
- "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'dbos' AND table_name = 'transaction_completion');",
29
- );
30
- if (result.rowCount !== 1) throw new Error(`unexpected rowcount ${result.rowCount}`);
31
- return result.rows[0].exists;
32
- }
33
-
34
- test('initializeDBOSSchema', async () => {
35
- const db = new Pool(config);
36
- await KyselyDataSource.initializeDBOSSchema(config);
37
-
38
- try {
39
- const client = await db.connect();
40
- await expect(txCompletionTableExists(client)).resolves.toBe(true);
41
- await expect(queryTxCompletionTable(client)).resolves.toEqual(0);
42
-
43
- await KyselyDataSource.uninitializeDBOSSchema(config);
44
-
45
- await expect(txCompletionTableExists(client)).resolves.toBe(false);
46
- client.release();
47
- } finally {
48
- await db.end();
49
- }
50
- });
51
- });
@@ -1,803 +0,0 @@
1
- import { DBOS, DBOSWorkflowConflictError } from '@dbos-inc/dbos-sdk';
2
- import { Client, Pool } from 'pg';
3
- import { KyselyDataSource } from '..';
4
- import { Database, dropDB, ensureDB } from './test-helpers';
5
- import { randomUUID } from 'crypto';
6
- import SuperJSON from 'superjson';
7
- import { Kysely, PostgresDialect, sql } from 'kysely';
8
-
9
- const config = { client: 'pg', connection: { user: 'postgres', database: 'kysely_ds_test_userdb' } };
10
- const dataSource = new KyselyDataSource<Database>('app-db', config.connection);
11
-
12
- interface transaction_completion {
13
- workflow_id: string;
14
- function_num: number;
15
- output: string | null;
16
- error: string | null;
17
- }
18
-
19
- describe('KyselyDataSource', () => {
20
- const userDB = new Pool(config.connection);
21
-
22
- beforeAll(async () => {
23
- await createDatabases(userDB, true);
24
- });
25
-
26
- afterAll(async () => {
27
- await userDB.end();
28
- });
29
-
30
- beforeEach(async () => {
31
- DBOS.setConfig({ name: 'kysely-ds-test' });
32
- await DBOS.launch();
33
- });
34
-
35
- afterEach(async () => {
36
- await DBOS.shutdown();
37
- });
38
-
39
- test('insert dataSource.register function', async () => {
40
- const user = 'helloTest1';
41
-
42
- await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
43
- const workflowID = randomUUID();
44
-
45
- await expect(DBOS.withNextWorkflowID(workflowID, () => regInsertWorkflowReg(user))).resolves.toMatchObject({
46
- user,
47
- greet_count: 1,
48
- });
49
-
50
- const { rows } = await userDB.query<transaction_completion>(
51
- 'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1',
52
- [workflowID],
53
- );
54
- // Completion cleared the checkpoint the transaction wrote.
55
- expect(rows).toHaveLength(0);
56
- });
57
-
58
- test('rerun insert dataSource.register function', async () => {
59
- const user = 'rerunTest1';
60
-
61
- await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
62
- const workflowID = randomUUID();
63
-
64
- const result = await DBOS.withNextWorkflowID(workflowID, () => regInsertWorkflowReg(user));
65
- expect(result).toMatchObject({ user, greet_count: 1 });
66
-
67
- await expect(DBOS.withNextWorkflowID(workflowID, () => regInsertWorkflowReg(user))).resolves.toMatchObject(result);
68
- });
69
-
70
- test('insert dataSource.runAsTx function', async () => {
71
- const user = 'helloTest2';
72
-
73
- await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
74
- const workflowID = randomUUID();
75
-
76
- await expect(DBOS.withNextWorkflowID(workflowID, () => regInsertWorkflowRunTx(user))).resolves.toMatchObject({
77
- user,
78
- greet_count: 1,
79
- });
80
-
81
- const { rows } = await userDB.query<transaction_completion>(
82
- 'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1',
83
- [workflowID],
84
- );
85
- // Completion cleared the checkpoint the transaction wrote.
86
- expect(rows).toHaveLength(0);
87
- });
88
-
89
- test('rerun insert dataSource.runAsTx function', async () => {
90
- const user = 'rerunTest2';
91
-
92
- await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
93
- const workflowID = randomUUID();
94
-
95
- const result = await DBOS.withNextWorkflowID(workflowID, () => regInsertWorkflowRunTx(user));
96
- expect(result).toMatchObject({ user, greet_count: 1 });
97
-
98
- await expect(DBOS.withNextWorkflowID(workflowID, () => regInsertWorkflowRunTx(user))).resolves.toMatchObject(
99
- result,
100
- );
101
- });
102
-
103
- async function throws<R>(func: () => Promise<R>): Promise<unknown> {
104
- try {
105
- await func();
106
- fail('Expected function to throw an error');
107
- } catch (error) {
108
- return error;
109
- }
110
- }
111
-
112
- test('error dataSource.register function', async () => {
113
- const user = 'errorTest1';
114
-
115
- await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
116
- await userDB.query('INSERT INTO greetings("name","greet_count") VALUES($1,10);', [user]);
117
- const workflowID = randomUUID();
118
-
119
- const error = await throws(() => DBOS.withNextWorkflowID(workflowID, () => regErrorWorkflowReg(user)));
120
- expect(error).toBeInstanceOf(Error);
121
- expect((error as Error).message).toMatch(/^test error \d+$/);
122
-
123
- const { rows } = await userDB.query<greetings>('SELECT * FROM greetings WHERE name = $1', [user]);
124
- expect(rows.length).toBe(1);
125
- expect(rows[0].greet_count).toBe(10);
126
-
127
- const { rows: txOutput } = await userDB.query<transaction_completion>(
128
- 'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1',
129
- [workflowID],
130
- );
131
- // Completion cleared the checkpoint the transaction wrote.
132
- expect(txOutput).toHaveLength(0);
133
- });
134
-
135
- test('rerun error dataSource.register function', async () => {
136
- const user = 'rerunErrorTest1';
137
-
138
- await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
139
- await userDB.query('INSERT INTO greetings("name","greet_count") VALUES($1,10);', [user]);
140
- const workflowID = randomUUID();
141
-
142
- const error = await throws(() => DBOS.withNextWorkflowID(workflowID, () => regErrorWorkflowReg(user)));
143
- expect(error).toBeInstanceOf(Error);
144
- expect((error as Error).message).toMatch(/^test error \d+$/);
145
-
146
- const error2 = await throws(() => DBOS.withNextWorkflowID(workflowID, () => regErrorWorkflowReg(user)));
147
- expect(error2).toBeInstanceOf(Error);
148
- expect((error2 as Error).message).toMatch((error as Error).message);
149
- });
150
-
151
- test('error dataSource.runAsTx function', async () => {
152
- const user = 'errorTest2';
153
-
154
- await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
155
- await userDB.query('INSERT INTO greetings("name","greet_count") VALUES($1,10);', [user]);
156
- const workflowID = randomUUID();
157
-
158
- const error = await throws(() => DBOS.withNextWorkflowID(workflowID, () => regErrorWorkflowRunTx(user)));
159
- expect(error).toBeInstanceOf(Error);
160
- expect((error as Error).message).toMatch(/^test error \d+$/);
161
-
162
- const { rows } = await userDB.query<greetings>('SELECT * FROM greetings WHERE name = $1', [user]);
163
- expect(rows.length).toBe(1);
164
- expect(rows[0].greet_count).toBe(10);
165
-
166
- const { rows: txOutput } = await userDB.query<transaction_completion>(
167
- 'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1',
168
- [workflowID],
169
- );
170
- // Completion cleared the checkpoint the transaction wrote.
171
- expect(txOutput).toHaveLength(0);
172
- });
173
-
174
- test('rerun error dataSource.runAsTx function', async () => {
175
- const user = 'rerunErrorTest2';
176
-
177
- await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
178
- await userDB.query('INSERT INTO greetings("name","greet_count") VALUES($1,10);', [user]);
179
- const workflowID = randomUUID();
180
-
181
- const error = await throws(() => DBOS.withNextWorkflowID(workflowID, () => regErrorWorkflowRunTx(user)));
182
- expect(error).toBeInstanceOf(Error);
183
- expect((error as Error).message).toMatch(/^test error \d+$/);
184
-
185
- const error2 = await throws(() => DBOS.withNextWorkflowID(workflowID, () => regErrorWorkflowRunTx(user)));
186
- expect(error2).toBeInstanceOf(Error);
187
- expect((error2 as Error).message).toMatch((error as Error).message);
188
- });
189
-
190
- test('readonly dataSource.register function', async () => {
191
- const user = 'readTest1';
192
-
193
- await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
194
- await userDB.query('INSERT INTO greetings("name","greet_count") VALUES($1,10);', [user]);
195
-
196
- const workflowID = randomUUID();
197
- await expect(DBOS.withNextWorkflowID(workflowID, () => regReadWorkflowReg(user))).resolves.toMatchObject({
198
- user,
199
- greet_count: 10,
200
- });
201
-
202
- const { rows } = await userDB.query('SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1', [
203
- workflowID,
204
- ]);
205
- expect(rows.length).toBe(0);
206
- });
207
-
208
- test('readonly dataSource.runAsTx function', async () => {
209
- const user = 'readTest2';
210
-
211
- await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
212
- await userDB.query('INSERT INTO greetings("name","greet_count") VALUES($1,10);', [user]);
213
-
214
- const workflowID = randomUUID();
215
- await expect(DBOS.withNextWorkflowID(workflowID, () => regReadWorkflowRunTx(user))).resolves.toMatchObject({
216
- user,
217
- greet_count: 10,
218
- });
219
-
220
- const { rows } = await userDB.query('SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1', [
221
- workflowID,
222
- ]);
223
- expect(rows.length).toBe(0);
224
- });
225
-
226
- test('static dataSource.register methods', async () => {
227
- const user = 'staticTest1';
228
-
229
- await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
230
-
231
- const workflowID = randomUUID();
232
- await expect(DBOS.withNextWorkflowID(workflowID, () => regStaticWorkflow(user))).resolves.toMatchObject([
233
- { user, greet_count: 1 },
234
- { user, greet_count: 1 },
235
- ]);
236
- });
237
-
238
- test('instance dataSource.register methods', async () => {
239
- const user = 'instanceTest1';
240
-
241
- await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
242
-
243
- const workflowID = randomUUID();
244
- await expect(DBOS.withNextWorkflowID(workflowID, () => regInstanceWorkflow(user))).resolves.toMatchObject([
245
- { user, greet_count: 1 },
246
- { user, greet_count: 1 },
247
- ]);
248
- });
249
-
250
- test('invoke-reg-tx-fun-outside-wf', async () => {
251
- const user = 'outsideWfUser' + Date.now();
252
- const result = await regInsertFunction(user);
253
- expect(result).toMatchObject({ user, greet_count: 1 });
254
-
255
- const txResults = await userDB.query('SELECT * FROM dbos.transaction_completion WHERE output LIKE $1', [
256
- `%${user}%`,
257
- ]);
258
- expect(txResults.rows.length).toBe(0);
259
- });
260
-
261
- test('invoke-reg-tx-static-method-outside-wf', async () => {
262
- const user = 'outsideWfUser' + Date.now();
263
- const result = await StaticClass.insertFunction(user);
264
- expect(result).toMatchObject({ user, greet_count: 1 });
265
-
266
- const txResults = await userDB.query('SELECT * FROM dbos.transaction_completion WHERE output LIKE $1', [
267
- `%${user}%`,
268
- ]);
269
- expect(txResults.rows.length).toBe(0);
270
- });
271
-
272
- test('invoke-reg-tx-inst-method-outside-wf', async () => {
273
- const user = 'outsideWfUser' + Date.now();
274
- const instance = new InstanceClass();
275
- const result = await instance.insertFunction(user);
276
- expect(result).toMatchObject({ user, greet_count: 1 });
277
-
278
- const txResults = await userDB.query('SELECT * FROM dbos.transaction_completion WHERE output LIKE $1', [
279
- `%${user}%`,
280
- ]);
281
- expect(txResults.rows.length).toBe(0);
282
- });
283
-
284
- test('duplicate execution replays the winner result', async () => {
285
- await userDB.query('DELETE FROM race_side_effects');
286
- raceState.callCount = 0;
287
-
288
- // A loser whose body succeeds: the collision happens on the result-recording insert.
289
- raceState.plantWinner = true;
290
- raceState.plantWinnerError = false;
291
- raceState.fail = false;
292
- const wfid1 = randomUUID();
293
- await expect(DBOS.withNextWorkflowID(wfid1, () => regRaceWorkflow())).resolves.toBe('winner-result');
294
- expect(raceState.callCount).toBe(1); // the loser did run its body
295
-
296
- // A loser whose body fails: the collision moves to the error-recording insert,
297
- // and the winner's result still wins over the loser's own error.
298
- raceState.plantWinner = true;
299
- raceState.plantWinnerError = false;
300
- raceState.fail = true;
301
- const wfid2 = randomUUID();
302
- await expect(DBOS.withNextWorkflowID(wfid2, () => regRaceWorkflow())).resolves.toBe('winner-result');
303
- expect(raceState.callCount).toBe(2);
304
-
305
- // A winner that recorded an error: the loser replays that throw instead of its own result.
306
- raceState.plantWinner = true;
307
- raceState.plantWinnerError = true;
308
- raceState.fail = false;
309
- const wfid3 = randomUUID();
310
- const winnerError = await throws(() => DBOS.withNextWorkflowID(wfid3, () => regRaceWorkflow()));
311
- expect((winnerError as Error).message).toBe('winner-error');
312
- expect(raceState.callCount).toBe(3);
313
-
314
- // Every loser's writes were discarded, and completion cleared the winners' checkpoints.
315
- const { rows: tags } = await userDB.query<{ tag: string }>('SELECT tag FROM race_side_effects');
316
- expect(tags).toHaveLength(0);
317
- const { rows: txOutput } = await userDB.query<transaction_completion>(
318
- 'SELECT * FROM dbos.transaction_completion WHERE workflow_id = ANY($1)',
319
- [[wfid1, wfid2, wfid3]],
320
- );
321
- expect(txOutput).toHaveLength(0);
322
- });
323
-
324
- /** Completion clears the checkpoints, so the workflow reads them back while it still runs. */
325
- test('transactions record their outcomes as checkpoints', async () => {
326
- const user = 'checkpointProbe';
327
- await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
328
- const workflowID = randomUUID();
329
- const message = await DBOS.withNextWorkflowID(workflowID, () => regCheckpointProbeWorkflow(user));
330
- expect(message).toMatch(/^test error \d+$/);
331
-
332
- expect(probeState.rows).toHaveLength(2);
333
- const [output, error] = probeState.rows;
334
- expect(output).toMatchObject({ workflow_id: workflowID, function_num: 0, error: null });
335
- expect(SuperJSON.parse(output.output!)).toMatchObject({ user, greet_count: 1 });
336
- expect(error).toMatchObject({ workflow_id: workflowID, function_num: 1, output: null });
337
- expect(SuperJSON.parse<Error>(error.error!).message).toBe(message);
338
- expect(await readCompletions(workflowID)).toHaveLength(0);
339
- });
340
-
341
- /** The checkpoint may be the new owner's only record of the transaction, so a stale execution's cleanup rolls back. */
342
- test('a stale execution keeps the checkpoints at completion', async () => {
343
- const user = 'handOffTest';
344
- await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
345
- const handedOff = new Promise<void>((resolve) => (handOffState.signal = resolve));
346
- const workflowID = randomUUID();
347
- const execution = DBOS.withNextWorkflowID(workflowID, () => regHandOffWorkflow(user));
348
- // Surfaces the workflow's own error if it fails before handing off.
349
- await Promise.race([handedOff, execution]);
350
-
351
- // Its outcome is refused too, so the execution parks until the workflow is cancelled.
352
- await DBOS.cancelWorkflow(workflowID);
353
- await expect(execution).rejects.toThrow(/has been cancelled/);
354
- expect(await readCompletions(workflowID)).toHaveLength(1);
355
- });
356
-
357
- /** A stale execution cannot apply a step the new owner also runs, nor leave an outcome for it. */
358
- test('an execution that loses the workflow mid-transaction rolls back', async () => {
359
- await userDB.query('DELETE FROM race_side_effects');
360
- const workflowID = randomUUID();
361
- await expect(DBOS.withNextWorkflowID(workflowID, () => regStaleWorkflow())).resolves.toBe('conflicted');
362
-
363
- const { rows: tags } = await userDB.query<{ tag: string }>('SELECT tag FROM race_side_effects');
364
- expect(tags).toHaveLength(0);
365
- expect(staleState.completionsAtConflict).toBe(0);
366
- const sysDB = new Client(sysConfig);
367
- try {
368
- await sysDB.connect();
369
- const { rows: steps } = await sysDB.query('SELECT * FROM dbos.operation_outputs WHERE workflow_uuid = $1', [
370
- workflowID,
371
- ]);
372
- expect(steps).toHaveLength(0);
373
- } finally {
374
- await sysDB.end();
375
- }
376
- });
377
- });
378
-
379
- export interface greetings {
380
- name: string;
381
- greet_count: number;
382
- }
383
-
384
- async function createDatabases(userDB: Pool, createTxCompletion: boolean) {
385
- {
386
- const client = new Client({ ...config.connection, database: 'postgres' });
387
- try {
388
- await client.connect();
389
- await dropDB(client, 'kysely_ds_test', true);
390
- await dropDB(client, 'kysely_ds_test_dbos_sys', true);
391
- await dropDB(client, config.connection.database, true);
392
- await ensureDB(client, config.connection.database);
393
- } finally {
394
- await client.end();
395
- }
396
- }
397
-
398
- {
399
- const client = await userDB.connect();
400
- try {
401
- await client.query(
402
- 'CREATE TABLE greetings(name text NOT NULL, greet_count integer DEFAULT 0, PRIMARY KEY(name))',
403
- );
404
- await client.query('CREATE TABLE race_side_effects(tag text NOT NULL)');
405
- } finally {
406
- client.release();
407
- }
408
- }
409
-
410
- if (createTxCompletion) {
411
- await KyselyDataSource.initializeDBOSSchema(config.connection);
412
- }
413
- }
414
-
415
- async function insertFunction(user: string) {
416
- const result = await dataSource.client
417
- .insertInto('greetings')
418
- .values({ name: user, greet_count: 1 })
419
- .onConflict((oc) => oc.column('name').doUpdateSet({ greet_count: sql`greetings.greet_count + 1` }))
420
- .returning('greet_count')
421
- .executeTakeFirst();
422
-
423
- return { user, greet_count: result?.greet_count, now: Date.now() };
424
- }
425
-
426
- async function errorFunction(user: string) {
427
- await insertFunction(user);
428
- throw new Error(`test error ${Date.now()}`);
429
- }
430
-
431
- const raceState = { callCount: 0, plantWinner: false, plantWinnerError: false, fail: false };
432
-
433
- async function raceFunction() {
434
- raceState.callCount += 1;
435
- await sql`INSERT INTO race_side_effects(tag) VALUES (${`run-${raceState.callCount}`})`.execute(dataSource.client);
436
- if (raceState.plantWinner) {
437
- raceState.plantWinner = false;
438
- await plantWinnerCompletion();
439
- }
440
- if (raceState.fail) {
441
- throw new Error("loser's own failure");
442
- }
443
- return `result-${raceState.callCount}`;
444
- }
445
-
446
- // A duplicate execution commits its result while this transaction is still open, so this
447
- // execution's pre-check missed it but its completion insert will collide with it.
448
- async function plantWinnerCompletion() {
449
- const winner = new Client(config.connection);
450
- try {
451
- await winner.connect();
452
- const [column, value] = raceState.plantWinnerError
453
- ? ['error', SuperJSON.stringify(new Error('winner-error'))]
454
- : ['output', SuperJSON.stringify('winner-result')];
455
- await winner.query(
456
- `INSERT INTO dbos.transaction_completion (workflow_id, function_num, ${column}) VALUES ($1, $2, $3)`,
457
- [DBOS.workflowID, DBOS.stepID, value],
458
- );
459
- } finally {
460
- await winner.end();
461
- }
462
- }
463
-
464
- const regRaceFunction = dataSource.registerTransaction(raceFunction);
465
-
466
- async function raceWorkflow() {
467
- return await regRaceFunction();
468
- }
469
-
470
- const regRaceWorkflow = DBOS.registerWorkflow(raceWorkflow);
471
-
472
- async function readFunction(user: string) {
473
- const row = await dataSource.client
474
- .selectFrom('greetings')
475
- .select('greet_count')
476
- .where('name', '=', user)
477
- .executeTakeFirst();
478
- return { user, greet_count: row?.greet_count, now: Date.now() };
479
- }
480
-
481
- const regInsertFunction = dataSource.registerTransaction(insertFunction);
482
- const regErrorFunction = dataSource.registerTransaction(errorFunction);
483
-
484
- const sysConfig = { ...config.connection, database: 'kysely_ds_test_dbos_sys' };
485
-
486
- /** Set the workflow's ownership token, returning the one it replaced. */
487
- async function setOwner(workflowID: string, ownerXid: string | null): Promise<string | null> {
488
- const sysDB = new Client(sysConfig);
489
- try {
490
- await sysDB.connect();
491
- const { rows } = await sysDB.query<{ owner_xid: string | null }>(
492
- 'SELECT owner_xid FROM dbos.workflow_status WHERE workflow_uuid = $1',
493
- [workflowID],
494
- );
495
- await sysDB.query('UPDATE dbos.workflow_status SET owner_xid = $2 WHERE workflow_uuid = $1', [
496
- workflowID,
497
- ownerXid,
498
- ]);
499
- return rows[0].owner_xid;
500
- } finally {
501
- await sysDB.end();
502
- }
503
- }
504
-
505
- /** The checkpoints this data source holds for the workflow, read on a connection of their own. */
506
- async function readCompletions(workflowID: string): Promise<transaction_completion[]> {
507
- const client = new Client(config.connection);
508
- try {
509
- await client.connect();
510
- const { rows } = await client.query<transaction_completion>(
511
- 'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1 ORDER BY function_num',
512
- [workflowID],
513
- );
514
- return rows;
515
- } finally {
516
- await client.end();
517
- }
518
- }
519
-
520
- const probeState = { rows: [] as transaction_completion[] };
521
-
522
- // Reads back the checkpoints its transactions wrote, before completion clears them.
523
- async function checkpointProbeWorkflow(user: string) {
524
- await regInsertFunction(user);
525
- const error = await regErrorFunction(user).then(
526
- () => undefined,
527
- (e: Error) => e,
528
- );
529
- probeState.rows = await readCompletions(DBOS.workflowID!);
530
- return error?.message;
531
- }
532
-
533
- const regCheckpointProbeWorkflow = DBOS.registerWorkflow(checkpointProbeWorkflow);
534
-
535
- const staleState = { originalOwner: null as string | null, completionsAtConflict: -1 };
536
-
537
- // Another execution claims the workflow while this transaction is still open.
538
- async function staleFunction() {
539
- await sql`INSERT INTO race_side_effects(tag) VALUES ('stale')`.execute(dataSource.client);
540
- staleState.originalOwner = await setOwner(DBOS.workflowID!, 'another-execution');
541
- return 'stale';
542
- }
543
-
544
- const regStaleFunction = dataSource.registerTransaction(staleFunction);
545
-
546
- async function staleWorkflow() {
547
- try {
548
- return await regStaleFunction();
549
- } catch (e) {
550
- if (!(e instanceof DBOSWorkflowConflictError)) throw e;
551
- // Read before completion clears it: the conflict must not have been recorded as the step's outcome.
552
- staleState.completionsAtConflict = (await readCompletions(DBOS.workflowID!)).length;
553
- // A real duplicate parks here instead; reclaimed so this execution's outcome lands.
554
- await setOwner(DBOS.workflowID!, staleState.originalOwner);
555
- return 'conflicted';
556
- }
557
- }
558
-
559
- const regStaleWorkflow = DBOS.registerWorkflow(staleWorkflow);
560
-
561
- const handOffState = { signal: () => {} };
562
-
563
- // Loses the workflow after its transaction, so its completion is a stale execution's.
564
- async function handOffWorkflow(user: string) {
565
- const result = await regInsertFunction(user);
566
- await setOwner(DBOS.workflowID!, 'another-execution');
567
- handOffState.signal();
568
- return result;
569
- }
570
-
571
- const regHandOffWorkflow = DBOS.registerWorkflow(handOffWorkflow);
572
- const regReadFunction = dataSource.registerTransaction(readFunction, { readOnly: true });
573
-
574
- class StaticClass {
575
- static async insertFunction(user: string) {
576
- return await insertFunction(user);
577
- }
578
-
579
- static async readFunction(user: string) {
580
- return await readFunction(user);
581
- }
582
- }
583
-
584
- StaticClass.insertFunction = dataSource.registerTransaction(StaticClass.insertFunction);
585
- StaticClass.readFunction = dataSource.registerTransaction(StaticClass.readFunction, { readOnly: true });
586
-
587
- class InstanceClass {
588
- async insertFunction(user: string) {
589
- return await insertFunction(user);
590
- }
591
-
592
- async readFunction(user: string) {
593
- return await readFunction(user);
594
- }
595
- }
596
-
597
- InstanceClass.prototype.insertFunction = dataSource.registerTransaction(
598
- // eslint-disable-next-line @typescript-eslint/unbound-method
599
- InstanceClass.prototype.insertFunction,
600
- );
601
- InstanceClass.prototype.readFunction = dataSource.registerTransaction(
602
- // eslint-disable-next-line @typescript-eslint/unbound-method
603
- InstanceClass.prototype.readFunction,
604
- { readOnly: true },
605
- );
606
-
607
- async function insertWorkflowReg(user: string) {
608
- return await regInsertFunction(user);
609
- }
610
-
611
- async function insertWorkflowRunTx(user: string) {
612
- return await dataSource.runTransaction(() => insertFunction(user), { name: 'insertFunction' });
613
- }
614
-
615
- async function errorWorkflowReg(user: string) {
616
- return await regErrorFunction(user);
617
- }
618
-
619
- async function errorWorkflowRunTx(user: string) {
620
- return await dataSource.runTransaction(() => errorFunction(user), { name: 'errorFunction' });
621
- }
622
-
623
- async function readWorkflowReg(user: string) {
624
- return await regReadFunction(user);
625
- }
626
-
627
- async function readWorkflowRunTx(user: string) {
628
- return await dataSource.runTransaction(() => readFunction(user), { name: 'readFunction', readOnly: true });
629
- }
630
-
631
- async function staticWorkflow(user: string) {
632
- const result = await StaticClass.insertFunction(user);
633
- const readResult = await StaticClass.readFunction(user);
634
- return [result, readResult];
635
- }
636
-
637
- async function instanceWorkflow(user: string) {
638
- const instance = new InstanceClass();
639
- const result = await instance.insertFunction(user);
640
- const readResult = await instance.readFunction(user);
641
- return [result, readResult];
642
- }
643
-
644
- const regInsertWorkflowReg = DBOS.registerWorkflow(insertWorkflowReg);
645
- const regInsertWorkflowRunTx = DBOS.registerWorkflow(insertWorkflowRunTx);
646
- const regErrorWorkflowReg = DBOS.registerWorkflow(errorWorkflowReg);
647
- const regErrorWorkflowRunTx = DBOS.registerWorkflow(errorWorkflowRunTx);
648
- const regReadWorkflowReg = DBOS.registerWorkflow(readWorkflowReg);
649
- const regReadWorkflowRunTx = DBOS.registerWorkflow(readWorkflowRunTx);
650
- const regStaticWorkflow = DBOS.registerWorkflow(staticWorkflow);
651
- const regInstanceWorkflow = DBOS.registerWorkflow(instanceWorkflow);
652
-
653
- describe('KyselyDataSourceCreateTxCompletion', () => {
654
- const userDB = new Pool(config.connection);
655
-
656
- beforeAll(async () => {
657
- await createDatabases(userDB, false);
658
- });
659
-
660
- afterAll(async () => {
661
- await userDB.end();
662
- });
663
-
664
- beforeEach(async () => {
665
- DBOS.setConfig({ name: 'kysely-ds-test' });
666
- await DBOS.launch();
667
- });
668
-
669
- afterEach(async () => {
670
- await DBOS.shutdown();
671
- });
672
-
673
- test('run wf auto register DS schema', async () => {
674
- const user = 'helloTest1';
675
-
676
- await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
677
- const workflowID = randomUUID();
678
-
679
- await expect(DBOS.withNextWorkflowID(workflowID, () => regInsertWorkflowReg(user))).resolves.toMatchObject({
680
- user,
681
- greet_count: 1,
682
- });
683
-
684
- const { rows } = await userDB.query<transaction_completion>(
685
- 'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1',
686
- [workflowID],
687
- );
688
- // Completion cleared the checkpoint the transaction wrote.
689
- expect(rows).toHaveLength(0);
690
- });
691
- });
692
-
693
- describe('KyselyDataSourceWithCustomKysely', () => {
694
- const userDB = new Pool(config.connection);
695
-
696
- beforeAll(async () => {
697
- await createDatabases(userDB, true);
698
- DBOS.setConfig({ name: 'kysely-ds-test' });
699
- });
700
-
701
- afterAll(async () => {
702
- // Deregister so later suites don't re-initialize the datasource over the destroyed custom Kysely.
703
- await DBOS.shutdown({ deregister: true });
704
- await userDB.end();
705
- });
706
-
707
- test('datasource with custom Kysely instance', async () => {
708
- // Create a custom Kysely instance
709
- const customKysely = new Kysely<Database>({
710
- dialect: new PostgresDialect({
711
- pool: new Pool(config.connection),
712
- }),
713
- });
714
-
715
- // Create a datasource using the custom Kysely instance
716
- const customDataSource = new KyselyDataSource<Database>('custom-kysely-db', customKysely);
717
-
718
- // Create transaction functions that use the custom datasource
719
- async function customInsertFunction(user: string) {
720
- const result = await customDataSource.client
721
- .insertInto('greetings')
722
- .values({ name: user, greet_count: 1 })
723
- .onConflict((oc) => oc.column('name').doUpdateSet({ greet_count: sql`greetings.greet_count + 1` }))
724
- .returning('greet_count')
725
- .executeTakeFirst();
726
-
727
- return { user, greet_count: result?.greet_count };
728
- }
729
-
730
- const regCustomInsert = customDataSource.registerTransaction(customInsertFunction);
731
-
732
- async function customInsertWorkflow(user: string) {
733
- return await regCustomInsert(user);
734
- }
735
-
736
- const regCustomWorkflow = DBOS.registerWorkflow(customInsertWorkflow);
737
- await DBOS.launch();
738
-
739
- const user = 'customKyselyTest' + Date.now();
740
- const workflowID = randomUUID();
741
-
742
- // Execute the workflow using the custom Kysely-backed datasource
743
- const result = await DBOS.withNextWorkflowID(workflowID, () => regCustomWorkflow(user));
744
- expect(result).toMatchObject({ user, greet_count: 1 });
745
-
746
- // Verify the transaction was recorded
747
- const { rows } = await userDB.query<transaction_completion>(
748
- 'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1',
749
- [workflowID],
750
- );
751
- // Completion cleared the checkpoint the transaction wrote.
752
- expect(rows).toHaveLength(0);
753
-
754
- // Clean up the custom Kysely instance
755
- await customKysely.destroy();
756
- });
757
- });
758
-
759
- describe('KyselyDataSource migrations', () => {
760
- const userDB = new Pool(config.connection);
761
-
762
- afterAll(async () => {
763
- await userDB.end();
764
- });
765
-
766
- afterEach(async () => {
767
- await DBOS.shutdown();
768
- });
769
-
770
- test('verify-only initialization requires the schema to be migrated out of band', async () => {
771
- const suffix = randomUUID().replace(/-/g, '').slice(0, 8);
772
- const schemaName = `ds_mig_${suffix}`;
773
- const role = `ds_app_${suffix}`;
774
- const admin = new Client({ ...config.connection, database: 'postgres' });
775
- await admin.connect();
776
- try {
777
- await ensureDB(admin, config.connection.database);
778
- } finally {
779
- await admin.end();
780
- }
781
- await userDB.query(`CREATE ROLE "${role}"`);
782
- try {
783
- new KyselyDataSource(`verify-only-${suffix}`, config.connection, schemaName, { runMigrations: false });
784
- DBOS.setConfig({ name: 'kysely-ds-test' });
785
- await expect(DBOS.launch()).rejects.toThrow(/is at transaction schema version 0/);
786
- await DBOS.shutdown();
787
- const { rowCount } = await userDB.query('SELECT 1 FROM pg_namespace WHERE nspname = $1', [schemaName]);
788
- expect(rowCount).toBe(0);
789
-
790
- await KyselyDataSource.initializeDBOSSchema(config.connection, schemaName, { applicationRole: role });
791
- await DBOS.launch();
792
- const { rows } = await userDB.query<{ allowed: boolean }>(
793
- `SELECT has_table_privilege($1, $2, 'INSERT') AS allowed`,
794
- [role, `"${schemaName}".transaction_completion`],
795
- );
796
- expect(rows).toEqual([{ allowed: true }]);
797
- } finally {
798
- await userDB.query(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`);
799
- await userDB.query(`DROP OWNED BY "${role}"`);
800
- await userDB.query(`DROP ROLE "${role}"`);
801
- }
802
- });
803
- });
@@ -1,13 +0,0 @@
1
- // Typechecked by esmtypes.test.ts, never executed: .mts makes it ESM regardless of the package type.
2
- import { KyselyDataSource } from '@dbos-inc/kysely-datasource';
3
- import type { Kysely } from 'kysely';
4
-
5
- interface Database {
6
- greetings: { greeting: string };
7
- }
8
-
9
- declare const dataSource: KyselyDataSource<Database>;
10
- declare const existingClient: Kysely<Database>;
11
-
12
- export const client: Kysely<Database> = dataSource.client;
13
- export const fromExistingClient = new KyselyDataSource<Database>('app', existingClient);
@@ -1,27 +0,0 @@
1
- import { execFileSync } from 'child_process';
2
- import { readFileSync } from 'fs';
3
- import path from 'path';
4
-
5
- const packageRoot = path.join(__dirname, '..');
6
-
7
- describe('ESM type declarations', () => {
8
- test('an ESM consumer under NodeNext typechecks against the built declarations', () => {
9
- const tsc = require.resolve('typescript/bin/tsc');
10
- const project = path.join(__dirname, 'tsconfig.esmcheck.json');
11
- try {
12
- execFileSync(process.execPath, [tsc, '--project', project], { cwd: packageRoot, stdio: 'pipe' });
13
- } catch (error) {
14
- const { stdout } = error as { stdout?: Buffer };
15
- throw new Error(`ESM consumer failed to typecheck. Run 'npm run build' first.\n${stdout?.toString() ?? ''}`);
16
- }
17
- }, 120000);
18
-
19
- test('the ESM declarations carry no reference to the CommonJS declaration map', () => {
20
- const declarations = readFileSync(path.join(packageRoot, 'dist', 'index.d.mts'), 'utf8');
21
- expect(declarations).not.toContain('sourceMappingURL');
22
- });
23
-
24
- test('the exports map still resolves the package manifest', () => {
25
- expect(() => require.resolve('@dbos-inc/kysely-datasource/package.json')).not.toThrow();
26
- });
27
- });
@@ -1,24 +0,0 @@
1
- import { ColumnType } from 'kysely';
2
- import { Client } from 'pg';
3
-
4
- export async function ensureDB(client: Client, name: string) {
5
- const results = await client.query('SELECT 1 FROM pg_database WHERE datname = $1', [name]);
6
- if (results.rows.length === 0) {
7
- await client.query(`CREATE DATABASE ${name}`);
8
- }
9
- }
10
-
11
- export async function dropDB(client: Client, name: string, force: boolean = false) {
12
- const withForce = force ? ' WITH (FORCE)' : '';
13
- await client.query(`DROP DATABASE IF EXISTS ${name} ${withForce}`);
14
- }
15
-
16
- // Kysely-specific database interface
17
- export interface GreetingsTable {
18
- name: string;
19
- greet_count: ColumnType<number, number | undefined, number>;
20
- }
21
-
22
- export interface Database {
23
- greetings: GreetingsTable;
24
- }
@@ -1,11 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "module": "nodenext",
4
- "moduleResolution": "nodenext",
5
- "target": "esnext",
6
- "strict": true,
7
- "skipLibCheck": true,
8
- "noEmit": true
9
- },
10
- "files": ["esm-consumer.mts"]
11
- }
package/tsconfig.json DELETED
@@ -1,9 +0,0 @@
1
- /* Visit https://aka.ms/tsconfig to read more about this file */
2
- {
3
- "extends": "../../tsconfig.shared.json",
4
- "compilerOptions": {
5
- "outDir": "./dist"
6
- },
7
- "include": ["index.ts"],
8
- "exclude": ["dist", "tests"]
9
- }