@teamkeel/functions-runtime 0.247.3 → 0.248.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/package.json +2 -2
- package/src/ModelAPI.js +2 -1
- package/src/ModelAPI.test.js +47 -2
- package/src/errors.js +78 -5
- package/src/handleRequest.js +1 -1
- package/src/handleRequest.test.js +189 -2
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teamkeel/functions-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.248.0",
|
|
4
4
|
"description": "Internal package used by @teamkeel/sdk",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "vitest run --reporter verbose",
|
|
7
|
+
"test": "vitest run --reporter verbose --threads false",
|
|
8
8
|
"format": "npx prettier --write src/**/*.js"
|
|
9
9
|
},
|
|
10
10
|
"keywords": [],
|
package/src/ModelAPI.js
CHANGED
|
@@ -47,11 +47,12 @@ class ModelAPI {
|
|
|
47
47
|
|
|
48
48
|
async create(values) {
|
|
49
49
|
try {
|
|
50
|
+
const defaults = this._defaultValues();
|
|
50
51
|
const row = await this._db
|
|
51
52
|
.insertInto(this._tableName)
|
|
52
53
|
.values(
|
|
53
54
|
snakeCaseObject({
|
|
54
|
-
...
|
|
55
|
+
...defaults,
|
|
55
56
|
...values,
|
|
56
57
|
})
|
|
57
58
|
)
|
package/src/ModelAPI.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { test, expect, beforeEach } from "vitest";
|
|
2
|
-
const { ModelAPI } = require("./ModelAPI");
|
|
2
|
+
const { ModelAPI, DatabaseError } = require("./ModelAPI");
|
|
3
3
|
const { sql } = require("kysely");
|
|
4
4
|
const { getDatabase } = require("./database");
|
|
5
5
|
const KSUID = require("ksuid");
|
|
@@ -9,6 +9,7 @@ process.env.DB_CONN = `postgresql://postgres:postgres@localhost:5432/functions-r
|
|
|
9
9
|
|
|
10
10
|
let personAPI;
|
|
11
11
|
let postAPI;
|
|
12
|
+
let authorAPI;
|
|
12
13
|
|
|
13
14
|
beforeEach(async () => {
|
|
14
15
|
const db = getDatabase();
|
|
@@ -16,6 +17,8 @@ beforeEach(async () => {
|
|
|
16
17
|
await sql`
|
|
17
18
|
DROP TABLE IF EXISTS post;
|
|
18
19
|
DROP TABLE IF EXISTS person;
|
|
20
|
+
DROP TABLE IF EXISTS author;
|
|
21
|
+
|
|
19
22
|
CREATE TABLE person(
|
|
20
23
|
id text PRIMARY KEY,
|
|
21
24
|
name text UNIQUE,
|
|
@@ -27,7 +30,11 @@ beforeEach(async () => {
|
|
|
27
30
|
id text PRIMARY KEY,
|
|
28
31
|
title text,
|
|
29
32
|
author_id text references person(id)
|
|
30
|
-
);
|
|
33
|
+
);
|
|
34
|
+
CREATE TABLE author(
|
|
35
|
+
id text PRIMARY KEY,
|
|
36
|
+
name text NOT NULL
|
|
37
|
+
);
|
|
31
38
|
`.execute(db);
|
|
32
39
|
|
|
33
40
|
const tableConfigMap = {
|
|
@@ -69,6 +76,17 @@ beforeEach(async () => {
|
|
|
69
76
|
db,
|
|
70
77
|
tableConfigMap
|
|
71
78
|
);
|
|
79
|
+
|
|
80
|
+
authorAPI = new ModelAPI(
|
|
81
|
+
"author",
|
|
82
|
+
() => {
|
|
83
|
+
return {
|
|
84
|
+
id: KSUID.randomSync().string,
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
db,
|
|
88
|
+
tableConfigMap
|
|
89
|
+
);
|
|
72
90
|
});
|
|
73
91
|
|
|
74
92
|
test("ModelAPI.create", async () => {
|
|
@@ -84,6 +102,14 @@ test("ModelAPI.create", async () => {
|
|
|
84
102
|
expect(KSUID.parse(row.id).string).toEqual(row.id);
|
|
85
103
|
});
|
|
86
104
|
|
|
105
|
+
test("ModelAPI.create - throws if not not null constraint violation", async () => {
|
|
106
|
+
await expect(
|
|
107
|
+
authorAPI.create({
|
|
108
|
+
name: null,
|
|
109
|
+
})
|
|
110
|
+
).rejects.toThrow('null value in column "name" violates not-null constraint');
|
|
111
|
+
});
|
|
112
|
+
|
|
87
113
|
test("ModelAPI.create - throws if database constraint fails", async () => {
|
|
88
114
|
const row = await personAPI.create({
|
|
89
115
|
name: "Jim",
|
|
@@ -590,6 +616,25 @@ test("ModelAPI.update - throws if not found", async () => {
|
|
|
590
616
|
await expect(result).rejects.toThrow("no result");
|
|
591
617
|
});
|
|
592
618
|
|
|
619
|
+
test("ModelAPI.update - throws if not not null constraint violation", async () => {
|
|
620
|
+
const jim = await authorAPI.create({
|
|
621
|
+
name: "jim",
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
const result = authorAPI.update(
|
|
625
|
+
{
|
|
626
|
+
id: jim.id,
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
name: null,
|
|
630
|
+
}
|
|
631
|
+
);
|
|
632
|
+
|
|
633
|
+
await expect(result).rejects.toThrow(
|
|
634
|
+
'null value in column "name" violates not-null constraint'
|
|
635
|
+
);
|
|
636
|
+
});
|
|
637
|
+
|
|
593
638
|
test("ModelAPI.delete", async () => {
|
|
594
639
|
const jim = await personAPI.create({
|
|
595
640
|
name: "Jim",
|
package/src/errors.js
CHANGED
|
@@ -1,19 +1,83 @@
|
|
|
1
|
-
const { DatabaseError } = require("./ModelAPI");
|
|
2
1
|
const { createJSONRPCErrorResponse } = require("json-rpc-2.0");
|
|
3
2
|
|
|
4
3
|
const RuntimeErrors = {
|
|
4
|
+
// Catchall error type for unhandled execution errors during custom function
|
|
5
5
|
UnknownError: -32001,
|
|
6
|
+
// DatabaseError represents any error at pg level that isn't handled explicitly below
|
|
6
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,
|
|
7
15
|
};
|
|
8
16
|
|
|
9
|
-
// transforms a JavaScript Error instance (or derivative) into a valid JSONRPC response object
|
|
10
|
-
// to pass back to the Keel runtime
|
|
17
|
+
// errorToJSONRPCResponse transforms a JavaScript Error instance (or derivative) into a valid JSONRPC response object to pass back to the Keel runtime.
|
|
11
18
|
function errorToJSONRPCResponse(request, e) {
|
|
19
|
+
if (!e.error) {
|
|
20
|
+
return createJSONRPCErrorResponse(
|
|
21
|
+
request.id,
|
|
22
|
+
RuntimeErrors.UnknownError,
|
|
23
|
+
e.message
|
|
24
|
+
);
|
|
25
|
+
}
|
|
12
26
|
// we want to switch on instanceof but there is no way to do that in js, so best to check the constructor class of the error
|
|
13
27
|
|
|
14
28
|
// todo: fuzzy matching on postgres errors from both rds-data-api and pg-protocol
|
|
15
|
-
|
|
16
|
-
|
|
29
|
+
|
|
30
|
+
switch (e.error.constructor.name) {
|
|
31
|
+
// Any error thrown in the ModelAPI class is
|
|
32
|
+
// wrapped in a DatabaseError in order to differentiate 'our code' vs the user's own code.
|
|
33
|
+
case "NoResultError":
|
|
34
|
+
return createJSONRPCErrorResponse(
|
|
35
|
+
request.id,
|
|
36
|
+
|
|
37
|
+
// to be matched to https://github.com/teamkeel/keel/blob/e3115ffe381bfc371d4f45bbf96a15072a994ce5/runtime/actions/update.go#L54-L54
|
|
38
|
+
RuntimeErrors.RecordNotFoundError,
|
|
39
|
+
e.message
|
|
40
|
+
);
|
|
41
|
+
case "DatabaseError":
|
|
42
|
+
const { error: originalError } = e;
|
|
43
|
+
|
|
44
|
+
// if the originalError responds to 'code' then assume it has other pg error message keys
|
|
45
|
+
// todo: make this more ironclad.
|
|
46
|
+
// when using lib-pq, should match https://github.com/brianc/node-postgres/blob/master/packages/pg-protocol/src/parser.ts#L371-L386
|
|
47
|
+
if ("code" in originalError) {
|
|
48
|
+
const { code, detail, table } = originalError;
|
|
49
|
+
|
|
50
|
+
let rpcErrorCode, column, value;
|
|
51
|
+
const [col, val] = parseKeyMessage(originalError.detail);
|
|
52
|
+
column = col;
|
|
53
|
+
value = val;
|
|
54
|
+
|
|
55
|
+
switch (code) {
|
|
56
|
+
case "23502":
|
|
57
|
+
rpcErrorCode = RuntimeErrors.NotNullConstraintError;
|
|
58
|
+
column = originalError.column;
|
|
59
|
+
break;
|
|
60
|
+
case "23503":
|
|
61
|
+
rpcErrorCode = RuntimeErrors.ForeignKeyConstraintError;
|
|
62
|
+
break;
|
|
63
|
+
case "23505":
|
|
64
|
+
rpcErrorCode = RuntimeErrors.UniqueConstraintError;
|
|
65
|
+
break;
|
|
66
|
+
default:
|
|
67
|
+
rpcErrorCode = RuntimeErrors.DatabaseError;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return createJSONRPCErrorResponse(request.id, rpcErrorCode, e.message, {
|
|
72
|
+
table,
|
|
73
|
+
column,
|
|
74
|
+
code,
|
|
75
|
+
detail,
|
|
76
|
+
value,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// we don't know what it is, but it's something else
|
|
17
81
|
return createJSONRPCErrorResponse(
|
|
18
82
|
request.id,
|
|
19
83
|
RuntimeErrors.DatabaseError,
|
|
@@ -28,6 +92,15 @@ function errorToJSONRPCResponse(request, e) {
|
|
|
28
92
|
}
|
|
29
93
|
}
|
|
30
94
|
|
|
95
|
+
// example data:
|
|
96
|
+
// Key (author_id)=(fake) is not present in table "author".
|
|
97
|
+
const keyMessagePattern = /\Key\s[(](.*)[)][=][(](.*)[)]/;
|
|
98
|
+
const parseKeyMessage = (msg) => {
|
|
99
|
+
const [, col, value] = keyMessagePattern.exec(msg) || [];
|
|
100
|
+
|
|
101
|
+
return [col, value];
|
|
102
|
+
};
|
|
103
|
+
|
|
31
104
|
module.exports = {
|
|
32
105
|
errorToJSONRPCResponse,
|
|
33
106
|
RuntimeErrors,
|
package/src/handleRequest.js
CHANGED
|
@@ -31,7 +31,7 @@ async function handleRequest(request, config) {
|
|
|
31
31
|
// no result returned from custom function
|
|
32
32
|
return createJSONRPCErrorResponse(
|
|
33
33
|
request.id,
|
|
34
|
-
|
|
34
|
+
RuntimeErrors.NoResultError,
|
|
35
35
|
`no result returned from function '${request.method}'`
|
|
36
36
|
);
|
|
37
37
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { createJSONRPCRequest, JSONRPCErrorCode } from "json-rpc-2.0";
|
|
2
|
+
import { sql } from "kysely";
|
|
2
3
|
import { handleRequest, RuntimeErrors } from "./handleRequest";
|
|
3
|
-
import { test, expect } from "vitest";
|
|
4
|
+
import { test, expect, beforeEach, describe } from "vitest";
|
|
5
|
+
import { ModelAPI } from "./ModelAPI";
|
|
6
|
+
import { getDatabase } from "./database";
|
|
7
|
+
import KSUID from "ksuid";
|
|
4
8
|
|
|
5
9
|
test("when the custom function returns expected value", async () => {
|
|
6
10
|
const config = {
|
|
@@ -43,7 +47,7 @@ test("when the custom function doesnt return a value", async () => {
|
|
|
43
47
|
id: "123",
|
|
44
48
|
jsonrpc: "2.0",
|
|
45
49
|
error: {
|
|
46
|
-
code:
|
|
50
|
+
code: RuntimeErrors.NoResultError,
|
|
47
51
|
message: "no result returned from function 'createPost'",
|
|
48
52
|
},
|
|
49
53
|
});
|
|
@@ -115,3 +119,186 @@ test("when there is an unexpected object thrown in the custom function", async (
|
|
|
115
119
|
},
|
|
116
120
|
});
|
|
117
121
|
});
|
|
122
|
+
|
|
123
|
+
// The following tests assert on the various
|
|
124
|
+
// jsonrpc responses that *should* happen when a user
|
|
125
|
+
// writes a custom function that inadvertently causes a pg constraint error to occur inside of our ModelAPI class instance.
|
|
126
|
+
describe("ModelAPI error handling", () => {
|
|
127
|
+
let functionConfig;
|
|
128
|
+
let db;
|
|
129
|
+
|
|
130
|
+
beforeEach(async () => {
|
|
131
|
+
process.env.DB_CONN_TYPE = "pg";
|
|
132
|
+
process.env.DB_CONN = `postgresql://postgres:postgres@localhost:5432/functions-runtime`;
|
|
133
|
+
|
|
134
|
+
db = getDatabase();
|
|
135
|
+
|
|
136
|
+
await sql`
|
|
137
|
+
DROP TABLE IF EXISTS post;
|
|
138
|
+
DROP TABLE IF EXISTS author;
|
|
139
|
+
|
|
140
|
+
CREATE TABLE author(
|
|
141
|
+
"id" text PRIMARY KEY,
|
|
142
|
+
"name" text NOT NULL
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
CREATE TABLE post(
|
|
146
|
+
"id" text PRIMARY KEY,
|
|
147
|
+
"title" text NOT NULL UNIQUE,
|
|
148
|
+
"author_id" text NOT NULL REFERENCES author(id)
|
|
149
|
+
);
|
|
150
|
+
`.execute(db);
|
|
151
|
+
|
|
152
|
+
await sql`
|
|
153
|
+
INSERT INTO author (id, name) VALUES ('adam', 'adam bull')
|
|
154
|
+
`.execute(db);
|
|
155
|
+
|
|
156
|
+
functionConfig = {
|
|
157
|
+
functions: {
|
|
158
|
+
createPost: async (inputs, api, ctx) => {
|
|
159
|
+
const post = await api.models.post.create(inputs);
|
|
160
|
+
|
|
161
|
+
return post;
|
|
162
|
+
},
|
|
163
|
+
deletePost: async (inputs, api, ctx) => {
|
|
164
|
+
const deleted = await api.models.post.delete(inputs);
|
|
165
|
+
|
|
166
|
+
return deleted;
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
createFunctionAPI: () => ({
|
|
170
|
+
models: {
|
|
171
|
+
post: new ModelAPI(
|
|
172
|
+
"post",
|
|
173
|
+
() => {
|
|
174
|
+
return {
|
|
175
|
+
id: KSUID.randomSync().string,
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
db,
|
|
179
|
+
{
|
|
180
|
+
post: {
|
|
181
|
+
author: {
|
|
182
|
+
relationshipType: "belongsTo",
|
|
183
|
+
foreignKey: "author_id",
|
|
184
|
+
referencesTable: "person",
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
),
|
|
189
|
+
},
|
|
190
|
+
}),
|
|
191
|
+
createContextAPI: () => ({}),
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("when kysely returns a no result error", async () => {
|
|
196
|
+
// a kysely NoResultError is thrown when attempting to delete/update a non existent record.
|
|
197
|
+
const rpcReq = createJSONRPCRequest("123", "deletePost", {
|
|
198
|
+
id: "non-existent-id",
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(await handleRequest(rpcReq, functionConfig)).toEqual({
|
|
202
|
+
id: "123",
|
|
203
|
+
jsonrpc: "2.0",
|
|
204
|
+
error: {
|
|
205
|
+
code: RuntimeErrors.RecordNotFoundError,
|
|
206
|
+
message: "no result",
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("when there is a not null constraint error", async () => {
|
|
212
|
+
const rpcReq = createJSONRPCRequest("123", "createPost", { title: null });
|
|
213
|
+
|
|
214
|
+
expect(await handleRequest(rpcReq, functionConfig)).toEqual({
|
|
215
|
+
id: "123",
|
|
216
|
+
jsonrpc: "2.0",
|
|
217
|
+
error: {
|
|
218
|
+
code: RuntimeErrors.NotNullConstraintError,
|
|
219
|
+
message: 'null value in column "title" violates not-null constraint',
|
|
220
|
+
data: {
|
|
221
|
+
code: "23502",
|
|
222
|
+
column: "title",
|
|
223
|
+
detail: expect.stringContaining("Failing row contains"),
|
|
224
|
+
table: "post",
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("when there is a uniqueness constraint error", async () => {
|
|
231
|
+
await sql`
|
|
232
|
+
|
|
233
|
+
INSERT INTO post (id, title, author_id) values(${
|
|
234
|
+
KSUID.randomSync().string
|
|
235
|
+
}, 'hello', 'adam')
|
|
236
|
+
`.execute(db);
|
|
237
|
+
|
|
238
|
+
const rpcReq = createJSONRPCRequest("123", "createPost", {
|
|
239
|
+
title: "hello",
|
|
240
|
+
author_id: "something",
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect(await handleRequest(rpcReq, functionConfig)).toEqual({
|
|
244
|
+
id: "123",
|
|
245
|
+
jsonrpc: "2.0",
|
|
246
|
+
error: {
|
|
247
|
+
code: RuntimeErrors.UniqueConstraintError,
|
|
248
|
+
message:
|
|
249
|
+
'duplicate key value violates unique constraint "post_title_key"',
|
|
250
|
+
data: {
|
|
251
|
+
code: "23505",
|
|
252
|
+
column: "title",
|
|
253
|
+
detail: "Key (title)=(hello) already exists.",
|
|
254
|
+
table: "post",
|
|
255
|
+
value: "hello",
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("when there is a null value in a foreign key column", async () => {
|
|
262
|
+
const rpcReq = createJSONRPCRequest("123", "createPost", { title: "123" });
|
|
263
|
+
|
|
264
|
+
expect(await handleRequest(rpcReq, functionConfig)).toEqual({
|
|
265
|
+
id: "123",
|
|
266
|
+
jsonrpc: "2.0",
|
|
267
|
+
error: {
|
|
268
|
+
code: RuntimeErrors.NotNullConstraintError,
|
|
269
|
+
message:
|
|
270
|
+
'null value in column "author_id" violates not-null constraint',
|
|
271
|
+
data: {
|
|
272
|
+
code: "23502",
|
|
273
|
+
column: "author_id",
|
|
274
|
+
detail: expect.stringContaining("Failing row contains"),
|
|
275
|
+
table: "post",
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("when there is a foreign key constraint violation", async () => {
|
|
282
|
+
const rpcReq2 = createJSONRPCRequest("123", "createPost", {
|
|
283
|
+
title: "123",
|
|
284
|
+
author_id: "fake",
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
expect(await handleRequest(rpcReq2, functionConfig)).toEqual({
|
|
288
|
+
id: "123",
|
|
289
|
+
jsonrpc: "2.0",
|
|
290
|
+
error: {
|
|
291
|
+
code: RuntimeErrors.ForeignKeyConstraintError,
|
|
292
|
+
message:
|
|
293
|
+
'insert or update on table "post" violates foreign key constraint "post_author_id_fkey"',
|
|
294
|
+
data: {
|
|
295
|
+
code: "23503",
|
|
296
|
+
column: "author_id",
|
|
297
|
+
detail: 'Key (author_id)=(fake) is not present in table "author".',
|
|
298
|
+
table: "post",
|
|
299
|
+
value: "fake",
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
});
|