@teamkeel/functions-runtime 0.247.2 → 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 CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@teamkeel/functions-runtime",
3
- "version": "0.247.2",
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
- ...this._defaultValues(),
55
+ ...defaults,
55
56
  ...values,
56
57
  })
57
58
  )
@@ -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
- switch (e.constructor) {
16
- case DatabaseError:
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,
@@ -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
- JSONRPCErrorCode.InternalError,
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: JSONRPCErrorCode.InternalError,
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
+ });