@balena/pinejs 21.4.0-build-add-odata-actions-8d4944e07924e5cf0a69183c18610e8fd2c41d3b-1 → 21.4.0-build-add-actions-example-f26873fd527dfaaabb15c7bc21476f6b48bfc38a-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.
Files changed (52) hide show
  1. package/.pinejs-cache.json +1 -1
  2. package/.versionbot/CHANGELOG.yml +49 -1
  3. package/CHANGELOG.md +6 -0
  4. package/out/config-loader/config-loader.js +8 -7
  5. package/out/config-loader/config-loader.js.map +1 -1
  6. package/out/config-loader/env.d.ts +4 -0
  7. package/out/config-loader/env.js +4 -0
  8. package/out/config-loader/env.js.map +1 -1
  9. package/out/server-glue/module.js +2 -0
  10. package/out/server-glue/module.js.map +1 -1
  11. package/out/tasks/index.d.ts +1 -0
  12. package/out/tasks/index.js +6 -1
  13. package/out/tasks/index.js.map +1 -1
  14. package/out/tasks/pine-tasks.d.ts +1 -0
  15. package/out/tasks/pine-tasks.js +7 -0
  16. package/out/tasks/pine-tasks.js.map +1 -0
  17. package/out/webresource-handler/actions/beginUpload.d.ts +3 -0
  18. package/out/webresource-handler/actions/beginUpload.js +105 -0
  19. package/out/webresource-handler/actions/beginUpload.js.map +1 -0
  20. package/out/webresource-handler/actions/canceUpload.d.ts +3 -0
  21. package/out/webresource-handler/actions/canceUpload.js +58 -0
  22. package/out/webresource-handler/actions/canceUpload.js.map +1 -0
  23. package/out/webresource-handler/actions/commitUpload.d.ts +3 -0
  24. package/out/webresource-handler/actions/commitUpload.js +85 -0
  25. package/out/webresource-handler/actions/commitUpload.js.map +1 -0
  26. package/out/webresource-handler/delete-file-task.d.ts +1 -0
  27. package/out/webresource-handler/delete-file-task.js +37 -0
  28. package/out/webresource-handler/delete-file-task.js.map +1 -0
  29. package/out/webresource-handler/index.d.ts +10 -1
  30. package/out/webresource-handler/index.js +83 -45
  31. package/out/webresource-handler/index.js.map +1 -1
  32. package/out/webresource-handler/multipartUpload.d.ts +4 -0
  33. package/out/webresource-handler/multipartUpload.js +14 -0
  34. package/out/webresource-handler/multipartUpload.js.map +1 -0
  35. package/out/webresource-handler/webresource.d.ts +42 -0
  36. package/out/webresource-handler/webresource.js +2 -0
  37. package/out/webresource-handler/webresource.js.map +1 -0
  38. package/out/webresource-handler/webresource.sbvr +60 -0
  39. package/package.json +5 -5
  40. package/src/config-loader/config-loader.ts +9 -9
  41. package/src/config-loader/env.ts +11 -0
  42. package/src/server-glue/module.ts +2 -0
  43. package/src/tasks/index.ts +9 -1
  44. package/src/tasks/pine-tasks.ts +7 -0
  45. package/src/webresource-handler/actions/beginUpload.ts +163 -0
  46. package/src/webresource-handler/actions/canceUpload.ts +92 -0
  47. package/src/webresource-handler/actions/commitUpload.ts +117 -0
  48. package/src/webresource-handler/delete-file-task.ts +42 -0
  49. package/src/webresource-handler/index.ts +117 -83
  50. package/src/webresource-handler/multipartUpload.ts +23 -0
  51. package/src/webresource-handler/webresource.sbvr +60 -0
  52. package/src/webresource-handler/webresource.ts +48 -0
@@ -11,10 +11,20 @@ import {
11
11
  odataNameToSqlName,
12
12
  sqlNameToODataName,
13
13
  } from '@balena/odata-to-abstract-sql';
14
+ import type { ConfigLoader } from '../server-glue/module.js';
14
15
  import { errors, permissions } from '../server-glue/module.js';
15
16
  import type { WebResourceType as WebResource } from '@balena/sbvr-types';
16
17
  import { TypedError } from 'typed-error';
17
18
  import type { Resolvable } from '../sbvr-api/common-types.js';
19
+ import type { Tx } from '../database-layer/db.js';
20
+ import { isPineTasksAvailable } from '../tasks/index.js';
21
+ import type WebresourceModel from './webresource.js';
22
+ import { importSBVR } from '../server-glue/sbvr-loader.js';
23
+ import { beginUploadAction } from './actions/beginUpload.js';
24
+ import { isMultipartUploadAvailable } from './multipartUpload.js';
25
+ import { commitUploadAction } from './actions/commitUpload.js';
26
+ import { cancelUploadAction } from './actions/canceUpload.js';
27
+ import { addAction } from '../sbvr-api/actions.js';
18
28
 
