@dbos-inc/drizzle-datasource 3.0.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/jest.config.js ADDED
@@ -0,0 +1,8 @@
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
+ };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@dbos-inc/drizzle-datasource",
3
+ "version": "3.0.6-preview",
4
+ "main": "dist/src/index.js",
5
+ "types": "dist/src/index.d.ts",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/dbos-inc/dbos-transact-ts",
9
+ "directory": "packages/drizzle-datasource"
10
+ },
11
+ "scripts": {
12
+ "build": "tsc --project tsconfig.json",
13
+ "test": "npm run build && jest --detectOpenHandles"
14
+ },
15
+ "keywords": [],
16
+ "author": "",
17
+ "license": "MIT",
18
+ "description": "",
19
+ "dependencies": {
20
+ "drizzle-kit": "^0.31.1",
21
+ "drizzle-orm": "^0.40.1",
22
+ "pg": "^8.11.3",
23
+ "superjson": "^1.13"
24
+ },
25
+ "peerDependencies": {
26
+ "@dbos-inc/dbos-sdk": "*"
27
+ },
28
+ "devDependencies": {
29
+ "@types/jest": "^29.5.12",
30
+ "@types/node": "^20.11.25",
31
+ "@types/supertest": "^6.0.2",
32
+ "jest": "^29.7.0",
33
+ "supertest": "^7.0.0",
34
+ "ts-jest": "^29.1.4",
35
+ "typescript": "^5.3.3"
36
+ }
37
+ }
@@ -0,0 +1,332 @@
1
+ import { Pool, PoolConfig } from 'pg';
2
+ import { DBOS, Error } from '@dbos-inc/dbos-sdk';
3
+ import {
4
+ type DataSourceTransactionHandler,
5
+ createTransactionCompletionSchemaPG,
6
+ createTransactionCompletionTablePG,
7
+ isPGRetriableTransactionError,
8
+ isPGKeyConflictError,
9
+ isPGFailedSqlTransactionError,
10
+ registerTransaction,
11
+ runTransaction,
12
+ PGIsolationLevel as IsolationLevel,
13
+ PGTransactionConfig as DrizzleTransactionConfig,
14
+ DBOSDataSource,
15
+ registerDataSource,
16
+ } from '@dbos-inc/dbos-sdk/datasource';
17
+ import { drizzle, NodePgDatabase } from 'drizzle-orm/node-postgres';
18
+ import { pushSchema } from 'drizzle-kit/api';
19
+ import { AsyncLocalStorage } from 'async_hooks';
20
+ import { SuperJSON } from 'superjson';
21
+
22
+ export { IsolationLevel, DrizzleTransactionConfig };
23
+
24
+ interface DrizzleLocalCtx {
25
+ drizzleClient: NodePgDatabase<{ [key: string]: object }>;
26
+ }
27
+ const asyncLocalCtx = new AsyncLocalStorage<DrizzleLocalCtx>();
28
+
29
+ function getCurrentDSContextStore(): DrizzleLocalCtx | undefined {
30
+ return asyncLocalCtx.getStore();
31
+ }
32
+
33
+ function assertCurrentDSContextStore(): DrizzleLocalCtx {
34
+ const ctx = getCurrentDSContextStore();
35
+ if (!ctx) throw new Error.DBOSInvalidWorkflowTransitionError('Invalid use of TypeOrmDs outside of a `transaction`');
36
+ return ctx;
37
+ }
38
+
39
+ export interface transaction_completion {
40
+ workflow_id: string;
41
+ function_num: number;
42
+ output: string | null;
43
+ error: string | null;
44
+ }
45
+
46
+ class DrizzleDSTH implements DataSourceTransactionHandler {
47
+ readonly dsType = 'drizzle';
48
+ dataSource: NodePgDatabase<{ [key: string]: object }> | undefined;
49
+ drizzlePool: Pool | undefined;
50
+
51
+ constructor(
52
+ readonly name: string,
53
+ readonly config: PoolConfig,
54
+ readonly entities: { [key: string]: object } = {},
55
+ ) {}
56
+
57
+ async initialize(): Promise<void> {
58
+ this.drizzlePool = new Pool(this.config);
59
+ this.dataSource = drizzle(this.drizzlePool, { schema: this.entities });
60
+
61
+ return Promise.resolve();
62
+ }
63
+
64
+ async destroy(): Promise<void> {
65
+ await this.drizzlePool?.end();
66
+ }
67
+
68
+ async #checkExecution<R>(
69
+ client: Pool,
70
+ workflowID: string,
71
+ funcNum: number,
72
+ ): Promise<
73
+ | {
74
+ res: R;
75
+ }
76
+ | undefined
77
+ > {
78
+ const result = await client.query<{
79
+ output: string;
80
+ }>(
81
+ `SELECT output
82
+ FROM dbos.transaction_completion
83
+ WHERE workflow_id = $1 AND function_num = $2`,
84
+ [workflowID, funcNum],
85
+ );
86
+
87
+ if (result.rows.length !== 1) {
88
+ return undefined;
89
+ }
90
+
91
+ return { res: SuperJSON.parse(result.rows[0].output) };
92
+ }
93
+
94
+ async #recordOutput<R>(client: Pool, workflowID: string, funcNum: number, output: R): Promise<void> {
95
+ const serialOutput = SuperJSON.stringify(output);
96
+ await client.query<{ rows: transaction_completion[] }>(
97
+ `INSERT INTO dbos.transaction_completion (
98
+ workflow_id,
99
+ function_num,
100
+ output,
101
+ created_at
102
+ ) VALUES ($1, $2, $3, $4)`,
103
+ [workflowID, funcNum, serialOutput, Date.now()],
104
+ );
105
+ }
106
+
107
+ async #recordError<R>(client: Pool, workflowID: string, funcNum: number, error: R): Promise<void> {
108
+ const serialError = SuperJSON.stringify(error);
109
+ await client.query<{ rows: transaction_completion[] }>(
110
+ `INSERT INTO dbos.transaction_completion (
111
+ workflow_id,
112
+ function_num,
113
+ error,
114
+ created_at
115
+ ) VALUES ($1, $2, $3, $4)`,
116
+ [workflowID, funcNum, serialError, Date.now()],
117
+ );
118
+ }
119
+
120
+ /* Invoke a transaction function, called by the framework */
121
+ async invokeTransactionFunction<This, Args extends unknown[], Return>(
122
+ config: DrizzleTransactionConfig,
123
+ target: This,
124
+ func: (this: This, ...args: Args) => Promise<Return>,
125
+ ...args: Args
126
+ ): Promise<Return> {
127
+ let isolationLevel: 'read uncommitted' | 'read committed' | 'repeatable read' | 'serializable';
128
+
129
+ if (config === undefined || config.isolationLevel === undefined) {
130
+ isolationLevel = 'serializable'; // Default isolation level
131
+ } else if (config.isolationLevel === IsolationLevel.ReadUncommitted) {
132
+ isolationLevel = 'read uncommitted';
133
+ } else if (config.isolationLevel === IsolationLevel.ReadCommitted) {
134
+ isolationLevel = 'read committed';
135
+ } else if (config.isolationLevel === IsolationLevel.RepeatableRead) {
136
+ isolationLevel = 'repeatable read';
137
+ } else {
138
+ isolationLevel = 'serializable';
139
+ }
140
+
141
+ const accessMode = 'read write';
142
+
143
+ const readOnly = config?.readOnly ? true : false;
144
+
145
+ const wfid = DBOS.workflowID!;
146
+ const funcnum = DBOS.stepID!;
147
+ const funcname = func.name;
148
+
149
+ // Retry loop if appropriate
150
+ let retryWaitMillis = 1;
151
+ const backoffFactor = 1.5;
152
+ const maxRetryWaitMs = 2000; // Maximum wait 2 seconds.
153
+ const shouldCheckOutput = false;
154
+
155
+ if (this.drizzlePool === undefined) {
156
+ throw new Error.DBOSInvalidWorkflowTransitionError('Invalid use of Datasource');
157
+ }
158
+
159
+ if (this.dataSource === undefined) {
160
+ throw new Error.DBOSInvalidWorkflowTransitionError('Invalid use of Datasource');
161
+ }
162
+
163
+ while (true) {
164
+ let failedForRetriableReasons = false;
165
+
166
+ try {
167
+ const result = await this.dataSource.transaction(
168
+ async (drizzleClient: NodePgDatabase<{ [key: string]: object }>) => {
169
+ if (this.drizzlePool === undefined) {
170
+ throw new Error.DBOSInvalidWorkflowTransitionError('Invalid use of Datasource');
171
+ }
172
+
173
+ if (shouldCheckOutput && !readOnly && wfid) {
174
+ const executionResult = await this.#checkExecution<Return>(this.drizzlePool, wfid, funcnum);
175
+
176
+ if (executionResult) {
177
+ DBOS.span?.setAttribute('cached', true);
178
+ return executionResult.res;
179
+ }
180
+ }
181
+
182
+ const result = await asyncLocalCtx.run({ drizzleClient }, async () => {
183
+ return await func.call(target, ...args);
184
+ });
185
+
186
+ // Save result
187
+ try {
188
+ if (!readOnly && wfid) {
189
+ await this.#recordOutput(this.drizzlePool, wfid, funcnum, result);
190
+ }
191
+ } catch (e) {
192
+ const error = e as Error;
193
+ await this.#recordError(this.drizzlePool, wfid, funcnum, error);
194
+
195
+ // Aside from a connectivity error, two kinds of error are anticipated here:
196
+ // 1. The transaction is marked failed, but the user code did not throw.
197
+ // Bad on them. We will throw an error (this will get recorded) and not retry.
198
+ // 2. There was a key conflict in the statement, and we need to use the fetched output
199
+ if (isPGFailedSqlTransactionError(error)) {
200
+ DBOS.logger.error(
201
+ `In workflow ${wfid}, Postgres aborted a transaction but the function '${funcname}' did not raise an exception. Please ensure that the transaction method raises an exception if the database transaction is aborted.`,
202
+ );
203
+ failedForRetriableReasons = false;
204
+ throw new Error.DBOSFailedSqlTransactionError(wfid, funcname);
205
+ } else if (isPGKeyConflictError(error)) {
206
+ throw new Error.DBOSWorkflowConflictError(
207
+ `In workflow ${wfid}, Postgres raised a key conflict error in transaction '${funcname}'. This is not retriable, but the output will be fetched from the database.`,
208
+ );
209
+ } else {
210
+ DBOS.logger.error(`Unexpected error raised in transaction '${funcname}: ${error}`);
211
+ failedForRetriableReasons = false;
212
+ throw error;
213
+ }
214
+ }
215
+
216
+ return result;
217
+ },
218
+ { isolationLevel, accessMode },
219
+ );
220
+
221
+ return result;
222
+ } catch (e) {
223
+ const err = e as Error;
224
+ if (failedForRetriableReasons || isPGRetriableTransactionError(err)) {
225
+ DBOS.span?.addEvent('TXN SERIALIZATION FAILURE', { retryWaitMillis: retryWaitMillis }, performance.now());
226
+ // Retry serialization failures.
227
+ await DBOS.sleepms(retryWaitMillis);
228
+ retryWaitMillis *= backoffFactor;
229
+ retryWaitMillis = retryWaitMillis < maxRetryWaitMs ? retryWaitMillis : maxRetryWaitMs;
230
+ continue;
231
+ } else {
232
+ throw err;
233
+ }
234
+ }
235
+ }
236
+ }
237
+
238
+ createInstance(): NodePgDatabase<{ [key: string]: object }> {
239
+ const drizzlePool = new Pool(this.config);
240
+ const ds = drizzle(drizzlePool, { schema: this.entities });
241
+ return ds;
242
+ }
243
+ }
244
+
245
+ export class DrizzleDataSource implements DBOSDataSource<DrizzleTransactionConfig> {
246
+ #provider: DrizzleDSTH;
247
+
248
+ constructor(
249
+ readonly name: string,
250
+ readonly config: PoolConfig,
251
+ readonly entities: { [key: string]: object } = {},
252
+ ) {
253
+ this.#provider = new DrizzleDSTH(name, config, entities);
254
+ registerDataSource(this.#provider);
255
+ }
256
+
257
+ // User calls this... DBOS not directly involved...
258
+ static get drizzleClient(): NodePgDatabase<{ [key: string]: object }> {
259
+ const ctx = assertCurrentDSContextStore();
260
+ if (!DBOS.isInTransaction())
261
+ throw new Error.DBOSInvalidWorkflowTransitionError(
262
+ 'Invalid use of `DrizzleDataSource.drizzleClient` outside of a `transaction`',
263
+ );
264
+ return ctx.drizzleClient;
265
+ }
266
+
267
+ get dataSource() {
268
+ return this.#provider.dataSource;
269
+ }
270
+
271
+ async initializeInternalSchema(): Promise<void> {
272
+ const drizzlePool = new Pool(this.config);
273
+ const ds = drizzle(drizzlePool, { schema: this.entities });
274
+
275
+ try {
276
+ await ds.execute(createTransactionCompletionSchemaPG);
277
+ await ds.execute(createTransactionCompletionTablePG);
278
+ } catch (e) {
279
+ const error = e as Error;
280
+ throw new Error.DBOSError(`Unexpected error initializing schema: ${error.message}`);
281
+ } finally {
282
+ try {
283
+ await drizzlePool.end();
284
+ } catch (e) {}
285
+ }
286
+ }
287
+
288
+ async runTransaction<T>(callback: () => Promise<T>, funcName: string, config?: DrizzleTransactionConfig) {
289
+ return await runTransaction(callback, funcName, { dsName: this.name, config });
290
+ }
291
+
292
+ registerTransaction<This, Args extends unknown[], Return>(
293
+ func: (this: This, ...args: Args) => Promise<Return>,
294
+ name: string,
295
+ config?: DrizzleTransactionConfig,
296
+ ): (this: This, ...args: Args) => Promise<Return> {
297
+ return registerTransaction(this.name, func, { name }, config);
298
+ }
299
+
300
+ // decorator
301
+ transaction(config?: DrizzleTransactionConfig) {
302
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
303
+ const ds = this;
304
+ return function decorator<This, Args extends unknown[], Return>(
305
+ _target: object,
306
+ propertyKey: string,
307
+ descriptor: TypedPropertyDescriptor<(this: This, ...args: Args) => Promise<Return>>,
308
+ ) {
309
+ if (!descriptor.value) {
310
+ throw new Error.DBOSError('Use of decorator when original method is undefined');
311
+ }
312
+
313
+ descriptor.value = ds.registerTransaction(descriptor.value, propertyKey.toString(), config);
314
+
315
+ return descriptor;
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Create user schema in database (for testing)
321
+ */
322
+ async createSchema() {
323
+ const drizzlePool = new Pool(this.config);
324
+ const db = drizzle(drizzlePool);
325
+ try {
326
+ const res = await pushSchema(this.entities, db);
327
+ await res.apply();
328
+ } finally {
329
+ await drizzlePool.end();
330
+ }
331
+ }
332
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { DrizzleDataSource, DrizzleTransactionConfig, IsolationLevel } from './drizzle_datasource';
@@ -0,0 +1,187 @@
1
+ /* eslint-disable */
2
+ import { DBOS } from '@dbos-inc/dbos-sdk';
3
+ import { DrizzleDataSource } from '../src';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { setUpDBOSTestDb } from './testutils';
6
+ import { pgTable, text } from 'drizzle-orm/pg-core';
7
+ import { eq } from 'drizzle-orm/expressions';
8
+
9
+ const kv = pgTable('kv', {
10
+ id: text('id').primaryKey().default('t'),
11
+ value: text('value').default('v'),
12
+ });
13
+
14
+ const dbPassword: string | undefined = process.env.DB_PASSWORD || process.env.PGPASSWORD;
15
+ if (!dbPassword) {
16
+ throw new Error('DB_PASSWORD or PGPASSWORD environment variable not set');
17
+ }
18
+
19
+ const databaseUrl = `postgresql://postgres:${dbPassword}@localhost:5432/drizzle_testdb?sslmode=disable`;
20
+
21
+ const poolconfig = {
22
+ connectionString: databaseUrl,
23
+ user: 'postgres',
24
+ password: dbPassword,
25
+ database: 'drizzle_testdb',
26
+
27
+ host: 'localhost',
28
+ port: 5432,
29
+ };
30
+
31
+ const drizzleDS = new DrizzleDataSource('app-db', poolconfig, { kv });
32
+
33
+ const dbosConfig = {
34
+ name: 'dbos_drizzle_test',
35
+ databaseUrl: databaseUrl,
36
+ poolConfig: poolconfig,
37
+ system_database: 'drizzle_testdb_dbos_sys',
38
+ telemetry: {
39
+ logs: {
40
+ silent: true,
41
+ },
42
+ },
43
+ };
44
+
45
+ async function txFunctionGuts() {
46
+ expect(DBOS.isInTransaction()).toBe(true);
47
+ expect(DBOS.isWithinWorkflow()).toBe(true);
48
+ const res = await DrizzleDataSource.drizzleClient.execute("SELECT 'Tx2 result' as a");
49
+ return res.rows[0].a as string;
50
+ }
51
+
52
+ const txFunc = drizzleDS.registerTransaction(txFunctionGuts, 'MySecondTx', {});
53
+
54
+ async function wfFunctionGuts() {
55
+ // Transaction variant 2: Let DBOS run a code snippet as a step
56
+ const p1 = await drizzleDS.runTransaction(
57
+ async () => {
58
+ return (await DrizzleDataSource.drizzleClient.execute("SELECT 'My first tx result' as a")).rows[0].a;
59
+ },
60
+ 'MyFirstTx',
61
+ { readOnly: true },
62
+ );
63
+
64
+ // Transaction variant 1: Use a registered DBOS transaction function
65
+ const p2 = await txFunc();
66
+
67
+ return p1 + '|' + p2;
68
+ }
69
+
70
+ // Workflow functions must always be registered before launch; this
71
+ // allows recovery to occur.
72
+ const wfFunction = DBOS.registerWorkflow(wfFunctionGuts, 'workflow');
73
+
74
+ class DBWFI {
75
+ @drizzleDS.transaction({ readOnly: true })
76
+ static async tx(): Promise<string> {
77
+ let res = await DrizzleDataSource.drizzleClient.execute("SELECT 'My decorated tx result' as a");
78
+ return res.rows[0].a as string;
79
+ }
80
+
81
+ @DBOS.workflow()
82
+ static async wf(): Promise<string> {
83
+ return await DBWFI.tx();
84
+ }
85
+ }
86
+
87
+ describe('decoratorless-api-tests', () => {
88
+ beforeAll(() => {
89
+ DBOS.setConfig(dbosConfig);
90
+ });
91
+
92
+ beforeEach(async () => {
93
+ await setUpDBOSTestDb(dbosConfig);
94
+ await drizzleDS.initializeInternalSchema();
95
+ await drizzleDS.createSchema();
96
+ await DBOS.launch();
97
+ });
98
+
99
+ afterEach(async () => {
100
+ await DBOS.shutdown();
101
+ });
102
+
103
+ test('bare-tx-wf-functions', async () => {
104
+ const wfid = randomUUID();
105
+
106
+ await DBOS.withNextWorkflowID(wfid, async () => {
107
+ const res = await wfFunction();
108
+ expect(res).toBe('My first tx result|Tx2 result');
109
+ });
110
+
111
+ const wfsteps = (await DBOS.listWorkflowSteps(wfid))!;
112
+ expect(wfsteps.length).toBe(2);
113
+ expect(wfsteps[0].functionID).toBe(0);
114
+ expect(wfsteps[0].name).toBe('MyFirstTx');
115
+ expect(wfsteps[1].functionID).toBe(1);
116
+ expect(wfsteps[1].name).toBe('MySecondTx');
117
+ });
118
+
119
+ test('decorated-tx-wf-functions', async () => {
120
+ const wfid = randomUUID();
121
+
122
+ await DBOS.withNextWorkflowID(wfid, async () => {
123
+ const res = await DBWFI.wf();
124
+ expect(res).toBe('My decorated tx result');
125
+ });
126
+
127
+ const wfsteps = (await DBOS.listWorkflowSteps(wfid))!;
128
+ expect(wfsteps.length).toBe(1);
129
+ expect(wfsteps[0].functionID).toBe(0);
130
+ expect(wfsteps[0].name).toBe('tx');
131
+ });
132
+ });
133
+
134
+ class KVController {
135
+ @drizzleDS.transaction()
136
+ static async testTxn(id: string, value: string) {
137
+ await drizzleDS.dataSource?.insert(kv).values({ id: id, value: value }).onConflictDoNothing().execute();
138
+
139
+ return id;
140
+ }
141
+
142
+ static async readTxn(id: string): Promise<string> {
143
+ const kvp = await drizzleDS.dataSource?.select().from(kv).where(eq(kv.id, id)).limit(1).execute();
144
+
145
+ return kvp?.[0]?.value ?? '<Not Found>';
146
+ }
147
+
148
+ @DBOS.workflow()
149
+ static async wf(id: string, value: string) {
150
+ return await KVController.testTxn(id, value);
151
+ }
152
+ }
153
+
154
+ const txFunc2 = drizzleDS.registerTransaction(KVController.readTxn, 'explicitRegister', {});
155
+ async function explicitWf(id: string): Promise<string> {
156
+ return await txFunc2(id);
157
+ }
158
+ const wfFunction2 = DBOS.registerWorkflow(explicitWf, 'explicitworkflow');
159
+
160
+ describe('drizzle-tests', () => {
161
+ beforeAll(() => {
162
+ DBOS.setConfig(dbosConfig);
163
+ });
164
+
165
+ beforeEach(async () => {
166
+ await setUpDBOSTestDb(dbosConfig);
167
+ await drizzleDS.initializeInternalSchema();
168
+ await drizzleDS.createSchema();
169
+ await DBOS.launch();
170
+ });
171
+
172
+ afterEach(async () => {
173
+ await DBOS.shutdown();
174
+ });
175
+
176
+ test('simple-drizzle', async () => {
177
+ await KVController.wf('test', 'value');
178
+ let read = await KVController.readTxn('test');
179
+ expect(read).toBe('value');
180
+ });
181
+
182
+ test('drizzle-register', async () => {
183
+ await expect(wfFunction2('test')).resolves.toBe('<Not Found>');
184
+ await KVController.wf('test', 'value');
185
+ await expect(wfFunction2('test')).resolves.toBe('value');
186
+ });
187
+ });
@@ -0,0 +1,30 @@
1
+ import { Client } from 'pg';
2
+ import { DBOSConfig } from '@dbos-inc/dbos-sdk';
3
+
4
+ export async function setUpDBOSTestDb(config: DBOSConfig) {
5
+ const pgSystemClient = new Client({
6
+ user: config.poolConfig?.user,
7
+ port: config.poolConfig?.port,
8
+ host: config.poolConfig?.host,
9
+ password: config.poolConfig?.password,
10
+ database: 'postgres',
11
+ });
12
+
13
+ try {
14
+ await pgSystemClient.connect();
15
+ await pgSystemClient.query(`DROP DATABASE IF EXISTS ${config.poolConfig?.database};`);
16
+ await pgSystemClient.query(`CREATE DATABASE ${config.poolConfig?.database};`);
17
+ await pgSystemClient.query(`DROP DATABASE IF EXISTS ${config.system_database};`);
18
+ await pgSystemClient.end();
19
+ } catch (e) {
20
+ if (e instanceof AggregateError) {
21
+ console.error(`Test database setup failed: AggregateError containing ${e.errors.length} errors:`);
22
+ e.errors.forEach((err, index) => {
23
+ console.error(` Error ${index + 1}:`, err);
24
+ });
25
+ } else {
26
+ console.error(`Test database setup failed:`, e);
27
+ }
28
+ throw e;
29
+ }
30
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.shared.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist"
5
+ },
6
+ "exclude": ["dist", "*.test.ts", "testutils.ts"],
7
+ "include": ["src/"]
8
+ }