@teamkeel/functions-runtime 0.394.2 → 0.395.0-next.1

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.394.2",
3
+ "version": "0.395.0-next.1",
4
4
  "description": "Internal package used by @teamkeel/sdk",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -21,6 +21,7 @@
21
21
  "dependencies": {
22
22
  "@aws-sdk/client-s3": "^3.637.0",
23
23
  "@aws-sdk/credential-providers": "^3.637.0",
24
+ "@aws-sdk/s3-request-presigner": "^3.637.0",
24
25
  "@neondatabase/serverless": "^0.9.4",
25
26
  "@opentelemetry/api": "^1.7.0",
26
27
  "@opentelemetry/exporter-trace-otlp-proto": "^0.46.0",
package/src/File.js CHANGED
@@ -4,7 +4,8 @@ const {
4
4
  GetObjectCommand,
5
5
  } = require("@aws-sdk/client-s3");
6
6
  const { fromEnv } = require("@aws-sdk/credential-providers");
7
- const { useDatabase } = require("./database");
7
+ const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
8
+ const { createDatabaseClient } = require("./database");
8
9
  const { DatabaseError } = require("./errors");
9
10
  const KSUID = require("ksuid");
10
11
 
@@ -105,9 +106,7 @@ class File extends InlineFile {
105
106
  async read() {
106
107
  if (this._contents) {
107
108
  const arrayBuffer = await this._contents.arrayBuffer();
108
- const buffer = Buffer.from(arrayBuffer);
109
-
110
- return buffer;
109
+ return Buffer.from(arrayBuffer);
111
110
  }
112
111
 
113
112
  if (isS3Storage()) {
@@ -122,12 +121,12 @@ class File extends InlineFile {
122
121
  };
123
122
  const command = new GetObjectCommand(params);
124
123
  const response = await s3Client.send(command);
125
- const blob = response.Body.transformToByteArray();
124
+ const blob = await response.Body.transformToByteArray();
126
125
  return Buffer.from(blob);
127
126
  }
128
127
 
129
128
  // default to db storage
130
- const db = useDatabase();
129
+ const db = createDatabaseClient();
131
130
 
132
131
  try {
133
132
  let query = db
@@ -139,6 +138,8 @@ class File extends InlineFile {
139
138
  return row.data;
140
139
  } catch (e) {
141
140
  throw new DatabaseError(e);
141
+ } finally {
142
+ await db.destroy();
142
143
  }
143
144
  }
144
145
 
@@ -157,12 +158,39 @@ class File extends InlineFile {
157
158
  return this;
158
159
  }
159
160
 
161
+ async getPresignedUrl() {
162
+ if (isS3Storage()) {
163
+ const s3Client = new S3Client({
164
+ credentials: fromEnv(),
165
+ region: process.env.KEEL_REGION,
166
+ });
167
+
168
+ const command = new GetObjectCommand({
169
+ Bucket: process.env.KEEL_FILES_BUCKET_NAME,
170
+ Key: "files/" + this.key,
171
+ ResponseContentDisposition: `attachment; filename="${encodeURIComponent(
172
+ this.filename
173
+ )}"`,
174
+ });
175
+
176
+ const url = await getSignedUrl(s3Client, command, { expiresIn: 60 * 60 });
177
+
178
+ return new URL(url);
179
+ } else {
180
+ const contents = await this.read();
181
+ const dataurl = `data:${this.contentType};name=${
182
+ this.filename
183
+ };base64,${contents.toString("base64")}`;
184
+ return new URL(dataurl);
185
+ }
186
+ }
187
+
160
188
  toDbRecord() {
161
189
  return {
162
190
  key: this.key,
163
191
  filename: this.filename,
164
192
  contentType: this.contentType,
165
- size: this._size,
193
+ size: this.size,
166
194
  };
167
195
  }
168
196
 
@@ -188,14 +216,21 @@ async function storeFile(contents, key, filename, contentType, expires) {
188
216
  Key: "files/" + key,
189
217
  Body: contents,
190
218
  ContentType: contentType,
219
+ ContentDisposition: `attachment; filename="${encodeURIComponent(
220
+ this.filename
221
+ )}"`,
191
222
  Metadata: {
192
- filename: filename,
223
+ filename: this.filename,
193
224
  },
194
225
  ACL: "private",
195
226
  };
196
227
 
197
228
  if (expires) {
198
- params.Expires = expires;
229
+ if (expires instanceof Date) {
230
+ params.Expires = expires;
231
+ } else {
232
+ console.warn("Invalid expires value. Skipping Expires parameter.");
233
+ }
199
234
  }
200
235
 
201
236
  const command = new PutObjectCommand(params);
@@ -206,8 +241,7 @@ async function storeFile(contents, key, filename, contentType, expires) {
206
241
  throw error;
207
242
  }
208
243
  } else {
209
- // default to db storage
210
- const db = useDatabase();
244
+ const db = createDatabaseClient();
211
245
 
212
246
  try {
213
247
  let query = db
@@ -233,6 +267,8 @@ async function storeFile(contents, key, filename, contentType, expires) {
233
267
  await query.execute();
234
268
  } catch (e) {
235
269
  throw new DatabaseError(e);
270
+ } finally {
271
+ await db.destroy();
236
272
  }
237
273
  }
238
274
  }
@@ -65,23 +65,19 @@ test("auditing - capturing identity id in transaction", async () => {
65
65
 
66
66
  const identityId = request.meta.identity.id;
67
67
 
68
- const row = await withDatabase(
69
- db,
70
- PROTO_ACTION_TYPES.CREATE, // CREATE will ensure a transaction is opened
71
- async ({ transaction }) => {
72
- const row = withAuditContext(request, async () => {
73
- return await personAPI.create({
74
- id: KSUID.randomSync().string,
75
- name: "James",
76
- });
68
+ const row = await withDatabase(db, true, async ({ transaction }) => {
69
+ const row = withAuditContext(request, async () => {
70
+ return await personAPI.create({
71
+ id: KSUID.randomSync().string,
72
+ name: "James",
77
73
  });
74
+ });
78
75
 
79
- expect(await identityIdFromConfigParam(transaction)).toEqual(identityId);
80
- expect(await identityIdFromConfigParam(db)).toBeNull();
76
+ expect(await identityIdFromConfigParam(transaction)).toEqual(identityId);
77
+ expect(await identityIdFromConfigParam(db)).toBeNull();
81
78
 
82
- return row;
83
- }
84
- );
79
+ return row;
80
+ });
85
81
 
86
82
  expect(row.name).toEqual("James");
87
83
  expect(await identityIdFromConfigParam(db)).toBeNull();
@@ -101,23 +97,19 @@ test("auditing - capturing tracing in transaction", async () => {
101
97
  request.meta.tracing.traceparent
102
98
  ).traceId;
103
99
 
104
- const row = await withDatabase(
105
- db,
106
- PROTO_ACTION_TYPES.CREATE, // CREATE will ensure a transaction is opened
107
- async ({ transaction }) => {
108
- const row = withAuditContext(request, async () => {
109
- return await personAPI.create({
110
- id: KSUID.randomSync().string,
111
- name: "Jim",
112
- });
100
+ const row = await withDatabase(db, true, async ({ transaction }) => {
101
+ const row = withAuditContext(request, async () => {
102
+ return await personAPI.create({
103
+ id: KSUID.randomSync().string,
104
+ name: "Jim",
113
105
  });
106
+ });
114
107
 
115
- expect(await traceIdFromConfigParam(transaction)).toEqual(traceId);
116
- expect(await traceIdFromConfigParam(db)).toBeNull();
108
+ expect(await traceIdFromConfigParam(transaction)).toEqual(traceId);
109
+ expect(await traceIdFromConfigParam(db)).toBeNull();
117
110
 
118
- return row;
119
- }
120
- );
111
+ return row;
112
+ });
121
113
 
122
114
  expect(row.name).toEqual("Jim");
123
115
  expect(await traceIdFromConfigParam(db)).toBeNull();
@@ -131,23 +123,19 @@ test("auditing - capturing identity id without transaction", async () => {
131
123
  },
132
124
  };
133
125
 
134
- const row = await withDatabase(
135
- db,
136
- PROTO_ACTION_TYPES.GET, // GET will _not_ open a transaction
137
- async ({ sDb }) => {
138
- const row = withAuditContext(request, async () => {
139
- return await personAPI.create({
140
- id: KSUID.randomSync().string,
141
- name: "James",
142
- });
126
+ const row = await withDatabase(db, false, async ({ sDb }) => {
127
+ const row = withAuditContext(request, async () => {
128
+ return await personAPI.create({
129
+ id: KSUID.randomSync().string,
130
+ name: "James",
143
131
  });
132
+ });
144
133
 
145
- expect(await identityIdFromConfigParam(sDb)).toBeNull();
146
- expect(await identityIdFromConfigParam(db)).toBeNull();
134
+ expect(await identityIdFromConfigParam(sDb)).toBeNull();
135
+ expect(await identityIdFromConfigParam(db)).toBeNull();
147
136
 
148
- return row;
149
- }
150
- );
137
+ return row;
138
+ });
151
139
 
152
140
  expect(row.name).toEqual("James");
153
141
  expect(await identityIdFromConfigParam(db)).toBeNull();
@@ -163,23 +151,19 @@ test("auditing - capturing tracing without transaction", async () => {
163
151
  },
164
152
  };
165
153
 
166
- const row = await withDatabase(
167
- db,
168
- PROTO_ACTION_TYPES.GET, // GET will _not_ open a transaction
169
- async ({ sDb }) => {
170
- const row = withAuditContext(request, async () => {
171
- return await personAPI.create({
172
- id: KSUID.randomSync().string,
173
- name: "Jim",
174
- });
154
+ const row = await withDatabase(db, false, async ({ sDb }) => {
155
+ const row = withAuditContext(request, async () => {
156
+ return await personAPI.create({
157
+ id: KSUID.randomSync().string,
158
+ name: "Jim",
175
159
  });
160
+ });
176
161
 
177
- expect(await traceIdFromConfigParam(sDb)).toBeNull();
178
- expect(await traceIdFromConfigParam(db)).toBeNull();
162
+ expect(await traceIdFromConfigParam(sDb)).toBeNull();
163
+ expect(await traceIdFromConfigParam(db)).toBeNull();
179
164
 
180
- return row;
181
- }
182
- );
165
+ return row;
166
+ });
183
167
 
184
168
  expect(row.name).toEqual("Jim");
185
169
  expect(await traceIdFromConfigParam(db)).toBeNull();
@@ -225,23 +209,19 @@ test("auditing - ModelAPI.create", async () => {
225
209
  request.meta.tracing.traceparent
226
210
  ).traceId;
227
211
 
228
- const row = await withDatabase(
229
- db,
230
- PROTO_ACTION_TYPES.CREATE,
231
- async ({ transaction }) => {
232
- const row = withAuditContext(request, async () => {
233
- return await personAPI.create({
234
- id: KSUID.randomSync().string,
235
- name: "Jake",
236
- });
212
+ const row = await withDatabase(db, true, async ({ transaction }) => {
213
+ const row = withAuditContext(request, async () => {
214
+ return await personAPI.create({
215
+ id: KSUID.randomSync().string,
216
+ name: "Jake",
237
217
  });
218
+ });
238
219
 
239
- expect(await identityIdFromConfigParam(transaction)).toEqual(identityId);
240
- expect(await traceIdFromConfigParam(transaction)).toEqual(traceId);
220
+ expect(await identityIdFromConfigParam(transaction)).toEqual(identityId);
221
+ expect(await traceIdFromConfigParam(transaction)).toEqual(traceId);
241
222
 
242
- return row;
243
- }
244
- );
223
+ return row;
224
+ });
245
225
 
246
226
  expect(row.name).toEqual("Jake");
247
227
  });
@@ -266,20 +246,16 @@ test("auditing - ModelAPI.update", async () => {
266
246
  name: "Jake",
267
247
  });
268
248
 
269
- const row = await withDatabase(
270
- db,
271
- PROTO_ACTION_TYPES.CREATE,
272
- async ({ transaction }) => {
273
- const row = withAuditContext(request, async () => {
274
- return await personAPI.update({ id: created.id }, { name: "Jim" });
275
- });
249
+ const row = await withDatabase(db, true, async ({ transaction }) => {
250
+ const row = withAuditContext(request, async () => {
251
+ return await personAPI.update({ id: created.id }, { name: "Jim" });
252
+ });
276
253
 
277
- expect(await identityIdFromConfigParam(transaction)).toEqual(identityId);
278
- expect(await traceIdFromConfigParam(transaction)).toEqual(traceId);
254
+ expect(await identityIdFromConfigParam(transaction)).toEqual(identityId);
255
+ expect(await traceIdFromConfigParam(transaction)).toEqual(traceId);
279
256
 
280
- return row;
281
- }
282
- );
257
+ return row;
258
+ });
283
259
 
284
260
  expect(row.name).toEqual("Jim");
285
261
  });
@@ -304,20 +280,16 @@ test("auditing - ModelAPI.delete", async () => {
304
280
  name: "Jake",
305
281
  });
306
282
 
307
- const row = await withDatabase(
308
- db,
309
- PROTO_ACTION_TYPES.CREATE,
310
- async ({ transaction }) => {
311
- const row = withAuditContext(request, async () => {
312
- return await personAPI.delete({ id: created.id });
313
- });
283
+ const row = await withDatabase(db, true, async ({ transaction }) => {
284
+ const row = withAuditContext(request, async () => {
285
+ return await personAPI.delete({ id: created.id });
286
+ });
314
287
 
315
- expect(await identityIdFromConfigParam(transaction)).toEqual(identityId);
316
- expect(await traceIdFromConfigParam(transaction)).toEqual(traceId);
288
+ expect(await identityIdFromConfigParam(transaction)).toEqual(identityId);
289
+ expect(await traceIdFromConfigParam(transaction)).toEqual(traceId);
317
290
 
318
- return row;
319
- }
320
- );
291
+ return row;
292
+ });
321
293
 
322
294
  expect(row).toEqual(created.id);
323
295
  });
@@ -337,23 +309,19 @@ test("auditing - identity id and trace id fields dropped from result", async ()
337
309
  request.meta.tracing.traceparent
338
310
  ).traceId;
339
311
 
340
- const row = await withDatabase(
341
- db,
342
- PROTO_ACTION_TYPES.CREATE,
343
- async ({ transaction }) => {
344
- const row = withAuditContext(request, async () => {
345
- return await personAPI.create({
346
- id: KSUID.randomSync().string,
347
- name: "Jake",
348
- });
312
+ const row = await withDatabase(db, true, async ({ transaction }) => {
313
+ const row = withAuditContext(request, async () => {
314
+ return await personAPI.create({
315
+ id: KSUID.randomSync().string,
316
+ name: "Jake",
349
317
  });
318
+ });
350
319
 
351
- expect(await identityIdFromConfigParam(transaction)).toEqual(identityId);
352
- expect(await traceIdFromConfigParam(transaction)).toEqual(traceId);
320
+ expect(await identityIdFromConfigParam(transaction)).toEqual(identityId);
321
+ expect(await traceIdFromConfigParam(transaction)).toEqual(traceId);
353
322
 
354
- return row;
355
- }
356
- );
323
+ return row;
324
+ });
357
325
 
358
326
  expect(row.name).toEqual("Jake");
359
327
  expect(row.keelIdentityId).toBeUndefined();
package/src/database.js CHANGED
@@ -3,7 +3,6 @@ const neonserverless = require("@neondatabase/serverless");
3
3
  const { AsyncLocalStorage } = require("async_hooks");
4
4
  const { AuditContextPlugin } = require("./auditing");
5
5
  const pg = require("pg");
6
- const { PROTO_ACTION_TYPES } = require("./consts");
7
6
  const { withSpan } = require("./tracing");
8
7
  const ws = require("ws");
9
8
 
@@ -14,18 +13,7 @@ const ws = require("ws");
14
13
  // the user's custom function is wrapped in a transaction so we can rollback
15
14
  // the transaction if something goes wrong.
16
15
  // withDatabase shouldn't be exposed in the public api of the sdk
17
- async function withDatabase(db, actionType, cb) {
18
- let requiresTransaction = true;
19
-
20
- switch (actionType) {
21
- case PROTO_ACTION_TYPES.SUBSCRIBER:
22
- case PROTO_ACTION_TYPES.JOB:
23
- case PROTO_ACTION_TYPES.GET:
24
- case PROTO_ACTION_TYPES.LIST:
25
- requiresTransaction = false;
26
- break;
27
- }
28
-
16
+ async function withDatabase(db, requiresTransaction, cb) {
29
17
  // db.transaction() provides a kysely instance bound to a transaction.
30
18
  if (requiresTransaction) {
31
19
  return db.transaction().execute(async (transaction) => {
@@ -70,6 +58,7 @@ function useDatabase() {
70
58
 
71
59
  // If we've gotten to this point, then we know that we are in a custom function runtime server
72
60
  // context and we haven't been able to retrieve the in-context instance of Kysely, which means we should throw an error.
61
+ console.trace();
73
62
  throw new Error("useDatabase must be called within a function");
74
63
  }
75
64
 
package/src/handleJob.js CHANGED
@@ -57,8 +57,10 @@ async function handleJob(request, config) {
57
57
  const jobFunction = jobs[request.method];
58
58
  const actionType = PROTO_ACTION_TYPES.JOB;
59
59
 
60
+ const functionConfig = jobFunction?.config ?? {};
61
+
60
62
  await tryExecuteJob(
61
- { request, permitted, db, actionType },
63
+ { request, permitted, db, actionType, functionConfig },
62
64
  async () => {
63
65
  // parse request params to convert objects into rich field types (e.g. InlineFile)
64
66
  const inputs = parseInputs(request.params);
@@ -68,8 +68,18 @@ async function handleRequest(request, config) {
68
68
  const customFunction = functions[request.method];
69
69
  const actionType = actionTypes[request.method];
70
70
 
71
+ const functionConfig = customFunction?.config ?? {};
72
+
71
73
  const result = await tryExecuteFunction(
72
- { request, ctx, permitted, db, permissionFns, actionType },
74
+ {
75
+ request,
76
+ ctx,
77
+ permitted,
78
+ db,
79
+ permissionFns,
80
+ actionType,
81
+ functionConfig,
82
+ },
73
83
  async () => {
74
84
  // parse request params to convert objects into rich field types (e.g. InlineFile)
75
85
  const inputs = parseInputs(request.params);
@@ -80,6 +90,7 @@ async function handleRequest(request, config) {
80
90
  return customFunction(ctx, inputs);
81
91
  }
82
92
  );
93
+
83
94
  if (result instanceof Error) {
84
95
  span.recordException(result);
85
96
  span.setStatus({
@@ -52,13 +52,18 @@ async function handleSubscriber(request, config) {
52
52
  const subscriberFunction = subscribers[request.method];
53
53
  const actionType = PROTO_ACTION_TYPES.SUBSCRIBER;
54
54
 
55
- await tryExecuteSubscriber({ request, db, actionType }, async () => {
56
- // parse request params to convert objects into rich field types (e.g. InlineFile)
57
- const inputs = parseInputs(request.params);
55
+ const functionConfig = subscriberFunction?.config ?? {};
58
56
 
59
- // Return the subscriber function to the containing tryExecuteSubscriber block
60
- return subscriberFunction(ctx, inputs);
61
- });
57
+ await tryExecuteSubscriber(
58
+ { request, db, actionType, functionConfig },
59
+ async () => {
60
+ // parse request params to convert objects into rich field types (e.g. InlineFile)
61
+ const inputs = parseInputs(request.params);
62
+
63
+ // Return the subscriber function to the containing tryExecuteSubscriber block
64
+ return subscriberFunction(ctx, inputs);
65
+ }
66
+ );
62
67
 
63
68
  return createJSONRPCSuccessResponse(request.id, null);
64
69
  } catch (e) {
package/src/index.d.ts CHANGED
@@ -176,6 +176,9 @@ export declare class File extends InlineFile {
176
176
  get key(): string;
177
177
  // Gets size of the file's contents in bytes
178
178
  get isPublic(): boolean;
179
+ // Generates a presigned download URL
180
+ getPresignedUrl(): Promise<URL>;
181
+ // Creates a new instance from the database record
179
182
  static fromDbRecord(input: FileDbRecord): File;
180
183
  // Persists the file
181
184
  toDbRecord(): FileDbRecord;
@@ -224,3 +227,15 @@ export type Errors = {
224
227
  */
225
228
  Unknown: typeof UnknownError;
226
229
  };
230
+
231
+ export type FunctionConfig = {
232
+ /**
233
+ * All DB calls within the function will be executed within a transaction.
234
+ * The transaction is rolled back if the function throws an error.
235
+ */
236
+ dbTransaction?: boolean;
237
+ };
238
+
239
+ export type FuncWithConfig<T> = T & {
240
+ config: FunctionConfig;
241
+ };
@@ -106,7 +106,7 @@ test("nested image test", async () => {
106
106
  });
107
107
  });
108
108
 
109
- test("nested structure", async () => {
109
+ test("arrays", async () => {
110
110
  const params = {
111
111
  names: ["james", "susan"],
112
112
  ages: [1, 2],
@@ -11,11 +11,24 @@ const { PROTO_ACTION_TYPES } = require("./consts");
11
11
  // tryExecuteFunction will create a new database transaction around a function call
12
12
  // and handle any permissions checks. If a permission check fails, then an Error will be thrown and the catch block will be hit.
13
13
  function tryExecuteFunction(
14
- { request, db, permitted, permissionFns, actionType, ctx },
14
+ { request, db, permitted, permissionFns, actionType, ctx, functionConfig },
15
15
  cb
16
16
  ) {
17
17
  return withPermissions(permitted, async ({ getPermissionState }) => {
18
- return withDatabase(db, actionType, async ({ transaction }) => {
18
+ let requiresTransaction = true;
19
+ switch (actionType) {
20
+ case PROTO_ACTION_TYPES.GET:
21
+ case PROTO_ACTION_TYPES.LIST:
22
+ case PROTO_ACTION_TYPES.READ:
23
+ requiresTransaction = false;
24
+ break;
25
+ }
26
+
27
+ if (functionConfig?.dbTransaction !== undefined) {
28
+ requiresTransaction = functionConfig.dbTransaction;
29
+ }
30
+
31
+ return withDatabase(db, requiresTransaction, async ({ transaction }) => {
19
32
  const fnResult = await withAuditContext(request, async () => {
20
33
  return cb();
21
34
  });
@@ -5,9 +5,13 @@ const { PermissionError } = require("./errors");
5
5
 
6
6
  // tryExecuteJob will create a new database transaction around a function call
7
7
  // and handle any permissions checks. If a permission check fails, then an Error will be thrown and the catch block will be hit.
8
- function tryExecuteJob({ db, permitted, actionType, request }, cb) {
8
+ function tryExecuteJob({ db, permitted, request, functionConfig }, cb) {
9
9
  return withPermissions(permitted, async ({ getPermissionState }) => {
10
- return withDatabase(db, actionType, async () => {
10
+ let requiresTransaction = false;
11
+ if (functionConfig?.dbTransaction !== undefined) {
12
+ requiresTransaction = functionConfig.dbTransaction;
13
+ }
14
+ return withDatabase(db, requiresTransaction, async () => {
11
15
  await withAuditContext(request, async () => {
12
16
  return cb();
13
17
  });
@@ -2,8 +2,12 @@ const { withDatabase } = require("./database");
2
2
  const { withAuditContext } = require("./auditing");
3
3
 
4
4
  // tryExecuteSubscriber will create a new database connection and execute the function call.
5
- function tryExecuteSubscriber({ request, db, actionType }, cb) {
6
- return withDatabase(db, actionType, async () => {
5
+ function tryExecuteSubscriber({ request, db, functionConfig }, cb) {
6
+ let requiresTransaction = false;
7
+ if (functionConfig?.dbTransaction !== undefined) {
8
+ requiresTransaction = functionConfig.dbTransaction;
9
+ }
10
+ return withDatabase(db, requiresTransaction, async () => {
7
11
  await withAuditContext(request, async () => {
8
12
  return cb();
9
13
  });