19
29
  export * from './handlers/index.js';
20
30
 
@@ -316,12 +326,34 @@ export const getWebResourceFields = (
316
326
  .map((f) => sqlNameToODataName(f.fieldName));
317
327
  };
318
328
 
319
- const deleteFiles = async (
320
- keysToDelete: string[],
321
- webResourceHandler: WebResourceHandler,
322
- ) => {
323
- const promises = keysToDelete.map((r) => webResourceHandler.removeFile(r));
324
- await Promise.all(promises);
329
+ const deleteOnStorage = async (keysToDelete: string[], tx?: Tx) => {
330
+ if (isPineTasksAvailable()) {
331
+ await Promise.all(
332
+ keysToDelete.map(async (fileKey) => {
333
+ return await sbvrUtils.api.tasks.post({
334
+ resource: 'task',
335
+ passthrough: {
336
+ req: permissions.root,
337
+ tx,
338
+ },
339
+ body: {
340
+ key: crypto.randomUUID(),
341
+ is_executed_by__handler: 'delete_webresource_file',
342
+ is_executed_with__parameter_set: {
343
+ fileKey: fileKey,
344
+ },
345
+ attempt_limit: 2 ** 31 - 1,
346
+ },
347
+ });
348
+ }),
349
+ );
350
+ } else {
351
+ await Promise.all(
352
+ keysToDelete.map(async (fileKey) => {
353
+ return await configuredWebResourceHandler?.removeFile(fileKey);
354
+ }),
355
+ );
356
+ }
325
357
  };
326
358
 
327
359
  const throwIfWebresourceNotInMultipart = (
@@ -329,6 +361,13 @@ const throwIfWebresourceNotInMultipart = (
329
361
  { req, request }: HookArgs,
330
362
  ) => {
331
363
  if (
364
+ // root needs to be able to bypass the multipart check as
365
+ // it needs to pass the direct payload on multipart uploads (on storage provider)
366
+ req.user !== permissions.root.user &&
367
+ // This is checking for HTTP multipart form submission/request (e.g. send the actual file via the API)
368
+ // Not to confuse with multipart uploads
369
+ // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods/POST#multipart_form_submission
370
+ // See: https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
332
371
  !req.is?.('multipart') &&
333
372
  webResourceFields.some((field) => request.values[field] != null)
334
373
  ) {
@@ -338,18 +377,22 @@ const throwIfWebresourceNotInMultipart = (
338
377
  }
339
378
  };
340
379
 
341
- const getCreateWebResourceHooks = (
342
- webResourceHandler: WebResourceHandler,
343
- ): sbvrUtils.Hooks => {
380
+ const getCreateWebResourceHooks = (): sbvrUtils.Hooks => {
344
381
  return {
345
382
  PRERUN: (hookArgs) => {
346
383
  const webResourceFields = getWebResourceFields(hookArgs.request);
347
384
  throwIfWebresourceNotInMultipart(webResourceFields, hookArgs);
348
385
  },
349
- 'POSTRUN-ERROR': ({ tx, request }) => {
350
- tx.on('rollback', () => {
351
- void deleteRollbackPendingFields(request, webResourceHandler);
352
- });
386
+ 'POSTRUN-ERROR': async ({ request }) => {
387
+ const fields = getWebResourceFields(request);
388
+
389
+ if (fields.length === 0) {
390
+ return;
391
+ }
392
+
393
+ const keysToDelete = getWebResourcesKeysFromRequest(fields, request);
394
+ // Explicitely not passing tx as it will be rolledback as it is an error hook
395
+ await deleteOnStorage(keysToDelete);
353
396
  },
354
397
  };
355
398
  };
@@ -378,21 +421,12 @@ const getWebResourcesKeysFromRequest = (
378
421
  .filter((href) => href != null);
379
422
  };
380
423
 
381
- const getRemoveWebResourceHooks = (
382
- webResourceHandler: WebResourceHandler,
383
- ): sbvrUtils.Hooks => {
424
+ const getRemoveWebResourceHooks = (): sbvrUtils.Hooks => {
384
425
  return {
385
426
  PRERUN: async (args) => {
386
427
  const { api, request, tx } = args;
387
428
  let webResourceFields = getWebResourceFields(request);
388
429
 
389
- throwIfWebresourceNotInMultipart(webResourceFields, args);
390
-
391
- // Request failed on DB roundtrip (e.g. DB constraint) and pending files need to be deleted
392
- tx.on('rollback', () => {
393
- void deleteRollbackPendingFields(request, webResourceHandler);
394
- });
395
-
396
430
  if (request.method === 'PATCH') {
397
431
  webResourceFields = Object.entries(request.values)
398
432
  .filter(
@@ -401,6 +435,8 @@ const getRemoveWebResourceHooks = (
401
435
  )
402
436
  .map(([key]) => key);
403
437
  }
438
+ request.custom.webResourceFields = webResourceFields;
439
+ throwIfWebresourceNotInMultipart(webResourceFields, args);
404
440
 
405
441
  if (webResourceFields.length === 0) {
406
442
  // No need to delete anything as no file is in the wire
@@ -411,43 +447,32 @@ const getRemoveWebResourceHooks = (
411
447
  // This can only be validated here because we need to first ensure the
412
448
  // request is actually modifying a webresource before erroring out
413
449
  if (request.method === 'PATCH' && request.odataQuery?.key == null) {
414
- // When we get here, files have already been uploaded. We need to mark them for deletion.
415
- const keysToDelete = getWebResourcesKeysFromRequest(
416
- webResourceFields,
417
- request,
418
- );
419
-
420
- // Set deletion of files on the wire as request will throw
421
- tx.on('end', () => {
422
- deletePendingFiles(keysToDelete, request, webResourceHandler);
423
- });
424
-
425
450
  throw new errors.BadRequestError(
426
451
  'WebResources can only be updated when providing a resource key.',
427
452
  );
428
453
  }
429
454
 
430
- // This can be > 1 in both DELETE requests or PATCH requests to not accessible IDs.
431
455
  const ids = await sbvrUtils.getAffectedIds(args);
432
456
  if (ids.length === 0) {
433
457
  // Set deletion of files on the wire as no resource was affected
434
- // Note that for DELETE requests it should not find any request on the wire
435
- const keysToDelete = getWebResourcesKeysFromRequest(
458
+ request.custom.onPostRunDelete = getWebResourcesKeysFromRequest(
436
459
  webResourceFields,
437
460
  request,
438
461
  );
439
- deletePendingFiles(keysToDelete, request, webResourceHandler);
440
462
  return;
441
463
  }
442
464
 
465
+ // If it reaches here, it means that it will try to patch/delete the webresource
466
+ // So we need (before postrun) get what are the current keys to be deleted
467
+ // if post run succeeds
443
468
  const webResources = (await api.get({
444
469
  resource: request.resourceName,
445
470
  passthrough: {
446
- tx: args.tx,
471
+ tx,
447
472
  req: permissions.root,
448
473
  },
449
474
  options: {
450
- $select: webResourceFields,
475
+ $select: request.custom.webResourceFields,
451
476
  $filter: {
452
477
  id: {
453
478
  $in: ids,
@@ -455,60 +480,34 @@ const getRemoveWebResourceHooks = (
455
480
  },
456
481
  },
457
482
  })) as WebResourcesDbResponse[] | undefined | null;
483
+ request.custom.onPostRunDelete = getWebResourcesHrefs(webResources);
484
+ },
458
485
 
459
- // Deletes previous stored resources in case they were patched or the whole entity was deleted
460
- tx.on('end', () => {
461
- deletePendingFiles(
462
- getWebResourcesHrefs(webResources),
463
- request,
464
- webResourceHandler,
465
- );
466
- });
486
+ POSTRUN: async ({ request, tx }) => {
487
+ // Either patch or delete worked. In either case, schedule the previous existing files to delete
488
+ await deleteOnStorage(request.custom.onPostRunDelete ?? [], tx);
489
+ },
490
+ 'POSTRUN-ERROR': async ({ request }) => {
491
+ const keysToDelete = getWebResourcesKeysFromRequest(
492
+ request.custom.webResourceFields,
493
+ request,
494
+ );
495
+ // Explicitely not passing tx as it will be rolledback as it is an error hook
496
+ await deleteOnStorage(keysToDelete);
467
497
  },
468
498
  };
469
499
  };
470
500
 
471
- const deleteRollbackPendingFields = async (
472
- request: uriParser.ODataRequest,
473
- webResourceHandler: WebResourceHandler,
474
- ) => {
475
- const fields = getWebResourceFields(request);
476
-
477
- if (fields.length === 0) {
478
- return;
479
- }
480
-
481
- const keysToDelete = getWebResourcesKeysFromRequest(fields, request);
482
- await deleteFiles(keysToDelete, webResourceHandler);
483
- };
484
-
485
- const deletePendingFiles = (
486
- keysToDelete: string[],
487
- request: uriParser.ODataRequest,
488
- webResourceHandler: WebResourceHandler,
489
- ): void => {
490
- // on purpose does not await for this promise to resolve
491
- try {
492
- void deleteFiles(keysToDelete, webResourceHandler);
493
- } catch (err) {
494
- getLogger(request.vocabulary).error(`Failed to delete pending files`, err);
495
- }
496
- };
497
-
498
501
  export const getDefaultHandler = (): WebResourceHandler => {
499
502
  return new NoopHandler();
500
503
  };
501
504
 
502
- export const setupUploadHooks = (
503
- handler: WebResourceHandler,
504
- apiRoot: string,
505
- resourceName: string,
506
- ) => {
505
+ export const setupUploadHooks = (apiRoot: string, resourceName: string) => {
507
506
  sbvrUtils.addPureHook(
508
507
  'DELETE',
509
508
  apiRoot,
510
509
  resourceName,
511
- getRemoveWebResourceHooks(handler),
510
+ getRemoveWebResourceHooks(),
512
511
  );
513
512
 
514
513
  sbvrUtils.addPureHook(
@@ -516,13 +515,48 @@ export const setupUploadHooks = (
516
515
  apiRoot,
517
516
  resourceName,
518
517
  // PATCH also needs to remove the old resource in case a webresource was modified
519
- getRemoveWebResourceHooks(handler),
518
+ getRemoveWebResourceHooks(),
520
519
  );
521
520
 
522
521
  sbvrUtils.addPureHook(
523
522
  'POST',
524
523
  apiRoot,
525
524
  resourceName,
526
- getCreateWebResourceHooks(handler),
525
+ getCreateWebResourceHooks(),
527
526
  );
528
527
  };
528
+
529
+ export const setupUploadActions = (apiRoot: string, resourceName: string) => {
530
+ const resource = sqlNameToODataName(resourceName);
531
+ if (isMultipartUploadAvailable(configuredWebResourceHandler)) {
532
+ addAction(apiRoot, resource, 'beginUpload', beginUploadAction);
533
+
534
+ addAction(apiRoot, resource, 'commitUpload', commitUploadAction);
535
+
536
+ addAction(apiRoot, resource, 'cancelUpload', cancelUploadAction);
537
+ }
538
+ };
539
+
540
+ const initSql = `
541
+ CREATE INDEX IF NOT EXISTS idx_multipart_upload_uuid ON "multipart upload" (uuid);
542
+ CREATE INDEX IF NOT EXISTS idx_multipart_upload_status ON "multipart upload" (status);
543
+ `;
544
+
545
+ const modelText = await importSBVR('./webresource.sbvr', import.meta);
546
+
547
+ declare module '../sbvr-api/sbvr-utils.js' {
548
+ export interface API {
549
+ webresource: PinejsClient<WebresourceModel>;
550
+ }
551
+ }
552
+
553
+ export const config: ConfigLoader.Config = {
554
+ models: [
555
+ {
556
+ modelName: 'webresource',
557
+ apiRoot: 'webresource',
558
+ modelText,
559
+ initSql,
560
+ },
561
+ ],
562
+ };
@@ -0,0 +1,23 @@
1
+ import { webResource as webResourceEnv } from '../config-loader/env.js';
2
+ import { NotImplementedError } from '../sbvr-api/errors.js';
3
+ import type { WebResourceHandler } from './index.js';
4
+ import { getWebresourceHandler } from './index.js';
5
+
6
+ export type MultipartUploadHandler = WebResourceHandler &
7
+ Required<Pick<WebResourceHandler, 'multipartUpload'>>;
8
+
9
+ export const isMultipartUploadAvailable = (
10
+ handler: WebResourceHandler | undefined,
11
+ ): handler is MultipartUploadHandler => {
12
+ return (
13
+ webResourceEnv.multipartUploadEnabled && handler?.multipartUpload != null
14
+ );
15
+ };
16
+
17
+ export const getMultipartUploadHandler = () => {
18
+ const handler = getWebresourceHandler();
19
+ if (!isMultipartUploadAvailable(handler)) {
20
+ throw new NotImplementedError('Multipart uploads not available');
21
+ }
22
+ return handler;
23
+ };
@@ -0,0 +1,60 @@
1
+ Vocabulary: webresource
2
+
3
+ Term: actor
4
+ Concept Type: Integer (Type)
5
+ Term: expiry date
6
+ Concept Type: Date Time (Type)
7
+ Term: uuid
8
+ Concept Type: Short Text (Type)
9
+ Term: resource name
10
+ Concept Type: Short Text (Type)
11
+ Term: field name
12
+ Concept Type: Short Text (Type)
13
+ Term: resource id
14
+ Concept Type: Integer (Type)
15
+ Term: upload id
16
+ Concept Type: Short Text (Type)
17
+ Term: file key
18
+ Concept Type: Short Text (Type)
19
+ Term: status
20
+ Concept Type: Short Text (Type)
21
+ Term: filename
22
+ Concept Type: Short Text (Type)
23
+ Term: content type
24
+ Concept Type: Short Text (Type)
25
+ Term: size
26
+ Concept Type: Big Integer (Type)
27
+ Term: chunk size
28
+ Concept Type: Integer (Type)
29
+ Term: valid until date
30
+ Concept Type: Date Time (Type)
31
+
32
+ Term: multipart upload
33
+ Fact type: multipart upload has uuid
34
+ Necessity: each multipart upload has exactly one uuid
35
+ Necessity: each uuid is of exactly one multipart upload
36
+ Fact type: multipart upload has resource name
37
+ Necessity: each multipart upload has exactly one resource name
38
+ Fact type: multipart upload has field name
39
+ Necessity: each multipart upload has exactly one field name
40
+ Fact type: multipart upload has resource id
41
+ Necessity: each multipart upload has exactly one resource id
42
+ Fact type: multipart upload has upload id
43
+ Necessity: each multipart upload has exactly one upload id
44
+ Fact type: multipart upload has file key
45
+ Necessity: each multipart upload has exactly one file key
46
+ Fact type: multipart upload has status
47
+ Necessity: each multipart upload has exactly one status
48
+ Definition: "pending" or "completed" or "cancelled"
49
+ Fact type: multipart upload has filename
50
+ Necessity: each multipart upload has exactly one filename
51
+ Fact type: multipart upload has content type
52
+ Necessity: each multipart upload has exactly one content type
53
+ Fact type: multipart upload has size
54
+ Necessity: each multipart upload has exactly one size
55
+ Fact type: multipart upload has chunk size
56
+ Necessity: each multipart upload has exactly one chunk size
57
+ Fact type: multipart upload has expiry date
58
+ Necessity: each multipart upload has exactly one expiry date
59
+ Fact type: multipart upload is created by actor
60
+ Necessity: each multipart upload is created by at most one actor
@@ -0,0 +1,48 @@
1
+ // These types were generated by @balena/abstract-sql-to-typescript v5.1.0
2
+
3
+ import type { Types } from '@balena/abstract-sql-to-typescript';
4
+
5
+ export interface MultipartUpload {
6
+ Read: {
7
+ created_at: Types['Date Time']['Read'];
8
+ modified_at: Types['Date Time']['Read'];
9
+ id: Types['Serial']['Read'];
10
+ uuid: Types['Short Text']['Read'];
11
+ resource_name: Types['Short Text']['Read'];
12
+ field_name: Types['Short Text']['Read'];
13
+ resource_id: Types['Integer']['Read'];
14
+ upload_id: Types['Short Text']['Read'];
15
+ file_key: Types['Short Text']['Read'];
16
+ status: 'pending' | 'completed' | 'cancelled';
17
+ filename: Types['Short Text']['Read'];
18
+ content_type: Types['Short Text']['Read'];
19
+ size: Types['Big Integer']['Read'];
20
+ chunk_size: Types['Integer']['Read'];
21
+ expiry_date: Types['Date Time']['Read'];
22
+ is_created_by__actor: Types['Integer']['Read'] | null;
23
+ };
24
+ Write: {
25
+ created_at: Types['Date Time']['Write'];
26
+ modified_at: Types['Date Time']['Write'];
27
+ id: Types['Serial']['Write'];
28
+ uuid: Types['Short Text']['Write'];
29
+ resource_name: Types['Short Text']['Write'];
30
+ field_name: Types['Short Text']['Write'];
31
+ resource_id: Types['Integer']['Write'];
32
+ upload_id: Types['Short Text']['Write'];
33
+ file_key: Types['Short Text']['Write'];
34
+ status: 'pending' | 'completed' | 'cancelled';
35
+ filename: Types['Short Text']['Write'];
36
+ content_type: Types['Short Text']['Write'];
37
+ size: Types['Big Integer']['Write'];
38
+ chunk_size: Types['Integer']['Write'];
39
+ expiry_date: Types['Date Time']['Write'];
40
+ is_created_by__actor: Types['Integer']['Write'] | null;
41
+ };
42
+ }
43
+
44
+ export default interface $Model {
45
+ multipart_upload: MultipartUpload;
46
+
47
+
48
+ }