@teamkeel/functions-runtime 0.412.0-next.3 → 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.
Files changed (49) hide show
  1. package/.env.test +2 -0
  2. package/compose.yaml +10 -0
  3. package/package.json +5 -23
  4. package/src/Duration.js +40 -0
  5. package/src/Duration.test.js +34 -0
  6. package/src/File.js +295 -0
  7. package/src/ModelAPI.js +377 -0
  8. package/src/ModelAPI.test.js +1428 -0
  9. package/src/QueryBuilder.js +184 -0
  10. package/src/QueryContext.js +90 -0
  11. package/src/RequestHeaders.js +21 -0
  12. package/src/TimePeriod.js +89 -0
  13. package/src/TimePeriod.test.js +148 -0
  14. package/src/applyAdditionalQueryConstraints.js +22 -0
  15. package/src/applyJoins.js +67 -0
  16. package/src/applyWhereConditions.js +124 -0
  17. package/src/auditing.js +110 -0
  18. package/src/auditing.test.js +330 -0
  19. package/src/camelCasePlugin.js +52 -0
  20. package/src/casing.js +54 -0
  21. package/src/casing.test.js +56 -0
  22. package/src/consts.js +14 -0
  23. package/src/database.js +244 -0
  24. package/src/errors.js +160 -0
  25. package/src/handleJob.js +110 -0
  26. package/src/handleJob.test.js +270 -0
  27. package/src/handleRequest.js +153 -0
  28. package/src/handleRequest.test.js +463 -0
  29. package/src/handleRoute.js +112 -0
  30. package/src/handleSubscriber.js +105 -0
  31. package/src/index.d.ts +317 -0
  32. package/src/index.js +38 -0
  33. package/src/parsing.js +113 -0
  34. package/src/parsing.test.js +140 -0
  35. package/src/permissions.js +77 -0
  36. package/src/permissions.test.js +118 -0
  37. package/src/tracing.js +184 -0
  38. package/src/tracing.test.js +147 -0
  39. package/src/tryExecuteFunction.js +91 -0
  40. package/src/tryExecuteJob.js +29 -0
  41. package/src/tryExecuteSubscriber.js +17 -0
  42. package/src/type-utils.js +18 -0
  43. package/vite.config.js +7 -0
  44. package/dist/index.d.mts +0 -604
  45. package/dist/index.d.ts +0 -604
  46. package/dist/index.js +0 -3104
  47. package/dist/index.js.map +0 -1
  48. package/dist/index.mjs +0 -3101
  49. package/dist/index.mjs.map +0 -1
