@teamkeel/functions-runtime 0.0.1
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/.env.test +2 -0
- package/README.md +3 -0
- package/compose.yaml +10 -0
- package/package.json +33 -0
- package/src/ModelAPI.js +229 -0
- package/src/ModelAPI.test.js +920 -0
- package/src/QueryBuilder.js +95 -0
- package/src/QueryContext.js +90 -0
- package/src/RequestHeaders.js +21 -0
- package/src/applyAdditionalQueryConstraints.js +22 -0
- package/src/applyJoins.js +65 -0
- package/src/applyWhereConditions.js +70 -0
- package/src/casing.js +30 -0
- package/src/casing.test.js +25 -0
- package/src/consts.js +13 -0
- package/src/database.js +163 -0
- package/src/errors.js +116 -0
- package/src/handleJob.js +100 -0
- package/src/handleJob.test.js +271 -0
- package/src/handleRequest.js +124 -0
- package/src/handleRequest.test.js +360 -0
- package/src/index.d.ts +86 -0
- package/src/index.js +27 -0
- package/src/permissions.js +78 -0
- package/src/permissions.test.js +120 -0
- package/src/tracing.js +135 -0
- package/src/tracing.test.js +119 -0
- package/src/tryExecuteFunction.js +74 -0
- package/vite.config.js +7 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const { applyWhereConditions } = require("./applyWhereConditions");
|
|
2
|
+
const {
|
|
3
|
+
applyLimit,
|
|
4
|
+
applyOffset,
|
|
5
|
+
applyOrderBy,
|
|
6
|
+
} = require("./applyAdditionalQueryConstraints");
|
|
7
|
+
const { applyJoins } = require("./applyJoins");
|
|
8
|
+
const { camelCaseObject } = require("./casing");
|
|
9
|
+
const { useDatabase } = require("./database");
|
|
10
|
+
const { QueryContext } = require("./QueryContext");
|
|
11
|
+
const tracing = require("./tracing");
|
|
12
|
+
|
|
13
|
+
class QueryBuilder {
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} tableName
|
|
16
|
+
* @param {import("./QueryContext").QueryContext} context
|
|
17
|
+
* @param {import("kysely").Kysely} db
|
|
18
|
+
*/
|
|
19
|
+
constructor(tableName, context, db) {
|
|
20
|
+
this._tableName = tableName;
|
|
21
|
+
this._context = context;
|
|
22
|
+
this._db = db;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
where(where) {
|
|
26
|
+
const context = this._context.clone();
|
|
27
|
+
|
|
28
|
+
let builder = applyJoins(context, this._db, where);
|
|
29
|
+
builder = applyWhereConditions(context, builder, where);
|
|
30
|
+
|
|
31
|
+
return new QueryBuilder(this._tableName, context, builder);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
orWhere(where) {
|
|
35
|
+
const context = this._context.clone();
|
|
36
|
+
|
|
37
|
+
let builder = applyJoins(context, this._db, where);
|
|
38
|
+
|
|
39
|
+
builder = builder.orWhere((qb) => {
|
|
40
|
+
return applyWhereConditions(context, qb, where);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return new QueryBuilder(this._tableName, context, builder);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async findMany(params) {
|
|
47
|
+
const name = tracing.spanNameForModelAPI(this._modelName, "findMany");
|
|
48
|
+
const db = useDatabase();
|
|
49
|
+
|
|
50
|
+
return tracing.withSpan(name, async (span) => {
|
|
51
|
+
const context = new QueryContext([this._tableName], this._tableConfigMap);
|
|
52
|
+
|
|
53
|
+
let builder = db
|
|
54
|
+
.selectFrom((qb) => {
|
|
55
|
+
// this._db contains all of the where constraints and joins
|
|
56
|
+
// we want to include that in the sub query in the same way we
|
|
57
|
+
// add all of this information into the sub query in the ModelAPI's
|
|
58
|
+
// implementation of findMany
|
|
59
|
+
return this._db.as(this._tableName);
|
|
60
|
+
})
|
|
61
|
+
.selectAll();
|
|
62
|
+
|
|
63
|
+
// The only constraints added to the main query are the orderBy, limit and offset as they are performed on the "outer" set
|
|
64
|
+
if (params?.limit) {
|
|
65
|
+
builder = applyLimit(context, builder, params.limit);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (params?.offset) {
|
|
69
|
+
builder = applyOffset(context, builder, params.offset);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (
|
|
73
|
+
params?.orderBy !== undefined &&
|
|
74
|
+
Object.keys(params?.orderBy).length > 0
|
|
75
|
+
) {
|
|
76
|
+
builder = applyOrderBy(
|
|
77
|
+
context,
|
|
78
|
+
builder,
|
|
79
|
+
this._tableName,
|
|
80
|
+
params.orderBy
|
|
81
|
+
);
|
|
82
|
+
} else {
|
|
83
|
+
builder = builder.orderBy(`${this._tableName}.id`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const query = builder;
|
|
87
|
+
|
|
88
|
+
span.setAttribute("sql", query.compile().sql);
|
|
89
|
+
const rows = await builder.execute();
|
|
90
|
+
return rows.map((x) => camelCaseObject(x));
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports.QueryBuilder = QueryBuilder;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QueryContext is used to store state about the current query, for example
|
|
3
|
+
* which joins have already been applied. It is used by applyJoins and
|
|
4
|
+
* applyWhereConditions to generate consistent table aliases for joins.
|
|
5
|
+
*
|
|
6
|
+
* This class has the concept of a "table path". This is just a list of tables, starting
|
|
7
|
+
* with some "root" table and ending with the table we're currently joining to. So
|
|
8
|
+
* for example if we started with a "product" table and joined from there to "order_item"
|
|
9
|
+
* and then to "order" and then to "customer" the table path would be:
|
|
10
|
+
* ["product", "order_item", "order", "customer"]
|
|
11
|
+
* At this point the "current" table is "customer" and it's alias would be:
|
|
12
|
+
* "product$order_item$order$customer"
|
|
13
|
+
*/
|
|
14
|
+
class QueryContext {
|
|
15
|
+
/**
|
|
16
|
+
* @param {string[]} tablePath This is the path from the "root" table to the "current table".
|
|
17
|
+
* @param {import("./ModelAPI").TableConfigMap} tableConfigMap
|
|
18
|
+
* @param {string[]} joins
|
|
19
|
+
*/
|
|
20
|
+
constructor(tablePath, tableConfigMap, joins = []) {
|
|
21
|
+
this._tablePath = tablePath;
|
|
22
|
+
this._tableConfigMap = tableConfigMap;
|
|
23
|
+
this._joins = joins;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
clone() {
|
|
27
|
+
return new QueryContext([...this._tablePath], this._tableConfigMap, [
|
|
28
|
+
...this._joins,
|
|
29
|
+
]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Returns true if, given the current table path, a join to the given
|
|
34
|
+
* table has already been added.
|
|
35
|
+
* @param {string} table
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
hasJoin(table) {
|
|
39
|
+
const alias = joinAlias([...this._tablePath, table]);
|
|
40
|
+
return this._joins.includes(alias);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Adds table to the QueryContext's path and registers the join,
|
|
45
|
+
* calls fn, then pops the table off the path.
|
|
46
|
+
* @param {string} table
|
|
47
|
+
* @param {Function} fn
|
|
48
|
+
*/
|
|
49
|
+
withJoin(table, fn) {
|
|
50
|
+
this._tablePath.push(table);
|
|
51
|
+
this._joins.push(this.tableAlias());
|
|
52
|
+
|
|
53
|
+
fn();
|
|
54
|
+
|
|
55
|
+
// Don't change the _joins list, we want to remember those
|
|
56
|
+
this._tablePath.pop();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns the alias that will be used for the current table
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
tableAlias() {
|
|
64
|
+
return joinAlias(this._tablePath);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Returns the current table name
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
tableName() {
|
|
72
|
+
return this._tablePath[this._tablePath.length - 1];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Return the TableConfig for the current table
|
|
77
|
+
* @returns {import("./ModelAPI").TableConfig | undefined}
|
|
78
|
+
*/
|
|
79
|
+
tableConfig() {
|
|
80
|
+
return this._tableConfigMap[this.tableName()];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function joinAlias(tablePath) {
|
|
85
|
+
return tablePath.join("$");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = {
|
|
89
|
+
QueryContext,
|
|
90
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class RequestHeaders {
|
|
2
|
+
/**
|
|
3
|
+
* @param {{Object.<string, string>}} requestHeaders Map of request headers submitted from the client
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
constructor(requestHeaders) {
|
|
7
|
+
this._headers = new Headers(requestHeaders);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
get(key) {
|
|
11
|
+
return this._headers.get(key);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
has(key) {
|
|
15
|
+
return this._headers.has(key);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = {
|
|
20
|
+
RequestHeaders,
|
|
21
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const { snakeCase } = require("change-case");
|
|
2
|
+
|
|
3
|
+
function applyLimit(context, qb, limit) {
|
|
4
|
+
return qb.limit(limit);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function applyOffset(context, qb, offset) {
|
|
8
|
+
return qb.offset(offset);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function applyOrderBy(context, qb, tableName, orderBy = {}) {
|
|
12
|
+
Object.entries(orderBy).forEach(([key, sortOrder]) => {
|
|
13
|
+
qb = qb.orderBy(`${tableName}.${snakeCase(key)}`, sortOrder);
|
|
14
|
+
});
|
|
15
|
+
return qb;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = {
|
|
19
|
+
applyLimit,
|
|
20
|
+
applyOffset,
|
|
21
|
+
applyOrderBy,
|
|
22
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adds the joins required by the where conditions to the given
|
|
3
|
+
* Kysely instance and returns the resulting new Kysely instance.
|
|
4
|
+
* @param {import("./QueryContext").QueryContext} context
|
|
5
|
+
* @param {import("kysely").Kysely} qb
|
|
6
|
+
* @param {Object} where
|
|
7
|
+
* @returns {import("kysely").Kysely}
|
|
8
|
+
*/
|
|
9
|
+
function applyJoins(context, qb, where) {
|
|
10
|
+
const conf = context.tableConfig();
|
|
11
|
+
if (!conf) {
|
|
12
|
+
return qb;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const srcTable = context.tableAlias();
|
|
16
|
+
|
|
17
|
+
for (const key of Object.keys(where)) {
|
|
18
|
+
const rel = conf[key];
|
|
19
|
+
if (!rel) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const targetTable = rel.referencesTable;
|
|
24
|
+
|
|
25
|
+
if (context.hasJoin(targetTable)) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
context.withJoin(targetTable, () => {
|
|
30
|
+
switch (rel.relationshipType) {
|
|
31
|
+
case "hasMany":
|
|
32
|
+
// For hasMany the primary key is on the source table
|
|
33
|
+
// and the foreign key is on the target table
|
|
34
|
+
qb = qb.innerJoin(
|
|
35
|
+
`${targetTable} as ${context.tableAlias()}`,
|
|
36
|
+
`${srcTable}.id`,
|
|
37
|
+
`${context.tableAlias()}.${rel.foreignKey}`
|
|
38
|
+
);
|
|
39
|
+
break;
|
|
40
|
+
|
|
41
|
+
case "belongsTo":
|
|
42
|
+
// For belongsTo the primary key is on the target table
|
|
43
|
+
// and the foreign key is on the source table
|
|
44
|
+
qb = qb.innerJoin(
|
|
45
|
+
`${targetTable} as ${context.tableAlias()}`,
|
|
46
|
+
`${srcTable}.${rel.foreignKey}`,
|
|
47
|
+
`${context.tableAlias()}.id`
|
|
48
|
+
);
|
|
49
|
+
break;
|
|
50
|
+
default:
|
|
51
|
+
throw new Error(`unknown relationshipType: ${rel.relationshipType}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Keep traversing through the where conditions to see if
|
|
55
|
+
// more joins need to be applied
|
|
56
|
+
qb = applyJoins(context, qb, where[key]);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return qb;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = {
|
|
64
|
+
applyJoins,
|
|
65
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const { sql } = require("kysely");
|
|
2
|
+
const { snakeCase } = require("./casing");
|
|
3
|
+
|
|
4
|
+
const opMapping = {
|
|
5
|
+
startsWith: { op: "like", value: (v) => `${v}%` },
|
|
6
|
+
endsWith: { op: "like", value: (v) => `%${v}` },
|
|
7
|
+
contains: { op: "like", value: (v) => `%${v}%` },
|
|
8
|
+
oneOf: { op: "in" },
|
|
9
|
+
greaterThan: { op: ">" },
|
|
10
|
+
greaterThanOrEquals: { op: ">=" },
|
|
11
|
+
lessThan: { op: "<" },
|
|
12
|
+
lessThanOrEquals: { op: "<=" },
|
|
13
|
+
before: { op: "<" },
|
|
14
|
+
onOrBefore: { op: "<=" },
|
|
15
|
+
after: { op: ">" },
|
|
16
|
+
onOrAfter: { op: ">=" },
|
|
17
|
+
equals: { op: sql`is not distinct from` },
|
|
18
|
+
notEquals: { op: sql`is distinct from` },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Applies the given where conditions to the provided Kysely
|
|
23
|
+
* instance and returns the resulting new Kysely instance.
|
|
24
|
+
* @param {import("./QueryContext").QueryContext} context
|
|
25
|
+
* @param {import("kysely").Kysely} qb
|
|
26
|
+
* @param {Object} where
|
|
27
|
+
* @returns {import("kysely").Kysely}
|
|
28
|
+
*/
|
|
29
|
+
function applyWhereConditions(context, qb, where = {}) {
|
|
30
|
+
const conf = context.tableConfig();
|
|
31
|
+
|
|
32
|
+
for (const key of Object.keys(where)) {
|
|
33
|
+
const v = where[key];
|
|
34
|
+
|
|
35
|
+
// Handle nested where conditions e.g. using a join table
|
|
36
|
+
if (conf && conf[key]) {
|
|
37
|
+
const rel = conf[key];
|
|
38
|
+
context.withJoin(rel.referencesTable, () => {
|
|
39
|
+
qb = applyWhereConditions(context, qb, v);
|
|
40
|
+
});
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const fieldName = `${context.tableAlias()}.${snakeCase(key)}`;
|
|
45
|
+
|
|
46
|
+
if (Object.prototype.toString.call(v) !== "[object Object]") {
|
|
47
|
+
qb = qb.where(fieldName, "=", v);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const op of Object.keys(v)) {
|
|
52
|
+
const mapping = opMapping[op];
|
|
53
|
+
if (!mapping) {
|
|
54
|
+
throw new Error(`invalid where condition: ${op}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
qb = qb.where(
|
|
58
|
+
fieldName,
|
|
59
|
+
mapping.op,
|
|
60
|
+
mapping.value ? mapping.value(v[op]) : v[op]
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return qb;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
applyWhereConditions,
|
|
70
|
+
};
|
package/src/casing.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const { snakeCase, camelCase } = require("change-case");
|
|
2
|
+
|
|
3
|
+
function camelCaseObject(obj = {}) {
|
|
4
|
+
const r = {};
|
|
5
|
+
for (const key of Object.keys(obj)) {
|
|
6
|
+
r[camelCase(key)] = obj[key];
|
|
7
|
+
}
|
|
8
|
+
return r;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function snakeCaseObject(obj) {
|
|
12
|
+
const r = {};
|
|
13
|
+
for (const key of Object.keys(obj)) {
|
|
14
|
+
r[snakeCase(key)] = obj[key];
|
|
15
|
+
}
|
|
16
|
+
return r;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function upperCamelCase(s) {
|
|
20
|
+
s = camelCase(s);
|
|
21
|
+
return s[0].toUpperCase() + s.substring(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = {
|
|
25
|
+
camelCaseObject,
|
|
26
|
+
snakeCaseObject,
|
|
27
|
+
snakeCase,
|
|
28
|
+
camelCase,
|
|
29
|
+
upperCamelCase,
|
|
30
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { test, expect } from "vitest";
|
|
2
|
+
import { camelCaseObject } from "./casing";
|
|
3
|
+
|
|
4
|
+
const cases = {
|
|
5
|
+
FROM_SNAKE: {
|
|
6
|
+
input: {
|
|
7
|
+
id: "123",
|
|
8
|
+
slack_id: "xxx_2929",
|
|
9
|
+
api_key: "1234",
|
|
10
|
+
},
|
|
11
|
+
expected: {
|
|
12
|
+
id: "123",
|
|
13
|
+
slackId: "xxx_2929",
|
|
14
|
+
apiKey: "1234",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
Object.entries(cases).map(([key, { input, expected }]) => {
|
|
20
|
+
test(key, () => {
|
|
21
|
+
const result = camelCaseObject(input);
|
|
22
|
+
|
|
23
|
+
expect(result).toEqual(expected);
|
|
24
|
+
});
|
|
25
|
+
});
|
package/src/consts.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const PROTO_ACTION_TYPES = {
|
|
2
|
+
UNKNOWN: "OPERATION_TYPE_UNKNOWN",
|
|
3
|
+
CREATE: "OPERATION_TYPE_CREATE",
|
|
4
|
+
GET: "OPERATION_TYPE_GET",
|
|
5
|
+
LIST: "OPERATION_TYPE_LIST",
|
|
6
|
+
UPDATE: "OPERATION_TYPE_UPDATE",
|
|
7
|
+
DELETE: "OPERATION_TYPE_DELETE",
|
|
8
|
+
READ: "OPERATION_TYPE_READ",
|
|
9
|
+
WRITE: "OPERATION_TYPE_WRITE",
|
|
10
|
+
JOB: "JOB_TYPE",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
module.exports.PROTO_ACTION_TYPES = PROTO_ACTION_TYPES;
|
package/src/database.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
const { Kysely, PostgresDialect, CamelCasePlugin } = require("kysely");
|
|
2
|
+
const { AsyncLocalStorage } = require("async_hooks");
|
|
3
|
+
const pg = require("pg");
|
|
4
|
+
const { PROTO_ACTION_TYPES } = require("./consts");
|
|
5
|
+
const { withSpan } = require("./tracing");
|
|
6
|
+
|
|
7
|
+
// withDatabase is responsible for setting the correct database client in our AsyncLocalStorage
|
|
8
|
+
// so that the the code in a custom function uses the correct client.
|
|
9
|
+
// For GET and LIST action types, no transaction is used, but for
|
|
10
|
+
// actions that mutate data such as CREATE, DELETE & UPDATE, all of the code inside
|
|
11
|
+
// the user's custom function is wrapped in a transaction so we can rollback
|
|
12
|
+
// the transaction if something goes wrong.
|
|
13
|
+
// withDatabase shouldn't be exposed in the public api of the sdk
|
|
14
|
+
async function withDatabase(db, actionType, cb) {
|
|
15
|
+
let requiresTransaction = true;
|
|
16
|
+
|
|
17
|
+
switch (actionType) {
|
|
18
|
+
case PROTO_ACTION_TYPES.JOB:
|
|
19
|
+
case PROTO_ACTION_TYPES.GET:
|
|
20
|
+
case PROTO_ACTION_TYPES.LIST:
|
|
21
|
+
requiresTransaction = false;
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (requiresTransaction) {
|
|
26
|
+
return db.transaction().execute(async (transaction) => {
|
|
27
|
+
return dbInstance.run(transaction, async () => {
|
|
28
|
+
return cb({ transaction });
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return dbInstance.run(db, async () => {
|
|
34
|
+
return cb({ transaction: db });
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let db = null;
|
|
39
|
+
|
|
40
|
+
const dbInstance = new AsyncLocalStorage();
|
|
41
|
+
|
|
42
|
+
// useDatabase will retrieve the database client set by withDatabase from the local storage
|
|
43
|
+
function useDatabase() {
|
|
44
|
+
// retrieve the instance of the database client from the store which is aware of
|
|
45
|
+
// which context the current connection to the db is running in - e.g does the context
|
|
46
|
+
// require a transaction or not?
|
|
47
|
+
let fromStore = dbInstance.getStore();
|
|
48
|
+
if (fromStore) {
|
|
49
|
+
return fromStore;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// if the NODE_ENV is 'test' then we know we are inside of the vitest environment
|
|
53
|
+
// which covers any test files ending in *.test.ts. Custom function code runs in a different node process which will not have this environment variable. Tests written using our testing
|
|
54
|
+
// framework call actions (and in turn custom function code) over http using the ActionExecutor class
|
|
55
|
+
if ("NODE_ENV" in process.env && process.env.NODE_ENV == "test") {
|
|
56
|
+
return getDatabaseClient();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// If we've gotten to this point, then we know that we are in a custom function runtime server
|
|
60
|
+
// context and we haven't been able to retrieve the in-context instance of Kysely, which means we should throw an error.
|
|
61
|
+
throw new Error("useDatabase must be called within a function");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// getDatabaseClient will return a brand new instance of Kysely. Every instance of Kysely
|
|
65
|
+
// represents an individual connection to the database.
|
|
66
|
+
// not to be exported externally from our sdk - consumers should use useDatabase
|
|
67
|
+
function getDatabaseClient() {
|
|
68
|
+
// 'db' represents the singleton connection to the database which is stored
|
|
69
|
+
// as a module scope variable.
|
|
70
|
+
if (db) {
|
|
71
|
+
return db;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
db = new Kysely({
|
|
75
|
+
dialect: getDialect(),
|
|
76
|
+
plugins: [
|
|
77
|
+
// allows users to query using camelCased versions of the database column names, which
|
|
78
|
+
// should match the names we use in our schema.
|
|
79
|
+
// https://kysely-org.github.io/kysely/classes/CamelCasePlugin.html
|
|
80
|
+
// If they don't, then we can create a custom implementation of the plugin where we control
|
|
81
|
+
// the casing behaviour (see url above for example)
|
|
82
|
+
new CamelCasePlugin(),
|
|
83
|
+
],
|
|
84
|
+
log(event) {
|
|
85
|
+
if ("DEBUG" in process.env) {
|
|
86
|
+
if (event.level === "query") {
|
|
87
|
+
console.log(event.query.sql);
|
|
88
|
+
console.log(event.query.parameters);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return db;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
class InstrumentedPool extends pg.Pool {
|
|
98
|
+
async connect(...args) {
|
|
99
|
+
const _super = super.connect.bind(this);
|
|
100
|
+
return withSpan("Database Connect", function () {
|
|
101
|
+
return _super(...args);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const txStatements = {
|
|
107
|
+
begin: "Transaction Begin",
|
|
108
|
+
commit: "Transaction Commit",
|
|
109
|
+
rollback: "Transaction Rollback",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
class InstrumentedClient extends pg.Client {
|
|
113
|
+
async query(...args) {
|
|
114
|
+
const _super = super.query.bind(this);
|
|
115
|
+
const sql = args[0];
|
|
116
|
+
|
|
117
|
+
let sqlAttribute = false;
|
|
118
|
+
|
|
119
|
+
let spanName = txStatements[sql.toLowerCase()];
|
|
120
|
+
if (!spanName) {
|
|
121
|
+
spanName = "Database Query";
|
|
122
|
+
sqlAttribute = true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return withSpan(spanName, function (span) {
|
|
126
|
+
if (sqlAttribute) {
|
|
127
|
+
span.setAttribute("sql", args[0]);
|
|
128
|
+
}
|
|
129
|
+
return _super(...args);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getDialect() {
|
|
135
|
+
const dbConnType = process.env["KEEL_DB_CONN_TYPE"];
|
|
136
|
+
switch (dbConnType) {
|
|
137
|
+
case "pg":
|
|
138
|
+
return new PostgresDialect({
|
|
139
|
+
pool: new InstrumentedPool({
|
|
140
|
+
Client: InstrumentedClient,
|
|
141
|
+
connectionString: mustEnv("KEEL_DB_CONN"),
|
|
142
|
+
}),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
default:
|
|
146
|
+
throw Error("unexpected KEEL_DB_CONN_TYPE: " + dbConnType);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function mustEnv(key) {
|
|
151
|
+
const v = process.env[key];
|
|
152
|
+
if (!v) {
|
|
153
|
+
throw new Error(`expected environment variable ${key} to be set`);
|
|
154
|
+
}
|
|
155
|
+
return v;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// initialise the database client at module scope level so the db variable is set
|
|
159
|
+
getDatabaseClient();
|
|
160
|
+
|
|
161
|
+
module.exports.getDatabaseClient = getDatabaseClient;
|
|
162
|
+
module.exports.useDatabase = useDatabase;
|
|
163
|
+
module.exports.withDatabase = withDatabase;
|