@teamkeel/functions-runtime 0.352.0 → 0.353.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamkeel/functions-runtime",
3
- "version": "0.352.0",
3
+ "version": "0.353.0",
4
4
  "description": "Internal package used by @teamkeel/sdk",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
package/src/consts.js CHANGED
@@ -7,6 +7,7 @@ const PROTO_ACTION_TYPES = {
7
7
  DELETE: "OPERATION_TYPE_DELETE",
8
8
  READ: "OPERATION_TYPE_READ",
9
9
  WRITE: "OPERATION_TYPE_WRITE",
10
+ JOB: "JOB_TYPE",
10
11
  };
11
12
 
12
13
  module.exports.PROTO_ACTION_TYPES = PROTO_ACTION_TYPES;
package/src/database.js CHANGED
@@ -15,6 +15,7 @@ async function withDatabase(db, actionType, cb) {
15
15
  let requiresTransaction = true;
16
16
 
17
17
  switch (actionType) {
18
+ case PROTO_ACTION_TYPES.JOB:
18
19
  case PROTO_ACTION_TYPES.GET:
19
20
  case PROTO_ACTION_TYPES.LIST:
20
21
  requiresTransaction = false;
@@ -0,0 +1,96 @@
1
+ const {
2
+ createJSONRPCErrorResponse,
3
+ createJSONRPCSuccessResponse,
4
+ JSONRPCErrorCode,
5
+ } = require("json-rpc-2.0");
6
+ const { getDatabaseClient } = require("./database");
7
+ const { tryExecuteFunction } = require("./tryExecuteFunction");
8
+ const { errorToJSONRPCResponse, RuntimeErrors } = require("./errors");
9
+ const opentelemetry = require("@opentelemetry/api");
10
+ const { withSpan } = require("./tracing");
11
+ const { PROTO_ACTION_TYPES } = require("./consts");
12
+
13
+ // Generic handler function that is agnostic to runtime environment (local or lambda)
14
+ // to execute a job function based on the contents of a jsonrpc-2.0 payload object.
15
+ // To read more about jsonrpc request and response shapes, please read https://www.jsonrpc.org/specification
16
+ async function handleJob(request, config) {
17
+ // Try to extract trace context from caller
18
+ const activeContext = opentelemetry.propagation.extract(
19
+ opentelemetry.context.active(),
20
+ request.meta?.tracing
21
+ );
22
+
23
+ // Run the whole request with the extracted context
24
+ return opentelemetry.context.with(activeContext, () => {
25
+ // Wrapping span for the whole request
26
+ return withSpan(request.method, async (span) => {
27
+ try {
28
+ const { createJobContextAPI, jobs } = config;
29
+
30
+ if (!(request.method in jobs)) {
31
+ const message = `no corresponding job found for '${request.method}'`;
32
+ span.setStatus({
33
+ code: opentelemetry.SpanStatusCode.ERROR,
34
+ message: message,
35
+ });
36
+ return createJSONRPCErrorResponse(
37
+ request.id,
38
+ JSONRPCErrorCode.MethodNotFound,
39
+ message
40
+ );
41
+ }
42
+
43
+ // The ctx argument passed into the job function.
44
+ const ctx = createJobContextAPI({
45
+ meta: request.meta,
46
+ });
47
+
48
+ const permitted =
49
+ request.meta && request.meta.permissionState.status === "granted"
50
+ ? true
51
+ : null;
52
+
53
+ const db = getDatabaseClient();
54
+ const jobFunction = jobs[request.method];
55
+ const actionType = PROTO_ACTION_TYPES.JOB;
56
+
57
+ const result = await tryExecuteFunction(
58
+ { request, ctx, permitted, db, actionType },
59
+ async () => {
60
+ // Return the job function to the containing tryExecuteFunction block
61
+ return jobFunction(ctx, request.params);
62
+ }
63
+ );
64
+
65
+ return createJSONRPCSuccessResponse(request.id, null);
66
+ } catch (e) {
67
+ if (e instanceof Error) {
68
+ span.recordException(e);
69
+ span.setStatus({
70
+ code: opentelemetry.SpanStatusCode.ERROR,
71
+ message: e.message,
72
+ });
73
+ return errorToJSONRPCResponse(request, e);
74
+ }
75
+
76
+ const message = JSON.stringify(e);
77
+
78
+ span.setStatus({
79
+ code: opentelemetry.SpanStatusCode.ERROR,
80
+ message: message,
81
+ });
82
+
83
+ return createJSONRPCErrorResponse(
84
+ request.id,
85
+ RuntimeErrors.UnknownError,
86
+ message
87
+ );
88
+ }
89
+ });
90
+ });
91
+ }
92
+
93
+ module.exports = {
94
+ handleJob,
95
+ RuntimeErrors,
96
+ };
@@ -0,0 +1,271 @@
1
+ import { createJSONRPCRequest, JSONRPCErrorCode } from "json-rpc-2.0";
2
+ import { sql } from "kysely";
3
+ import { handleJob, RuntimeErrors } from "./handleJob";
4
+ import { test, expect, beforeEach, describe } from "vitest";
5
+ import { ModelAPI } from "./ModelAPI";
6
+ import { useDatabase } from "./database";
7
+ const { Permissions } = require("./permissions");
8
+ import KSUID from "ksuid";
9
+
10
+ test("when the job returns nothing as expected", async () => {
11
+ const config = {
12
+ jobs: {
13
+ myJob: async (ctx, inputs) => {},
14
+ },
15
+ createJobContextAPI: () => {},
16
+ permissions: {},
17
+ };
18
+
19
+ const rpcReq = createJSONRPCRequest("123", "myJob", { title: "a post" });
20
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
21
+
22
+ expect(await handleJob(rpcReq, config)).toEqual({
23
+ id: "123",
24
+ jsonrpc: "2.0",
25
+ result: null,
26
+ });
27
+ });
28
+
29
+ test("when there is an unexpected error in the job", async () => {
30
+ const config = {
31
+ jobs: {
32
+ myJob: async (ctx, inputs) => {
33
+ throw new Error("oopsie daisy");
34
+ },
35
+ },
36
+ createJobContextAPI: () => {},
37
+ };
38
+
39
+ const rpcReq = createJSONRPCRequest("123", "myJob", { title: "a post" });
40
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
41
+
42
+ expect(await handleJob(rpcReq, config)).toEqual({
43
+ id: "123",
44
+ jsonrpc: "2.0",
45
+ error: {
46
+ code: RuntimeErrors.UnknownError,
47
+ message: "oopsie daisy",
48
+ },
49
+ });
50
+ });
51
+
52
+ test("when there is an unexpected object thrown in the job", async () => {
53
+ const config = {
54
+ jobs: {
55
+ myJob: async (ctx, inputs) => {
56
+ throw { err: "oopsie daisy" };
57
+ },
58
+ },
59
+ createJobContextAPI: () => {},
60
+ };
61
+
62
+ const rpcReq = createJSONRPCRequest("123", "myJob", { title: "a post" });
63
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
64
+
65
+ expect(await handleJob(rpcReq, config)).toEqual({
66
+ id: "123",
67
+ jsonrpc: "2.0",
68
+ error: {
69
+ code: RuntimeErrors.UnknownError,
70
+ message: '{"err":"oopsie daisy"}',
71
+ },
72
+ });
73
+ });
74
+
75
+ test("when there is no matching job for the path", async () => {
76
+ const config = {
77
+ jobs: {
78
+ myJob: async (ctx, inputs) => {},
79
+ },
80
+ createJobContextAPI: () => {},
81
+ };
82
+
83
+ const rpcReq = createJSONRPCRequest("123", "unknown", { title: "a post" });
84
+
85
+ expect(await handleJob(rpcReq, config)).toEqual({
86
+ id: "123",
87
+ jsonrpc: "2.0",
88
+ error: {
89
+ code: JSONRPCErrorCode.MethodNotFound,
90
+ message: "no corresponding job found for 'unknown'",
91
+ },
92
+ });
93
+ });
94
+
95
+ // The following tests assert on the various
96
+ // jsonrpc responses that *should* happen when a user
97
+ // writes a job that inadvertently causes a pg constraint error to occur inside of our ModelAPI class instance.
98
+ describe("ModelAPI error handling", () => {
99
+ let functionConfig;
100
+ let db;
101
+
102
+ beforeEach(async () => {
103
+ process.env.KEEL_DB_CONN_TYPE = "pg";
104
+ process.env.KEEL_DB_CONN = `postgresql://postgres:postgres@localhost:5432/functions-runtime`;
105
+
106
+ db = useDatabase();
107
+
108
+ await sql`
109
+ DROP TABLE IF EXISTS post;
110
+ DROP TABLE IF EXISTS author;
111
+
112
+ CREATE TABLE author(
113
+ "id" text PRIMARY KEY,
114
+ "name" text NOT NULL
115
+ );
116
+
117
+ CREATE TABLE post(
118
+ "id" text PRIMARY KEY,
119
+ "title" text NOT NULL UNIQUE,
120
+ "author_id" text NOT NULL REFERENCES author(id)
121
+ );
122
+ `.execute(db);
123
+
124
+ await sql`
125
+ INSERT INTO author (id, name) VALUES ('123', 'Bob')
126
+ `.execute(db);
127
+
128
+ const models = {
129
+ post: new ModelAPI("post", undefined, {
130
+ post: {
131
+ author: {
132
+ relationshipType: "belongsTo",
133
+ foreignKey: "author_id",
134
+ referencesTable: "person",
135
+ },
136
+ },
137
+ }),
138
+ };
139
+
140
+ functionConfig = {
141
+ jobs: {
142
+ createPost: async (ctx, inputs) => {
143
+ const post = await models.post.create({
144
+ id: KSUID.randomSync().string,
145
+ ...inputs,
146
+ });
147
+ return post;
148
+ },
149
+ deletePost: async (ctx, inputs) => {
150
+ const deleted = await models.post.delete(inputs);
151
+ return deleted;
152
+ },
153
+ },
154
+ createJobContextAPI: () => ({}),
155
+ };
156
+ });
157
+
158
+ test("when kysely returns a no result error", async () => {
159
+ // a kysely NoResultError is thrown when attempting to delete/update a non existent record.
160
+ const rpcReq = createJSONRPCRequest("123", "deletePost", {
161
+ id: "non-existent-id",
162
+ });
163
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
164
+
165
+ expect(await handleJob(rpcReq, functionConfig)).toEqual({
166
+ id: "123",
167
+ jsonrpc: "2.0",
168
+ error: {
169
+ code: RuntimeErrors.RecordNotFoundError,
170
+ message: "no result",
171
+ },
172
+ });
173
+ });
174
+
175
+ test("when there is a not null constraint error", async () => {
176
+ const rpcReq = createJSONRPCRequest("123", "createPost", { title: null });
177
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
178
+
179
+ expect(await handleJob(rpcReq, functionConfig)).toEqual({
180
+ id: "123",
181
+ jsonrpc: "2.0",
182
+ error: {
183
+ code: RuntimeErrors.NotNullConstraintError,
184
+ message: 'null value in column "title" violates not-null constraint',
185
+ data: {
186
+ code: "23502",
187
+ column: "title",
188
+ detail: expect.stringContaining("Failing row contains"),
189
+ table: "post",
190
+ },
191
+ },
192
+ });
193
+ });
194
+
195
+ test("when there is a uniqueness constraint error", async () => {
196
+ await sql`
197
+ INSERT INTO post (id, title, author_id) values(${
198
+ KSUID.randomSync().string
199
+ }, 'hello', '123')
200
+ `.execute(db);
201
+
202
+ const rpcReq = createJSONRPCRequest("123", "createPost", {
203
+ title: "hello",
204
+ author_id: "something",
205
+ });
206
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
207
+
208
+ expect(await handleJob(rpcReq, functionConfig)).toEqual({
209
+ id: "123",
210
+ jsonrpc: "2.0",
211
+ error: {
212
+ code: RuntimeErrors.UniqueConstraintError,
213
+ message:
214
+ 'duplicate key value violates unique constraint "post_title_key"',
215
+ data: {
216
+ code: "23505",
217
+ column: "title",
218
+ detail: "Key (title)=(hello) already exists.",
219
+ table: "post",
220
+ value: "hello",
221
+ },
222
+ },
223
+ });
224
+ });
225
+
226
+ test("when there is a null value in a foreign key column", async () => {
227
+ const rpcReq = createJSONRPCRequest("123", "createPost", { title: "123" });
228
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
229
+
230
+ expect(await handleJob(rpcReq, functionConfig)).toEqual({
231
+ id: "123",
232
+ jsonrpc: "2.0",
233
+ error: {
234
+ code: RuntimeErrors.NotNullConstraintError,
235
+ message:
236
+ 'null value in column "author_id" violates not-null constraint',
237
+ data: {
238
+ code: "23502",
239
+ column: "author_id",
240
+ detail: expect.stringContaining("Failing row contains"),
241
+ table: "post",
242
+ },
243
+ },
244
+ });
245
+ });
246
+
247
+ test("when there is a foreign key constraint violation", async () => {
248
+ const rpcReq = createJSONRPCRequest("123", "createPost", {
249
+ title: "123",
250
+ author_id: "fake",
251
+ });
252
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
253
+
254
+ expect(await handleJob(rpcReq, functionConfig)).toEqual({
255
+ id: "123",
256
+ jsonrpc: "2.0",
257
+ error: {
258
+ code: RuntimeErrors.ForeignKeyConstraintError,
259
+ message:
260
+ 'insert or update on table "post" violates foreign key constraint "post_author_id_fkey"',
261
+ data: {
262
+ code: "23503",
263
+ column: "author_id",
264
+ detail: 'Key (author_id)=(fake) is not present in table "author".',
265
+ table: "post",
266
+ value: "fake",
267
+ },
268
+ },
269
+ });
270
+ });
271
+ });
@@ -59,9 +59,10 @@ async function handleRequest(request, config) {
59
59
 
60
60
  const db = getDatabaseClient();
61
61
  const customFunction = functions[request.method];
62
+ const actionType = actionTypes[request.method];
62
63
 
63
64
  const result = await tryExecuteFunction(
64
- { request, ctx, permitted, db, permissionFns, actionTypes },
65
+ { request, ctx, permitted, db, permissionFns, actionType },
65
66
  async () => {
66
67
  // Return the custom function to the containing tryExecuteFunction block
67
68
  // Once the custom function is called, tryExecuteFunction will check the schema's permission rules to see if it can continue committing
@@ -106,6 +107,7 @@ async function handleRequest(request, config) {
106
107
  code: opentelemetry.SpanStatusCode.ERROR,
107
108
  message: message,
108
109
  });
110
+
109
111
  return createJSONRPCErrorResponse(
110
112
  request.id,
111
113
  RuntimeErrors.UnknownError,
package/src/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const { ModelAPI } = require("./ModelAPI");
2
2
  const { RequestHeaders } = require("./RequestHeaders");
3
3
  const { handleRequest } = require("./handleRequest");
4
+ const { handleJob } = require("./handleJob");
4
5
  const KSUID = require("ksuid");
5
6
  const { useDatabase } = require("./database");
6
7
  const {
@@ -14,6 +15,7 @@ module.exports = {
14
15
  ModelAPI,
15
16
  RequestHeaders,
16
17
  handleRequest,
18
+ handleJob,
17
19
  useDatabase,
18
20
  Permissions,
19
21
  PERMISSION_STATE,
@@ -10,18 +10,16 @@ const { PROTO_ACTION_TYPES } = require("./consts");
10
10
  // tryExecuteFunction will create a new database transaction around a function call
11
11
  // and handle any permissions checks. If a permission check fails, then an Error will be thrown and the catch block will be hit.
12
12
  function tryExecuteFunction(
13
- { db, permitted, permissionFns, actionTypes, request, ctx },
13
+ { db, permitted, permissionFns, actionType, request, ctx },
14
14
  cb
15
15
  ) {
16
- const actionType = actionTypes[request.method];
17
-
18
16
  return withPermissions(permitted, async ({ getPermissionState }) => {
19
17
  return withDatabase(db, actionType, async ({ transaction }) => {
20
18
  const fnResult = await cb();
21
-
22
19
  // api.permissions maintains an internal state of whether the current operation has been *explicitly* permitted/denied by the user in the course of their custom function, or if execution has already been permitted by a role based permission (evaluated in the main runtime).
23
20
  // we need to check that the final state is permitted or unpermitted. if it's not, then it means that the user has taken no explicit action to permit/deny
24
21
  // and therefore we default to checking the permissions defined in the schema automatically.
22
+
25
23
  switch (getPermissionState()) {
26
24
  case PERMISSION_STATE.PERMITTED:
27
25
  return fnResult;