@teamkeel/functions-runtime 0.412.0-next.2 → 0.412.0
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/compose.yaml +10 -0
- package/package.json +5 -23
- package/src/Duration.js +40 -0
- package/src/Duration.test.js +34 -0
- package/src/File.js +295 -0
- package/src/ModelAPI.js +377 -0
- package/src/ModelAPI.test.js +1428 -0
- package/src/QueryBuilder.js +184 -0
- package/src/QueryContext.js +90 -0
- package/src/RequestHeaders.js +21 -0
- package/src/TimePeriod.js +89 -0
- package/src/TimePeriod.test.js +148 -0
- package/src/applyAdditionalQueryConstraints.js +22 -0
- package/src/applyJoins.js +67 -0
- package/src/applyWhereConditions.js +124 -0
- package/src/auditing.js +110 -0
- package/src/auditing.test.js +330 -0
- package/src/camelCasePlugin.js +52 -0
- package/src/casing.js +54 -0
- package/src/casing.test.js +56 -0
- package/src/consts.js +14 -0
- package/src/database.js +244 -0
- package/src/errors.js +160 -0
- package/src/handleJob.js +110 -0
- package/src/handleJob.test.js +270 -0
- package/src/handleRequest.js +153 -0
- package/src/handleRequest.test.js +463 -0
- package/src/handleRoute.js +112 -0
- package/src/handleSubscriber.js +105 -0
- package/src/index.d.ts +317 -0
- package/src/index.js +38 -0
- package/src/parsing.js +113 -0
- package/src/parsing.test.js +140 -0
- package/src/permissions.js +77 -0
- package/src/permissions.test.js +118 -0
- package/src/tracing.js +184 -0
- package/src/tracing.test.js +147 -0
- package/src/tryExecuteFunction.js +91 -0
- package/src/tryExecuteJob.js +29 -0
- package/src/tryExecuteSubscriber.js +17 -0
- package/src/type-utils.js +18 -0
- package/vite.config.js +7 -0
- package/dist/index.d.mts +0 -340
- package/dist/index.d.ts +0 -340
- package/dist/index.js +0 -3093
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -3097
- package/dist/index.mjs.map +0 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { test, expect } from "vitest";
|
|
2
|
+
import { camelCaseObject, snakeCaseObject } from "./casing";
|
|
3
|
+
|
|
4
|
+
const cases = {
|
|
5
|
+
FROM_SNAKE: {
|
|
6
|
+
input: {
|
|
7
|
+
id: "123",
|
|
8
|
+
slack_id: "xxx_2929",
|
|
9
|
+
api_key: "1234",
|
|
10
|
+
test_11: "1234",
|
|
11
|
+
test_1_test: "1234",
|
|
12
|
+
test12: "1234",
|
|
13
|
+
abc123_test: "1234",
|
|
14
|
+
},
|
|
15
|
+
expected: {
|
|
16
|
+
id: "123",
|
|
17
|
+
slackId: "xxx_2929",
|
|
18
|
+
apiKey: "1234",
|
|
19
|
+
test11: "1234",
|
|
20
|
+
test1Test: "1234",
|
|
21
|
+
test12: "1234",
|
|
22
|
+
abc123Test: "1234",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
FROM_CAMEL: {
|
|
26
|
+
input: {
|
|
27
|
+
id: "123",
|
|
28
|
+
slackId: "xxx_2929",
|
|
29
|
+
apiKey: "1234",
|
|
30
|
+
test1: "test",
|
|
31
|
+
testA1: "test",
|
|
32
|
+
test1Test: "test",
|
|
33
|
+
test20: "test",
|
|
34
|
+
testURL: "test",
|
|
35
|
+
},
|
|
36
|
+
expected: {
|
|
37
|
+
id: "123",
|
|
38
|
+
slack_id: "xxx_2929",
|
|
39
|
+
api_key: "1234",
|
|
40
|
+
test_1: "test",
|
|
41
|
+
test_a_1: "test",
|
|
42
|
+
test_1_test: "test",
|
|
43
|
+
test_20: "test",
|
|
44
|
+
test_url: "test",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
Object.entries(cases).map(([key, { input, expected }]) => {
|
|
50
|
+
test(key, () => {
|
|
51
|
+
const result =
|
|
52
|
+
key === "FROM_SNAKE" ? camelCaseObject(input) : snakeCaseObject(input);
|
|
53
|
+
|
|
54
|
+
expect(result).toEqual(expected);
|
|
55
|
+
});
|
|
56
|
+
});
|
package/src/consts.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const PROTO_ACTION_TYPES = {
|
|
2
|
+
UNKNOWN: "ACTION_TYPE_UNKNOWN",
|
|
3
|
+
CREATE: "ACTION_TYPE_CREATE",
|
|
4
|
+
GET: "ACTION_TYPE_GET",
|
|
5
|
+
LIST: "ACTION_TYPE_LIST",
|
|
6
|
+
UPDATE: "ACTION_TYPE_UPDATE",
|
|
7
|
+
DELETE: "ACTION_TYPE_DELETE",
|
|
8
|
+
READ: "ACTION_TYPE_READ",
|
|
9
|
+
WRITE: "ACTION_TYPE_WRITE",
|
|
10
|
+
JOB: "JOB_TYPE",
|
|
11
|
+
SUBSCRIBER: "SUBSCRIBER_TYPE",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
module.exports.PROTO_ACTION_TYPES = PROTO_ACTION_TYPES;
|
package/src/database.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
const { Kysely, PostgresDialect } = require("kysely");
|
|
2
|
+
const neonserverless = require("@neondatabase/serverless");
|
|
3
|
+
const { AsyncLocalStorage } = require("async_hooks");
|
|
4
|
+
const { AuditContextPlugin } = require("./auditing");
|
|
5
|
+
const { KeelCamelCasePlugin } = require("./camelCasePlugin");
|
|
6
|
+
const pg = require("pg");
|
|
7
|
+
const { withSpan } = require("./tracing");
|
|
8
|
+
const ws = require("ws");
|
|
9
|
+
const fs = require("node:fs");
|
|
10
|
+
const { Duration } = require("./Duration");
|
|
11
|
+
|
|
12
|
+
// withDatabase is responsible for setting the correct database client in our AsyncLocalStorage
|
|
13
|
+
// so that the the code in a custom function uses the correct client.
|
|
14
|
+
// For GET and LIST action types, no transaction is used, but for
|
|
15
|
+
// actions that mutate data such as CREATE, DELETE & UPDATE, all of the code inside
|
|
16
|
+
// the user's custom function is wrapped in a transaction so we can rollback
|
|
17
|
+
// the transaction if something goes wrong.
|
|
18
|
+
// withDatabase shouldn't be exposed in the public api of the sdk
|
|
19
|
+
async function withDatabase(db, requiresTransaction, cb) {
|
|
20
|
+
// db.transaction() provides a kysely instance bound to a transaction.
|
|
21
|
+
if (requiresTransaction) {
|
|
22
|
+
return db.transaction().execute(async (transaction) => {
|
|
23
|
+
return dbInstance.run(transaction, async () => {
|
|
24
|
+
return cb({ transaction });
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// db.connection() provides a kysely instance bound to a single database connection.
|
|
30
|
+
return db.connection().execute(async (sDb) => {
|
|
31
|
+
return dbInstance.run(sDb, async () => {
|
|
32
|
+
return cb({ sDb });
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const dbInstance = new AsyncLocalStorage();
|
|
38
|
+
|
|
39
|
+
// used to establish a singleton for our vitest environment
|
|
40
|
+
let vitestDb = null;
|
|
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
|
+
if (!vitestDb) {
|
|
57
|
+
vitestDb = createDatabaseClient();
|
|
58
|
+
}
|
|
59
|
+
return vitestDb;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// If we've gotten to this point, then we know that we are in a custom function runtime server
|
|
63
|
+
// context and we haven't been able to retrieve the in-context instance of Kysely, which means we should throw an error.
|
|
64
|
+
console.trace();
|
|
65
|
+
throw new Error("useDatabase must be called within a function");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// createDatabaseClient will return a brand new instance of Kysely. Every instance of Kysely
|
|
69
|
+
// represents an individual connection to the database.
|
|
70
|
+
// not to be exported externally from our sdk - consumers should use useDatabase
|
|
71
|
+
function createDatabaseClient({ connString } = {}) {
|
|
72
|
+
const db = new Kysely({
|
|
73
|
+
dialect: getDialect(connString),
|
|
74
|
+
plugins: [
|
|
75
|
+
// ensures that the audit context data is written to Postgres configuration parameters
|
|
76
|
+
new AuditContextPlugin(),
|
|
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
|
+
// We're using an extended version of Kysely's CamelCasePlugin which avoids changing keys of objects that represent
|
|
80
|
+
// rich data formats, specific to Keel (e.g. Duration)
|
|
81
|
+
new KeelCamelCasePlugin(),
|
|
82
|
+
],
|
|
83
|
+
log(event) {
|
|
84
|
+
if ("DEBUG" in process.env) {
|
|
85
|
+
if (event.level === "query") {
|
|
86
|
+
console.log(event.query.sql);
|
|
87
|
+
console.log(event.query.parameters);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return db;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
class InstrumentedPool extends pg.Pool {
|
|
97
|
+
async connect(...args) {
|
|
98
|
+
const _super = super.connect.bind(this);
|
|
99
|
+
return withSpan("Database Connect", function (span) {
|
|
100
|
+
span.setAttribute("dialect", process.env["KEEL_DB_CONN_TYPE"]);
|
|
101
|
+
return _super(...args);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
class InstrumentedNeonServerlessPool extends neonserverless.Pool {
|
|
107
|
+
async connect(...args) {
|
|
108
|
+
const _super = super.connect.bind(this);
|
|
109
|
+
return withSpan("Database Connect", function (span) {
|
|
110
|
+
span.setAttribute("dialect", process.env["KEEL_DB_CONN_TYPE"]);
|
|
111
|
+
return _super(...args);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const txStatements = {
|
|
117
|
+
begin: "Transaction Begin",
|
|
118
|
+
commit: "Transaction Commit",
|
|
119
|
+
rollback: "Transaction Rollback",
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
class InstrumentedClient extends pg.Client {
|
|
123
|
+
async query(...args) {
|
|
124
|
+
const _super = super.query.bind(this);
|
|
125
|
+
const sql = args[0];
|
|
126
|
+
|
|
127
|
+
let sqlAttribute = false;
|
|
128
|
+
let spanName = txStatements[sql.toLowerCase()];
|
|
129
|
+
if (!spanName) {
|
|
130
|
+
spanName = "Database Query";
|
|
131
|
+
sqlAttribute = true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return withSpan(spanName, function (span) {
|
|
135
|
+
if (sqlAttribute) {
|
|
136
|
+
span.setAttribute("sql", args[0]);
|
|
137
|
+
span.setAttribute("dialect", process.env["KEEL_DB_CONN_TYPE"]);
|
|
138
|
+
}
|
|
139
|
+
return _super(...args);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function getDialect(connString) {
|
|
145
|
+
const dbConnType = process.env.KEEL_DB_CONN_TYPE;
|
|
146
|
+
switch (dbConnType) {
|
|
147
|
+
case "pg":
|
|
148
|
+
// Adding a custom type parser for numeric fields: see https://kysely.dev/docs/recipes/data-types#configuring-runtime-javascript-types
|
|
149
|
+
// 1700 = type for NUMERIC
|
|
150
|
+
pg.types.setTypeParser(pg.types.builtins.NUMERIC, function (val) {
|
|
151
|
+
return parseFloat(val);
|
|
152
|
+
});
|
|
153
|
+
// Adding a custom type parser for interval fields: see https://kysely.dev/docs/recipes/data-types#configuring-runtime-javascript-types
|
|
154
|
+
// 1186 = type for INTERVAL
|
|
155
|
+
pg.types.setTypeParser(pg.types.builtins.INTERVAL, function (val) {
|
|
156
|
+
return new Duration(val);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return new PostgresDialect({
|
|
160
|
+
pool: new InstrumentedPool({
|
|
161
|
+
Client: InstrumentedClient,
|
|
162
|
+
// Increased idle time before closing a connection in the local pool (from 10s default).
|
|
163
|
+
// Establising a new connection on (almost) every functions query can be expensive, so this
|
|
164
|
+
// will reduce having to open connections as regularly. https://node-postgres.com/apis/pool
|
|
165
|
+
//
|
|
166
|
+
// NOTE: We should consider setting this to 0 (i.e. never pool locally) and open and close
|
|
167
|
+
// connections with each invocation. This is because the freeze/thaw nature of lambdas can cause problems
|
|
168
|
+
// with long-lived connections - see https://github.com/brianc/node-postgres/issues/2718
|
|
169
|
+
// Once we're "fully regional" this should not be a performance problem anymore.
|
|
170
|
+
//
|
|
171
|
+
// Although I doubt we will run into these freeze/thaw issues if idleTimeoutMillis is always shorter than the
|
|
172
|
+
// time is takes for a lambda to freeze (which is not a constant, but could be as short as several minutes,
|
|
173
|
+
// https://www.pluralsight.com/resources/blog/cloud/how-long-does-aws-lambda-keep-your-idle-functions-around-before-a-cold-start)
|
|
174
|
+
idleTimeoutMillis: 50000,
|
|
175
|
+
|
|
176
|
+
// If connString is not passed fall back to reading from env var
|
|
177
|
+
connectionString: connString || process.env.KEEL_DB_CONN,
|
|
178
|
+
|
|
179
|
+
// Allow the setting of a cert (.pem) file. RDS requires this to enforce SSL.
|
|
180
|
+
...(process.env.KEEL_DB_CERT
|
|
181
|
+
? { ssl: { ca: fs.readFileSync(process.env.KEEL_DB_CERT) } }
|
|
182
|
+
: undefined),
|
|
183
|
+
}),
|
|
184
|
+
});
|
|
185
|
+
case "neon":
|
|
186
|
+
// Adding a custom type parser for numeric fields: see https://kysely.dev/docs/recipes/data-types#configuring-runtime-javascript-types
|
|
187
|
+
// 1700 = type for NUMERIC
|
|
188
|
+
neonserverless.types.setTypeParser(
|
|
189
|
+
pg.types.builtins.NUMERIC,
|
|
190
|
+
function (val) {
|
|
191
|
+
return parseFloat(val);
|
|
192
|
+
}
|
|
193
|
+
);
|
|
194
|
+
// Adding a custom type parser for interval fields: see https://kysely.dev/docs/recipes/data-types#configuring-runtime-javascript-types
|
|
195
|
+
// 1186 = type for INTERVAL
|
|
196
|
+
neonserverless.types.setTypeParser(
|
|
197
|
+
pg.types.builtins.INTERVAL,
|
|
198
|
+
function (val) {
|
|
199
|
+
return new Duration(val);
|
|
200
|
+
}
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
neonserverless.neonConfig.webSocketConstructor = ws;
|
|
204
|
+
|
|
205
|
+
const pool = new InstrumentedNeonServerlessPool({
|
|
206
|
+
// If connString is not passed fall back to reading from env var
|
|
207
|
+
connectionString: connString || process.env.KEEL_DB_CONN,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
pool.on("connect", (client) => {
|
|
211
|
+
const originalQuery = client.query;
|
|
212
|
+
client.query = function (...args) {
|
|
213
|
+
const sql = args[0];
|
|
214
|
+
|
|
215
|
+
let sqlAttribute = false;
|
|
216
|
+
let spanName = txStatements[sql.toLowerCase()];
|
|
217
|
+
if (!spanName) {
|
|
218
|
+
spanName = "Database Query";
|
|
219
|
+
sqlAttribute = true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return withSpan(spanName, function (span) {
|
|
223
|
+
if (sqlAttribute) {
|
|
224
|
+
span.setAttribute("sql", args[0]);
|
|
225
|
+
span.setAttribute("dialect", dbConnType);
|
|
226
|
+
}
|
|
227
|
+
return originalQuery.apply(client, args);
|
|
228
|
+
});
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return new PostgresDialect({
|
|
233
|
+
pool: pool,
|
|
234
|
+
});
|
|
235
|
+
default:
|
|
236
|
+
throw Error("unexpected KEEL_DB_CONN_TYPE: " + dbConnType);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
module.exports = {
|
|
241
|
+
createDatabaseClient,
|
|
242
|
+
useDatabase,
|
|
243
|
+
withDatabase,
|
|
244
|
+
};
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
const { createJSONRPCErrorResponse } = require("json-rpc-2.0");
|
|
2
|
+
|
|
3
|
+
const RuntimeErrors = {
|
|
4
|
+
// Catchall error type for unhandled execution errors during custom function
|
|
5
|
+
UnknownError: -32001,
|
|
6
|
+
// DatabaseError represents any error at pg level that isn't handled explicitly below
|
|
7
|
+
DatabaseError: -32002,
|
|
8
|
+
// No result returned from custom function by user
|
|
9
|
+
NoResultError: -32003,
|
|
10
|
+
// When trying to delete/update a non existent record in the db
|
|
11
|
+
RecordNotFoundError: -32004,
|
|
12
|
+
ForeignKeyConstraintError: -32005,
|
|
13
|
+
NotNullConstraintError: -32006,
|
|
14
|
+
UniqueConstraintError: -32007,
|
|
15
|
+
PermissionError: -32008,
|
|
16
|
+
BadRequestError: -32009,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Error presets
|
|
20
|
+
class PermissionError extends Error {}
|
|
21
|
+
|
|
22
|
+
class DatabaseError extends Error {
|
|
23
|
+
constructor(error) {
|
|
24
|
+
super(error.message);
|
|
25
|
+
this.error = error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class NotFoundError extends Error {
|
|
30
|
+
errorCode = RuntimeErrors.RecordNotFoundError;
|
|
31
|
+
constructor(message) {
|
|
32
|
+
super(message); // Default message is handled by the runtime for consistency with built in actions
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class BadRequestError extends Error {
|
|
37
|
+
errorCode = RuntimeErrors.BadRequestError;
|
|
38
|
+
constructor(message = "bad request") {
|
|
39
|
+
super(message);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class UnknownError extends Error {
|
|
44
|
+
errorCode = RuntimeErrors.UnknownError;
|
|
45
|
+
constructor(message = "unknown error") {
|
|
46
|
+
super(message);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ErrorPresets = {
|
|
51
|
+
NotFound: NotFoundError,
|
|
52
|
+
BadRequest: BadRequestError,
|
|
53
|
+
Unknown: UnknownError,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// errorToJSONRPCResponse transforms a JavaScript Error instance (or derivative) into a valid JSONRPC response object to pass back to the Keel runtime.
|
|
57
|
+
function errorToJSONRPCResponse(request, e) {
|
|
58
|
+
switch (e.constructor.name) {
|
|
59
|
+
case "PermissionError":
|
|
60
|
+
return createJSONRPCErrorResponse(
|
|
61
|
+
request.id,
|
|
62
|
+
RuntimeErrors.PermissionError,
|
|
63
|
+
e.message
|
|
64
|
+
);
|
|
65
|
+
// Any error thrown in the ModelAPI class is
|
|
66
|
+
// wrapped in a DatabaseError in order to differentiate 'our code' vs the user's own code.
|
|
67
|
+
case "NoResultError":
|
|
68
|
+
return createJSONRPCErrorResponse(
|
|
69
|
+
request.id,
|
|
70
|
+
|
|
71
|
+
// to be matched to https://github.com/teamkeel/keel/blob/e3115ffe381bfc371d4f45bbf96a15072a994ce5/runtime/actions/update.go#L54-L54
|
|
72
|
+
RuntimeErrors.RecordNotFoundError,
|
|
73
|
+
"" // Don't pass on the message as we want to normalise these at the runtime layer but still support custom messages in other NotFound errors
|
|
74
|
+
);
|
|
75
|
+
case "DatabaseError":
|
|
76
|
+
let err = e;
|
|
77
|
+
|
|
78
|
+
// If wrapped error then unwrap
|
|
79
|
+
if (e instanceof DatabaseError) {
|
|
80
|
+
err = e.error;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (err.constructor.name == "NoResultError") {
|
|
84
|
+
return createJSONRPCErrorResponse(
|
|
85
|
+
request.id,
|
|
86
|
+
|
|
87
|
+
// to be matched to https://github.com/teamkeel/keel/blob/e3115ffe381bfc371d4f45bbf96a15072a994ce5/runtime/actions/update.go#L54-L54
|
|
88
|
+
RuntimeErrors.RecordNotFoundError,
|
|
89
|
+
"" // Don't pass on the message as we want to normalise these at the runtime layer but still support custom messages in other NotFound errors
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// if the error contains 'code' then assume it has other pg error message keys
|
|
94
|
+
// todo: make this more ironclad.
|
|
95
|
+
// when using lib-pq, should match https://github.com/brianc/node-postgres/blob/master/packages/pg-protocol/src/parser.ts#L371-L386
|
|
96
|
+
if ("code" in err) {
|
|
97
|
+
const { code, detail, table } = err;
|
|
98
|
+
|
|
99
|
+
let rpcErrorCode, column, value;
|
|
100
|
+
const [col, val] = parseKeyMessage(err.detail);
|
|
101
|
+
column = col;
|
|
102
|
+
value = val;
|
|
103
|
+
|
|
104
|
+
switch (code) {
|
|
105
|
+
case "23502":
|
|
106
|
+
rpcErrorCode = RuntimeErrors.NotNullConstraintError;
|
|
107
|
+
column = err.column;
|
|
108
|
+
break;
|
|
109
|
+
case "23503":
|
|
110
|
+
rpcErrorCode = RuntimeErrors.ForeignKeyConstraintError;
|
|
111
|
+
break;
|
|
112
|
+
case "23505":
|
|
113
|
+
rpcErrorCode = RuntimeErrors.UniqueConstraintError;
|
|
114
|
+
break;
|
|
115
|
+
default:
|
|
116
|
+
rpcErrorCode = RuntimeErrors.DatabaseError;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return createJSONRPCErrorResponse(request.id, rpcErrorCode, e.message, {
|
|
121
|
+
table,
|
|
122
|
+
column,
|
|
123
|
+
code,
|
|
124
|
+
detail,
|
|
125
|
+
value,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// we don't know what it is, but it's something else
|
|
130
|
+
return createJSONRPCErrorResponse(
|
|
131
|
+
request.id,
|
|
132
|
+
RuntimeErrors.DatabaseError,
|
|
133
|
+
e.message
|
|
134
|
+
);
|
|
135
|
+
default:
|
|
136
|
+
// Use the errorCode in the error if we have some from a preset
|
|
137
|
+
return createJSONRPCErrorResponse(
|
|
138
|
+
request.id,
|
|
139
|
+
e.errorCode ?? RuntimeErrors.UnknownError,
|
|
140
|
+
e.message
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// example data:
|
|
146
|
+
// Key (author_id)=(fake) is not present in table "author".
|
|
147
|
+
const keyMessagePattern = /\Key\s[(](.*)[)][=][(](.*)[)]/;
|
|
148
|
+
const parseKeyMessage = (msg) => {
|
|
149
|
+
const [, col, value] = keyMessagePattern.exec(msg) || [];
|
|
150
|
+
|
|
151
|
+
return [col, value];
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
module.exports = {
|
|
155
|
+
errorToJSONRPCResponse,
|
|
156
|
+
RuntimeErrors,
|
|
157
|
+
DatabaseError,
|
|
158
|
+
PermissionError,
|
|
159
|
+
ErrorPresets,
|
|
160
|
+
};
|
package/src/handleJob.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const {
|
|
2
|
+
createJSONRPCErrorResponse,
|
|
3
|
+
createJSONRPCSuccessResponse,
|
|
4
|
+
JSONRPCErrorCode,
|
|
5
|
+
} = require("json-rpc-2.0");
|
|
6
|
+
const { createDatabaseClient } = require("./database");
|
|
7
|
+
const { errorToJSONRPCResponse, RuntimeErrors } = require("./errors");
|
|
8
|
+
const opentelemetry = require("@opentelemetry/api");
|
|
9
|
+
const { withSpan } = require("./tracing");
|
|
10
|
+
const { tryExecuteJob } = require("./tryExecuteJob");
|
|
11
|
+
const { parseInputs } = require("./parsing");
|
|
12
|
+
const { PROTO_ACTION_TYPES } = require("./consts");
|
|
13
|
+
|
|
14
|
+
// Generic handler function that is agnostic to runtime environment (local or lambda)
|
|
15
|
+
// to execute a job function based on the contents of a jsonrpc-2.0 payload object.
|
|
16
|
+
// To read more about jsonrpc request and response shapes, please read https://www.jsonrpc.org/specification
|
|
17
|
+
async function handleJob(request, config) {
|
|
18
|
+
// Try to extract trace context from caller
|
|
19
|
+
const activeContext = opentelemetry.propagation.extract(
|
|
20
|
+
opentelemetry.context.active(),
|
|
21
|
+
request.meta?.tracing
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// Run the whole request with the extracted context
|
|
25
|
+
return opentelemetry.context.with(activeContext, () => {
|
|
26
|
+
// Wrapping span for the whole request
|
|
27
|
+
return withSpan(request.method, async (span) => {
|
|
28
|
+
let db = null;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const { createJobContextAPI, jobs } = config;
|
|
32
|
+
|
|
33
|
+
if (!(request.method in jobs)) {
|
|
34
|
+
const message = `no corresponding job found for '${request.method}'`;
|
|
35
|
+
span.setStatus({
|
|
36
|
+
code: opentelemetry.SpanStatusCode.ERROR,
|
|
37
|
+
message: message,
|
|
38
|
+
});
|
|
39
|
+
return createJSONRPCErrorResponse(
|
|
40
|
+
request.id,
|
|
41
|
+
JSONRPCErrorCode.MethodNotFound,
|
|
42
|
+
message
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The ctx argument passed into the job function.
|
|
47
|
+
const ctx = createJobContextAPI({
|
|
48
|
+
meta: request.meta,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const permitted =
|
|
52
|
+
request.meta && request.meta.permissionState.status === "granted"
|
|
53
|
+
? true
|
|
54
|
+
: null;
|
|
55
|
+
|
|
56
|
+
db = createDatabaseClient({
|
|
57
|
+
connString: request.meta?.secrets?.KEEL_DB_CONN,
|
|
58
|
+
});
|
|
59
|
+
const jobFunction = jobs[request.method];
|
|
60
|
+
const actionType = PROTO_ACTION_TYPES.JOB;
|
|
61
|
+
|
|
62
|
+
const functionConfig = jobFunction?.config ?? {};
|
|
63
|
+
|
|
64
|
+
await tryExecuteJob(
|
|
65
|
+
{ request, permitted, db, actionType, functionConfig },
|
|
66
|
+
async () => {
|
|
67
|
+
// parse request params to convert objects into rich field types (e.g. InlineFile)
|
|
68
|
+
const inputs = parseInputs(request.params);
|
|
69
|
+
|
|
70
|
+
// Return the job function to the containing tryExecuteJob block
|
|
71
|
+
return jobFunction(ctx, inputs);
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
return createJSONRPCSuccessResponse(request.id, null);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
if (e instanceof Error) {
|
|
78
|
+
span.recordException(e);
|
|
79
|
+
span.setStatus({
|
|
80
|
+
code: opentelemetry.SpanStatusCode.ERROR,
|
|
81
|
+
message: e.message,
|
|
82
|
+
});
|
|
83
|
+
return errorToJSONRPCResponse(request, e);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const message = JSON.stringify(e);
|
|
87
|
+
|
|
88
|
+
span.setStatus({
|
|
89
|
+
code: opentelemetry.SpanStatusCode.ERROR,
|
|
90
|
+
message: message,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return createJSONRPCErrorResponse(
|
|
94
|
+
request.id,
|
|
95
|
+
RuntimeErrors.UnknownError,
|
|
96
|
+
message
|
|
97
|
+
);
|
|
98
|
+
} finally {
|
|
99
|
+
if (db) {
|
|
100
|
+
await db.destroy();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = {
|
|
108
|
+
handleJob,
|
|
109
|
+
RuntimeErrors,
|
|
110
|
+
};
|