@@ -0,0 +1,270 @@
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
+ import KSUID from "ksuid";
8
+
9
+ test("when the job returns nothing as expected", async () => {
10
+ const config = {
11
+ jobs: {
12
+ myJob: async (ctx, inputs) => {},
13
+ },
14
+ createJobContextAPI: () => {},
15
+ permissions: {},
16
+ };
17
+
18
+ const rpcReq = createJSONRPCRequest("123", "myJob", { title: "a post" });
19
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
20
+
21
+ expect(await handleJob(rpcReq, config)).toEqual({
22
+ id: "123",
23
+ jsonrpc: "2.0",
24
+ result: null,
25
+ });
26
+ });
27
+
28
+ test("when there is an unexpected error in the job", async () => {
29
+ const config = {
30
+ jobs: {
31
+ myJob: async (ctx, inputs) => {
32
+ throw new Error("oopsie daisy");
33
+ },
34
+ },
35
+ createJobContextAPI: () => {},
36
+ };
37
+
38
+ const rpcReq = createJSONRPCRequest("123", "myJob", { title: "a post" });
39
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
40
+
41
+ expect(await handleJob(rpcReq, config)).toEqual({
42
+ id: "123",
43
+ jsonrpc: "2.0",
44
+ error: {
45
+ code: RuntimeErrors.UnknownError,
46
+ message: "oopsie daisy",
47
+ },
48
+ });
49
+ });
50
+
51
+ test("when there is an unexpected object thrown in the job", async () => {
52
+ const config = {
53
+ jobs: {
54
+ myJob: async (ctx, inputs) => {
55
+ throw { err: "oopsie daisy" };
56
+ },
57
+ },
58
+ createJobContextAPI: () => {},
59
+ };
60
+
61
+ const rpcReq = createJSONRPCRequest("123", "myJob", { title: "a post" });
62
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
63
+
64
+ expect(await handleJob(rpcReq, config)).toEqual({
65
+ id: "123",
66
+ jsonrpc: "2.0",
67
+ error: {
68
+ code: RuntimeErrors.UnknownError,
69
+ message: '{"err":"oopsie daisy"}',
70
+ },
71
+ });
72
+ });
73
+
74
+ test("when there is no matching job for the path", async () => {
75
+ const config = {
76
+ jobs: {
77
+ myJob: async (ctx, inputs) => {},
78
+ },
79
+ createJobContextAPI: () => {},
80
+ };
81
+
82
+ const rpcReq = createJSONRPCRequest("123", "unknown", { title: "a post" });
83
+
84
+ expect(await handleJob(rpcReq, config)).toEqual({
85
+ id: "123",
86
+ jsonrpc: "2.0",
87
+ error: {
88
+ code: JSONRPCErrorCode.MethodNotFound,
89
+ message: "no corresponding job found for 'unknown'",
90
+ },
91
+ });
92
+ });
93
+
94
+ // The following tests assert on the various
95
+ // jsonrpc responses that *should* happen when a user
96
+ // writes a job that inadvertently causes a pg constraint error to occur inside of our ModelAPI class instance.
97
+ describe("ModelAPI error handling", () => {
98
+ let functionConfig;
99
+ let db;
100
+
101
+ beforeEach(async () => {
102
+ db = useDatabase();
103
+
104
+ await sql`
105
+ DROP TABLE IF EXISTS post;
106
+ DROP TABLE IF EXISTS author;
107
+
108
+ CREATE TABLE author(
109
+ "id" text PRIMARY KEY,
110
+ "name" text NOT NULL
111
+ );
112
+
113
+ CREATE TABLE post(
114
+ "id" text PRIMARY KEY,
115
+ "title" text NOT NULL UNIQUE,
116
+ "author_id" text NOT NULL REFERENCES author(id)
117
+ );
118
+ `.execute(db);
119
+
120
+ await sql`
121
+ INSERT INTO author (id, name) VALUES ('123', 'Bob')
122
+ `.execute(db);
123
+
124
+ const models = {
125
+ post: new ModelAPI("post", undefined, {
126
+ post: {
127
+ author: {
128
+ relationshipType: "belongsTo",
129
+ foreignKey: "author_id",
130
+ referencesTable: "person",
131
+ },
132
+ },
133
+ }),
134
+ };
135
+
136
+ functionConfig = {
137
+ jobs: {
138
+ createPost: async (ctx, inputs) => {
139
+ const post = await models.post.create({
140
+ id: KSUID.randomSync().string,
141
+ ...inputs,
142
+ });
143
+ return post;
144
+ },
145
+ deletePost: async (ctx, inputs) => {
146
+ const deleted = await models.post.delete(inputs);
147
+ return deleted;
148
+ },
149
+ },
150
+ createJobContextAPI: () => ({}),
151
+ };
152
+ });
153
+
154
+ test("when kysely returns a no result error", async () => {
155
+ // a kysely NoResultError is thrown when attempting to delete/update a non existent record.
156
+ const rpcReq = createJSONRPCRequest("123", "deletePost", {
157
+ id: "non-existent-id",
158
+ });
159
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
160
+
161
+ expect(await handleJob(rpcReq, functionConfig)).toEqual({
162
+ id: "123",
163
+ jsonrpc: "2.0",
164
+ error: {
165
+ code: RuntimeErrors.RecordNotFoundError,
166
+ message: "",
167
+ },
168
+ });
169
+ });
170
+
171
+ test("when there is a not null constraint error", async () => {
172
+ const rpcReq = createJSONRPCRequest("123", "createPost", { title: null });
173
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
174
+
175
+ expect(await handleJob(rpcReq, functionConfig)).toEqual({
176
+ id: "123",
177
+ jsonrpc: "2.0",
178
+ error: {
179
+ code: RuntimeErrors.NotNullConstraintError,
180
+ message:
181
+ 'null value in column "title" of relation "post" violates not-null constraint',
182
+ data: {
183
+ code: "23502",
184
+ column: "title",
185
+ detail: expect.stringContaining("Failing row contains"),
186
+ table: "post",
187
+ value: undefined,
188
+ },
189
+ },
190
+ });
191
+ });
192
+
193
+ test("when there is a uniqueness constraint error", async () => {
194
+ await sql`
195
+ INSERT INTO post (id, title, author_id) values(${
196
+ KSUID.randomSync().string
197
+ }, 'hello', '123')
198
+ `.execute(db);
199
+
200
+ const rpcReq = createJSONRPCRequest("123", "createPost", {
201
+ title: "hello",
202
+ author_id: "something",
203
+ });
204
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
205
+
206
+ expect(await handleJob(rpcReq, functionConfig)).toEqual({
207
+ id: "123",
208
+ jsonrpc: "2.0",
209
+ error: {
210
+ code: RuntimeErrors.UniqueConstraintError,
211
+ message:
212
+ 'duplicate key value violates unique constraint "post_title_key"',
213
+ data: {
214
+ code: "23505",
215
+ column: "title",
216
+ detail: "Key (title)=(hello) already exists.",
217
+ table: "post",
218
+ value: "hello",
219
+ },
220
+ },
221
+ });
222
+ });
223
+
224
+ test("when there is a null value in a foreign key column", async () => {
225
+ const rpcReq = createJSONRPCRequest("123", "createPost", { title: "123" });
226
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
227
+
228
+ expect(await handleJob(rpcReq, functionConfig)).toEqual({
229
+ id: "123",
230
+ jsonrpc: "2.0",
231
+ error: {
232
+ code: RuntimeErrors.NotNullConstraintError,
233
+ message:
234
+ 'null value in column "author_id" of relation "post" violates not-null constraint',
235
+ data: {
236
+ code: "23502",
237
+ column: "author_id",
238
+ detail: expect.stringContaining("Failing row contains"),
239
+ table: "post",
240
+ value: undefined,
241
+ },
242
+ },
243
+ });
244
+ });
245
+
246
+ test("when there is a foreign key constraint violation", async () => {
247
+ const rpcReq = createJSONRPCRequest("123", "createPost", {
248
+ title: "123",
249
+ author_id: "fake",
250
+ });
251
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
252
+
253
+ expect(await handleJob(rpcReq, functionConfig)).toEqual({
254
+ id: "123",
255
+ jsonrpc: "2.0",
256
+ error: {
257
+ code: RuntimeErrors.ForeignKeyConstraintError,
258
+ message:
259
+ 'insert or update on table "post" violates foreign key constraint "post_author_id_fkey"',
260
+ data: {
261
+ code: "23503",
262
+ column: "author_id",
263
+ detail: 'Key (author_id)=(fake) is not present in table "author".',
264
+ table: "post",
265
+ value: "fake",
266
+ },
267
+ },
268
+ });
269
+ });
270
+ });
@@ -0,0 +1,153 @@
1
+ const {
2
+ createJSONRPCErrorResponse,
3
+ createJSONRPCSuccessResponse,
4
+ JSONRPCErrorCode,
5
+ } = require("json-rpc-2.0");
6
+ const { createDatabaseClient } = 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 { parseInputs, parseOutputs } = require("./parsing");
12
+
13
+ // Generic handler function that is agnostic to runtime environment (local or lambda)
14
+ // to execute a custom 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 handleRequest(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
+ if (process.env.KEEL_LOG_LEVEL == "debug") {
24
+ console.log(request);
25
+ }
26
+
27
+ // Run the whole request with the extracted context
28
+ return opentelemetry.context.with(activeContext, () => {
29
+ // Wrapping span for the whole request
30
+ return withSpan(request.method, async (span) => {
31
+ let db = null;
32
+
33
+ try {
34
+ const { createContextAPI, functions, permissionFns, actionTypes } =
35
+ config;
36
+
37
+ if (!(request.method in functions)) {
38
+ const message = `no corresponding function found for '${request.method}'`;
39
+ span.setStatus({
40
+ code: opentelemetry.SpanStatusCode.ERROR,
41
+ message: message,
42
+ });
43
+ return createJSONRPCErrorResponse(
44
+ request.id,
45
+ JSONRPCErrorCode.MethodNotFound,
46
+ message
47
+ );
48
+ }
49
+
50
+ // headers reference passed to custom function where object data can be modified
51
+ const headers = new Headers();
52
+
53
+ // The ctx argument passed into the custom function.
54
+ const ctx = createContextAPI({
55
+ responseHeaders: headers,
56
+ meta: request.meta,
57
+ });
58
+
59
+ // The Go runtime does *some* permissions checks up front before the request reaches
60
+ // this method, so we pass in a permissionState object on the request.meta object that
61
+ // indicates whether a call to a custom function has already been authorised
62
+ const permitted =
63
+ request.meta && request.meta.permissionState.status === "granted"
64
+ ? true
65
+ : null;
66
+
67
+ db = createDatabaseClient({
68
+ connString: request.meta?.secrets?.KEEL_DB_CONN,
69
+ });
70
+ const customFunction = functions[request.method];
71
+ const actionType = actionTypes[request.method];
72
+
73
+ const functionConfig = customFunction?.config ?? {};
74
+
75
+ const result = await tryExecuteFunction(
76
+ {
77
+ request,
78
+ ctx,
79
+ permitted,
80
+ db,
81
+ permissionFns,
82
+ actionType,
83
+ functionConfig,
84
+ },
85
+ async () => {
86
+ // parse request params to convert objects into rich field types (e.g. InlineFile)
87
+ const inputs = parseInputs(request.params);
88
+
89
+ // Return the custom function to the containing tryExecuteFunction block
90
+ // Once the custom function is called, tryExecuteFunction will check the schema's permission rules to see if it can continue committing
91
+ // the transaction to the db. If a permission rule is violated, any changes made inside the transaction are rolled back.
92
+ const result = await customFunction(ctx, inputs);
93
+
94
+ return parseOutputs(result);
95
+ }
96
+ );
97
+
98
+ if (result instanceof Error) {
99
+ span.recordException(result);
100
+ span.setStatus({
101
+ code: opentelemetry.SpanStatusCode.ERROR,
102
+ message: result.message,
103
+ });
104
+ return errorToJSONRPCResponse(request, result);
105
+ }
106
+
107
+ const response = createJSONRPCSuccessResponse(request.id, result);
108
+
109
+ const responseHeaders = {};
110
+ for (const pair of headers.entries()) {
111
+ responseHeaders[pair[0]] = pair[1].split(", ");
112
+ }
113
+ response.meta = {
114
+ headers: responseHeaders,
115
+ status: ctx.response.status,
116
+ };
117
+
118
+ return response;
119
+ } catch (e) {
120
+ if (e instanceof Error) {
121
+ span.recordException(e);
122
+ span.setStatus({
123
+ code: opentelemetry.SpanStatusCode.ERROR,
124
+ message: e.message,
125
+ });
126
+ return errorToJSONRPCResponse(request, e);
127
+ }
128
+
129
+ const message = JSON.stringify(e);
130
+
131
+ span.setStatus({
132
+ code: opentelemetry.SpanStatusCode.ERROR,
133
+ message: message,
134
+ });
135
+
136
+ return createJSONRPCErrorResponse(
137
+ request.id,
138
+ RuntimeErrors.UnknownError,
139
+ message
140
+ );
141
+ } finally {
142
+ if (db) {
143
+ await db.destroy();
144
+ }
145
+ }
146
+ });
147
+ });
148
+ }
149
+
150
+ module.exports = {
151
+ handleRequest,
152
+ RuntimeErrors,
153
+ };