├── .dockerignore ├── .gitattributes ├── .github └── workflows │ └── flowzone.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmignore ├── .npmrc ├── .resinci.yml ├── .versionbot └── CHANGELOG.yml ├── CHANGELOG.md ├── Dockerfile ├── Gruntfile.cts ├── LICENSE ├── README.md ├── VERSION ├── bin ├── abstract-sql-compiler.js ├── odata-compiler.js └── sbvr-compiler.js ├── build ├── browser.cts ├── config.cts ├── module.cts └── server.cts ├── demo ├── editor │ ├── data.sql │ └── model.sbvr └── validation │ └── nhs.sbvr.txt ├── docker-compose.npm-test.yml ├── docs ├── AdvancedUsage.md ├── Architecture.md ├── Building.md ├── CustomServerCode.md ├── GettingStarted.md ├── Hooks.md ├── Migrations.md ├── ProjectConfig.md ├── Testing.md ├── Types.md ├── coding-guidelines │ ├── coffee.txt │ ├── filenames.txt │ ├── jade.txt │ ├── js.txt │ └── ometa.txt └── sequence-diagrams │ ├── parse-request.sequence │ ├── process-odata-response.sequence │ ├── run-query.sequence │ ├── run-request.sequence │ └── validate-database.sequence ├── migrations ├── 0.6-add-created-at.sql ├── 2.0-add-actors.sql ├── 2.0-add-expiry-date.sql ├── 4.0.0-add-api_key-name-description.sql ├── 4.0.0-change-permission-to-text.sql ├── 5.0.0-schema-changes.sql └── 8.3.0-fix-migration-primary-key.sql ├── package.json ├── pinejs.png ├── repo.yml ├── src ├── bin │ ├── abstract-sql-compiler.ts │ ├── odata-compiler.ts │ ├── sbvr-compiler.ts │ └── utils.ts ├── config-loader │ ├── config-loader.ts │ ├── env.ts │ └── sample-config.json ├── database-layer │ └── db.ts ├── express-emulator │ └── express.js ├── extended-sbvr-parser │ └── extended-sbvr-parser.ts ├── migrator │ ├── async.ts │ ├── migrations.sbvr │ ├── migrations.ts │ ├── sync.ts │ └── utils.ts ├── odata-metadata │ └── odata-metadata-generator.ts ├── passport-pinejs │ ├── mount-login-router.ts │ └── passport-pinejs.ts ├── pinejs-session-store │ └── pinejs-session-store.ts ├── sbvr-api │ ├── abstract-sql.ts │ ├── actions.ts │ ├── cached-compile.ts │ ├── common-types.ts │ ├── control-flow.ts │ ├── dev.sbvr │ ├── dev.ts │ ├── errors.ts │ ├── express-extension.ts │ ├── hooks.ts │ ├── odata-response.ts │ ├── permissions.ts │ ├── sbvr-utils.ts │ ├── translations.ts │ ├── uri-parser.ts │ ├── user.sbvr │ └── user.ts ├── server-glue │ ├── global-ext.d.ts │ ├── module.ts │ ├── sbvr-loader.ts │ └── server.ts ├── tasks │ ├── common.ts │ ├── index.ts │ ├── pine-tasks.ts │ ├── tasks.sbvr │ ├── tasks.ts │ └── worker.ts └── webresource-handler │ ├── actions │ ├── beginUpload.ts │ ├── cancelUpload.ts │ ├── commitUpload.ts │ └── index.ts │ ├── delete-file-task.ts │ ├── handlers │ ├── NoopHandler.ts │ └── index.ts │ ├── index.ts │ ├── multipartUpload.ts │ ├── webresource.sbvr │ └── webresource.ts ├── test ├── 00-basic.test.ts ├── 01-constrain.test.ts ├── 02-sync-migrator.test.ts ├── 03-async-migrator.test.ts ├── 04-translations.test.ts ├── 05-request-cancellation.test.ts ├── 06-webresource.test.ts ├── 07-permissions.test.ts ├── 08-tasks.test.ts ├── 09-actions.test.ts ├── canvas-demo │ ├── setup │ │ └── instructions.txt │ ├── test-demo.py │ └── test-multiword.py ├── fixtures │ ├── 00-basic │ │ ├── config.ts │ │ └── example.sbvr │ ├── 01-constrain │ │ ├── config.ts │ │ └── university.sbvr │ ├── 02-sync-migrator │ │ ├── 00-execute-model.ts │ │ ├── 01-migrations.ts │ │ ├── 01-migrations │ │ │ ├── 0001-add-data.sql │ │ │ └── 0002-test-migrations.sync.ts │ │ ├── 02-migrations-error.ts │ │ ├── 02-migrations-error │ │ │ ├── 0001-add-data.sql │ │ │ └── 0002-test-migrations.sync.ts │ │ ├── 03-exclusive-category.ts │ │ ├── 04-new-model-with-init.ts │ │ ├── example.sbvr │ │ └── init-data.sql │ ├── 03-async-migrator │ │ ├── 00-execute-model.ts │ │ ├── 01-migrations.ts │ │ ├── 01-migrations │ │ │ └── migrations │ │ │ │ ├── 0001-add-data.sql │ │ │ │ ├── 0002-test-migrations.async.ts │ │ │ │ └── 0003-finalized-test-migration.async.ts │ │ ├── 02-parallel-migrations.ts │ │ ├── 02-parallel-migrations │ │ │ └── migrations │ │ │ │ ├── 0001-migrate-testa.async.ts │ │ │ │ └── 0002-migrate-testb.async.ts │ │ ├── 03-finalize-async.ts │ │ ├── 03-finalize-async │ │ │ └── migrations │ │ │ │ ├── m0001-data-copy.async.ts │ │ │ │ ├── m0002-data-copy.async.ts │ │ │ │ └── m0003-add-data.sync.sql │ │ ├── 04-migration-errors.ts │ │ ├── 04-migration-errors │ │ │ └── migrations │ │ │ │ ├── 0001-successful-migration.async.ts │ │ │ │ ├── 0002-table-not-exists.async.ts │ │ │ │ └── 0003-error-unique-key.async.ts │ │ ├── 05-massive-data.ts │ │ ├── 05-massive-data │ │ │ ├── 00-execute-model.ts │ │ │ ├── init-data.sql │ │ │ └── migrations │ │ │ │ └── 0001-massive-migration.async.ts │ │ ├── 06-setup-errors.ts │ │ ├── 06-setup-errors │ │ │ └── 0001-error-async-sync-pair.async.ts │ │ ├── 07-setup-error-mixed-migrations.ts │ │ ├── 08-01-async-lock-taker.ts │ │ ├── 08-02-sync-lock-starvation.ts │ │ ├── example.sbvr │ │ └── init-data.sql │ ├── 04-translations │ │ ├── config.ts │ │ ├── translations │ │ │ ├── hooks.ts │ │ │ ├── v1 │ │ │ │ ├── hooks.ts │ │ │ │ ├── index.ts │ │ │ │ └── university.sbvr │ │ │ ├── v2 │ │ │ │ ├── hooks.ts │ │ │ │ ├── index.ts │ │ │ │ └── university.sbvr │ │ │ ├── v3 │ │ │ │ ├── hooks.ts │ │ │ │ ├── index.ts │ │ │ │ └── university.sbvr │ │ │ ├── v4 │ │ │ │ ├── index.ts │ │ │ │ └── university.sbvr │ │ │ └── v5 │ │ │ │ ├── index.ts │ │ │ │ └── university.sbvr │ │ └── university.sbvr │ ├── 05-request-cancellation │ │ ├── config.ts │ │ ├── example.sbvr │ │ ├── hooks.ts │ │ ├── routes.ts │ │ └── util.ts │ ├── 06-webresource │ │ ├── config.ts │ │ ├── example.sbvr │ │ ├── resources │ │ │ ├── avatar-profile.png │ │ │ └── other-image.png │ │ └── translations │ │ │ ├── hooks.ts │ │ │ └── v1 │ │ │ ├── example.sbvr │ │ │ ├── hooks.ts │ │ │ └── index.ts │ ├── 07-permissions │ │ ├── config.ts │ │ └── university.sbvr │ ├── 08-tasks │ │ ├── config.ts │ │ ├── example.sbvr │ │ └── task-handlers.ts │ └── 09-actions │ │ ├── actions-university.ts │ │ ├── actions.ts │ │ ├── actionsUniversity.sbvr │ │ ├── config.ts │ │ └── translations │ │ ├── hooks.ts │ │ └── v1 │ │ ├── actions.ts │ │ ├── hooks.ts │ │ ├── index.ts │ │ ├── v1-actions-university.ts │ │ └── v1actionsUniversity.sbvr └── lib │ ├── common.ts │ ├── pine-in-process.ts │ ├── pine-init.ts │ └── test-init.ts ├── tsconfig.dev.json ├── tsconfig.json └── typings ├── lf-to-abstract-sql.d.ts ├── memoizee.d.ts └── sbvr-parser.d.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | out 3 | #ignore .env in docker container as they are executed with docker compose environments 4 | .env 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | *.sln merge=union 7 | *.csproj merge=union 8 | *.vbproj merge=union 9 | *.fsproj merge=union 10 | *.dbproj merge=union 11 | 12 | # Standard to msysgit 13 | *.doc diff=astextplain 14 | *.DOC diff=astextplain 15 | *.docx diff=astextplain 16 | *.DOCX diff=astextplain 17 | *.dot diff=astextplain 18 | *.DOT diff=astextplain 19 | *.pdf diff=astextplain 20 | *.PDF diff=astextplain 21 | *.rtf diff=astextplain 22 | *.RTF diff=astextplain 23 | -------------------------------------------------------------------------------- /.github/workflows/flowzone.yml: -------------------------------------------------------------------------------- 1 | name: Flowzone 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, closed] 6 | branches: [main, master] 7 | # allow external contributions to use secrets within trusted code 8 | pull_request_target: 9 | types: [opened, synchronize, closed] 10 | branches: [main, master] 11 | 12 | jobs: 13 | flowzone: 14 | name: Flowzone 15 | uses: product-os/flowzone/.github/workflows/flowzone.yml@master 16 | # prevent duplicate workflows and only allow one `pull_request` or `pull_request_target` for 17 | # internal or external contributions respectively 18 | if: | 19 | (github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request') || 20 | (github.event.pull_request.head.repo.full_name != github.repository && github.event_name == 'pull_request_target') 21 | secrets: inherit 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Numerous always-ignore extensions 2 | *.diff 3 | *.err 4 | *.orig 5 | *.log 6 | *.rej 7 | *.swo 8 | *.swp 9 | *.vi 10 | *~ 11 | *.sass-cache 12 | 13 | # OS or Editor folders 14 | .DS_Store 15 | .cache 16 | .project 17 | .settings 18 | .tmproj 19 | nbproject 20 | Thumbs.db 21 | 22 | # Dreamweaver added files 23 | _notes 24 | dwsync.xml 25 | 26 | # Komodo 27 | *.komodoproject 28 | .komodotools 29 | 30 | # Folders to ignore 31 | .hg 32 | .svn 33 | .CVS 34 | intermediate 35 | publish 36 | .idea 37 | 38 | # build script local files 39 | build/buildinfo.properties 40 | build/config/buildinfo.properties 41 | 42 | /cache.properties 43 | node_modules 44 | out 45 | 46 | .baseDir.ts 47 | /.tscache 48 | 49 | .pine* 50 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | 2 | npx --no lint-staged 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /test 2 | /demo 3 | /.vscode 4 | /.tscache 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.resinci.yml: -------------------------------------------------------------------------------- 1 | disabled: true -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine as runner 2 | 3 | WORKDIR /usr/src/pine 4 | 5 | COPY . ./ 6 | RUN npm install 7 | 8 | 9 | FROM runner as sut 10 | CMD npm run mocha 11 | 12 | FROM runner 13 | -------------------------------------------------------------------------------- /Gruntfile.cts: -------------------------------------------------------------------------------- 1 | import type * as Grunt from 'grunt'; 2 | import type { WebpackPluginInstance } from 'webpack'; 3 | 4 | import _ from 'lodash'; 5 | import TerserPlugin from 'terser-webpack-plugin'; 6 | import browserConfig from './build/browser.cts'; 7 | import moduleConfig from './build/module.cts'; 8 | import serverConfig from './build/server.cts'; 9 | 10 | const serverConfigs = { 11 | browser: browserConfig, 12 | module: moduleConfig, 13 | server: serverConfig, 14 | }; 15 | 16 | for (const config of Object.values(serverConfigs)) { 17 | config.optimization = { 18 | minimizer: [ 19 | new TerserPlugin({ 20 | parallel: true, 21 | terserOptions: { 22 | output: { 23 | beautify: true, 24 | ascii_only: true, 25 | }, 26 | compress: { 27 | sequences: false, 28 | unused: false, // We need this off for OMeta 29 | }, 30 | mangle: false, 31 | }, 32 | }), 33 | ], 34 | }; 35 | } 36 | 37 | export = (grunt: typeof Grunt) => { 38 | grunt.initConfig({ 39 | clean: { 40 | default: { 41 | src: [`<%= grunt.option('target') %>`], 42 | options: { 43 | force: true, 44 | }, 45 | }, 46 | }, 47 | 48 | checkDependencies: { 49 | this: { 50 | options: { 51 | packageManager: 'npm', 52 | // TODO: Enable when grunt-check-dependencies works correctly with deduped packages. 53 | // onlySpecified: true 54 | }, 55 | }, 56 | }, 57 | 58 | concat: _.mapValues(serverConfigs, (config, task) => { 59 | const defines = ( 60 | config.plugins as Array< 61 | WebpackPluginInstance & { definitions?: object } 62 | > 63 | ).find((plugin) => plugin.definitions != null)!.definitions; 64 | return { 65 | options: { 66 | banner: ` 67 | /*! Build: ${task} - <%= grunt.option('version') %> 68 | Defines: ${JSON.stringify(defines, null, '\t')} 69 | */ 70 | `, 71 | }, 72 | src: ['out/pine.js'], 73 | dest: 'out/pine.js', 74 | }; 75 | }), 76 | 77 | copy: { 78 | default: { 79 | files: [ 80 | { 81 | expand: true, 82 | cwd: 'src', 83 | src: ['**'], 84 | dest: `<%= grunt.option('target') %>`, 85 | filter: (filename: string) => 86 | filename.endsWith('.d.ts') || !filename.endsWith('.ts'), 87 | }, 88 | ], 89 | }, 90 | }, 91 | 92 | gitinfo: { 93 | commands: { 94 | describe: ['describe', '--tags', '--always', '--long', '--dirty'], 95 | }, 96 | }, 97 | 98 | rename: (() => { 99 | const renames: Record = {}; 100 | for (const task of Object.keys(serverConfigs)) { 101 | renames[task] = { 102 | src: 'out/pine.js', 103 | dest: `out/pine-${task}-<%= grunt.option('version') %>.js`, 104 | }; 105 | renames[`${task}.map`] = { 106 | src: 'out/pine.js.map', 107 | dest: `out/pine-${task}-<%= grunt.option('version') %>.js.map`, 108 | }; 109 | } 110 | return renames; 111 | })(), 112 | 113 | replace: _.mapValues(serverConfigs, (_config, task) => { 114 | return { 115 | src: 'out/pine.js', 116 | overwrite: true, 117 | replacements: [ 118 | { 119 | from: /sourceMappingURL=pine.js.map/g, 120 | to: `sourceMappingURL=pine-${task}-<%= grunt.option('version') %>.js.map`, 121 | }, 122 | ], 123 | }; 124 | }), 125 | 126 | webpack: serverConfigs, 127 | 128 | ts: { 129 | default: { 130 | tsconfig: { 131 | passThrough: true, 132 | }, 133 | options: { 134 | additionalFlags: `--outDir <%= grunt.option('target') %>`, 135 | }, 136 | }, 137 | }, 138 | }); 139 | 140 | // eslint-disable-next-line @typescript-eslint/no-require-imports 141 | require('load-grunt-tasks')(grunt); 142 | 143 | if (!grunt.option('target')) { 144 | grunt.option('target', 'out/'); 145 | } 146 | 147 | grunt.registerTask('version', function () { 148 | this.requires('gitinfo:describe'); 149 | grunt.option('version', grunt.config.get('gitinfo.describe')); 150 | }); 151 | 152 | for (const task of Object.keys(serverConfigs)) { 153 | grunt.registerTask(task, [ 154 | 'checkDependencies', 155 | 'webpack:' + task, 156 | 'gitinfo:describe', 157 | 'version', 158 | `replace:${task}`, 159 | `concat:${task}`, 160 | `rename:${task}`, 161 | `rename:${task}.map`, 162 | ]); 163 | } 164 | 165 | grunt.registerTask('build', ['clean', 'checkDependencies', 'ts', 'copy']); 166 | }; 167 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 21.6.3 -------------------------------------------------------------------------------- /bin/abstract-sql-compiler.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import '../out/bin/abstract-sql-compiler.js'; 3 | -------------------------------------------------------------------------------- /bin/odata-compiler.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import '../out/bin/odata-compiler.js'; 3 | -------------------------------------------------------------------------------- /bin/sbvr-compiler.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import '../out/bin/sbvr-compiler.js'; 3 | -------------------------------------------------------------------------------- /build/browser.cts: -------------------------------------------------------------------------------- 1 | import * as webpack from 'webpack'; 2 | import type { Configuration } from 'webpack'; 3 | import sharedConfig from './config.cts'; 4 | 5 | if (typeof sharedConfig.externals !== 'object') { 6 | throw new Error('Expected externals to be an object'); 7 | } 8 | if (Array.isArray(sharedConfig.resolve.alias)) { 9 | throw new Error('Expected resolve.alias to be an object'); 10 | } 11 | 12 | const root = sharedConfig.entry; 13 | const config: Configuration = { 14 | ...sharedConfig, 15 | entry: `${root}/src/server-glue/server`, 16 | externals: { 17 | ...sharedConfig.externals, 18 | // Disable node express and load express-emulator instead 19 | express: false, 20 | }, 21 | resolve: { 22 | ...sharedConfig.resolve, 23 | alias: { 24 | ...sharedConfig.resolve.alias, 25 | express: `${root}/src/express-emulator/express`, 26 | }, 27 | }, 28 | plugins: [ 29 | ...sharedConfig.plugins, 30 | new webpack.DefinePlugin({ 31 | 'process.browser': true, 32 | 'process.env.CONFIG_LOADER_DISABLED': true, 33 | 'process.env.PINEJS_DEBUG': true, 34 | }), 35 | ], 36 | }; 37 | 38 | export default config; 39 | -------------------------------------------------------------------------------- /build/config.cts: -------------------------------------------------------------------------------- 1 | import type { RequiredField } from '../src/sbvr-api/common-types.js' with { 'resolution-mode': 'import' }; 2 | 3 | import * as path from 'path'; 4 | import * as webpack from 'webpack'; 5 | import type { Configuration } from 'webpack'; 6 | const root = path.dirname(__dirname); 7 | 8 | const config: RequiredField< 9 | Configuration, 10 | 'plugins' | 'resolve' | 'externals' 11 | > & { 12 | externals: { 13 | [index: string]: string | boolean | string[] | { [index: string]: any }; 14 | }; 15 | } = { 16 | mode: 'production', 17 | devtool: 'source-map', 18 | entry: root, 19 | output: { 20 | libraryTarget: 'commonjs', 21 | path: root, 22 | filename: 'out/pine.js', 23 | }, 24 | target: 'node', 25 | node: false, 26 | externals: { 27 | bcrypt: true, 28 | bcryptjs: true, 29 | 'body-parser': true, 30 | child_process: true, 31 | compression: true, 32 | 'cookie-parser': true, 33 | express: true, 34 | 'express-session': true, 35 | fs: true, 36 | lodash: true, 37 | 'method-override': true, 38 | multer: true, 39 | mysql: true, 40 | passport: true, 41 | 'passport-local': true, 42 | 'pinejs-client-core': true, 43 | pg: true, 44 | 'serve-static': true, 45 | 'typed-error': true, 46 | }, 47 | resolve: { 48 | extensions: ['.js', '.ts'], 49 | extensionAlias: { 50 | '.js': ['.ts', '.js'], 51 | '.mjs': ['.mts', '.mjs'], 52 | }, 53 | }, 54 | plugins: [new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 })], 55 | module: { 56 | rules: [ 57 | { 58 | test: /\.sbvr$/, 59 | use: { 60 | loader: 'raw-loader', 61 | options: { 62 | esModule: false, 63 | }, 64 | }, 65 | }, 66 | { 67 | test: /\.ts$|\.js$/, 68 | exclude: /node_modules/, 69 | use: 'ts-loader', 70 | }, 71 | ], 72 | }, 73 | }; 74 | 75 | export default config; 76 | -------------------------------------------------------------------------------- /build/module.cts: -------------------------------------------------------------------------------- 1 | import * as webpack from 'webpack'; 2 | import type { Configuration } from 'webpack'; 3 | import sharedConfig from './config.cts'; 4 | 5 | const config: Configuration = { 6 | ...sharedConfig, 7 | entry: `${sharedConfig.entry}/src/server-glue/module`, 8 | plugins: [ 9 | ...sharedConfig.plugins, 10 | new webpack.DefinePlugin({ 11 | 'process.browser': false, 12 | 13 | 'process.env.CONFIG_LOADER_DISABLED': false, 14 | }), 15 | // When we're compiling the module build we want to always ignore the server build file 16 | new webpack.IgnorePlugin({ 17 | resourceRegExp: /server/, 18 | contextRegExp: /server-glue/, 19 | }), 20 | ], 21 | }; 22 | 23 | export default config; 24 | -------------------------------------------------------------------------------- /build/server.cts: -------------------------------------------------------------------------------- 1 | import * as webpack from 'webpack'; 2 | import type { Configuration } from 'webpack'; 3 | import sharedConfig from './config.cts'; 4 | 5 | const config: Configuration = { 6 | ...sharedConfig, 7 | entry: `${sharedConfig.entry}/src/server-glue/server`, 8 | plugins: [ 9 | ...sharedConfig.plugins, 10 | new webpack.DefinePlugin({ 11 | 'process.browser': false, 12 | 13 | 'process.env.CONFIG_LOADER_DISABLED': false, 14 | }), 15 | ], 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /demo/editor/data.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO "pilot" ("id","value", "is experienced") values ('1','Joachim','1'); 2 | INSERT INTO "pilot" ("id","value") values ('2','Esteban'); 3 | INSERT INTO "plane" ("id","value") values ('1','Boeing 747'); 4 | INSERT INTO "plane" ("id","value") values ('2','Spitfire'); 5 | INSERT INTO "plane" ("id","value") values ('3','Concorde'); 6 | INSERT INTO "plane" ("id","value") values ('4','Mirage 2000'); 7 | INSERT INTO "pilot-can_fly-plane" ("id","pilot","plane") values ('1','1','2'); 8 | INSERT INTO "pilot-can_fly-plane" ("id","pilot","plane") values ('2','1','3'); 9 | INSERT INTO "pilot-can_fly-plane" ("id","pilot","plane") values ('3','1','4'); 10 | INSERT INTO "pilot-can_fly-plane" ("id","pilot","plane") values ('4','2','1'); 11 | -------------------------------------------------------------------------------- /demo/editor/model.sbvr: -------------------------------------------------------------------------------- 1 | T: pilot 2 | T: plane 3 | F: pilot can fly plane 4 | F: pilot is experienced 5 | R: It is obligatory that each pilot can fly at least 1 plane 6 | R: It is obligatory that each pilot that is experienced can fly at least 3 planes 7 | -------------------------------------------------------------------------------- /demo/validation/nhs.sbvr.txt: -------------------------------------------------------------------------------- 1 | Term: patient 2 | Term: disease 3 | Term: treatment 4 | 5 | Fact type: patient is diagnosed with disease 6 | Fact type: disease responds to treatment 7 | Fact type: patient is prescribed with treatment 8 | 9 | Rule: It is obligatory that each patient that is diagnosed with a disease that responds to a treatment is prescribed with a treatment that the disease responds to -------------------------------------------------------------------------------- /docker-compose.npm-test.yml: -------------------------------------------------------------------------------- 1 | version: "2.1" 2 | services: 3 | postgres: 4 | image: postgres:17-alpine 5 | environment: 6 | POSTGRES_USER: docker 7 | POSTGRES_PASSWORD: docker 8 | POSTGRES_DB: postgres 9 | TZ: UTC 10 | ports: 11 | - "5431:5432" 12 | command: 13 | - -c 14 | - synchronous_commit=off 15 | - -c 16 | - fsync=off 17 | - -c 18 | - full_page_writes=off 19 | minio-server: 20 | image: minio/minio:RELEASE.2025-05-24T17-08-30Z 21 | environment: 22 | MINIO_ROOT_USER: USERNAME 23 | MINIO_ROOT_PASSWORD: PASSWORD 24 | command: server /data --console-address ":9001" 25 | ports: 26 | - "43680:9000" 27 | - "43697:9001" 28 | minio-client: 29 | image: minio/mc:RELEASE.2025-05-21T01-59-54Z 30 | depends_on: 31 | - minio-server 32 | entrypoint: > 33 | /bin/sh -c " 34 | set -e; 35 | sleep 1; 36 | /usr/bin/mc alias set minio-server http://minio-server:9000 USERNAME PASSWORD; 37 | /usr/bin/mc mb --ignore-existing minio-server/balena-pine-web-resources; 38 | sleep infinity; 39 | " 40 | -------------------------------------------------------------------------------- /docs/Architecture.md: -------------------------------------------------------------------------------- 1 | # Pinejs Architecture 2 | 3 | This documents aims to serve as a high-level overview of how Pinejs works, especially oriented for contributors who want to understand the big picture. 4 | 5 | 6 | ## Techologies 7 | 8 | This is a non-exhaustive list of the technologies used in Pinejs 9 | 10 | - SBVR 11 | - OData 12 | - Express 13 | - OMetaJS 14 | - ... 15 | 16 | ## Modules 17 | ### Config Loader 18 | 19 | This module exports a function to load configuration files into Pinejs. This module exports a ```setup``` function which must be called with the express app object as argument. It will return an object that can be used to load internal and external configuration files when starting up Pinejs. 20 | Refer to [ProjectConfig](https://github.com/balena-io/pinejs/blob/master/docs/ProjectConfig.md) for more information about the structure of configuration files. 21 | Most of the following internal modules define an SBVR model that is loaded through the config loader, the config loader allows the user to specify some ```customServerCode``` that is executed right after loading the model. You can refer to [CustomServerCode](https://github.com/balena-io/pinejs/blob/master/docs/CustomServerCode.md) for more information on how this can be done. 22 | 23 | ### Database Layer 24 | 25 | This module defines an abstract ```Tx``` class that represents database transactions. It currently contains three concrete implementations of this class, one for each supported database: MySql, Postgres, WebSql. The ```Tx``` constructor, which should be called in the constructor of any concrete implementation, takes three functions as arguments: one to execute SQL queries, one to rollback a transaction, and one to commit. 26 | 27 | ### Migrator 28 | 29 | This module is in charge of checking and running migrations over the database. Migrations are usually loaded at startup time via the configuration loader. Once a migration has ran this fact is permanently recorded in the database through the ```migrations``` resource. Migrations are supposed to run only when a database schema already exists in order to align the already existing schema and data with the desired state. If a database schema does not exist yet (i.e. the very first run of the Pinejs application), migrations are skipped and marked as executed. 30 | 31 | ### Passport Pinejs 32 | 33 | This module defines express middleware to enable passport (or passport like if running in the browser) authentication. The module exports middleware functions ```login``` and ```logout```. 34 | 35 | ### Pinejs Session Store 36 | 37 | This module defines the session model and exports the ```PinejsSessionStore``` object to store/retrieve/delete sessions. 38 | 39 | ### SBVR-Api 40 | 41 | This module takes care of a lot of the heavy lifting happening in Pinejs. It takes care of initializing the ```Auth``` and the ```Dev``` model, it also defines the ```handleODataRequest``` route, which is used to interact with the database via OData requests. 42 | 43 | This models ```setup``` function is very important, it must be called before loading any other models, this is because it must initialize both the ```Dev``` and ```Auth``` models, which should be loaded before any other. 44 | 45 | This module essentially provides an API to interact with OData and SBVR from the rest of the codebase. For this reason it is imported by many of the previously mentioned modules. 46 | For example the ```config-loader``` will import this and use the ```executeModel``` function to parse an SBVR file into a set of database operations. 47 | This module also exports ```handleODataRequest``` which is an express endpoint that can handle OData queries against the models that are initialized. Alongside that, the module also exports a ```runURI``` function which is used internally to perform OData queries at runtime, it is simply implemented as a wrapper around ```handleODataRequest```. 48 | -------------------------------------------------------------------------------- /docs/Building.md: -------------------------------------------------------------------------------- 1 | # Building from Source 2 | 3 | Pine.js can be distributed as a single javascript file 4 | typically named `pine.js` which contains aggregated code capable of reading an SBVR file and generating an API. Configuration is read from `config.json` in the same directory as `pine.js` and described further in [project configuration document][project-config]. 5 | 6 | ## Requirements 7 | * [node.js](https://nodejs.org) >= 0.8.18 8 | * [require.js](http://requirejs.org) >= 2.1.4 (`npm install -g requirejs`) 9 | * [node-bcrypt dependencies](https://github.com/ncb000gt/node.bcrypt.js/#dependencies) 10 | 11 | The required steps to build Pine.js are: 12 | 13 | 1. Check out (or update your copy of) `pinejs/master` (all paths below will be relative to this working directory unless otherwise specified). 14 | 2. Run `bower install` in the root of pinejs. 15 | 3. Run `npm install` in the root of pinejs. 16 | 4. Run `./node_modules/.bin/grunt $TYPE -o server.build.js` on Linux/Mac OSX or `node_modules\.bin\grunt $TYPE` on Windows, where `$TYPE` is `server` or `module`. 17 | 18 | Pine.js used to be copied to other projects as a single built file. This is no longer required, since it is a proper npm module. So, if you want to start a project with Pine.js you just need to include it as a dependency in your package.json: 19 | 20 | ``` 21 | "dependencies": { 22 | "@balena/pinejs": "^13.0.0", 23 | ``` 24 | 25 | 26 | ## Example Application 27 | 28 | The [Pine.js Example Application][pine-example] is the best means of getting up to speed with Pine.js and the [Pine.js API][pinejs-client-js] (a library for interacting with Pine.js simply from the frontend) - it contains an end-to-end working 'ToDo' web application. 29 | 30 | 31 | ## Contributors 32 | 33 | You can use `grunt` to build the project when working directly on pine, this is useful to test some changes locally before submitting a PR. 34 | If you are using pine bundled into a single js file, you can simply run step 4 from the build again to obtain the new file. 35 | The entry-point for the npm module is located at `out/server-glue/module.js`, you can test your local changes by running `grunt build`, which will build all files in the `src/` folder and copy the output to `out/`. 36 | You can also specify a different target folder in which to build Pine.js via the following command `grunt build --target=path-to-your-pinejs-dependency/out`. 37 | 38 | [docs]:. 39 | [pine-example]:https://github.com/resin-io/pine-example 40 | [pinejs-client-js]:https://github.com/balena-io/pinejs-client-js 41 | [project-config]:ProjectConfig.md 42 | -------------------------------------------------------------------------------- /docs/Hooks.md: -------------------------------------------------------------------------------- 1 | # Hooks 2 | Hooks are functions that you can implement in order to execute custom code when API calls are requested. The methods that are supported are `GET`, `POST`, `PUT`, `PATCH`. The sbvrUtils module of Pine.js is the mechanism that supports hooks addition. There are two kind of hooks that can be defined: side-effect hooks and pure hooks. These are respectively defined using `sbvrUtils.addSideEffectHook` and `sbvrUtils.addPureHook`. 3 | 4 | Hooks will have access to a `tx` object representing the transaction of the current request, in order to to roll back the transaction you can either throw an error, or return a rejected promise. Also, any promises that are returned will be waited on before continuing with processing the request. 5 | However, some hooks might need to perform actions against external resources, such as HTTP calls to different services or other forms of side-effectful actions. To undo these actions we can not rely on the `tx` object, instead we need to make sure we setup the appropriate rollback logic should the request error out. Remember this can also happen at a later time than the hook runs, e.g. we can perform some `PRERUN` action, only to realise the request fails when we later attempt to run it, because of some database constraint. 6 | 7 | To deal with these cases we can define a side-effect hook. 8 | The hook will now have access to a `registerRollback` function defined on the hook itself, which can be used to store any action we need to perform the undo the effects of our hook (e.g. delete an external resource that was created). We can use `registerRollback` to register any number of actions; these will be later ran if we need to undo the side-effects of the hook. 9 | 10 | The following example of a side-effect hook will create two external resources and register the appropriate rollback actions. 11 | 12 | ```coffee 13 | addSideEffectHook 'method', 'vocabulary', 'resource', 14 | PHASE: () -> 15 | createExternalResource(1) 16 | .then (id1) => 17 | @registerRollback(-> deleteExternalResource(id1)) 18 | # Additional logic 19 | createExternalResource(2) 20 | .then (id2) => 21 | @registerRollback(-> deleteExternalResource(id2)) 22 | ``` 23 | 24 | 25 | ## Hook phases 26 | * `POSTPARSE({req, request, api[, tx]})` - runs right after the OData URI is parsed into a tree and before it gets converted to any SQL. 27 | * The `request` object for POSTPARSE is lacking the `abstractSqlQuery` and `sqlQuery` entries. 28 | * The `tx` object will only be available if running in the context of an internal request with a provided transaction. 29 | * `PRERUN({req, request, api, tx})` - runs right before the main body/SQL elements run, which also happens to be after compiling to SQL has happened. 30 | * `POSTRUN({req, request, result, api, tx})` - runs after the main body/SQL statements have run. 31 | * `PRERESPOND({req, res, request, api, result, data[, tx]})` - runs right before we send the response to the API caller, which can be an internal or an external caller. It contains the data in OData response format. 32 | * The `data` object for PRERESPOND is only present for GET requests. 33 | * The `tx` object will only be available if running in the context of an internal request with a provided transaction. 34 | 35 | ## Arguments 36 | 37 | ### req 38 | This is usually an express.js req object, however in the case of an internal API call it will only have the following properties (so you should only rely on these being available): 39 | 40 | * user 41 | * method 42 | * url 43 | * body 44 | 45 | ### request 46 | This is an object describing the current request being made and contains the following properties: 47 | 48 | * method: The method of the current request, eg GET/PUT/POST 49 | * vocabulary: The API root that the request is for, eg Auth 50 | * resourceName: The resource that the request relates to, eg user 51 | * odataQuery: The OData OMeta structure. 52 | * abstractSqlQuery: The Abstract SQL OMeta structure. 53 | * sqlQuery: The SQL OMeta structure. 54 | * values: The `body` of the request. 55 | * custom: This is an empty object, you may store whatever you like here and have it available in later hooks. 56 | 57 | ### result 58 | This is the result from running the transaction. 59 | 60 | * GET - A database result object with the unprocessed rows that have been queried. 61 | * POST - The inserted/updated id. 62 | * PUT/PATCH/MERGE/DELETE - null 63 | 64 | ### data 65 | * GET - This is the result after being processed into a JSON OData response (i.e. the `d` field). 66 | 67 | ### tx 68 | The database transaction object, so that you can run queries in the same transaction or make API calls that use the same transaction. 69 | 70 | ### api 71 | An instance of pinejs-client for the current api, using the permissions and transaction of the current request. 72 | In the case of not being in a transaction, ie in cases where the `tx` argument is null, any requests via this object will be run in their own, separate, transaction. 73 | 74 | See [tx](./CustomServerCode.md#markdown-header-tx_2) 75 | -------------------------------------------------------------------------------- /docs/ProjectConfig.md: -------------------------------------------------------------------------------- 1 | # Configuring A Project 2 | 3 | The project configuration is placed inside `config.json` that provides all the necessary information for Pine.js regarding the models, the API, and the users. 4 | This file should follow a specification based on the example shown below: 5 | 6 | ```json 7 | { 8 | "models": [{ 9 | "modelName": "Example", 10 | "modelFile": "example.sbvr", 11 | "apiRoot": "example", 12 | "customServerCode": "example.coffee", 13 | "logging": { 14 | "log": false, 15 | "error": true, 16 | "default": false 17 | } 18 | }], 19 | "users": [{ 20 | "username": "guest", 21 | "password": " ", 22 | "permissions": [ 23 | "resource.all" 24 | ] 25 | }] 26 | } 27 | ``` 28 | 29 | ## Models 30 | The `models` object contains the following fields: 31 | 32 | * `modelName` - This field is required. The string value is used in messages about whether the model passes/fails. 33 | * `modelFile` - This field is required. It is pointing to the file that contains the sbvr model. In this example, it's the `example.sbvr` inside the same directory. 34 | * `apiRoot` - This field is required. It defines the root path to access the model's API. In this example, it's `/example/{OData URL}`. 35 | * `customServerCode` - This field is optional and it's a string pointing to a file (`.coffee` or `.js`) that will be run by the server on startup. 36 | * `logging` - This field is optional. This is an object of `true`/`false` values for whether calls to console[key] should be output, with the special `default` value being used for any unspecified keys (defaults to `true`). 37 | 38 | ## User System & Permissions 39 | Permissions currently work by having a name which defines what the permission covers. The formats for this are as follows: 40 | 41 | * `resource.{action}` - Grants the permission for `{action}` on all resources. 42 | * `{vocabulary}.{action}` - Grants the permission for `{action}` on all resources of `{vocabulary}`. 43 | * `{vocabulary}.{resource}.{action}` - Grants the permission for `{action}` on the `{resource}` of `{vocabulary}`. 44 | 45 | ### Actions 46 | 47 | * `model` - Used for accessing the model alone for a resource 48 | * `read` - Used for reading records of a resource (model is included when fetching records) 49 | * `create` - Used for creating records of a resource 50 | * `update` - Used for updating records of a resource 51 | * `delete` - Used to deleting records of a resource 52 | 53 | ### Special Variables 54 | 55 | * `@__ACTOR_ID` - This is replaced by the `id` of the currently logged in actor (or `0` if not logged in), or the actor who owns the API key in use. 56 | 57 | ### Default/Guest User Permissions 58 | All users (including ones who are not logged in) automatically gain any permissions assigned to the account named "guest". 59 | You can create this user in the `config.json` as shown in the example above. 60 | 61 | ### Model 62 | The SBVR model for users can be found at [/src/sbvr-api/user.sbvr](https://github.com/balena-io/pinejs/blob/master/src/sbvr-api/user.sbvr) 63 | 64 | ### Exposing the OData API 65 | To expose the user model over the OData API, use the following in your custom server code: 66 | 67 | ``` 68 | app.get('/Auth/*', sbvrUtils.runGet) 69 | ``` 70 | This will allow you to access the user model under the `/Auth` entry point as you would any other model, e.g. `GET /Auth/user`. 71 | 72 | Alternatively, you can copy the user model vocabulary into your SBVR file, which will expose it under the same entry point as your vocabulary. 73 | The benefit to this is that you can add custom attributes for the user vocabulary to your vocabulary and have them be accessible via the API. 74 | 75 | ## Database 76 | You can specify your database url in an environment variable called `DATABASE_URL`, 77 | refer to your OS documentation on how to do this (either on a global level for all programs, or just set it temporarily whilst launching your project). 78 | 79 | If you do not specify this environment variable, then the defaults are as follows: 80 | 81 | MySQL: `mysql://mysql:.@localhost:3306` 82 | PostgresSQL: `postgres://postgres:.@localhost:5432/postgres` 83 | -------------------------------------------------------------------------------- /docs/Testing.md: -------------------------------------------------------------------------------- 1 | ## Test environment 2 | Pine is a system that needs a database. 3 | Therefore the `npm run test:compose` is used to test pinejs with a postgres database spin up from a docker compose file: `docker-compose.npm-test.yml` 4 | This approach guarantees that the node versions are tested by the flowzone test actions on a github pull request. 5 | 6 | #### Debug pinejs queries 7 | Specifying the environment variable PINEJS_DEBUG=1 will log debug information of each pine query (This can be a very verbose output.) 8 | -------------------------------------------------------------------------------- /docs/coding-guidelines/coffee.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io/pinejs/197b1226d597af1f79face9f62d47e72c7c91d0e/docs/coding-guidelines/coffee.txt -------------------------------------------------------------------------------- /docs/coding-guidelines/filenames.txt: -------------------------------------------------------------------------------- 1 | File Guidelines 2 | 3 | 1. Use lowercase 4 | 5 | 2. Separate with dashes 6 | 7 | 3. Use file extensions: 8 | .coffee - CoffeeScript 9 | .js - JavaScript 10 | .ometajs - OMeta/JS 2 11 | .jade - Jade 12 | .css - CSS 13 | .html - HTML5 -------------------------------------------------------------------------------- /docs/coding-guidelines/jade.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io/pinejs/197b1226d597af1f79face9f62d47e72c7c91d0e/docs/coding-guidelines/jade.txt -------------------------------------------------------------------------------- /docs/coding-guidelines/js.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io/pinejs/197b1226d597af1f79face9f62d47e72c7c91d0e/docs/coding-guidelines/js.txt -------------------------------------------------------------------------------- /docs/coding-guidelines/ometa.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io/pinejs/197b1226d597af1f79face9f62d47e72c7c91d0e/docs/coding-guidelines/ometa.txt -------------------------------------------------------------------------------- /docs/sequence-diagrams/parse-request.sequence: -------------------------------------------------------------------------------- 1 | title Parse Request 2 | 3 | * -> SBVR-Utils: HTTP Request 4 | 5 | SBVR-Utils -> SBVR-Utils: Check valid API root 6 | 7 | SBVR-Utils -> +URI-Parser: Parse URI 8 | URI-Parser -> +OData-Parser: URI 9 | OData-Parser --> -URI-Parser: OData request structure 10 | URI-Parser --> -SBVR-Utils: "Request" object. 11 | 12 | SBVR-Utils -> +URI-Parser: Check permissions/add conditional permissions 13 | URI-Parser -> +Permissions: Check permissions. 14 | Permissions --> -URI-Parser: Conditional permissions list 15 | URI-Parser -> URI-Parser: Add conditional permissions 16 | URI-Parser --> -SBVR-Utils: "Request" object. 17 | 18 | SBVR-Utils -> SBVR-Utils: Run POSTPARSE hooks. 19 | 20 | SBVR-Utils -> +URI-Parser: Translate request 21 | URI-Parser -> +OData2AbstractSQL: OData request structure 22 | OData2AbstractSQL --> -URI-Parser: Abstract SQL structure. 23 | URI-Parser --> -SBVR-Utils: "Request" object. 24 | 25 | SBVR-Utils -> +AbstractSQLCompiler: Abstract SQL structure 26 | AbstractSQLCompiler --> -SBVR-Utils: SQL structure. 27 | 28 | SBVR-Utils --> *: "Request" object -------------------------------------------------------------------------------- /docs/sequence-diagrams/process-odata-response.sequence: -------------------------------------------------------------------------------- 1 | title Process OData Response 2 | 3 | * -> SBVR-Utils: Database result 4 | 5 | loop Rows 6 | SBVR-Utils -> SBVR-Utils: Add OData metadata 7 | end 8 | 9 | 10 | loop Expandable Rows 11 | SBVR-Utils -> ExpandFields: Expand fields 12 | opt If JSON 13 | alt If array 14 | ExpandFields -> SBVR-Utils: <> 15 | SBVR-Utils --> ExpandFields: OData Response 16 | else 17 | ExpandFields -> ExpandFields: Add deferred metadata 18 | end 19 | end 20 | ExpandFields --> SBVR-Utils: Expanded fields 21 | end 22 | 23 | loop Rows Requiring Processing 24 | SBVR-Utils -> SBVR-Utils: Apply datatype fetch processing 25 | end 26 | 27 | SBVR-Utils --> *: OData Response -------------------------------------------------------------------------------- /docs/sequence-diagrams/run-query.sequence: -------------------------------------------------------------------------------- 1 | title Run Query 2 | 3 | * -> SBVR-Utils: Query + Bind Values 4 | 5 | loop Check Bind Values 6 | SBVR-Utils -> AbstractSQL2SQL: Validate/transform bind value datatype 7 | AbstractSQL2SQL --> SBVR-Utils: Validated bind value 8 | end 9 | 10 | SBVR-Utils -> Database: Run query 11 | Database --> SBVR-Utils: Query result 12 | 13 | SBVR-Utils --> *: Query result -------------------------------------------------------------------------------- /docs/sequence-diagrams/run-request.sequence: -------------------------------------------------------------------------------- 1 | title Run Request 2 | 3 | HTTP -> +SBVR-Utils: HTTP Request 4 | SBVR-Utils -> SBVR-Utils: <> 5 | 6 | 7 | SBVR-Utils -> +Database: Start transaction 8 | Database --> SBVR-Utils: Transaction object 9 | 10 | SBVR-Utils -> SBVR-Utils: Run PRERUN hooks. 11 | 12 | alt GET 13 | SBVR-Utils -> +SBVR-Utils: runGet(req, res, request, result) 14 | opt If query 15 | SBVR-Utils -> SBVR-Utils: <> 16 | SBVR-Utils --> -SBVR-Utils: Database result 17 | end 18 | 19 | else POST 20 | SBVR-Utils -> +SBVR-Utils: runPost(req, res, request, result) 21 | SBVR-Utils -> SBVR-Utils: <> 22 | SBVR-Utils -> SBVR-Utils: <> 23 | SBVR-Utils --> -SBVR-Utils: Inserted/Updated ID 24 | 25 | else PUT/PATCH/MERGE 26 | SBVR-Utils -> +SBVR-Utils: runPut(req, res, request, result) 27 | alt UPSERT 28 | SBVR-Utils -> SBVR-Utils: <> 29 | opt If nothing updated 30 | SBVR-Utils -> SBVR-Utils: <> 31 | end 32 | else UPDATE 33 | SBVR-Utils -> SBVR-Utils: <> 34 | end 35 | SBVR-Utils -> SBVR-Utils: <> 36 | SBVR-Utils --> -SBVR-Utils: null 37 | 38 | else DELETE 39 | SBVR-Utils -> +SBVR-Utils: runPost(req, res, request, result) 40 | SBVR-Utils -> SBVR-Utils: <> 41 | SBVR-Utils -> SBVR-Utils: <> 42 | SBVR-Utils --> -SBVR-Utils: null 43 | end 44 | 45 | SBVR-Utils -> SBVR-Utils: Run POSTRUN hooks. 46 | 47 | SBVR-Utils -> Database: End transaction 48 | Database -> -SBVR-Utils: 49 | destroy Database 50 | 51 | 52 | 53 | alt GET 54 | SBVR-Utils -> SBVR-Utils: respondGet(req, res, request, result) 55 | alt If query 56 | SBVR-Utils -> SBVR-Utils: <> 57 | SBVR-Utils --> HTTP: 200, OData JSON 58 | else $metadata 59 | SBVR-Utils --> HTTP: 200, OData $metadata XML 60 | else $serviceroot 61 | SBVR-Utils --> HTTP: 200, Client model resources 62 | else 63 | SBVR-Utils --> HTTP: 200, Client model for given resource 64 | end 65 | 66 | else POST 67 | SBVR-Utils -> SBVR-Utils: respondPost(req, res, request, result) 68 | SBVR-Utils --> HTTP: 201, {id: Inserted/Updated ID} 69 | 70 | else PUT/PATCH/MERGE 71 | SBVR-Utils -> SBVR-Utils: respondPut(req, res, request, result) 72 | SBVR-Utils --> HTTP: 200 73 | 74 | else DELETE 75 | SBVR-Utils -> SBVR-Utils: respondDelete(req, res, request, result) 76 | SBVR-Utils --> HTTP: 200 77 | end 78 | deactivate SBVR-Utils -------------------------------------------------------------------------------- /docs/sequence-diagrams/validate-database.sequence: -------------------------------------------------------------------------------- 1 | title Validate Database 2 | 3 | * -> SBVR-Utils: Model 4 | 5 | loop Model Rules 6 | SBVR-Utils -> Database: Execute Rule 7 | Database --> SBVR-Utils: True/False 8 | opt If False 9 | SBVR-Utils --> *: Return error 10 | end 11 | end 12 | SBVR-Utils --> *: Return success 13 | -------------------------------------------------------------------------------- /migrations/0.6-add-created-at.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "api_key" 2 | ADD COLUMN "created at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; 3 | ALTER TABLE "api_key-has-permission" 4 | ADD COLUMN "created at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; 5 | ALTER TABLE "api_key-has-role" 6 | ADD COLUMN "created at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; 7 | ALTER TABLE "conditional_field" 8 | ADD COLUMN "created at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; 9 | ALTER TABLE "conditional_resource" 10 | ADD COLUMN "created at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; 11 | ALTER TABLE "lock" 12 | ADD COLUMN "created at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; 13 | ALTER TABLE "migration" 14 | ADD COLUMN "created at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; 15 | ALTER TABLE "model" 16 | ADD COLUMN "created at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; 17 | ALTER TABLE "permission" 18 | ADD COLUMN "created at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; 19 | ALTER TABLE "resource" 20 | ADD COLUMN "created at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; 21 | ALTER TABLE "resource-is_under-lock" 22 | ADD COLUMN "created at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; 23 | ALTER TABLE "role" 24 | ADD COLUMN "created at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; 25 | ALTER TABLE "role-has-permission" 26 | ADD COLUMN "created at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; 27 | ALTER TABLE "transaction" 28 | ADD COLUMN "created at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; 29 | ALTER TABLE "user" 30 | ADD COLUMN "created at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; 31 | ALTER TABLE "user-has-permission" 32 | ADD COLUMN "created at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; 33 | ALTER TABLE "user-has-public_key" 34 | ADD COLUMN "created at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; 35 | ALTER TABLE "user-has-role" 36 | ADD COLUMN "created at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; 37 | -------------------------------------------------------------------------------- /migrations/2.0-add-actors.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "user" 2 | ADD COLUMN "actor" INTEGER NULL; 3 | 4 | DO $$ 5 | DECLARE r record; 6 | BEGIN 7 | FOR r IN SELECT "id", "created at", "actor" 8 | FROM "user" 9 | LOOP 10 | INSERT INTO "actor" ("created at") 11 | VALUES (r."created at") 12 | RETURNING "id" INTO r."actor"; 13 | 14 | UPDATE "user" 15 | SET "actor" = r."actor" 16 | WHERE "id" = r."id"; 17 | END LOOP; 18 | END $$; 19 | 20 | ALTER TABLE "user" 21 | ALTER COLUMN "actor" SET NOT NULL, 22 | ADD CONSTRAINT "user_actor_fkey" 23 | FOREIGN KEY ("actor") REFERENCES "actor" ("id"); 24 | 25 | ALTER TABLE "api_key" 26 | ADD COLUMN "actor" INTEGER NULL; 27 | 28 | UPDATE "api_key" 29 | SET "actor" = ( 30 | SELECT u."actor" 31 | FROM "user" u 32 | WHERE u."id" = "api_key"."user" 33 | ); 34 | 35 | ALTER TABLE "api_key" 36 | ALTER COLUMN "actor" SET NOT NULL, 37 | ADD CONSTRAINT "api_key_actor_fkey" 38 | FOREIGN KEY ("actor") REFERENCES "actor" ("id"); 39 | 40 | ALTER TABLE "api_key" 41 | DROP CONSTRAINT "api_key_user_fkey"; 42 | 43 | ALTER TABLE "api_key" 44 | DROP COLUMN "user"; 45 | -------------------------------------------------------------------------------- /migrations/2.0-add-expiry-date.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "user-has-permission" ADD COLUMN "expiry date" TIMESTAMP NULL; 2 | ALTER TABLE "user-has-role" ADD COLUMN "expiry date" TIMESTAMP NULL; 3 | -------------------------------------------------------------------------------- /migrations/4.0.0-add-api_key-name-description.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "api_key" 2 | ADD COLUMN "name" VARCHAR(255), 3 | ADD COLUMN "description" TEXT; 4 | -------------------------------------------------------------------------------- /migrations/4.0.0-change-permission-to-text.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "permission" 2 | ALTER COLUMN "name" TYPE TEXT; 3 | -------------------------------------------------------------------------------- /migrations/5.0.0-schema-changes.sql: -------------------------------------------------------------------------------- 1 | -- dev.sbvr 2 | ALTER TABLE "model" 3 | RENAME COLUMN "vocabulary" TO "is of-vocabulary"; 4 | 5 | -- users.sbvr 6 | ALTER TABLE "api_key" 7 | RENAME COLUMN "actor" TO "is of-actor"; 8 | 9 | ALTER TABLE "api_key" RENAME TO "api key"; 10 | ALTER TABLE "api_key-has-role" RENAME TO "api key-has-role"; 11 | ALTER TABLE "api_key-has-permission" RENAME TO "api key-has-permission"; 12 | 13 | ALTER INDEX "api_key_pkey" RENAME TO "api key_pkey"; 14 | ALTER INDEX "api_key-has-permission_pkey" RENAME TO "api key-has-permission_pkey"; 15 | ALTER INDEX "api_key-has-role_pkey" RENAME TO "api key-has-role_pkey"; 16 | 17 | ALTER INDEX "api_key_key_key" RENAME TO "api key_key_key"; 18 | ALTER INDEX "api_key-has-permission_api key_permission_key" RENAME TO "api key-has-permission_api key_permission_key"; 19 | ALTER INDEX "api_key-has-role_api key_role_key" RENAME TO "api key-has-role_api key_role_key"; 20 | 21 | ALTER SEQUENCE "api_key-has-permission_id_seq" RENAME TO "api key-has-permission_id_seq"; 22 | ALTER SEQUENCE "api_key-has-role_id_seq" RENAME TO "api key-has-role_id_seq"; 23 | ALTER SEQUENCE "api_key_id_seq" RENAME TO "api key_id_seq"; 24 | 25 | -- transactions.sbvr 26 | ALTER TABLE "conditional_field" RENAME TO "conditional field"; 27 | ALTER TABLE "conditional_resource" RENAME TO "conditional resource"; 28 | ALTER TABLE "resource-is_under-lock" RENAME TO "resource-is under-lock"; 29 | -------------------------------------------------------------------------------- /migrations/8.3.0-fix-migration-primary-key.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "migration" 2 | ADD PRIMARY KEY ("model name"); 3 | -------------------------------------------------------------------------------- /pinejs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io/pinejs/197b1226d597af1f79face9f62d47e72c7c91d0e/pinejs.png -------------------------------------------------------------------------------- /repo.yml: -------------------------------------------------------------------------------- 1 | type: 'docker' 2 | upstream: 3 | - repo: '@balena/abstract-sql-compiler' 4 | url: 'https://github.com/balena-io-modules/abstract-sql-compiler' 5 | - repo: '@balena/abstract-sql-to-typescript' 6 | url: 'https://github.com/balena-io-modules/abstract-sql-to-typescript' 7 | - repo: '@balena/lf-to-abstract-sql' 8 | url: 'https://github.com/balena-io-modules/lf-to-abstract-sql' 9 | - repo: '@balena/odata-parser' 10 | url: 'https://github.com/balena-io-modules/odata-parser' 11 | - repo: '@balena/odata-to-abstract-sql' 12 | url: 'https://github.com/balena-io-modules/odata-to-abstract-sql' 13 | - repo: 'pinejs-client-core' 14 | url: 'https://github.com/balena-io-modules/pinejs-client-js' 15 | - repo: '@balena/sbvr-parser' 16 | url: 'https://github.com/balena-io-modules/sbvr-parser' 17 | - repo: '@balena/sbvr-types' 18 | url: 'https://github.com/balena-io-modules/sbvr-types' 19 | - repo: 'typed-error' 20 | url: 'https://github.com/balena-io-modules/typed-error' 21 | -------------------------------------------------------------------------------- /src/bin/abstract-sql-compiler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getAbstractSqlModelFromFile, 3 | version, 4 | writeAll, 5 | writeSqlModel, 6 | } from './utils.js'; 7 | 8 | import { program } from 'commander'; 9 | 10 | const runCompile = async (inputFile: string, outputFile?: string) => { 11 | const { generateSqlModel } = await import('../sbvr-api/sbvr-utils.js'); 12 | const abstractSql = await getAbstractSqlModelFromFile( 13 | inputFile, 14 | program.opts().model, 15 | ); 16 | const sqlModel = generateSqlModel(abstractSql, program.opts().engine); 17 | 18 | writeSqlModel(sqlModel, outputFile); 19 | }; 20 | 21 | const generateTypes = async ( 22 | inputFile: string, 23 | options: { 24 | outputFile?: string; 25 | convertSerialToInteger?: boolean; 26 | }, 27 | ) => { 28 | const { abstractSqlToTypescriptTypes } = await import( 29 | '@balena/abstract-sql-to-typescript/generate' 30 | ); 31 | const abstractSql = await getAbstractSqlModelFromFile( 32 | inputFile, 33 | program.opts().model, 34 | ); 35 | const types = abstractSqlToTypescriptTypes(abstractSql, { 36 | convertSerialToInteger: options.convertSerialToInteger, 37 | }); 38 | 39 | writeAll(types, options.outputFile); 40 | }; 41 | 42 | program 43 | .version(version) 44 | .option( 45 | '-e, --engine ', 46 | 'The target database engine (postgres|websql|mysql), default: postgres', 47 | /postgres|websql|mysql/, 48 | 'postgres', 49 | ) 50 | .option( 51 | '-m, --model ', 52 | 'The target model for config files with multiple models, default: first model', 53 | ); 54 | 55 | program 56 | .command('compile [output-file]') 57 | .description('compile the input AbstractSql model into SQL') 58 | .action(runCompile); 59 | 60 | program 61 | .command('compile-schema [output-file]') 62 | .description('compile the input AbstractSql model into SQL') 63 | .action(runCompile); 64 | 65 | program 66 | .command('generate-types [output-file]') 67 | .description('generate typescript types from the input AbstractSql') 68 | .option('--convert-serial-to-integer', 'Convert serials to integers') 69 | .action(async (inputFile, outputFile, opts) => { 70 | await generateTypes(inputFile, { 71 | outputFile, 72 | convertSerialToInteger: opts.convertSerialToInteger, 73 | }); 74 | }); 75 | 76 | program 77 | .command('help') 78 | .description('print the help') 79 | .action(() => program.help()); 80 | 81 | program.arguments(' [output-file]').action(runCompile); 82 | 83 | if (process.argv.length === 2) { 84 | program.help(); 85 | } 86 | 87 | void program.parseAsync(process.argv); 88 | -------------------------------------------------------------------------------- /src/bin/odata-compiler.ts: -------------------------------------------------------------------------------- 1 | import { getAbstractSqlModelFromFile, version, writeAll } from './utils.js'; 2 | import type { 3 | AbstractSqlModel, 4 | SqlResult, 5 | } from '@balena/abstract-sql-compiler'; 6 | 7 | import { program } from 'commander'; 8 | 9 | const generateAbstractSqlQuery = async ( 10 | abstractSqlModel: AbstractSqlModel, 11 | odata: string, 12 | ) => { 13 | const { memoizedParseOdata, translateUri } = await import( 14 | '../sbvr-api/uri-parser.js' 15 | ); 16 | const odataAST = memoizedParseOdata(odata); 17 | const vocabulary = ''; 18 | return translateUri({ 19 | engine: program.opts().engine, 20 | method: 'GET', 21 | url: odata, 22 | resourceName: odataAST.tree.resource, 23 | originalResourceName: odataAST.tree.resource, 24 | odataQuery: odataAST.tree, 25 | odataBinds: odataAST.binds, 26 | values: {}, 27 | vocabulary, 28 | abstractSqlModel, 29 | custom: {}, 30 | translateVersions: [vocabulary], 31 | }); 32 | }; 33 | 34 | const parseOData = async (odata: string, outputFile?: string) => { 35 | const { memoizedParseOdata } = await import('../sbvr-api/uri-parser.js'); 36 | const result = memoizedParseOdata(odata); 37 | const json = JSON.stringify(result, null, 2); 38 | writeAll(json, outputFile); 39 | }; 40 | 41 | const translateOData = async ( 42 | modelFile: string, 43 | odata: string, 44 | outputFile?: string, 45 | ) => { 46 | const request = await generateAbstractSqlQuery( 47 | await getAbstractSqlModelFromFile(modelFile, program.opts().model), 48 | odata, 49 | ); 50 | const json = JSON.stringify(request.abstractSqlQuery, null, 2); 51 | writeAll(json, outputFile); 52 | }; 53 | 54 | const formatSqlQuery = (sqlQuery: SqlResult | SqlResult[]): string => { 55 | if (Array.isArray(sqlQuery)) { 56 | return sqlQuery.map(formatSqlQuery).join('\n'); 57 | } else { 58 | return `\ 59 | Query: ${sqlQuery.query} 60 | Bindings: ${JSON.stringify(sqlQuery.bindings, null, 2)} 61 | `; 62 | } 63 | }; 64 | 65 | const compileOData = async ( 66 | modelFile: string, 67 | odata: string, 68 | outputFile?: string, 69 | ) => { 70 | const translatedRequest = await generateAbstractSqlQuery( 71 | await getAbstractSqlModelFromFile(modelFile, program.opts().model), 72 | odata, 73 | ); 74 | const { compileRequest } = await import('../sbvr-api/abstract-sql.js'); 75 | const compiledRequest = compileRequest(translatedRequest); 76 | let output; 77 | if (program.opts().json) { 78 | output = JSON.stringify(compiledRequest.sqlQuery, null, 2); 79 | } else { 80 | output = formatSqlQuery(compiledRequest.sqlQuery!); 81 | } 82 | writeAll(output, outputFile); 83 | }; 84 | 85 | program 86 | .version(version) 87 | .option( 88 | '-e, --engine ', 89 | 'The target database engine (postgres|websql|mysql), default: postgres', 90 | /postgres|websql|mysql/, 91 | 'postgres', 92 | ) 93 | .option('--json', 'Force json output, default: false'); 94 | 95 | program 96 | .command('parse [output-file]') 97 | .description('parse the input OData URL into OData AST') 98 | .action(parseOData); 99 | 100 | program 101 | .command('translate [output-file]') 102 | .description('translate the input OData URL into abstract SQL') 103 | .option( 104 | '-m, --model ', 105 | 'The target model for config files with multiple models, default: first model', 106 | ) 107 | .action(translateOData); 108 | 109 | program 110 | .command('compile [output-file]') 111 | .description('compile the input OData URL into SQL') 112 | .option( 113 | '-m, --model ', 114 | 'The target model for config files with multiple models, default: first model', 115 | ) 116 | .action(compileOData); 117 | 118 | program 119 | .command('help') 120 | .description('print the help') 121 | .action(() => program.help()); 122 | 123 | program.arguments(' [output-file]').action(compileOData); 124 | 125 | if (process.argv.length === 2) { 126 | program.help(); 127 | } 128 | 129 | void program.parseAsync(process.argv); 130 | -------------------------------------------------------------------------------- /src/bin/sbvr-compiler.ts: -------------------------------------------------------------------------------- 1 | import { version, writeAll, writeSqlModel } from './utils.js'; 2 | import { program } from 'commander'; 3 | import fs from 'fs'; 4 | 5 | const getSE = (inputFile: string) => fs.readFileSync(inputFile, 'utf8'); 6 | 7 | const parse = async (inputFile: string, outputFile?: string) => { 8 | const { generateLfModel } = await import('../sbvr-api/sbvr-utils.js'); 9 | const seModel = getSE(inputFile); 10 | const result = generateLfModel(seModel); 11 | const json = JSON.stringify(result, null, 2); 12 | writeAll(json, outputFile); 13 | }; 14 | 15 | const transform = async (inputFile: string, outputFile?: string) => { 16 | const { generateLfModel, generateAbstractSqlModel } = await import( 17 | '../sbvr-api/sbvr-utils.js' 18 | ); 19 | const seModel = getSE(inputFile); 20 | const lfModel = generateLfModel(seModel); 21 | const result = generateAbstractSqlModel(lfModel); 22 | const json = JSON.stringify(result, null, 2); 23 | writeAll(json, outputFile); 24 | }; 25 | 26 | const runCompile = async (inputFile: string, outputFile?: string) => { 27 | const { generateModels } = await import('../sbvr-api/sbvr-utils.js'); 28 | const seModel = getSE(inputFile); 29 | const models = generateModels( 30 | { apiRoot: 'sbvr-compiler', modelText: seModel }, 31 | program.opts().engine, 32 | ); 33 | 34 | writeSqlModel(models.sql, outputFile); 35 | }; 36 | 37 | const generateTypes = async (inputFile: string, outputFile?: string) => { 38 | const { generateLfModel, generateAbstractSqlModel } = await import( 39 | '../sbvr-api/sbvr-utils.js' 40 | ); 41 | const seModel = getSE(inputFile); 42 | const lfModel = generateLfModel(seModel); 43 | const abstractSql = generateAbstractSqlModel(lfModel); 44 | const { abstractSqlToTypescriptTypes } = await import( 45 | '@balena/abstract-sql-to-typescript/generate' 46 | ); 47 | const types = abstractSqlToTypescriptTypes(abstractSql); 48 | 49 | writeAll(types, outputFile); 50 | }; 51 | 52 | program 53 | .version(version) 54 | .option( 55 | '-e, --engine ', 56 | 'The target database engine (postgres|websql|mysql), default: postgres', 57 | /postgres|websql|mysql/, 58 | 'postgres', 59 | ); 60 | 61 | program 62 | .command('parse [output-file]') 63 | .description('parse the input SBVR file into LF') 64 | .action(parse); 65 | 66 | program 67 | .command('transform [output-file]') 68 | .description('transform the input SBVR file into abstract SQL') 69 | .action(transform); 70 | 71 | program 72 | .command('compile [output-file]') 73 | .description('compile the input SBVR file into SQL') 74 | .action(runCompile); 75 | 76 | program 77 | .command('generate-types [output-file]') 78 | .description('generate typescript types from the input SBVR') 79 | .action(generateTypes); 80 | 81 | program 82 | .command('help') 83 | .description('print the help') 84 | .action(() => program.help()); 85 | 86 | program.arguments(' [output-file]').action(runCompile); 87 | 88 | if (process.argv.length === 2) { 89 | program.help(); 90 | } 91 | 92 | void program.parseAsync(process.argv); 93 | -------------------------------------------------------------------------------- /src/bin/utils.ts: -------------------------------------------------------------------------------- 1 | process.env.PINEJS_CACHE_FILE = optionalVar( 2 | 'PINEJS_CACHE_FILE', 3 | fileURLToPath(new URL('.pinejs-cache.json', import.meta.url)), 4 | ); 5 | 6 | import type { SqlModel } from '@balena/abstract-sql-compiler'; 7 | import type { Config, Model } from '../config-loader/config-loader.js'; 8 | import type { AbstractSqlModel } from '@balena/abstract-sql-compiler'; 9 | 10 | import fs from 'fs'; 11 | import path from 'path'; 12 | import '../server-glue/sbvr-loader.js'; 13 | import { fileURLToPath } from 'url'; 14 | import { loadSBVR } from '../server-glue/sbvr-loader.js'; 15 | import { optionalVar } from '@balena/env-parsing'; 16 | 17 | export { version } from '../config-loader/env.js'; 18 | 19 | export const writeAll = (output: string, outputFile?: string): void => { 20 | if (outputFile) { 21 | fs.writeFileSync(outputFile, output); 22 | } else { 23 | console.log(output); 24 | } 25 | }; 26 | 27 | export const writeSqlModel = ( 28 | sqlModel: SqlModel, 29 | outputFile?: string, 30 | ): void => { 31 | const output = `\ 32 | -- 33 | -- Create table statements 34 | -- 35 | 36 | ${sqlModel.createSchema.join('\n\n')} 37 | 38 | -- 39 | -- Rule validation queries 40 | -- 41 | 42 | ${sqlModel.rules 43 | .map( 44 | (rule) => `\ 45 | -- ${rule.structuredEnglish} 46 | ${rule.sql}`, 47 | ) 48 | .join('\n\n')} 49 | `; 50 | writeAll(output, outputFile); 51 | }; 52 | 53 | const getConfigModel = ( 54 | fileContents: Model | AbstractSqlModel | Config, 55 | modelName?: string, 56 | ): Model | AbstractSqlModel => { 57 | if ('models' in fileContents) { 58 | if (fileContents.models.length === 0) { 59 | throw new Error('No models found in config file'); 60 | } 61 | if (modelName != null) { 62 | const model = fileContents.models.find((m) => m.modelName === modelName); 63 | if (model == null) { 64 | throw new Error( 65 | `Could not find model with name '${modelName}', found: ${fileContents.models.map((m) => m.modelName).join(', ')}`, 66 | ); 67 | } 68 | return model; 69 | } 70 | return fileContents.models[0]; 71 | } 72 | return fileContents; 73 | }; 74 | 75 | export const getAbstractSqlModelFromFile = async ( 76 | modelFile: string, 77 | modelName: string | undefined, 78 | ): Promise => { 79 | let fileContents: string | Model | AbstractSqlModel | Config; 80 | try { 81 | fileContents = await import(path.resolve(modelFile)); 82 | } catch { 83 | fileContents = await fs.promises.readFile(path.resolve(modelFile), 'utf8'); 84 | try { 85 | // Try to parse the file as JSON 86 | fileContents = JSON.parse(fileContents); 87 | } catch { 88 | // Ignore error as it's likely just a text sbvr file 89 | } 90 | } 91 | let seModel: string; 92 | if (fileContents == null) { 93 | throw new Error('Invalid model file'); 94 | } 95 | if (typeof fileContents === 'string') { 96 | seModel = fileContents; 97 | } else if (typeof fileContents === 'object') { 98 | if ('tables' in fileContents) { 99 | return fileContents; 100 | } 101 | const configModel = getConfigModel(fileContents, modelName); 102 | if ('abstractSql' in configModel && configModel.abstractSql != null) { 103 | return configModel.abstractSql; 104 | } else if ('modelText' in configModel && configModel.modelText != null) { 105 | seModel = configModel.modelText; 106 | } else if ('modelFile' in configModel && configModel.modelFile != null) { 107 | seModel = await loadSBVR(configModel.modelFile, import.meta); 108 | } else { 109 | throw new Error('Unrecognized config file'); 110 | } 111 | } else { 112 | throw new Error('Unrecognized config file'); 113 | } 114 | const { generateLfModel, generateAbstractSqlModel } = await import( 115 | '../sbvr-api/sbvr-utils.js' 116 | ); 117 | let lfModel; 118 | try { 119 | lfModel = generateLfModel(seModel); 120 | } catch (e: any) { 121 | throw new Error( 122 | `Got '${e.message}' whilst trying to parse the model file as sbvr, if you're using a transpiled language for the model file you will need to either transpile in advance or run via its loader`, 123 | ); 124 | } 125 | return generateAbstractSqlModel(lfModel); 126 | }; 127 | -------------------------------------------------------------------------------- /src/config-loader/sample-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "models": [{ 3 | "modelName": "Example", 4 | "modelFile": "example.sbvr", 5 | "apiRoot": "example", 6 | "customServerCode": "example.coffee" // Optional 7 | }], 8 | "users": [{ 9 | "username": "guest", 10 | "password": " ", 11 | "permissions": [ 12 | "resource.all" 13 | ] 14 | }] 15 | } -------------------------------------------------------------------------------- /src/extended-sbvr-parser/extended-sbvr-parser.ts: -------------------------------------------------------------------------------- 1 | import { SBVRParser } from '@balena/sbvr-parser'; 2 | import { importSBVR } from '../server-glue/sbvr-loader.js'; 3 | import SbvrParserPackage from '@balena/sbvr-parser/package.json' with { type: 'json' }; 4 | import { version } from '../config-loader/env.js'; 5 | 6 | const Types = await importSBVR('@balena/sbvr-types/Type.sbvr', import.meta); 7 | 8 | export const ExtendedSBVRParser = SBVRParser._extend({ 9 | initialize() { 10 | SBVRParser.initialize.call(this); 11 | this.AddCustomAttribute('Database ID Field:'); 12 | this.AddCustomAttribute('Database Table Name:'); 13 | this.AddBuiltInVocab(Types); 14 | return this; 15 | }, 16 | version: SbvrParserPackage.version + '+' + version, 17 | }); 18 | -------------------------------------------------------------------------------- /src/migrator/migrations.sbvr: -------------------------------------------------------------------------------- 1 | Vocabulary: migrations 2 | 3 | Term: model name 4 | Concept Type: Short Text (Type) 5 | Term: executed migrations 6 | Concept Type: JSON (Type) 7 | Term: lock time 8 | Concept Type: Date Time (Type) 9 | 10 | Term: migration 11 | Reference Scheme: model name 12 | Database ID Field: model name 13 | Fact Type: migration has model name 14 | Necessity: each migration has exactly one model name 15 | Fact Type: migration has executed migrations 16 | Necessity: each migration has exactly one executed migrations 17 | 18 | Term: migration lock 19 | Reference Scheme: model name 20 | Database ID Field: model name 21 | 22 | Fact Type: migration lock has model name 23 | Necessity: each migration lock has exactly one model name 24 | 25 | Term: migration key 26 | Concept Type: Short Text (Type) 27 | Term: start time 28 | Concept Type: Date Time (Type) 29 | Term: last run time 30 | Concept Type: Date Time (Type) 31 | Term: run count 32 | Concept Type: Integer (Type) 33 | Term: migrated row count 34 | Concept Type: Integer (Type) 35 | Term: error count 36 | Concept Type: Integer (Type) 37 | Term: converged time 38 | Concept Type: Date Time (Type) 39 | 40 | 41 | Term: migration status 42 | Reference Scheme: migration key 43 | Database ID Field: migration key 44 | 45 | Fact Type: migration status has migration key 46 | Necessity: each migration status has exactly one migration key 47 | 48 | Fact Type: migration status has start time 49 | Necessity: each migration status has at most one start time 50 | 51 | Fact Type: migration status has last run time 52 | Necessity: each migration status has at most one last run time 53 | 54 | Fact Type: migration status has run count 55 | Necessity: each migration status has exactly one run count 56 | 57 | Fact Type: migration status has migrated row count 58 | Necessity: each migration status has at most one migrated row count 59 | 60 | Fact Type: migration status has error count 61 | Necessity: each migration status has at most one error count 62 | 63 | Fact Type: migration status is backing off 64 | 65 | Fact Type: migration status has converged time 66 | Necessity: each migration status has at most one converged time 67 | -------------------------------------------------------------------------------- /src/migrator/migrations.ts: -------------------------------------------------------------------------------- 1 | // These types were generated by @balena/abstract-sql-to-typescript v5.1.1 2 | 3 | import type { Types } from '@balena/abstract-sql-to-typescript'; 4 | 5 | export interface Migration { 6 | Read: { 7 | created_at: Types['Date Time']['Read']; 8 | modified_at: Types['Date Time']['Read']; 9 | model_name: Types['Short Text']['Read']; 10 | executed_migrations: Types['JSON']['Read']; 11 | }; 12 | Write: { 13 | created_at: Types['Date Time']['Write']; 14 | modified_at: Types['Date Time']['Write']; 15 | model_name: Types['Short Text']['Write']; 16 | executed_migrations: Types['JSON']['Write']; 17 | }; 18 | } 19 | 20 | export interface MigrationLock { 21 | Read: { 22 | created_at: Types['Date Time']['Read']; 23 | modified_at: Types['Date Time']['Read']; 24 | model_name: Types['Short Text']['Read']; 25 | }; 26 | Write: { 27 | created_at: Types['Date Time']['Write']; 28 | modified_at: Types['Date Time']['Write']; 29 | model_name: Types['Short Text']['Write']; 30 | }; 31 | } 32 | 33 | export interface MigrationStatus { 34 | Read: { 35 | created_at: Types['Date Time']['Read']; 36 | modified_at: Types['Date Time']['Read']; 37 | migration_key: Types['Short Text']['Read']; 38 | start_time: Types['Date Time']['Read'] | null; 39 | last_run_time: Types['Date Time']['Read'] | null; 40 | run_count: Types['Integer']['Read']; 41 | migrated_row_count: Types['Integer']['Read'] | null; 42 | error_count: Types['Integer']['Read'] | null; 43 | is_backing_off: Types['Boolean']['Read']; 44 | converged_time: Types['Date Time']['Read'] | null; 45 | }; 46 | Write: { 47 | created_at: Types['Date Time']['Write']; 48 | modified_at: Types['Date Time']['Write']; 49 | migration_key: Types['Short Text']['Write']; 50 | start_time: Types['Date Time']['Write'] | null; 51 | last_run_time: Types['Date Time']['Write'] | null; 52 | run_count: Types['Integer']['Write']; 53 | migrated_row_count: Types['Integer']['Write'] | null; 54 | error_count: Types['Integer']['Write'] | null; 55 | is_backing_off: Types['Boolean']['Write']; 56 | converged_time: Types['Date Time']['Write'] | null; 57 | }; 58 | } 59 | 60 | export default interface $Model { 61 | migration: Migration; 62 | migration_lock: MigrationLock; 63 | migration_status: MigrationStatus; 64 | } 65 | -------------------------------------------------------------------------------- /src/passport-pinejs/mount-login-router.ts: -------------------------------------------------------------------------------- 1 | import * as passportPinejs from './passport-pinejs.js'; 2 | import type { Express } from 'express'; 3 | import { PinejsSessionStore } from '../pinejs-session-store/pinejs-session-store.js'; 4 | import type { setup } from '../config-loader/config-loader.js'; 5 | 6 | export const mountLoginRouter = async ( 7 | configLoader: ReturnType, 8 | expressApp: Express, 9 | ) => { 10 | await Promise.all([ 11 | configLoader.loadConfig(passportPinejs.config), 12 | configLoader.loadConfig(PinejsSessionStore.config), 13 | ]); 14 | 15 | if (typeof process === 'undefined' || !process?.env.DISABLE_DEFAULT_AUTH) { 16 | expressApp.post( 17 | '/login', 18 | passportPinejs.login((err, user, req, res) => { 19 | if (err) { 20 | console.error('Error logging in', err); 21 | res.status(500).end(); 22 | } else if (user === false) { 23 | if (req.xhr === true) { 24 | res.status(401).end(); 25 | } else { 26 | res.redirect('/login.html'); 27 | } 28 | } else { 29 | if (req.xhr === true) { 30 | res.status(200).end(); 31 | } else { 32 | res.redirect('/'); 33 | } 34 | } 35 | }), 36 | ); 37 | 38 | expressApp.get('/logout', passportPinejs.logout, (_req, res) => { 39 | res.redirect('/'); 40 | }); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /src/passport-pinejs/passport-pinejs.ts: -------------------------------------------------------------------------------- 1 | import type Express from 'express'; 2 | import type Passport from 'passport'; 3 | import type PassportLocal from 'passport-local'; 4 | import type * as ConfigLoader from '../config-loader/config-loader.js'; 5 | import type { User } from '../sbvr-api/sbvr-utils.js'; 6 | 7 | import * as permissions from '../sbvr-api/permissions.js'; 8 | 9 | // Returns a middleware that will handle logging in using `username` and `password` body properties 10 | export let login: ( 11 | fn: ( 12 | err: any, 13 | user: object | null | false | undefined, 14 | req: Express.Request, 15 | res: Express.Response, 16 | next: Express.NextFunction, 17 | ) => void, 18 | ) => Express.RequestHandler; 19 | 20 | // Returns a middleware that logs the user out and then calls next() 21 | export let logout: Express.RequestHandler; 22 | 23 | export const checkPassword: PassportLocal.VerifyFunction = async ( 24 | username, 25 | password, 26 | done: (error: undefined, user?: any) => void, 27 | ) => { 28 | try { 29 | const result = await permissions.checkPassword(username, password); 30 | done(undefined, result); 31 | } catch { 32 | done(undefined, false); 33 | } 34 | }; 35 | 36 | const setup: ConfigLoader.SetupFunction = async (app: Express.Application) => { 37 | if (!process.browser) { 38 | const { default: passport } = await import('passport'); 39 | app.use(passport.initialize()); 40 | app.use(passport.session()); 41 | 42 | const { Strategy: LocalStrategy } = await import('passport-local'); 43 | 44 | passport.serializeUser((user, done) => { 45 | done(null, user); 46 | }); 47 | 48 | passport.deserializeUser((user, done) => { 49 | done(null, user); 50 | }); 51 | 52 | passport.use(new LocalStrategy(checkPassword)); 53 | 54 | login = (fn) => (req, res, next) => 55 | passport.authenticate('local', ((err, user) => { 56 | if (err || user == null || user === false) { 57 | fn(err, user, req, res, next); 58 | return; 59 | } 60 | req.login(user, (error) => { 61 | fn(error, user, req, res, next); 62 | }); 63 | }) as Passport.AuthenticateCallback)(req, res, next); 64 | 65 | logout = (req, _res, next) => { 66 | req.logout((error) => { 67 | if (error) { 68 | next(error); 69 | return; 70 | } 71 | next(); 72 | }); 73 | }; 74 | } else { 75 | let loggedIn = false; 76 | let loggedInUser: any = null; 77 | app.use((req, _res, next) => { 78 | if (loggedIn === false) { 79 | req.user = loggedInUser; 80 | } 81 | next(); 82 | }); 83 | 84 | login = (fn) => (req, res, next) => { 85 | checkPassword(req.body.username, req.body.password, (err, user) => { 86 | if (user) { 87 | loggedIn = true; 88 | loggedInUser = user; 89 | } 90 | fn(err, user, req, res, next); 91 | }); 92 | }; 93 | 94 | logout = (req, _res, next) => { 95 | delete req.user; 96 | loggedIn = false; 97 | loggedInUser = null; 98 | next(); 99 | }; 100 | } 101 | }; 102 | 103 | export const config: ConfigLoader.Config = { 104 | models: [ 105 | { 106 | customServerCode: { setup }, 107 | }, 108 | ], 109 | }; 110 | -------------------------------------------------------------------------------- /src/pinejs-session-store/pinejs-session-store.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '../config-loader/config-loader.js'; 2 | import type { AnyObject } from '../sbvr-api/common-types.js'; 3 | 4 | import { Store } from 'express-session'; 5 | import * as permissions from '../sbvr-api/permissions.js'; 6 | import { api } from '../sbvr-api/sbvr-utils.js'; 7 | 8 | export { Store }; 9 | 10 | const sessionModel = ` 11 | Vocabulary: session 12 | 13 | Term: session id 14 | Concept Type: Short Text (Type) 15 | Term: data 16 | Concept Type: JSON (Type) 17 | Term: expiry time 18 | Concept type: Date Time (Type) 19 | 20 | Term: session 21 | Database ID Field: session id 22 | Reference Scheme: session id 23 | 24 | Fact type: session has data 25 | Necessity: Each session has exactly 1 data 26 | Fact type: session has session id 27 | Necessity: Each session has exactly 1 session id 28 | Necessity: Each session id is of exactly 1 session 29 | Fact type: session has expiry time 30 | Necessity: Each session has at most 1 expiry time 31 | `; 32 | 33 | const asCallback = async ( 34 | callback: undefined | ((err: any, result?: T) => void), 35 | promise: Promise, 36 | ) => { 37 | let err; 38 | let result; 39 | try { 40 | result = await promise; 41 | } catch ($err) { 42 | err = $err; 43 | } 44 | try { 45 | callback?.(err, result); 46 | } catch { 47 | // ignore errors in the callback 48 | } 49 | }; 50 | 51 | export class PinejsSessionStore extends Store { 52 | public get = ((sid, callback) => { 53 | void asCallback( 54 | callback, 55 | api.session 56 | .get({ 57 | resource: 'session', 58 | id: sid, 59 | passthrough: { 60 | req: permissions.rootRead, 61 | }, 62 | options: { 63 | $select: 'data', 64 | }, 65 | }) 66 | .then((session: AnyObject) => { 67 | if (session != null) { 68 | return session.data; 69 | } 70 | }), 71 | ); 72 | }) as Store['get']; 73 | 74 | public set = ((sid, data, callback) => { 75 | const body = { 76 | session_id: sid, 77 | data, 78 | expiry_time: data?.cookie?.expires ?? null, 79 | }; 80 | void asCallback( 81 | callback, 82 | api.session.put({ 83 | resource: 'session', 84 | id: sid, 85 | passthrough: { 86 | req: permissions.root, 87 | }, 88 | body, 89 | }), 90 | ); 91 | }) as Store['set']; 92 | 93 | public destroy = ((sid, callback) => { 94 | void asCallback( 95 | callback, 96 | api.session.delete({ 97 | resource: 'session', 98 | id: sid, 99 | passthrough: { 100 | req: permissions.root, 101 | }, 102 | }), 103 | ); 104 | }) as Store['destroy']; 105 | 106 | public all = ((callback) => { 107 | void asCallback( 108 | callback, 109 | api.session 110 | .get({ 111 | resource: 'session', 112 | passthrough: { 113 | req: permissions.root, 114 | }, 115 | options: { 116 | $select: 'session_id', 117 | $filter: { 118 | expiry_time: { $ge: Date.now() }, 119 | }, 120 | }, 121 | }) 122 | .then((sessions: AnyObject[]) => sessions.map((s) => s.session_id)), 123 | ); 124 | }) as Store['all']; 125 | 126 | public clear = ((callback) => { 127 | void asCallback( 128 | callback, 129 | // TODO: Use a truncate 130 | api.session.delete({ 131 | resource: 'session', 132 | passthrough: { 133 | req: permissions.root, 134 | }, 135 | }), 136 | ); 137 | }) as Store['clear']; 138 | 139 | public length = ((callback) => { 140 | void asCallback( 141 | callback, 142 | api.session.get({ 143 | resource: 'session/', 144 | passthrough: { 145 | req: permissions.rootRead, 146 | }, 147 | options: { 148 | $count: { 149 | $filter: { 150 | expiry_time: { 151 | $ge: Date.now(), 152 | }, 153 | }, 154 | }, 155 | }, 156 | }), 157 | ); 158 | }) as Store['length']; 159 | 160 | public static config: Config = { 161 | models: [ 162 | { 163 | modelName: 'session', 164 | modelText: sessionModel, 165 | apiRoot: 'session', 166 | logging: { 167 | default: false, 168 | error: true, 169 | }, 170 | migrations: { 171 | '11.0.0-modified-at': ` 172 | ALTER TABLE "session" 173 | ADD COLUMN IF NOT EXISTS "modified at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL; 174 | `, 175 | '15.0.0-data-types': async (tx, sbvrUtils) => { 176 | switch (sbvrUtils.db.engine) { 177 | case 'mysql': 178 | await tx.executeSql(`\ 179 | ALTER TABLE "session" 180 | MODIFY "data" JSON NOT NULL;`); 181 | break; 182 | case 'postgres': 183 | await tx.executeSql(`\ 184 | ALTER TABLE "session" 185 | ALTER COLUMN "data" SET DATA TYPE JSONB USING "data"::JSONB;`); 186 | break; 187 | // No need to migrate for websql 188 | } 189 | }, 190 | }, 191 | }, 192 | ], 193 | }; 194 | } 195 | -------------------------------------------------------------------------------- /src/sbvr-api/actions.ts: -------------------------------------------------------------------------------- 1 | import type { ODataQuery } from '@balena/odata-parser'; 2 | import { 3 | BadRequestError, 4 | type ParsedODataRequest, 5 | } from '../sbvr-api/uri-parser.js'; 6 | import { api, type Response } from '../sbvr-api/sbvr-utils.js'; 7 | import type { Tx } from '../database-layer/db.js'; 8 | import { sbvrUtils } from '../server-glue/module.js'; 9 | import type { AnyObject } from 'pinejs-client-core'; 10 | import { UnauthorizedError } from '../sbvr-api/errors.js'; 11 | 12 | export type ODataActionRequest = Omit & { 13 | odataQuery: Omit & { 14 | property: { 15 | resource: string; 16 | }; 17 | }; 18 | }; 19 | 20 | type ActionReq = Express.Request; 21 | 22 | export type ODataActionArgs = { 23 | request: ODataActionRequest; 24 | tx: Tx; 25 | api: (typeof api)[Vocab]; 26 | id: unknown; 27 | req: ActionReq; 28 | }; 29 | export type ODataAction = ( 30 | args: ODataActionArgs, 31 | ) => Promise; 32 | 33 | const actions: { 34 | [vocab: string]: { 35 | [resourceName: string]: { 36 | [actionName: string]: ODataAction; 37 | }; 38 | }; 39 | } = {}; 40 | 41 | export const isActionRequest = ( 42 | request: ParsedODataRequest, 43 | ): request is ODataActionRequest => { 44 | // OData actions must always be POST 45 | // See: https://www.odata.org/blog/actions-in-odata/ 46 | return ( 47 | request.method === 'POST' && 48 | request.odataQuery.property?.resource != null && 49 | actions[request.vocabulary]?.[request.resourceName]?.[ 50 | request.odataQuery.property.resource 51 | ] != null 52 | ); 53 | }; 54 | 55 | const runActionInTrasaction = async ( 56 | request: ODataActionRequest, 57 | req: ActionReq, 58 | tx: Tx, 59 | ): Promise => { 60 | const actionName = request.odataQuery.property.resource; 61 | const action = 62 | actions[request.vocabulary]?.[request.resourceName]?.[actionName]; 63 | if (action == null) { 64 | throw new BadRequestError(); 65 | } 66 | 67 | // in practice, the parser does not currently allow actions without a key 68 | // so we keep it strict throwing in case this expectation is broken 69 | if (request.odataQuery.key == null) { 70 | throw new BadRequestError('Unbound OData actions are not supported'); 71 | } 72 | const id = await canRunAction(request, req, actionName, tx); 73 | const applicationApi = api[request.vocabulary].clone({ 74 | passthrough: { tx, req }, 75 | }); 76 | 77 | return await action({ 78 | request, 79 | tx, 80 | api: applicationApi, 81 | req, 82 | id, 83 | }); 84 | }; 85 | 86 | export const runAction = async ( 87 | request: ODataActionRequest, 88 | req: ActionReq, 89 | ) => { 90 | if (api[request.vocabulary] == null) { 91 | throw new BadRequestError(); 92 | } 93 | 94 | return await (req.tx 95 | ? runActionInTrasaction(request, req, req.tx) 96 | : sbvrUtils.db.transaction(async (tx) => { 97 | req.tx = tx; 98 | return await runActionInTrasaction(request, req, tx); 99 | })); 100 | }; 101 | 102 | export const addAction = ( 103 | vocabulary: Vocab, 104 | resourceName: string, 105 | actionName: string, 106 | action: ODataAction, 107 | ) => { 108 | actions[vocabulary] ??= {}; 109 | actions[vocabulary][resourceName] ??= {}; 110 | actions[vocabulary][resourceName][actionName] = action; 111 | }; 112 | 113 | export const canRunAction = async ( 114 | request: ParsedODataRequest, 115 | req: ActionReq, 116 | actionName: string, 117 | tx: Tx, 118 | ) => { 119 | const canAccessUrl = request.url 120 | .slice(1) 121 | .split('?', 1)[0] 122 | .replace(new RegExp(`(${actionName})$`), 'canAccess'); 123 | 124 | if (!canAccessUrl.endsWith('/canAccess')) { 125 | throw new UnauthorizedError(); 126 | } 127 | 128 | const applicationApi = api[request.vocabulary]; 129 | if (applicationApi == null) { 130 | throw new BadRequestError(`Could not find model ${request.vocabulary}`); 131 | } 132 | 133 | const res = await applicationApi.request({ 134 | method: 'POST', 135 | url: canAccessUrl, 136 | body: { action: actionName }, 137 | passthrough: { tx, req }, 138 | }); 139 | 140 | return canAccessResourceId(res); 141 | }; 142 | 143 | const canAccessResourceId = (canAccessResponse: AnyObject): unknown => { 144 | const item = canAccessResponse?.d?.[0]; 145 | if (item == null || typeof item !== 'object') { 146 | throw new UnauthorizedError(); 147 | } 148 | const keys = Object.keys(item); 149 | if (keys.length !== 1 || item[keys[0]] == null) { 150 | throw new UnauthorizedError(); 151 | } 152 | 153 | return item[keys[0]]; 154 | }; 155 | -------------------------------------------------------------------------------- /src/sbvr-api/cached-compile.ts: -------------------------------------------------------------------------------- 1 | import type Fs from 'fs'; 2 | 3 | import { optionalVar } from '@balena/env-parsing'; 4 | import _ from 'lodash'; 5 | 6 | const cacheFile = optionalVar('PINEJS_CACHE_FILE', '.pinejs-cache.json'); 7 | let cache: null | { 8 | [name: string]: { 9 | [version: string]: { 10 | [srcJson: string]: any; 11 | }; 12 | }; 13 | } = null; 14 | let fs: undefined | typeof Fs; 15 | try { 16 | fs = await import('fs'); 17 | } catch { 18 | // Ignore error 19 | } 20 | 21 | const SAVE_DEBOUNCE_TIME = 5000; 22 | 23 | const saveCache = _.debounce(() => { 24 | if (fs != null) { 25 | fs.writeFile(cacheFile, JSON.stringify(cache), 'utf8', (err) => { 26 | if (err) { 27 | console.warn('Error saving pinejs cache:', err); 28 | } 29 | }); 30 | } 31 | }, SAVE_DEBOUNCE_TIME); 32 | 33 | const clearCache = _.debounce(() => { 34 | cache = null; 35 | }, SAVE_DEBOUNCE_TIME * 2); 36 | 37 | export const cachedCompile = ( 38 | name: string, 39 | version: string, 40 | src: any, 41 | fn: () => T, 42 | ): T => { 43 | if (cache == null) { 44 | if (fs != null) { 45 | try { 46 | cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); 47 | } catch { 48 | // Ignore error 49 | } 50 | } 51 | cache ??= {}; 52 | } 53 | const key = [name, version, JSON.stringify(src)]; 54 | let result = _.get(cache, key); 55 | if (result == null) { 56 | result = fn(); 57 | _.set(cache, key, result); 58 | saveCache(); 59 | } 60 | // Schedule clearing the cache once we have made use of it since it usually means we're 61 | // done with it and it allows us to free up the memory - if it does end up being 62 | // requested again after being cleared then it will just trigger a reload of the cache 63 | // but that should be a rare case, as long as the clear timeout is a reasonable length 64 | clearCache(); 65 | return _.cloneDeep(result); 66 | }; 67 | -------------------------------------------------------------------------------- /src/sbvr-api/common-types.ts: -------------------------------------------------------------------------------- 1 | export type { AnyObject, Dictionary } from 'pinejs-client-core'; 2 | 3 | type Overwrite = Pick> & U; 4 | export type RequiredField = Overwrite< 5 | T, 6 | Required> 7 | >; 8 | export type OptionalField = Overwrite< 9 | T, 10 | Partial> 11 | >; 12 | export type Resolvable = R | PromiseLike; 13 | export type Tail = T extends [any, ...infer U] ? U : never; 14 | -------------------------------------------------------------------------------- /src/sbvr-api/control-flow.ts: -------------------------------------------------------------------------------- 1 | import type { Resolvable } from './common-types.js'; 2 | 3 | import _ from 'lodash'; 4 | import { TypedError } from 'typed-error'; 5 | 6 | export type MappingFunction = ( 7 | a: T[], 8 | fn: (v: T) => Resolvable, 9 | ) => Promise>; 10 | 11 | export const mapSeries = async (a: T[], fn: (v: T) => Resolvable) => { 12 | const results: U[] = []; 13 | for (const p of a) { 14 | results.push(await fn(p)); 15 | } 16 | return results; 17 | }; 18 | 19 | // The settle version of `Promise.mapSeries` 20 | export const settleMapSeries: MappingFunction = async ( 21 | a: T[], 22 | fn: (v: T) => Resolvable, 23 | ) => 24 | await mapSeries(a, async (p) => { 25 | try { 26 | return await fn(p); 27 | } catch (err: any) { 28 | return ensureError(err); 29 | } 30 | }); 31 | 32 | // This is used to guarantee that we convert a `.catch` result into an error, so that later code checking `_.isError` will work as expected 33 | const ensureError = (err: any): Error => { 34 | if (err instanceof Error || _.isError(err)) { 35 | return err; 36 | } 37 | return new Error(err); 38 | }; 39 | 40 | // Maps fn over collection and returns an array of Promises. If any promise in the 41 | // collection is rejected it returns an array with the error, along with all the 42 | // promises that were fulfilled up to that point 43 | const mapTill: MappingFunction = async ( 44 | a: T[], 45 | fn: (v: T) => Resolvable, 46 | ) => { 47 | const results: Array = []; 48 | for (const p of a) { 49 | try { 50 | const result = await fn(p); 51 | results.push(result); 52 | } catch (err: any) { 53 | results.push(ensureError(err)); 54 | break; 55 | } 56 | } 57 | return results; 58 | }; 59 | 60 | // Used to obtain the appropriate mapping function depending on the 61 | // semantics specified by the Prefer: header. 62 | export const getMappingFn = (headers?: { 63 | prefer?: string | string[]; 64 | [key: string]: string | string[] | undefined; 65 | }): MappingFunction => { 66 | if (headers != null && headers.prefer === 'odata.continue-on-error') { 67 | return settleMapSeries; 68 | } else { 69 | return mapTill; 70 | } 71 | }; 72 | 73 | export const delay = (ms: number) => 74 | new Promise((resolve) => setTimeout(resolve, ms)); 75 | 76 | export const fromCallback = ( 77 | resolver: (callback: (err: any, result?: T) => void) => void, 78 | ): Promise => 79 | new Promise((resolve, reject) => { 80 | resolver((err, result?: T) => { 81 | if (err) { 82 | reject(err as Error); 83 | } else { 84 | resolve(result as T); 85 | } 86 | }); 87 | }); 88 | 89 | export class TimeoutError extends TypedError {} 90 | export const timeout = async ( 91 | promise: Promise, 92 | ms: number, 93 | msg = 'operation timed out', 94 | ): Promise => 95 | await Promise.race([ 96 | promise, 97 | delay(ms).then(() => { 98 | throw new TimeoutError(msg); 99 | }), 100 | ]); 101 | -------------------------------------------------------------------------------- /src/sbvr-api/dev.sbvr: -------------------------------------------------------------------------------- 1 | Vocabulary: dev 2 | 3 | Term: model value 4 | Concept Type: JSON (Type) 5 | Term: model 6 | Reference Scheme: model value 7 | Term: vocabulary 8 | Concept Type: Short Text (Type) 9 | Term: model type 10 | Concept Type: Short Text (Type) 11 | 12 | Fact Type: model is of vocabulary 13 | Necessity: Each model is of exactly one vocabulary 14 | Fact Type: model has model type 15 | Necessity: Each model has exactly one model type 16 | Fact Type: model has model value 17 | Necessity: Each model has exactly one model value 18 | -------------------------------------------------------------------------------- /src/sbvr-api/dev.ts: -------------------------------------------------------------------------------- 1 | // These types were generated by @balena/abstract-sql-to-typescript v5.1.1 2 | 3 | import type { Types } from '@balena/abstract-sql-to-typescript'; 4 | 5 | export interface Model { 6 | Read: { 7 | created_at: Types['Date Time']['Read']; 8 | modified_at: Types['Date Time']['Read']; 9 | id: Types['Serial']['Read']; 10 | is_of__vocabulary: Types['Short Text']['Read']; 11 | model_type: Types['Short Text']['Read']; 12 | model_value: Types['JSON']['Read']; 13 | }; 14 | Write: { 15 | created_at: Types['Date Time']['Write']; 16 | modified_at: Types['Date Time']['Write']; 17 | id: Types['Serial']['Write']; 18 | is_of__vocabulary: Types['Short Text']['Write']; 19 | model_type: Types['Short Text']['Write']; 20 | model_value: Types['JSON']['Write']; 21 | }; 22 | } 23 | 24 | export default interface $Model { 25 | model: Model; 26 | } 27 | -------------------------------------------------------------------------------- /src/sbvr-api/express-extension.ts: -------------------------------------------------------------------------------- 1 | // Augment express.js with pinejs-specific attributes via declaration merging. 2 | import type { User as PineUser } from './sbvr-utils.js'; 3 | 4 | declare global { 5 | // eslint-disable-next-line @typescript-eslint/no-namespace 6 | namespace Express { 7 | // Augment Express.User to include the props of our PineUser. 8 | // eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type 9 | interface User extends PineUser {} 10 | 11 | interface Request { 12 | user?: User; 13 | apiKey?: import('./sbvr-utils.js').ApiKey; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/sbvr-api/user.sbvr: -------------------------------------------------------------------------------- 1 | Vocabulary: Auth 2 | 3 | Term: username 4 | Concept Type: Short Text (Type) 5 | Term: password 6 | Concept Type: Hashed (Type) 7 | Term: name 8 | Concept Type: Text (Type) 9 | Term: key 10 | Concept Type: Short Text (Type) 11 | Term: expiry date 12 | Concept Type: Date Time (Type) 13 | Term: description 14 | Concept Type: Text (Type) 15 | 16 | Term: permission 17 | Reference Scheme: name 18 | Fact type: permission has name 19 | Necessity: Each permission has exactly one name. 20 | Necessity: Each name is of exactly one permission. 21 | 22 | Term: role 23 | Reference Scheme: name 24 | Fact type: role has name 25 | Necessity: Each role has exactly one name. 26 | Necessity: Each name is of exactly one role. 27 | Fact type: role has permission 28 | 29 | Term: actor 30 | 31 | Term: user 32 | Reference Scheme: username 33 | Concept Type: actor 34 | Fact type: user has username 35 | Necessity: Each user has exactly one username. 36 | Necessity: Each username is of exactly one user. 37 | Fact type: user has password 38 | Necessity: Each user has exactly one password. 39 | Fact type: user has role 40 | Note: A 'user' will inherit all the 'permissions' that the 'role' has. 41 | Term Form: user role 42 | Fact type: user role has expiry date 43 | Necessity: Each user role has at most one expiry date. 44 | Fact type: user has permission 45 | Term Form: user permission 46 | Fact type: user permission has expiry date 47 | Necessity: Each user permission has at most one expiry date. 48 | 49 | Term: api key 50 | Reference Scheme: key 51 | Fact type: api key has key 52 | Necessity: each api key has exactly one key 53 | Necessity: each key is of exactly one api key 54 | Fact type: api key has expiry date 55 | Necessity: each api key has at most one expiry date. 56 | Fact type: api key has role 57 | Note: An 'api key' will inherit all the 'permissions' that the 'role' has. 58 | Fact type: api key has permission 59 | Fact type: api key is of actor 60 | Necessity: each api key is of exactly one actor 61 | Fact type: api key has name 62 | Necessity: Each api key has at most one name. 63 | Fact type: api key has description 64 | Necessity: Each api key has at most one description. 65 | -------------------------------------------------------------------------------- /src/server-glue/global-ext.d.ts: -------------------------------------------------------------------------------- 1 | // We have to use var when extending the global namespace 2 | // eslint-disable-next-line no-var 3 | declare var nodeRequire: NodeRequire; 4 | declare namespace NodeJS { 5 | export interface Process { 6 | browser: boolean; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/server-glue/module.ts: -------------------------------------------------------------------------------- 1 | import type Express from 'express'; 2 | 3 | import './sbvr-loader.js'; 4 | 5 | import * as dbModule from '../database-layer/db.js'; 6 | import * as configLoader from '../config-loader/config-loader.js'; 7 | import * as migrator from '../migrator/sync.js'; 8 | import type * as migratorUtils from '../migrator/utils.js'; 9 | import * as tasks from '../tasks/index.js'; 10 | export * as actions from '../sbvr-api/actions.js'; 11 | import * as webresource from '../webresource-handler/index.js'; 12 | 13 | import * as sbvrUtils from '../sbvr-api/sbvr-utils.js'; 14 | import { PINEJS_ADVISORY_LOCK } from '../config-loader/env.js'; 15 | 16 | export * as dbModule from '../database-layer/db.js'; 17 | export { PinejsSessionStore } from '../pinejs-session-store/pinejs-session-store.js'; 18 | export { mountLoginRouter } from '../passport-pinejs/mount-login-router.js'; 19 | export * as sbvrUtils from '../sbvr-api/sbvr-utils.js'; 20 | export * as permissions from '../sbvr-api/permissions.js'; 21 | export * as errors from '../sbvr-api/errors.js'; 22 | export * as env from '../config-loader/env.js'; 23 | export * as types from '../sbvr-api/common-types.js'; 24 | export * as hooks from '../sbvr-api/hooks.js'; 25 | export * as tasks from '../tasks/index.js'; 26 | export * as webResourceHandler from '../webresource-handler/index.js'; 27 | export type { configLoader as ConfigLoader }; 28 | export type { migratorUtils as Migrator }; 29 | 30 | let envDatabaseOptions: dbModule.DatabaseOptions; 31 | if (dbModule.engines.websql != null) { 32 | envDatabaseOptions = { 33 | engine: 'websql', 34 | params: 'rulemotion', 35 | }; 36 | } else { 37 | let databaseURL: string; 38 | if (process.env.DATABASE_URL) { 39 | databaseURL = process.env.DATABASE_URL; 40 | } else if (dbModule.engines.postgres != null) { 41 | databaseURL = 'postgres://postgres:.@localhost:5432/postgres'; 42 | } else if (dbModule.engines.mysql == null) { 43 | databaseURL = 'mysql://mysql:.@localhost:3306'; 44 | } else { 45 | throw new Error('No supported database options available'); 46 | } 47 | envDatabaseOptions = { 48 | engine: databaseURL.slice(0, databaseURL.indexOf(':')), 49 | params: databaseURL, 50 | }; 51 | } 52 | 53 | export const init = async ( 54 | app: Express.Application, 55 | config?: string | configLoader.Config, 56 | databaseOptions: 57 | | dbModule.DatabaseOptions 58 | | typeof envDatabaseOptions = envDatabaseOptions, 59 | ): Promise> => { 60 | try { 61 | const db = dbModule.connect(databaseOptions); 62 | // register a pinejs unique lock namespace 63 | dbModule.registerTransactionLockNamespace( 64 | PINEJS_ADVISORY_LOCK.namespaceKey, 65 | PINEJS_ADVISORY_LOCK.namespaceId, 66 | ); 67 | await sbvrUtils.setup(app, db); 68 | const cfgLoader = configLoader.setup(app); 69 | await cfgLoader.loadConfig(migrator.config); 70 | await cfgLoader.loadConfig(tasks.config); 71 | await cfgLoader.loadConfig(webresource.config); 72 | 73 | if (!process.env.CONFIG_LOADER_DISABLED) { 74 | await cfgLoader.loadApplicationConfig(config); 75 | } 76 | // Execute it after all other promises have resolved. Execution of promises is not neccessarily 77 | // guaranteed to be sequentially resolving them with Promise.all 78 | await sbvrUtils.postSetup(app, db); 79 | 80 | return cfgLoader; 81 | } catch (err: any) { 82 | console.error('Error initialising server', err); 83 | process.exit(1); 84 | } 85 | }; 86 | -------------------------------------------------------------------------------- /src/server-glue/sbvr-loader.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @param filePath The module to load 4 | * @param parentUrl Use import.meta.url 5 | * @returns 6 | */ 7 | export async function loadSBVR(filePath: string, meta: ImportMeta) { 8 | return await ( 9 | await import('fs') 10 | ).promises.readFile(new URL(meta.resolve(filePath)), 'utf8'); 11 | } 12 | 13 | /** 14 | * 15 | * @param filePath The module to load 16 | * @param parentUrl Use `import.meta.url` 17 | * @returns The sbvr file contents 18 | */ 19 | export async function importSBVR(filePath: string, meta: ImportMeta) { 20 | return await ( 21 | await import('fs') 22 | ).promises.readFile(new URL(meta.resolve(filePath)), 'utf8'); 23 | } 24 | -------------------------------------------------------------------------------- /src/server-glue/server.ts: -------------------------------------------------------------------------------- 1 | import * as Pinejs from './module.js'; 2 | export { sbvrUtils, PinejsSessionStore } from './module.js'; 3 | 4 | export { ExtendedSBVRParser } from '../extended-sbvr-parser/extended-sbvr-parser.js'; 5 | 6 | import { mountLoginRouter } from '../passport-pinejs/mount-login-router.js'; 7 | 8 | import express from 'express'; 9 | 10 | const app = express(); 11 | 12 | switch (app.get('env')) { 13 | case 'production': 14 | console.log = () => { 15 | // noop 16 | }; 17 | break; 18 | } 19 | 20 | if (!process.browser) { 21 | const { default: passport } = await import('passport'); 22 | const { default: path } = await import('path'); 23 | const { default: compression } = await import('compression'); 24 | const { default: serveStatic } = await import('serve-static'); 25 | const { default: cookieParser } = await import('cookie-parser'); 26 | const { default: bodyParser } = await import('body-parser'); 27 | const { default: methodOverride } = await import('method-override'); 28 | const { default: expressSession } = await import('express-session'); 29 | 30 | app.use(compression()); 31 | 32 | const root = process.argv[2] || import.meta.dirname; 33 | app.use('/', serveStatic(path.join(root, 'static'))); 34 | 35 | app.use(cookieParser()); 36 | app.use(bodyParser()); 37 | app.use(methodOverride()); 38 | app.use( 39 | expressSession({ 40 | secret: 'A pink cat jumped over a rainbow', 41 | store: new Pinejs.PinejsSessionStore(), 42 | }), 43 | ); 44 | app.use(passport.initialize()); 45 | app.use(passport.session()); 46 | 47 | app.use((req, res, next) => { 48 | const origin = req.get('Origin') ?? '*'; 49 | res.header('Access-Control-Allow-Origin', origin); 50 | res.header( 51 | 'Access-Control-Allow-Methods', 52 | 'GET, PUT, POST, PATCH, DELETE, OPTIONS, HEAD', 53 | ); 54 | res.header( 55 | 'Access-Control-Allow-Headers', 56 | 'Content-Type, Authorization, Application-Record-Count, MaxDataServiceVersion, X-Requested-With', 57 | ); 58 | res.header('Access-Control-Allow-Credentials', 'true'); 59 | next(); 60 | }); 61 | 62 | app.use((req, _res, next) => { 63 | console.log('%s %s', req.method, req.url); 64 | next(); 65 | }); 66 | } 67 | 68 | export const initialised = Pinejs.init(app) 69 | .then(async (configLoader) => { 70 | await mountLoginRouter(configLoader, app); 71 | 72 | app.listen(process.env.PORT ?? 1337, () => { 73 | console.info('Server started'); 74 | }); 75 | }) 76 | .catch((err) => { 77 | console.error('Error initialising server', err); 78 | process.exit(1); 79 | }); 80 | -------------------------------------------------------------------------------- /src/tasks/common.ts: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv'; 2 | 3 | // Root path for the tasks API 4 | export const apiRoot = 'tasks'; 5 | 6 | export const ajv = new Ajv.default(); 7 | -------------------------------------------------------------------------------- /src/tasks/pine-tasks.ts: -------------------------------------------------------------------------------- 1 | import { tasks } from '../server-glue/module.js'; 2 | import { addDeleteFileTaskHandler } from '../webresource-handler/delete-file-task.js'; 3 | 4 | export const addPineTaskHandlers = () => { 5 | addDeleteFileTaskHandler(); 6 | void tasks.worker?.start(); 7 | }; 8 | -------------------------------------------------------------------------------- /src/tasks/tasks.sbvr: -------------------------------------------------------------------------------- 1 | Vocabulary: tasks 2 | 3 | Term: id 4 | Concept Type: Big Serial (Type) 5 | Term: actor 6 | Concept Type: Integer (Type) 7 | Term: attempt count 8 | Concept Type: Integer (Type) 9 | Term: attempt limit 10 | Concept Type: Integer (Type) 11 | Term: cron expression 12 | Concept Type: Short Text (Type) 13 | Term: error message 14 | Concept Type: Short Text (Type) 15 | Term: handler 16 | Concept Type: Short Text (Type) 17 | Term: key 18 | Concept Type: Short Text (Type) 19 | Term: parameter set 20 | Concept Type: JSON (Type) 21 | Term: status 22 | Concept Type: Short Text (Type) 23 | Term: time 24 | Concept Type: Date Time (Type) 25 | 26 | Term: task 27 | Fact type: task has id 28 | Necessity: each task has exactly one id 29 | Fact type: task has key 30 | Necessity: each task has at most one key 31 | Fact type: task is created by actor 32 | Necessity: each task is created by exactly one actor 33 | Fact type: task is executed by handler 34 | Necessity: each task is executed by exactly one handler 35 | Fact type: task is executed with parameter set 36 | Necessity: each task is executed with at most one parameter set 37 | Fact type: task is scheduled with cron expression 38 | Necessity: each task is scheduled with at most one cron expression 39 | Fact type: task is scheduled to execute on time 40 | Necessity: each task is scheduled to execute on at most one time 41 | Fact type: task has status 42 | Necessity: each task has exactly one status 43 | Definition: "queued" or "cancelled" or "succeeded" or "failed" 44 | Fact type: task started on time 45 | Necessity: each task started on at most one time 46 | Fact type: task ended on time 47 | Necessity: each task ended on at most one time 48 | Fact type: task has error message 49 | Necessity: each task has at most one error message 50 | Fact type: task has attempt count 51 | Necessity: each task has exactly one attempt count 52 | Fact type: task has attempt limit 53 | Necessity: each task has exactly one attempt limit 54 | Necessity: each task has an attempt limit that is greater than or equal to 1 55 | 56 | -------------------------------------------------------------------------------- /src/tasks/tasks.ts: -------------------------------------------------------------------------------- 1 | // These types were generated by @balena/abstract-sql-to-typescript v5.1.1 2 | 3 | import type { Types } from '@balena/abstract-sql-to-typescript'; 4 | 5 | export interface Task { 6 | Read: { 7 | created_at: Types['Date Time']['Read']; 8 | modified_at: Types['Date Time']['Read']; 9 | id: Types['Big Serial']['Read']; 10 | key: Types['Short Text']['Read'] | null; 11 | is_created_by__actor: Types['Integer']['Read']; 12 | is_executed_by__handler: Types['Short Text']['Read']; 13 | is_executed_with__parameter_set: Types['JSON']['Read'] | null; 14 | is_scheduled_with__cron_expression: Types['Short Text']['Read'] | null; 15 | is_scheduled_to_execute_on__time: Types['Date Time']['Read'] | null; 16 | status: 'queued' | 'cancelled' | 'succeeded' | 'failed'; 17 | started_on__time: Types['Date Time']['Read'] | null; 18 | ended_on__time: Types['Date Time']['Read'] | null; 19 | error_message: Types['Short Text']['Read'] | null; 20 | attempt_count: Types['Integer']['Read']; 21 | attempt_limit: Types['Integer']['Read']; 22 | }; 23 | Write: { 24 | created_at: Types['Date Time']['Write']; 25 | modified_at: Types['Date Time']['Write']; 26 | id: Types['Big Serial']['Write']; 27 | key: Types['Short Text']['Write'] | null; 28 | is_created_by__actor: Types['Integer']['Write']; 29 | is_executed_by__handler: Types['Short Text']['Write']; 30 | is_executed_with__parameter_set: Types['JSON']['Write'] | null; 31 | is_scheduled_with__cron_expression: Types['Short Text']['Write'] | null; 32 | is_scheduled_to_execute_on__time: Types['Date Time']['Write'] | null; 33 | status: 'queued' | 'cancelled' | 'succeeded' | 'failed'; 34 | started_on__time: Types['Date Time']['Write'] | null; 35 | ended_on__time: Types['Date Time']['Write'] | null; 36 | error_message: Types['Short Text']['Write'] | null; 37 | attempt_count: Types['Integer']['Write']; 38 | attempt_limit: Types['Integer']['Write']; 39 | }; 40 | } 41 | 42 | export default interface $Model { 43 | task: Task; 44 | } 45 | -------------------------------------------------------------------------------- /src/webresource-handler/actions/cancelUpload.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ODataActionArgs, 3 | ODataActionRequest, 4 | } from '../../sbvr-api/actions.js'; 5 | import type { Tx } from '../../database-layer/db.js'; 6 | import { 7 | BadRequestError, 8 | NotImplementedError, 9 | UnauthorizedError, 10 | } from '../../sbvr-api/errors.js'; 11 | import { api, type Response } from '../../sbvr-api/sbvr-utils.js'; 12 | import { permissions } from '../../server-glue/module.js'; 13 | import { getMultipartUploadHandler } from '../multipartUpload.js'; 14 | 15 | const cancelUploadAction = async ({ 16 | request, 17 | tx, 18 | id: resourceId, 19 | }: ODataActionArgs): Promise => { 20 | if (typeof resourceId !== 'number') { 21 | throw new NotImplementedError( 22 | 'multipart upload do not yet support non-numeric ids', 23 | ); 24 | } 25 | const { id, fileKey, uploadId } = await getOngoingUpload( 26 | request, 27 | resourceId, 28 | tx, 29 | ); 30 | const handler = getMultipartUploadHandler(); 31 | 32 | await api.webresource.patch({ 33 | resource: 'multipart_upload', 34 | body: { 35 | status: 'cancelled', 36 | }, 37 | id, 38 | passthrough: { 39 | tx: tx, 40 | req: permissions.root, 41 | }, 42 | }); 43 | 44 | // Note that different then beginUpload/commitUpload where we first do the action on the external service 45 | // and then reflect it on the DB, for cancel upload it is the other way around 46 | // as the worst case scenario is having a canceled upload which is marked on the DB as something else 47 | await handler.multipartUpload.cancel({ fileKey, uploadId }); 48 | 49 | return { 50 | statusCode: 204, 51 | }; 52 | }; 53 | 54 | const getOngoingUpload = async ( 55 | request: ODataActionRequest, 56 | affectedId: number, 57 | tx: Tx, 58 | ) => { 59 | const { uuid } = request.values; 60 | if (uuid == null || typeof uuid !== 'string') { 61 | throw new BadRequestError('Invalid uuid type'); 62 | } 63 | 64 | const multipartUpload = await api.webresource.get({ 65 | resource: 'multipart_upload', 66 | id: { 67 | uuid, 68 | }, 69 | options: { 70 | $select: ['id', 'file_key', 'upload_id'], 71 | $filter: { 72 | status: 'pending', 73 | expiry_date: { $gt: { $now: {} } }, 74 | resource_name: request.resourceName, 75 | resource_id: affectedId, 76 | }, 77 | }, 78 | passthrough: { 79 | tx, 80 | req: permissions.rootRead, 81 | }, 82 | }); 83 | 84 | if (multipartUpload == null) { 85 | throw new UnauthorizedError(); 86 | } 87 | 88 | return { 89 | id: multipartUpload.id, 90 | fileKey: multipartUpload.file_key, 91 | uploadId: multipartUpload.upload_id, 92 | }; 93 | }; 94 | 95 | export default cancelUploadAction; 96 | -------------------------------------------------------------------------------- /src/webresource-handler/actions/commitUpload.ts: -------------------------------------------------------------------------------- 1 | import type { Tx } from '../../database-layer/db.js'; 2 | import type { 3 | ODataActionArgs, 4 | ODataActionRequest, 5 | } from '../../sbvr-api/actions.js'; 6 | import { 7 | BadRequestError, 8 | NotImplementedError, 9 | UnauthorizedError, 10 | } from '../../sbvr-api/errors.js'; 11 | import type { Response } from '../../sbvr-api/sbvr-utils.js'; 12 | import { api } from '../../sbvr-api/sbvr-utils.js'; 13 | import { permissions } from '../../server-glue/module.js'; 14 | import { getMultipartUploadHandler } from '../multipartUpload.js'; 15 | 16 | const commitUploadAction = async ({ 17 | request, 18 | tx, 19 | id, 20 | api: applicationApi, 21 | }: ODataActionArgs): Promise => { 22 | if (typeof id !== 'number') { 23 | throw new NotImplementedError( 24 | 'multipart upload do not yet support non-numeric ids', 25 | ); 26 | } 27 | 28 | const multipartUpload = await getOngoingUpload(request, id, tx); 29 | const handler = getMultipartUploadHandler(); 30 | 31 | const webresource = await handler.multipartUpload.commit({ 32 | fileKey: multipartUpload.fileKey, 33 | uploadId: multipartUpload.uploadId, 34 | filename: multipartUpload.filename, 35 | providerCommitData: multipartUpload.providerCommitData, 36 | }); 37 | 38 | await Promise.all([ 39 | api.webresource.patch({ 40 | resource: 'multipart_upload', 41 | body: { 42 | status: 'completed', 43 | }, 44 | id: { 45 | uuid: multipartUpload.uuid, 46 | }, 47 | passthrough: { 48 | tx: tx, 49 | req: permissions.root, 50 | }, 51 | }), 52 | applicationApi.patch({ 53 | resource: request.resourceName, 54 | id, 55 | body: { 56 | [multipartUpload.fieldName]: webresource, 57 | }, 58 | passthrough: { 59 | tx: tx, 60 | // Root is needed as, if you are not root, you are not allowed to directly modify the actual metadata 61 | req: permissions.root, 62 | }, 63 | }), 64 | ]); 65 | 66 | const body = await handler.onPreRespond(webresource); 67 | 68 | return { 69 | body, 70 | statusCode: 200, 71 | }; 72 | }; 73 | 74 | const getOngoingUpload = async ( 75 | request: ODataActionRequest, 76 | affectedId: number, 77 | tx: Tx, 78 | ) => { 79 | const { uuid, providerCommitData } = request.values; 80 | if (uuid == null || typeof uuid !== 'string') { 81 | throw new BadRequestError('Invalid uuid type'); 82 | } 83 | 84 | const multipartUpload = await api.webresource.get({ 85 | resource: 'multipart_upload', 86 | id: { 87 | uuid, 88 | }, 89 | options: { 90 | $select: ['id', 'file_key', 'upload_id', 'field_name', 'filename'], 91 | $filter: { 92 | status: 'pending', 93 | expiry_date: { $gt: { $now: {} } }, 94 | resource_name: request.resourceName, 95 | resource_id: affectedId, 96 | }, 97 | }, 98 | passthrough: { 99 | tx, 100 | req: permissions.rootRead, 101 | }, 102 | }); 103 | 104 | if (multipartUpload == null) { 105 | throw new UnauthorizedError(); 106 | } 107 | 108 | return { 109 | uuid, 110 | providerCommitData, 111 | fileKey: multipartUpload.file_key, 112 | uploadId: multipartUpload.upload_id, 113 | filename: multipartUpload.filename, 114 | fieldName: multipartUpload.field_name, 115 | }; 116 | }; 117 | 118 | export default commitUploadAction; 119 | -------------------------------------------------------------------------------- /src/webresource-handler/actions/index.ts: -------------------------------------------------------------------------------- 1 | export { default as beginUpload } from './beginUpload.js'; 2 | export { default as commitUpload } from './commitUpload.js'; 3 | export { default as cancelUpload } from './cancelUpload.js'; 4 | -------------------------------------------------------------------------------- /src/webresource-handler/delete-file-task.ts: -------------------------------------------------------------------------------- 1 | import { addTaskHandler } from '../tasks/index.js'; 2 | import { getWebresourceHandler } from './index.js'; 3 | 4 | const deleteFileSchema = { 5 | type: 'object', 6 | properties: { 7 | fileKey: { 8 | type: 'string', 9 | }, 10 | }, 11 | required: ['fileKey'], 12 | additionalProperties: false, 13 | } as const; 14 | 15 | export const addDeleteFileTaskHandler = () => { 16 | addTaskHandler( 17 | 'delete_webresource_file', 18 | async (task) => { 19 | const handler = getWebresourceHandler(); 20 | if (!handler) { 21 | return { 22 | error: 'Webresource handler not available', 23 | status: 'failed', 24 | }; 25 | } 26 | 27 | try { 28 | await handler.removeFile(task.params.fileKey); 29 | return { 30 | status: 'succeeded', 31 | }; 32 | } catch (error) { 33 | console.error('Error deleting file:', error); 34 | return { 35 | error: `${error}`, 36 | status: 'failed', 37 | }; 38 | } 39 | }, 40 | deleteFileSchema, 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/webresource-handler/handlers/NoopHandler.ts: -------------------------------------------------------------------------------- 1 | import type { WebResourceType as WebResource } from '@balena/sbvr-types'; 2 | import type { 3 | IncomingFile, 4 | UploadResponse, 5 | WebResourceHandler, 6 | } from '../index.js'; 7 | 8 | export class NoopHandler implements WebResourceHandler { 9 | // eslint-disable-next-line @typescript-eslint/require-await -- We need to return a promise for compatibility reasons. 10 | public async handleFile(resource: IncomingFile): Promise { 11 | // handleFile must consume the file stream 12 | resource.stream.resume(); 13 | return { 14 | filename: 'noop', 15 | size: 0, 16 | }; 17 | } 18 | 19 | public async removeFile(): Promise { 20 | // noop 21 | } 22 | 23 | // eslint-disable-next-line @typescript-eslint/require-await -- We need to return a promise for compatibility reasons. 24 | public async onPreRespond(webResource: WebResource): Promise { 25 | return webResource; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/webresource-handler/handlers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './NoopHandler.js'; 2 | -------------------------------------------------------------------------------- /src/webresource-handler/multipartUpload.ts: -------------------------------------------------------------------------------- 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>; 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 | }; 24 | -------------------------------------------------------------------------------- /src/webresource-handler/webresource.sbvr: -------------------------------------------------------------------------------- 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 61 | -------------------------------------------------------------------------------- /src/webresource-handler/webresource.ts: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /test/00-basic.test.ts: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | import { expect } from 'chai'; 3 | const configPath = import.meta.dirname + '/fixtures/00-basic/config.js'; 4 | import { testInit, testDeInit, testLocalServer } from './lib/test-init.js'; 5 | 6 | describe('00 basic tests', function () { 7 | let pineServer: Awaited>; 8 | before(async () => { 9 | pineServer = await testInit({ configPath }); 10 | }); 11 | 12 | after(() => { 13 | testDeInit(pineServer); 14 | }); 15 | 16 | describe('Basic', () => { 17 | it('check /ping route is OK', async () => { 18 | await supertest(testLocalServer).get('/ping').expect(200, 'OK'); 19 | }); 20 | }); 21 | 22 | describe('example vocabular', () => { 23 | it('check /example/device is served by pinejs', async () => { 24 | const res = await supertest(testLocalServer) 25 | .get('/example/device') 26 | .expect(200); 27 | expect(res.body) 28 | .to.be.an('object') 29 | .that.has.ownProperty('d') 30 | .to.be.an('array'); 31 | }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /test/01-constrain.test.ts: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | import { expect } from 'chai'; 3 | const configPath = import.meta.dirname + '/fixtures/01-constrain/config.js'; 4 | import { testInit, testDeInit, testLocalServer } from './lib/test-init.js'; 5 | 6 | describe('01 basic constrain tests', function () { 7 | let pineServer: Awaited>; 8 | before(async () => { 9 | pineServer = await testInit({ configPath, deleteDb: true }); 10 | }); 11 | 12 | after(() => { 13 | testDeInit(pineServer); 14 | }); 15 | 16 | describe('Basic', () => { 17 | it('check /ping route is OK', async () => { 18 | await supertest(testLocalServer).get('/ping').expect(200, 'OK'); 19 | }); 20 | }); 21 | 22 | describe('university vocabulary', () => { 23 | it('check /university/student is served by pinejs', async () => { 24 | const res = await supertest(testLocalServer) 25 | .get('/university/student') 26 | .expect(200); 27 | expect(res.body) 28 | .to.be.an('object') 29 | .that.has.ownProperty('d') 30 | .to.be.an('array'); 31 | }); 32 | 33 | it('create a student', async () => { 34 | await supertest(testLocalServer) 35 | .post('/university/student') 36 | .send({ 37 | matrix_number: 1, 38 | name: 'John', 39 | lastname: 'Doe', 40 | birthday: new Date(), 41 | semester_credits: 10, 42 | }) 43 | .expect(201); 44 | }); 45 | 46 | it('should fail to create a student with same matrix number ', async () => { 47 | await supertest(testLocalServer) 48 | .post('/university/student') 49 | .send({ 50 | matrix_number: 1, 51 | name: 'John', 52 | lastname: 'Doe', 53 | birthday: new Date(), 54 | semester_credits: 10, 55 | }) 56 | .expect(409); 57 | }); 58 | 59 | it('should fail to create a student with too few semester credits ', async () => { 60 | const res = await supertest(testLocalServer) 61 | .post('/university/student') 62 | .send({ 63 | matrix_number: 2, 64 | name: 'Jenny', 65 | lastname: 'Dea', 66 | birthday: new Date(), 67 | semester_credits: 2, 68 | }) 69 | .expect(400); 70 | expect(res.body) 71 | .to.be.a('string') 72 | .that.equals( 73 | 'It is necessary that each student that has a semester credits, has a semester credits that is greater than or equal to 4 and is less than or equal to 16.', 74 | ); 75 | }); 76 | 77 | it('should error on invalid parameter type', async () => { 78 | await supertest(testLocalServer) 79 | .patch('/university/student(1)') 80 | .send({ 81 | name: null, 82 | }) 83 | .expect(400, '"\\"name\\" cannot be null"'); 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /test/02-sync-migrator.test.ts: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | import type { ChildProcess } from 'child_process'; 3 | import { expect } from 'chai'; 4 | import { testInit, testDeInit, testLocalServer } from './lib/test-init.js'; 5 | 6 | const fixturesBasePath = import.meta.dirname + '/fixtures/02-sync-migrator/'; 7 | 8 | type TestDevice = { 9 | created_at: Date; 10 | modified_at: Date; 11 | id: number; 12 | name: string; 13 | note: string; 14 | type: string; 15 | }; 16 | 17 | async function executeModelBeforeMigrations( 18 | modelFixturePath = fixturesBasePath + '00-execute-model.js', 19 | ) { 20 | // start pine instace with a configuration without migrations to execute the model in the DB once. 21 | // model has an initSqlPath declared so that the database gets filled first 22 | const executeModelsOnceBeforeTesting: ChildProcess = await testInit({ 23 | configPath: modelFixturePath, 24 | deleteDb: true, 25 | }); 26 | testDeInit(executeModelsOnceBeforeTesting); 27 | } 28 | 29 | describe('02 Sync Migrations', function () { 30 | this.timeout(30000); 31 | 32 | describe('Execute model and migrations should run', () => { 33 | let pineTestInstance: ChildProcess; 34 | before(async () => { 35 | await executeModelBeforeMigrations(); 36 | pineTestInstance = await testInit({ 37 | configPath: fixturesBasePath + '01-migrations.js', 38 | deleteDb: false, 39 | }); 40 | }); 41 | after(() => { 42 | testDeInit(pineTestInstance); 43 | }); 44 | 45 | it('check /example/device data has been migrated', async () => { 46 | const res = await supertest(testLocalServer) 47 | .get('/example/device') 48 | .expect(200); 49 | expect(res.body) 50 | .to.be.an('object') 51 | .that.has.ownProperty('d') 52 | .to.be.an('array'); 53 | expect(res.body.d).to.have.length(20); 54 | res.body.d.map((device: TestDevice) => { 55 | expect(device.note).to.contain('#migrated'); 56 | }); 57 | }); 58 | }); 59 | 60 | describe('Should fail to executed migrations', () => { 61 | let pineErrorInstace: ChildProcess; 62 | let pineTestInstance: ChildProcess; 63 | before(async () => { 64 | await executeModelBeforeMigrations(); 65 | }); 66 | after(() => { 67 | testDeInit(pineErrorInstace); 68 | testDeInit(pineTestInstance); 69 | }); 70 | 71 | it('Starting pine should fail when migrations fail', async () => { 72 | try { 73 | pineErrorInstace = await testInit({ 74 | configPath: fixturesBasePath + '02-migrations-error.js', 75 | deleteDb: false, 76 | listenPort: 1338, 77 | }); 78 | } catch (err: any) { 79 | expect(err.message).to.equal('exit'); 80 | } 81 | }); 82 | 83 | it('Check that failed migrations did not manipulated data', async () => { 84 | // get a pineInstance without data manipulations to check data 85 | pineTestInstance = await testInit({ 86 | configPath: fixturesBasePath + '00-execute-model.js', 87 | deleteDb: false, 88 | }); 89 | 90 | const res = await supertest(testLocalServer) 91 | .get('/example/device') 92 | .expect(200); 93 | expect(res?.body?.d).to.have.length(10); 94 | res.body.d.map((device: TestDevice) => { 95 | expect(device.note).to.not.exist; 96 | }); 97 | }); 98 | }); 99 | 100 | describe('Should not execute migrations for new executed model but run initSql', () => { 101 | let pineTestInstance: ChildProcess; 102 | before(async () => { 103 | pineTestInstance = await testInit({ 104 | configPath: fixturesBasePath + '04-new-model-with-init.js', 105 | deleteDb: true, 106 | }); 107 | }); 108 | 109 | after(() => { 110 | testDeInit(pineTestInstance); 111 | }); 112 | 113 | it('check that model migration was loaded and set executed', async () => { 114 | const migs = await supertest(testLocalServer) 115 | .get(`/migrations/migration?$filter=model_name eq 'example'`) 116 | .expect(200); 117 | expect(migs?.body?.d?.[0]?.model_name).to.eql('example'); 118 | expect(migs?.body?.d?.[0]?.executed_migrations).to.have.ordered.members([ 119 | '0001', 120 | ]); 121 | }); 122 | 123 | it('Check that /example/device data has not additionally migrated', async () => { 124 | const res = await supertest(testLocalServer) 125 | .get('/example/device') 126 | .expect(200); 127 | expect(res?.body?.d).to.have.length(1); 128 | }); 129 | }); 130 | 131 | describe('Should execute no mixed category migrations loaded from model.migrationsPath and model.migrations', () => { 132 | let pineErrorInstance: ChildProcess; 133 | it('should fail to start pine instance with mixed migration categories', async function () { 134 | try { 135 | await executeModelBeforeMigrations(); 136 | pineErrorInstance = await testInit({ 137 | configPath: fixturesBasePath + '03-exclusive-category.js', 138 | deleteDb: false, 139 | }); 140 | expect(pineErrorInstance).to.not.exist; 141 | } catch (err: any) { 142 | expect(err.message).to.equal('exit'); 143 | } 144 | }); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /test/canvas-demo/setup/instructions.txt: -------------------------------------------------------------------------------- 1 | install Python (2.7+) 2 | BE CAREFUL: get the right python for your OS. Do not install x86 python on a x86-64 OS. 3 | 4 | install setuptools (http://pypi.python.org/pypi/setuptools) 5 | 6 | get to C:\Python27\Scripts or equivalent 7 | 8 | easy_install -U selenium 9 | 10 | download ChromeDriver from here: http://code.google.com/p/chromedriver/downloads/list 11 | 12 | create ChromeDriver directory somewhere in your system 13 | 14 | place ChromeDriver.exe in the directory 15 | 16 | place directory in path 17 | 18 | get to tests\canvas-demo 19 | 20 | python test-demo.py -------------------------------------------------------------------------------- /test/canvas-demo/test-multiword.py: -------------------------------------------------------------------------------- 1 | from selenium import webdriver 2 | from selenium.common.exceptions import NoSuchElementException 3 | from selenium.webdriver.common.keys import Keys 4 | import time, sys, os 5 | 6 | def find_and_click(where, why): 7 | try: 8 | #print(where, why) 9 | elem = browser.find_element_by_xpath(where) 10 | #elem.click() # this should work except for a selenium/chrome bug. http://code.google.com/p/selenium/issues/detail?id=2766 11 | #So we are forced to directly navigate to the link below. 12 | #print elem.get_attribute('href') 13 | browser.get(elem.get_attribute('href')) 14 | except NoSuchElementException: 15 | assert 0, "can't %s" % why 16 | time.sleep(1) 17 | 18 | #TODO: Reduce time by waiting for next event specifically instead of adding random sleep time. 19 | 20 | browser = webdriver.Chrome() 21 | 22 | #expects to be three levels deep from the root. If path changes, the value '3' below may need to change also. 23 | root_path = sys.path[0].split('\\')[:-2] 24 | root_path = '/'.join(root_path) 25 | 26 | browser.get("file:///%s/src/client/frame-glue/out/compiled/index.html" % root_path) 27 | time.sleep(1) 28 | 29 | browser.find_element_by_id("bcdb").click() 30 | time.sleep(1) 31 | 32 | browser.find_element_by_id("blm1").click() 33 | time.sleep(1) 34 | 35 | modeldata = '''Term: pilot\\nTerm: plane type\\nFact type: pilot can fly plane type\\nFact type: pilot is experienced\\nRule: It is obligatory that each pilot can fly at least 1 plane type\\nRule: It is obligatory that each pilot that is experienced can fly at least 3 plane types''' 36 | 37 | browser.execute_script('window.sbvrEditor.setValue("%s");' % modeldata) 38 | browser.find_element_by_id("bem").click() 39 | time.sleep(1) 40 | 41 | try: 42 | browser.find_element_by_xpath("//a[contains(@href,'#importExportTab')]").click() 43 | except NoSuchElementException: 44 | assert 0, "can't switch to importExportTab" 45 | 46 | #This string has double escaped double quotes because the first slash is consumed by 47 | #the python string, and then the second slash is used to differentiate the double quotes 48 | #within the string (escaped) from those enclosing the string (not escaped). Also, it's 49 | #all one line as newlines make the javascript fail. Same goes for the '\\n'. 50 | 51 | SQLdata = '''INSERT INTO \\"pilot\\" (\\"id\\",\\"value\\",\\"is experienced\\") values ('1','Joachim','1');\\nINSERT INTO \\"pilot\\" (\\"id\\",\\"value\\") values ('2','Esteban');\\nINSERT INTO \\"plane_type\\" (\\"id\\",\\"value\\") values ('1','Boeing 747');\\nINSERT INTO \\"plane_type\\" (\\"id\\",\\"value\\") values ('2','Spitfire');\\nINSERT INTO \\"plane_type\\" (\\"id\\",\\"value\\") values ('3','Concorde');\\nINSERT INTO \\"plane_type\\" (\\"id\\",\\"value\\") values ('4','Mirage 2000');\\nINSERT INTO \\"pilot-can_fly-plane_type\\" (\\"id\\",\\"pilot\\",\\"plane_type\\") values ('1','1','2');\\nINSERT INTO \\"pilot-can_fly-plane_type\\" (\\"id\\",\\"pilot\\",\\"plane_type\\") values ('2','1','3');\\nINSERT INTO \\"pilot-can_fly-plane_type\\" (\\"id\\",\\"pilot\\",\\"plane_type\\") values ('3','1','4');\\nINSERT INTO \\"pilot-can_fly-plane_type\\" (\\"id\\",\\"pilot\\",\\"plane_type\\") values ('4','2','1');''' 52 | 53 | browser.execute_script('window.importExportEditor.setValue("%s");' % SQLdata) 54 | browser.find_element_by_id("bidb").click() 55 | time.sleep(1) 56 | 57 | try: 58 | browser.find_element_by_xpath("//a[contains(@href,'#dataTab')]").click() 59 | except NoSuchElementException: 60 | assert 0, "can't switch to dataTab" 61 | time.sleep(1) 62 | 63 | #here are the 11 remaining steps for this test case: 64 | 65 | find_and_click("//a[contains(@onclick,\"pilot'\")]", "expand pilots") 66 | 67 | find_and_click("//a[contains(@onclick,\"pilot/pilot-can_fly-plane_type'\")]", "can't expand pilot can fly plane type") 68 | 69 | find_and_click("//a[contains(@onclick,\"pilot/pilot-can_fly-plane_type/pilot-can_fly-plane_type*del*filt:id=3'\")]", "delete fact [Joachim can fly Mirage 2000]") 70 | 71 | '''click -confirm- or whatever''' 72 | browser.find_element_by_xpath("//tr[@id='tr--data--pilot-can_fly-plane_type']//input[@type='submit']").click() 73 | time.sleep(1) 74 | 75 | '''click revise request''' 76 | browser.find_element_by_xpath("//button[descendant::text()='Revise Request']").click() 77 | time.sleep(1) 78 | 79 | '''expand pilot is experienced''' 80 | find_and_click("//a[contains(@onclick,\"pilot/(pilot-can_fly-plane_type/pilot-can_fly-plane_type*del*filt:id=3,pilot-is_experienced)'\")]", "expand pilot is experienced") 81 | 82 | '''delete joachim is experienced''' 83 | find_and_click("//a[contains(@onclick,\"/pilot/(pilot-can_fly-plane_type/pilot-can_fly-plane_type*del*filt:id=3,pilot-is_experienced/pilot-is_experienced*del*filt:pilot=1)'\")]", "delete fact [Joachim is experienced]") 84 | 85 | '''click Apply All''' 86 | browser.find_element_by_xpath("//input[@value='Apply All Changes']").click() 87 | time.sleep(1) 88 | 89 | '''check if error''' 90 | if not browser.current_url.split('#')[-1] == '!/data/': 91 | assert 0, "Transaction not completed successfully" 92 | 93 | '''close browser''' 94 | browser.close() -------------------------------------------------------------------------------- /test/fixtures/00-basic/config.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | 3 | const apiRoot = 'example'; 4 | const modelName = 'example'; 5 | const modelFile = import.meta.dirname + '/example.sbvr'; 6 | 7 | export default { 8 | models: [ 9 | { 10 | modelName, 11 | modelFile, 12 | apiRoot, 13 | }, 14 | ], 15 | users: [ 16 | { 17 | username: 'guest', 18 | password: ' ', 19 | permissions: ['resource.all'], 20 | }, 21 | ], 22 | } as ConfigLoader.Config; 23 | -------------------------------------------------------------------------------- /test/fixtures/00-basic/example.sbvr: -------------------------------------------------------------------------------- 1 | Vocabulary: example 2 | 3 | Term: name 4 | Concept Type: Short Text (Type) 5 | 6 | Term: note 7 | Concept Type: Text (Type) 8 | 9 | Term: type 10 | Concept Type: Short Text (Type) 11 | 12 | Term: device 13 | 14 | Fact Type: device has name 15 | Necessity: each device has at most one name. 16 | 17 | Fact Type: device has note 18 | Necessity: each device has at most one note. 19 | 20 | Fact Type: device has type 21 | Necessity: each device has exactly one type. 22 | -------------------------------------------------------------------------------- /test/fixtures/01-constrain/config.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | 3 | const apiRoot = 'university'; 4 | const modelName = 'university'; 5 | const modelFile = import.meta.dirname + '/university.sbvr'; 6 | 7 | export default { 8 | models: [ 9 | { 10 | modelName, 11 | modelFile, 12 | apiRoot, 13 | }, 14 | ], 15 | users: [ 16 | { 17 | username: 'guest', 18 | password: ' ', 19 | permissions: ['resource.all'], 20 | }, 21 | ], 22 | } as ConfigLoader.Config; 23 | -------------------------------------------------------------------------------- /test/fixtures/01-constrain/university.sbvr: -------------------------------------------------------------------------------- 1 | Vocabulary: university 2 | 3 | Term: name 4 | Concept Type: Short Text (Type) 5 | 6 | Term: lastname 7 | Concept Type: Short Text (Type) 8 | 9 | Term: birthday 10 | Concept Type: Date Time (Type) 11 | 12 | Term: semester credits 13 | Concept Type: Integer (Type) 14 | 15 | Term: matrix number 16 | Concept Type: Integer (Type) 17 | 18 | 19 | Term: student 20 | 21 | Fact Type: student has matrix number 22 | Necessity: each student has exactly one matrix number 23 | Necessity: each matrix number is of exactly one student 24 | 25 | Fact Type: student has name 26 | Necessity: each student has exactly one name 27 | 28 | Fact Type: student has lastname 29 | Necessity: each student has exactly one lastname 30 | 31 | Fact Type: student has birthday 32 | Necessity: each student has exactly one birthday 33 | 34 | Fact Type: student has semester credits 35 | Necessity: each student has at most one semester credits 36 | Necessity: each student that has a semester credits, has a semester credits that is greater than or equal to 4 and is less than or equal to 16. 37 | -------------------------------------------------------------------------------- /test/fixtures/02-sync-migrator/00-execute-model.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | 3 | const apiRoot = 'example'; 4 | const modelName = 'example'; 5 | const modelFile = import.meta.dirname + '/example.sbvr'; 6 | const initSqlPath = import.meta.dirname + '/init-data.sql'; 7 | 8 | export default { 9 | models: [ 10 | { 11 | modelName, 12 | modelFile, 13 | apiRoot, 14 | initSqlPath, 15 | }, 16 | ], 17 | users: [ 18 | { 19 | username: 'guest', 20 | password: ' ', 21 | permissions: ['resource.all'], 22 | }, 23 | ], 24 | } as ConfigLoader.Config; 25 | -------------------------------------------------------------------------------- /test/fixtures/02-sync-migrator/01-migrations.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | 3 | const apiRoot = 'example'; 4 | const modelName = 'example'; 5 | const modelFile = import.meta.dirname + '/example.sbvr'; 6 | const migrationsPath = import.meta.dirname + '/01-migrations'; 7 | 8 | export default { 9 | models: [ 10 | { 11 | modelName, 12 | modelFile, 13 | apiRoot, 14 | migrationsPath, 15 | }, 16 | ], 17 | users: [ 18 | { 19 | username: 'guest', 20 | password: ' ', 21 | permissions: ['resource.all'], 22 | }, 23 | ], 24 | } as ConfigLoader.Config; 25 | -------------------------------------------------------------------------------- /test/fixtures/02-sync-migrator/01-migrations/0001-add-data.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO "device" ( 2 | "id", "name", "note", "type" 3 | ) 4 | SELECT 5 | i as "id", 6 | CONCAT('a','b',trim(to_char(i,'0000000'))) as "name", 7 | NULL as "note", 8 | CONCAT('b','b',trim(to_char(i,'0000000'))) as "type" 9 | FROM generate_series(101, 110) s(i); -------------------------------------------------------------------------------- /test/fixtures/02-sync-migrator/01-migrations/0002-test-migrations.sync.ts: -------------------------------------------------------------------------------- 1 | import type { Migrator } from '@balena/pinejs'; 2 | 3 | const migration: Migrator.MigrationFn = async (tx) => { 4 | const staticSql = `\ 5 | UPDATE "device" 6 | SET "note" = CONCAT("device"."type",'#migrated') 7 | WHERE id IN ( 8 | SELECT id FROM "device" 9 | WHERE "device"."note" <> "device"."type" OR "device"."note" IS NULL 10 | );`; 11 | await tx.executeSql(staticSql); 12 | }; 13 | 14 | export default migration; 15 | -------------------------------------------------------------------------------- /test/fixtures/02-sync-migrator/02-migrations-error.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | 3 | const apiRoot = 'example'; 4 | const modelName = 'example'; 5 | const modelFile = import.meta.dirname + '/example.sbvr'; 6 | const migrationsPath = import.meta.dirname + '/02-migrations-error'; 7 | 8 | export default { 9 | models: [ 10 | { 11 | modelName, 12 | modelFile, 13 | apiRoot, 14 | migrationsPath, 15 | }, 16 | ], 17 | users: [ 18 | { 19 | username: 'guest', 20 | password: ' ', 21 | permissions: ['resource.all'], 22 | }, 23 | ], 24 | } as ConfigLoader.Config; 25 | -------------------------------------------------------------------------------- /test/fixtures/02-sync-migrator/02-migrations-error/0001-add-data.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO "table-does-not-exsits" ( 2 | "id", "name", "note", "type" 3 | ) 4 | SELECT 5 | i as "id", 6 | CONCAT('a','b',trim(to_char(i,'0000000'))) as "name", 7 | NULL as "note", 8 | CONCAT('b','b',trim(to_char(i,'0000000'))) as "type" 9 | FROM generate_series(101, 110) s(i); -------------------------------------------------------------------------------- /test/fixtures/02-sync-migrator/02-migrations-error/0002-test-migrations.sync.ts: -------------------------------------------------------------------------------- 1 | import type { Migrator } from '@balena/pinejs'; 2 | 3 | const migration: Migrator.MigrationFn = async (tx) => { 4 | const staticSql = `\ 5 | UPDATE "device" 6 | SET "note" = CONCAT("device"."type",'#migrated') 7 | WHERE id IN ( 8 | SELECT id FROM "device" 9 | WHERE "device"."note" <> "device"."type" OR "device"."note" IS NULL 10 | );`; 11 | await tx.executeSql(staticSql); 12 | }; 13 | 14 | export default migration; 15 | -------------------------------------------------------------------------------- /test/fixtures/02-sync-migrator/03-exclusive-category.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | 3 | const apiRoot = 'example'; 4 | const modelName = 'example'; 5 | const modelFile = import.meta.dirname + '/example.sbvr'; 6 | const migrationsPath = import.meta.dirname + '/01-migrations'; 7 | 8 | export default { 9 | models: [ 10 | { 11 | modelName, 12 | modelFile, 13 | apiRoot, 14 | migrationsPath, 15 | migrations: { 16 | 'should-never-execute': ` 17 | INSERT INTO "device" ( 18 | "id", "name", "note", "type" 19 | ) 20 | SELECT 21 | i as "id", 22 | CONCAT('a','b',trim(to_char(i,'0000000'))) as "name", 23 | NULL as "note", 24 | CONCAT('b','b',trim(to_char(i,'0000000'))) as "type" 25 | FROM generate_series(1001, 1010) s(i); 26 | `, 27 | }, 28 | }, 29 | ], 30 | users: [ 31 | { 32 | username: 'guest', 33 | password: ' ', 34 | permissions: ['resource.all'], 35 | }, 36 | ], 37 | } as ConfigLoader.Config; 38 | -------------------------------------------------------------------------------- /test/fixtures/02-sync-migrator/04-new-model-with-init.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | 3 | const apiRoot = 'example'; 4 | const modelName = 'example'; 5 | const modelFile = import.meta.dirname + '/example.sbvr'; 6 | 7 | export default { 8 | models: [ 9 | { 10 | modelName, 11 | modelFile, 12 | apiRoot, 13 | migrations: { 14 | '0001': ` 15 | INSERT INTO "device" ("id", "name", "note", "type") 16 | VALUES (2, 'no run', 'shouldNotRun', 'empty') 17 | `, 18 | }, 19 | initSql: ` 20 | INSERT INTO "device" ("id", "name", "note", "type") 21 | VALUES (1, 'initName', 'shouldBeInit', 'init') 22 | `, 23 | }, 24 | ], 25 | users: [ 26 | { 27 | username: 'guest', 28 | password: ' ', 29 | permissions: ['resource.all'], 30 | }, 31 | ], 32 | } as ConfigLoader.Config; 33 | -------------------------------------------------------------------------------- /test/fixtures/02-sync-migrator/example.sbvr: -------------------------------------------------------------------------------- 1 | Vocabulary: example 2 | 3 | Term: name 4 | Concept Type: Short Text (Type) 5 | 6 | Term: note 7 | Concept Type: Text (Type) 8 | 9 | Term: type 10 | Concept Type: Short Text (Type) 11 | 12 | Term: device 13 | 14 | Fact Type: device has name 15 | Necessity: each device has at most one name. 16 | 17 | Fact Type: device has note 18 | Necessity: each device has at most one note. 19 | 20 | Fact Type: device has type 21 | Necessity: each device has exactly one type. 22 | -------------------------------------------------------------------------------- /test/fixtures/02-sync-migrator/init-data.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO "device" ( 2 | "id", "name", "note", "type" 3 | ) 4 | SELECT 5 | i as "id", 6 | CONCAT('a','b',trim(to_char(i,'0000000'))) as "name", 7 | NULL as "note", 8 | CONCAT('b','b',trim(to_char(i,'0000000'))) as "type" 9 | FROM generate_series(1, 10) s(i); -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/00-execute-model.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | 3 | const apiRoot = 'example'; 4 | const modelName = 'example'; 5 | const modelFile = import.meta.dirname + '/example.sbvr'; 6 | const initSqlPath = import.meta.dirname + '/init-data.sql'; 7 | 8 | export default { 9 | models: [ 10 | { 11 | modelName, 12 | modelFile, 13 | apiRoot, 14 | initSqlPath, 15 | }, 16 | ], 17 | users: [ 18 | { 19 | username: 'guest', 20 | password: ' ', 21 | permissions: ['resource.all'], 22 | }, 23 | ], 24 | } as ConfigLoader.Config; 25 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/01-migrations.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | 3 | const apiRoot = 'example'; 4 | const modelName = 'example'; 5 | const modelFile = import.meta.dirname + '/example.sbvr'; 6 | const migrationsPath = import.meta.dirname + '/01-migrations/migrations'; 7 | 8 | export default { 9 | models: [ 10 | { 11 | modelName, 12 | modelFile, 13 | apiRoot, 14 | migrationsPath, 15 | }, 16 | ], 17 | users: [ 18 | { 19 | username: 'guest', 20 | password: ' ', 21 | permissions: ['resource.all'], 22 | }, 23 | ], 24 | } as ConfigLoader.Config; 25 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/01-migrations/migrations/0001-add-data.sql: -------------------------------------------------------------------------------- 1 | UPDATE "device" 2 | SET "note" = "device"."note" -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/01-migrations/migrations/0002-test-migrations.async.ts: -------------------------------------------------------------------------------- 1 | import type { Migrator } from '@balena/pinejs'; 2 | 3 | const migration: Migrator.AsyncMigration = { 4 | asyncFn: async (tx, options) => { 5 | const staticSql = `\ 6 | UPDATE "device" 7 | SET "note" = "device"."name" 8 | WHERE id IN ( 9 | SELECT id FROM "device" 10 | WHERE "device"."name" <> "device"."note" OR "device"."note" IS NULL 11 | LIMIT ${options.batchSize} 12 | );`; 13 | 14 | return (await tx.executeSql(staticSql)).rowsAffected; 15 | }, 16 | asyncBatchSize: 1, 17 | syncFn: async (tx) => { 18 | const staticSql = `\ 19 | UPDATE "device" 20 | SET "note" = "device"."name" 21 | WHERE id IN ( 22 | SELECT id FROM "device" 23 | WHERE "device"."name" <> "device"."note" OR "device"."note" IS NULL 24 | );`; 25 | await tx.executeSql(staticSql); 26 | }, 27 | delayMS: 50, 28 | backoffDelayMS: 1000, 29 | errorThreshold: 15, 30 | finalize: false, 31 | }; 32 | 33 | export default migration; 34 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/01-migrations/migrations/0003-finalized-test-migration.async.ts: -------------------------------------------------------------------------------- 1 | import type { Migrator } from '@balena/pinejs'; 2 | 3 | const migration: Migrator.AsyncMigration = { 4 | asyncFn: async (tx) => { 5 | const staticSql = `\ 6 | SELECT 1;`; 7 | 8 | return (await tx.executeSql(staticSql)).rowsAffected; 9 | }, 10 | asyncBatchSize: 1, 11 | syncFn: async (tx) => { 12 | const staticSql = `\ 13 | SELECT 1;`; 14 | await tx.executeSql(staticSql); 15 | }, 16 | delayMS: 50, 17 | backoffDelayMS: 1000, 18 | errorThreshold: 15, 19 | finalize: true, 20 | }; 21 | 22 | export default migration; 23 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/02-parallel-migrations.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | 3 | const apiRoot = 'example'; 4 | const modelName = 'example'; 5 | const modelFile = import.meta.dirname + '/example.sbvr'; 6 | const migrationsPath = 7 | import.meta.dirname + '/02-parallel-migrations/migrations'; 8 | 9 | export default { 10 | models: [ 11 | { 12 | modelName, 13 | modelFile, 14 | apiRoot, 15 | migrationsPath, 16 | }, 17 | ], 18 | users: [ 19 | { 20 | username: 'guest', 21 | password: ' ', 22 | permissions: ['resource.all'], 23 | }, 24 | ], 25 | } as ConfigLoader.Config; 26 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/02-parallel-migrations/migrations/0001-migrate-testa.async.ts: -------------------------------------------------------------------------------- 1 | import type { Migrator } from '@balena/pinejs'; 2 | 3 | const migration: Migrator.AsyncMigration = { 4 | asyncFn: async (tx, options) => { 5 | const staticSql = `\ 6 | UPDATE "device" 7 | SET "note" = "device"."name" 8 | WHERE id IN ( 9 | SELECT id FROM "device" 10 | WHERE "device"."name" <> "device"."note" OR "device"."note" IS NULL 11 | LIMIT ${options.batchSize} 12 | );`; 13 | 14 | return (await tx.executeSql(staticSql)).rowsAffected; 15 | }, 16 | syncFn: async (tx) => { 17 | const staticSql = `\ 18 | UPDATE "device" 19 | SET "note" = "device"."name" 20 | WHERE id IN ( 21 | SELECT id FROM "device" 22 | WHERE "device"."name" <> "device"."note" OR "device"."note" IS NULL 23 | );`; 24 | 25 | await tx.executeSql(staticSql); 26 | }, 27 | asyncBatchSize: 1, 28 | delayMS: 250, 29 | backoffDelayMS: 4000, 30 | errorThreshold: 15, 31 | }; 32 | 33 | export default migration; 34 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/02-parallel-migrations/migrations/0002-migrate-testb.async.ts: -------------------------------------------------------------------------------- 1 | import type { Migrator } from '@balena/pinejs'; 2 | 3 | const migration: Migrator.AsyncMigration = { 4 | asyncFn: async (tx, options) => { 5 | const staticSql = `\ 6 | UPDATE "deviceb" 7 | SET "note" = "deviceb"."name" 8 | WHERE id IN ( 9 | SELECT id FROM "deviceb" 10 | WHERE "deviceb"."name" <> "deviceb"."note" OR "deviceb"."note" IS NULL 11 | LIMIT ${options.batchSize} 12 | );`; 13 | 14 | return (await tx.executeSql(staticSql)).rowsAffected; 15 | }, 16 | syncFn: async (tx) => { 17 | const staticSql = `\ 18 | UPDATE "deviceb" 19 | SET "note" = "deviceb"."name" 20 | WHERE id IN ( 21 | SELECT id FROM "deviceb" 22 | WHERE "deviceb"."name" <> "deviceb"."note" OR "deviceb"."note" IS NULL 23 | );`; 24 | 25 | await tx.executeSql(staticSql); 26 | }, 27 | asyncBatchSize: 1, 28 | delayMS: 250, 29 | backoffDelayMS: 4000, 30 | errorThreshold: 15, 31 | }; 32 | 33 | export default migration; 34 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/03-finalize-async.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | 3 | const apiRoot = 'example'; 4 | const modelName = 'example'; 5 | const modelFile = import.meta.dirname + '/example.sbvr'; 6 | const migrationsPath = import.meta.dirname + '/03-finalize-async/migrations'; 7 | 8 | export default { 9 | models: [ 10 | { 11 | modelName, 12 | modelFile, 13 | apiRoot, 14 | migrationsPath, 15 | }, 16 | ], 17 | users: [ 18 | { 19 | username: 'guest', 20 | password: ' ', 21 | permissions: ['resource.all'], 22 | }, 23 | ], 24 | } as ConfigLoader.Config; 25 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/03-finalize-async/migrations/m0001-data-copy.async.ts: -------------------------------------------------------------------------------- 1 | import type { Migrator } from '@balena/pinejs'; 2 | 3 | const migration: Migrator.AsyncMigration = { 4 | asyncFn: async (tx, options) => { 5 | const staticSql = `\ 6 | UPDATE "device" 7 | SET "note" = "device"."name" 8 | WHERE id IN ( 9 | SELECT id FROM "device" 10 | WHERE "device"."name" <> "device"."note" OR "device"."note" IS NULL 11 | LIMIT ${options.batchSize} 12 | ); `; 13 | 14 | return (await tx.executeSql(staticSql)).rowsAffected; 15 | }, 16 | syncFn: async (tx) => { 17 | const staticSql = `\ 18 | UPDATE "device" 19 | SET "note" = "device"."name" 20 | WHERE id IN ( 21 | SELECT id FROM "device" 22 | WHERE "device"."name" <> "device"."note" OR "device"."note" IS NULL 23 | );`; 24 | 25 | await tx.executeSql(staticSql); 26 | }, 27 | asyncBatchSize: 1, 28 | delayMS: 100, 29 | backoffDelayMS: 4000, 30 | errorThreshold: 15, 31 | finalize: true, 32 | }; 33 | 34 | export default migration; 35 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/03-finalize-async/migrations/m0002-data-copy.async.ts: -------------------------------------------------------------------------------- 1 | import type { Migrator } from '@balena/pinejs'; 2 | 3 | const migration: Migrator.AsyncMigration = { 4 | asyncSql: `\ 5 | UPDATE "deviceb" 6 | SET "note" = "deviceb"."name" 7 | WHERE id IN ( 8 | SELECT id FROM "deviceb" 9 | WHERE "deviceb"."name" <> "deviceb"."note" OR "deviceb"."note" IS NULL 10 | LIMIT %%ASYNC_BATCH_SIZE%% 11 | ); 12 | `, 13 | syncSql: `\ 14 | UPDATE "deviceb" 15 | SET "note" = "deviceb"."name" 16 | WHERE id IN ( 17 | SELECT id FROM "deviceb" 18 | WHERE "deviceb"."name" <> "deviceb"."note" OR "deviceb"."note" IS NULL 19 | ); 20 | `, 21 | delayMS: 100, 22 | backoffDelayMS: 4000, 23 | errorThreshold: 15, 24 | asyncBatchSize: 1, 25 | finalize: true, 26 | }; 27 | 28 | export default migration; 29 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/03-finalize-async/migrations/m0003-add-data.sync.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO "device" ( 2 | "id", "name", "note", "type" 3 | ) 4 | SELECT 5 | i as "id", 6 | CONCAT('a','b',trim(to_char(i,'0000000'))) as "name", 7 | NULL as "note", 8 | CONCAT('b','b',trim(to_char(i,'0000000'))) as "type" 9 | FROM generate_series(101, 110) s(i); -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/04-migration-errors.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | 3 | const apiRoot = 'example'; 4 | const modelName = 'example'; 5 | const modelFile = import.meta.dirname + '/example.sbvr'; 6 | const migrationsPath = import.meta.dirname + '/04-migration-errors/migrations'; 7 | 8 | export default { 9 | models: [ 10 | { 11 | modelName, 12 | modelFile, 13 | apiRoot, 14 | migrationsPath, 15 | }, 16 | ], 17 | users: [ 18 | { 19 | username: 'guest', 20 | password: ' ', 21 | permissions: ['resource.all'], 22 | }, 23 | ], 24 | } as ConfigLoader.Config; 25 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/04-migration-errors/migrations/0001-successful-migration.async.ts: -------------------------------------------------------------------------------- 1 | import type { Migrator } from '@balena/pinejs'; 2 | 3 | const migration: Migrator.AsyncMigration = { 4 | asyncFn: async (tx, options) => { 5 | const staticSql = `\ 6 | UPDATE "device" 7 | SET "note" = "device"."name" 8 | WHERE id IN ( 9 | SELECT id FROM "device" 10 | WHERE "device"."name" <> "device"."note" OR "device"."note" IS NULL 11 | LIMIT ${options.batchSize} 12 | );`; 13 | 14 | return (await tx.executeSql(staticSql)).rowsAffected; 15 | }, 16 | syncFn: async (tx) => { 17 | const staticSql = `\ 18 | UPDATE "device" 19 | SET "note" = "device"."name" 20 | WHERE id IN ( 21 | SELECT id FROM "device" 22 | WHERE "device"."name" <> "device"."note" OR "device"."note" IS NULL 23 | );`; 24 | 25 | await tx.executeSql(staticSql); 26 | }, 27 | asyncBatchSize: 1, 28 | delayMS: 250, 29 | backoffDelayMS: 4000, 30 | errorThreshold: 15, 31 | }; 32 | 33 | export default migration; 34 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/04-migration-errors/migrations/0002-table-not-exists.async.ts: -------------------------------------------------------------------------------- 1 | import type { Migrator } from '@balena/pinejs'; 2 | 3 | const migration: Migrator.AsyncMigration = { 4 | asyncFn: async (tx, options) => { 5 | const staticSql = `\ 6 | UPDATE "device-not-exists" 7 | SET "note" = "device-not-exists"."name" 8 | WHERE id IN ( 9 | SELECT id FROM "device-not-exists" 10 | WHERE "device-not-exists"."name" <> "device-not-exists"."note" OR "device-not-exists"."note" IS NULL 11 | LIMIT ${options.batchSize} 12 | );`; 13 | 14 | return (await tx.executeSql(staticSql)).rowsAffected; 15 | }, 16 | syncFn: async (tx: any) => { 17 | const staticSql = `\ 18 | UPDATE "device-not-exists" 19 | SET "note" = "device-not-exists"."name" 20 | WHERE id IN ( 21 | SELECT id FROM "device-not-exists" 22 | WHERE "device-not-exists"."name" <> "device-not-exists"."note" OR "device-not-exists"."note" IS NULL 23 | );`; 24 | await tx.executeSql(staticSql); 25 | }, 26 | delayMS: 250, 27 | backoffDelayMS: 1000, 28 | errorThreshold: 5, 29 | }; 30 | export default migration; 31 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/04-migration-errors/migrations/0003-error-unique-key.async.ts: -------------------------------------------------------------------------------- 1 | import type { Migrator } from '@balena/pinejs'; 2 | 3 | const migration: Migrator.AsyncMigration = { 4 | asyncFn: async (tx) => { 5 | const staticSql = `\ 6 | UPDATE "device" 7 | SET "note" = "device"."name", 8 | "id" = 1 9 | WHERE id IN ( 10 | SELECT id FROM "device" 11 | WHERE "id" = 2 12 | );`; 13 | 14 | return (await tx.executeSql(staticSql)).rowsAffected; 15 | }, 16 | syncFn: async (tx) => { 17 | const staticSql = `\ 18 | UPDATE "device" 19 | SET "note" = "device"."name", 20 | "id" = 1 21 | WHERE id IN ( 22 | SELECT id FROM "device" 23 | WHERE "id" = 2 24 | );`; 25 | 26 | await tx.executeSql(staticSql); 27 | }, 28 | delayMS: 250, 29 | backoffDelayMS: 1000, 30 | errorThreshold: 5, 31 | }; 32 | export default migration; 33 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/05-massive-data.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | 3 | const apiRoot = 'example'; 4 | const modelName = 'example'; 5 | const modelFile = import.meta.dirname + '/example.sbvr'; 6 | const migrationsPath = import.meta.dirname + '/05-massive-data/migrations'; 7 | 8 | export default { 9 | models: [ 10 | { 11 | modelName, 12 | modelFile, 13 | apiRoot, 14 | migrationsPath, 15 | }, 16 | ], 17 | users: [ 18 | { 19 | username: 'guest', 20 | password: ' ', 21 | permissions: ['resource.all'], 22 | }, 23 | ], 24 | } as ConfigLoader.Config; 25 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/05-massive-data/00-execute-model.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import type { ConfigLoader } from '@balena/pinejs'; 3 | 4 | const apiRoot = 'example'; 5 | const modelName = 'example'; 6 | const modelFile = resolve(import.meta.dirname, '../example.sbvr'); 7 | const initSqlPath = import.meta.dirname + '/init-data.sql'; 8 | 9 | export default { 10 | models: [ 11 | { 12 | modelName, 13 | modelFile, 14 | apiRoot, 15 | initSqlPath, 16 | }, 17 | ], 18 | users: [ 19 | { 20 | username: 'guest', 21 | password: ' ', 22 | permissions: ['resource.all'], 23 | }, 24 | ], 25 | } as ConfigLoader.Config; 26 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/05-massive-data/init-data.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO "device" ( 2 | "id", "name", "note", "type" 3 | ) 4 | SELECT 5 | i as "id", 6 | CONCAT('a','b',trim(to_char(i,'0000000'))) as "name", 7 | NULL as "note", 8 | CONCAT('b','b',trim(to_char(i,'0000000'))) as "type" 9 | FROM generate_series(1, 1000000) s(i); 10 | 11 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/05-massive-data/migrations/0001-massive-migration.async.ts: -------------------------------------------------------------------------------- 1 | import type { Migrator } from '@balena/pinejs'; 2 | 3 | const migration: Migrator.AsyncMigration = { 4 | asyncFn: async (tx, options) => { 5 | const staticSql = `\ 6 | UPDATE "device" 7 | SET "note" = "device"."name" 8 | WHERE id IN ( 9 | SELECT id FROM "device" 10 | WHERE "device"."name" <> "device"."note" OR "device"."note" IS NULL 11 | LIMIT ${options.batchSize} 12 | );`; 13 | return (await tx.executeSql(staticSql)).rowsAffected; 14 | }, 15 | syncFn: async (tx) => { 16 | const staticSql = `\ 17 | UPDATE "device" 18 | SET "note" = "device"."name" 19 | WHERE id IN ( 20 | SELECT id FROM "device" 21 | WHERE "device"."name" <> "device"."note" OR "device"."note" IS NULL 22 | );`; 23 | 24 | await tx.executeSql(staticSql); 25 | }, 26 | asyncBatchSize: 100000, 27 | delayMS: 250, 28 | backoffDelayMS: 4000, 29 | errorThreshold: 15, 30 | finalize: false, 31 | }; 32 | 33 | export default migration; 34 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/06-setup-errors.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | 3 | const apiRoot = 'example'; 4 | const modelName = 'example'; 5 | const modelFile = import.meta.dirname + '/example.sbvr'; 6 | const migrationsPath = import.meta.dirname + '/06-setup-errors'; 7 | 8 | export default { 9 | models: [ 10 | { 11 | modelName, 12 | modelFile, 13 | apiRoot, 14 | migrationsPath, 15 | }, 16 | ], 17 | users: [ 18 | { 19 | username: 'guest', 20 | password: ' ', 21 | permissions: ['resource.all'], 22 | }, 23 | ], 24 | } as ConfigLoader.Config; 25 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/06-setup-errors/0001-error-async-sync-pair.async.ts: -------------------------------------------------------------------------------- 1 | import type { Migrator } from '@balena/pinejs'; 2 | 3 | const migration: Migrator.AsyncMigration = { 4 | asyncFn: async (tx) => { 5 | return (await tx.executeSql(`SELECT 1`)).rowsAffected; 6 | }, 7 | // @ts-expect-error Test passing unknown properties 8 | syncFnFalse: {}, 9 | delayMS: 250, 10 | backoffDelayMS: 1000, 11 | errorThreshold: 5, 12 | }; 13 | 14 | export default migration; 15 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/07-setup-error-mixed-migrations.ts: -------------------------------------------------------------------------------- 1 | const apiRoot = 'example'; 2 | const modelName = 'example'; 3 | const modelFile = import.meta.dirname + '/example.sbvr'; 4 | 5 | export default { 6 | models: [ 7 | { 8 | modelName, 9 | modelFile, 10 | apiRoot, 11 | migrations: { 12 | '0001': '', 13 | sync: { 14 | '0002': '', 15 | }, 16 | }, 17 | }, 18 | ], 19 | users: [ 20 | { 21 | username: 'guest', 22 | password: ' ', 23 | permissions: ['resource.all'], 24 | }, 25 | ], 26 | } as any; 27 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/08-01-async-lock-taker.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | 3 | const apiRoot = 'example'; 4 | const modelName = 'example'; 5 | const modelFile = import.meta.dirname + '/example.sbvr'; 6 | 7 | const asyncSpammer = { 8 | asyncFn: async (tx: any) => { 9 | const staticSql = `\ 10 | select pg_sleep(1); 11 | `; 12 | return await tx.executeSql(staticSql); 13 | }, 14 | syncFn: async (tx: any) => { 15 | const staticSql = `\ 16 | select pg_sleep(1); 17 | `; 18 | 19 | await tx.executeSql(staticSql); 20 | }, 21 | asyncBatchSize: 10, 22 | delayMS: 50, // aggressive small delays 23 | backoffDelayMS: 50, // aggressive small delays 24 | errorThreshold: 15, 25 | finalize: false, 26 | type: 'async', 27 | }; 28 | 29 | const asyncSpammers: { [key: string]: object } = {}; 30 | 31 | for (let spammerKey = 2; spammerKey < 20; spammerKey++) { 32 | const key: string = spammerKey.toString().padStart(4, '0') + '-async-spammer'; 33 | asyncSpammers[key] = asyncSpammer; 34 | } 35 | 36 | export default { 37 | models: [ 38 | { 39 | modelName, 40 | modelFile, 41 | apiRoot, 42 | migrations: { 43 | sync: {}, 44 | async: asyncSpammers, 45 | }, 46 | }, 47 | ], 48 | users: [ 49 | { 50 | username: 'guest', 51 | password: ' ', 52 | permissions: ['resource.all'], 53 | }, 54 | ], 55 | } as ConfigLoader.Config; 56 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/08-02-sync-lock-starvation.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | 3 | const apiRoot = 'example'; 4 | const modelName = 'example'; 5 | const modelFile = import.meta.dirname + '/example.sbvr'; 6 | 7 | export default { 8 | models: [ 9 | { 10 | modelName, 11 | modelFile, 12 | apiRoot, 13 | migrations: { 14 | async: {}, 15 | sync: { 16 | '1000-another-data-insert': async (tx) => { 17 | await tx.executeSql(` 18 | INSERT INTO "device" ( 19 | "id", "name", "note", "type" 20 | ) 21 | SELECT 22 | i as "id", 23 | CONCAT('a','b',trim(to_char(i,'0000000'))) as "name", 24 | NULL as "note", 25 | CONCAT('b','b',trim(to_char(i,'0000000'))) as "type" 26 | FROM generate_series(21, 30) s(i); 27 | `); 28 | }, 29 | }, 30 | }, 31 | }, 32 | ], 33 | users: [ 34 | { 35 | username: 'guest', 36 | password: ' ', 37 | permissions: ['resource.all'], 38 | }, 39 | ], 40 | } as ConfigLoader.Config; 41 | -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/example.sbvr: -------------------------------------------------------------------------------- 1 | Vocabulary: example 2 | 3 | Term: name 4 | Concept Type: Short Text (Type) 5 | 6 | Term: note 7 | Concept Type: Text (Type) 8 | 9 | Term: type 10 | Concept Type: Short Text (Type) 11 | 12 | Term: device 13 | 14 | Fact Type: device has name 15 | Necessity: each device has at most one name. 16 | 17 | Fact Type: device has note 18 | Necessity: each device has at most one note. 19 | 20 | Fact Type: device has type 21 | Necessity: each device has exactly one type. 22 | 23 | Term: deviceb 24 | 25 | Fact Type: deviceb has name 26 | Necessity: each deviceb has at most one name. 27 | 28 | Fact Type: deviceb has note 29 | Necessity: each deviceb has at most one note. 30 | 31 | Fact Type: deviceb has type 32 | Necessity: each deviceb has exactly one type. -------------------------------------------------------------------------------- /test/fixtures/03-async-migrator/init-data.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO "device" ( 2 | "id", "name", "note", "type" 3 | ) 4 | SELECT 5 | i as "id", 6 | CONCAT('a','b',trim(to_char(i,'0000000'))) as "name", 7 | NULL as "note", 8 | CONCAT('b','b',trim(to_char(i,'0000000'))) as "type" 9 | FROM generate_series(1, 20) s(i); 10 | 11 | 12 | INSERT INTO "deviceb" ( 13 | "id", "name", "note", "type" 14 | ) 15 | SELECT 16 | i as "id", 17 | CONCAT('a','b',trim(to_char(i,'0000000'))) as "name", 18 | NULL as "note", 19 | CONCAT('b','b',trim(to_char(i,'0000000'))) as "type" 20 | FROM generate_series(1, 20) s(i); -------------------------------------------------------------------------------- /test/fixtures/04-translations/config.ts: -------------------------------------------------------------------------------- 1 | import type { AbstractSqlQuery } from '@balena/abstract-sql-compiler'; 2 | import { getAbstractSqlModelFromFile } from '@balena/pinejs/out/bin/utils.js'; 3 | import type { ConfigLoader } from '@balena/pinejs'; 4 | 5 | const apiRoot = 'university'; 6 | const modelName = 'university'; 7 | const modelFile = import.meta.dirname + '/university.sbvr'; 8 | 9 | import { v1AbstractSqlModel, v1Translations } from './translations/v1/index.js'; 10 | import { v2AbstractSqlModel, v2Translations } from './translations/v2/index.js'; 11 | import { v3AbstractSqlModel, v3Translations } from './translations/v3/index.js'; 12 | import { v4AbstractSqlModel } from './translations/v4/index.js'; 13 | import { v5AbstractSqlModel } from './translations/v5/index.js'; 14 | 15 | export const abstractSql = await getAbstractSqlModelFromFile( 16 | modelFile, 17 | undefined, 18 | ); 19 | 20 | abstractSql.tables['student'].fields.push({ 21 | fieldName: 'computed field', 22 | dataType: 'Text', 23 | required: false, 24 | computed: ['EmbeddedText', 'latest_computed_field'] as AbstractSqlQuery, 25 | }); 26 | 27 | export default { 28 | models: [ 29 | { 30 | modelName, 31 | abstractSql, 32 | apiRoot, 33 | }, 34 | { 35 | apiRoot: 'v5', 36 | modelName: 'v5', 37 | abstractSql: v5AbstractSqlModel, 38 | translateTo: 'university', 39 | translations: {}, 40 | }, 41 | { 42 | apiRoot: 'v4', 43 | modelName: 'v4', 44 | abstractSql: v4AbstractSqlModel, 45 | translateTo: 'v5', 46 | translations: {}, 47 | }, 48 | { 49 | apiRoot: 'v3', 50 | modelName: 'v3', 51 | abstractSql: v3AbstractSqlModel, 52 | translateTo: 'v4', 53 | translations: v3Translations, 54 | }, 55 | { 56 | apiRoot: 'v2', 57 | modelName: 'v2', 58 | abstractSql: v2AbstractSqlModel, 59 | translateTo: 'v3', 60 | translations: v2Translations, 61 | }, 62 | { 63 | apiRoot: 'v1', 64 | modelName: 'v1', 65 | abstractSql: v1AbstractSqlModel, 66 | translateTo: 'v2', 67 | translations: v1Translations, 68 | }, 69 | ], 70 | users: [ 71 | { 72 | username: 'guest', 73 | password: ' ', 74 | permissions: ['resource.all'], 75 | }, 76 | ], 77 | } as ConfigLoader.Config; 78 | -------------------------------------------------------------------------------- /test/fixtures/04-translations/translations/hooks.ts: -------------------------------------------------------------------------------- 1 | import './v1/hooks.js'; 2 | import './v2/hooks.js'; 3 | import './v3/hooks.js'; 4 | -------------------------------------------------------------------------------- /test/fixtures/04-translations/translations/v1/hooks.ts: -------------------------------------------------------------------------------- 1 | import { sbvrUtils } from '@balena/pinejs'; 2 | 3 | const addHook = ( 4 | methods: Array[0]>, 5 | resource: string, 6 | hook: sbvrUtils.Hooks, 7 | ) => { 8 | methods.map((method) => { 9 | sbvrUtils.addPureHook(method, 'v1', resource, hook); 10 | }); 11 | }; 12 | 13 | addHook(['PUT', 'POST', 'PATCH'], 'student', { 14 | POSTPARSE({ request }) { 15 | request.values.last_name = request.values.lastname; 16 | delete request.values.lastname; 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /test/fixtures/04-translations/translations/v1/index.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | import { getAbstractSqlModelFromFile } from '@balena/pinejs/out/bin/utils.js'; 3 | import type { AbstractSqlQuery } from '@balena/abstract-sql-compiler'; 4 | 5 | export const toVersion = 'v2'; 6 | 7 | export const v1AbstractSqlModel = await getAbstractSqlModelFromFile( 8 | import.meta.dirname + '/university.sbvr', 9 | undefined, 10 | ); 11 | 12 | v1AbstractSqlModel.tables['student'].fields.push({ 13 | fieldName: 'computed field', 14 | dataType: 'Text', 15 | required: false, 16 | computed: ['EmbeddedText', 'v1_computed_field'] as AbstractSqlQuery, 17 | }); 18 | 19 | v1AbstractSqlModel.relationships['version'] = { v1: {} }; 20 | 21 | export const v1Translations: ConfigLoader.Model['translations'] = { 22 | student: { 23 | lastname: 'last name', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /test/fixtures/04-translations/translations/v1/university.sbvr: -------------------------------------------------------------------------------- 1 | Vocabulary: university 2 | 3 | Term: name 4 | Concept Type: Short Text (Type) 5 | 6 | Term: lastname 7 | Concept Type: Short Text (Type) 8 | 9 | Term: matrix number 10 | Concept Type: Integer (Type) 11 | 12 | Term: campus 13 | Concept Type: Short Text (Type) 14 | 15 | Term: student 16 | 17 | Fact Type: student has matrix number 18 | Necessity: each student has exactly one matrix number 19 | Necessity: each matrix number is of exactly one student 20 | 21 | Fact Type: student has name 22 | Necessity: each student has exactly one name 23 | 24 | Fact Type: student has lastname 25 | Necessity: each student has exactly one lastname 26 | 27 | Fact Type: student studies at campus 28 | Necessity: each student studies at exactly one campus -------------------------------------------------------------------------------- /test/fixtures/04-translations/translations/v2/hooks.ts: -------------------------------------------------------------------------------- 1 | import { sbvrUtils, errors } from '@balena/pinejs'; 2 | 3 | const addHook = ( 4 | methods: Array[0]>, 5 | resource: string, 6 | hook: sbvrUtils.Hooks, 7 | ) => { 8 | methods.map((method) => { 9 | sbvrUtils.addPureHook(method, 'v2', resource, hook); 10 | }); 11 | }; 12 | 13 | addHook(['PUT', 'POST', 'PATCH'], 'student', { 14 | async POSTPARSE({ request, api }) { 15 | if (Object.hasOwn(request.values, 'studies_at__campus')) { 16 | const resinApi = sbvrUtils.api['v3'].clone({ 17 | passthrough: api.passthrough, 18 | }); 19 | 20 | const campus = await resinApi.get({ 21 | resource: 'campus', 22 | id: { name: request.values.studies_at__campus }, 23 | }); 24 | 25 | if (campus == null) { 26 | throw new errors.NotFoundError( 27 | `Campus with name '${request.values.studies_at__campus}' does not exist`, 28 | ); 29 | } 30 | delete request.values.studies_at__campus; 31 | request.values.studies_at__campus = campus?.id; 32 | } 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /test/fixtures/04-translations/translations/v2/index.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | import { getAbstractSqlModelFromFile } from '@balena/pinejs/out/bin/utils.js'; 3 | import type { 4 | AbstractSqlQuery, 5 | SelectQueryNode, 6 | } from '@balena/abstract-sql-compiler'; 7 | 8 | export const v2AbstractSqlModel = await getAbstractSqlModelFromFile( 9 | import.meta.dirname + '/university.sbvr', 10 | undefined, 11 | ); 12 | 13 | export const toVersion = 'v3'; 14 | 15 | v2AbstractSqlModel.tables['student'].fields.push({ 16 | fieldName: 'computed field', 17 | dataType: 'Text', 18 | required: false, 19 | computed: ['EmbeddedText', 'v2_computed_field'] as AbstractSqlQuery, 20 | }); 21 | 22 | v2AbstractSqlModel.relationships['version'] = { v2: {} }; 23 | 24 | export const v2Translations: ConfigLoader.Model['translations'] = { 25 | student: { 26 | 'studies at-campus': [ 27 | 'SelectQuery', 28 | ['Select', [['ReferencedField', 'student.studies at-campus', 'name']]], 29 | [ 30 | 'From', 31 | [ 32 | 'Alias', 33 | ['Resource', `campus$${toVersion}`], 34 | 'student.studies at-campus', 35 | ], 36 | ], 37 | [ 38 | 'Where', 39 | [ 40 | 'Equals', 41 | ['ReferencedField', 'student', 'studies at-campus'], 42 | ['ReferencedField', 'student.studies at-campus', 'id'], 43 | ], 44 | ], 45 | ] as SelectQueryNode, 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /test/fixtures/04-translations/translations/v2/university.sbvr: -------------------------------------------------------------------------------- 1 | Vocabulary: university 2 | 3 | Term: name 4 | Concept Type: Short Text (Type) 5 | 6 | Term: last name 7 | Concept Type: Short Text (Type) 8 | 9 | Term: matrix number 10 | Concept Type: Integer (Type) 11 | 12 | Term: campus 13 | Concept Type: Short Text (Type) 14 | 15 | Term: student 16 | 17 | Fact Type: student has matrix number 18 | Necessity: each student has exactly one matrix number 19 | Necessity: each matrix number is of exactly one student 20 | 21 | Fact Type: student has name 22 | Necessity: each student has exactly one name 23 | 24 | Fact Type: student has last name 25 | Necessity: each student has exactly one last name 26 | 27 | Fact Type: student studies at campus 28 | Necessity: each student studies at exactly one campus -------------------------------------------------------------------------------- /test/fixtures/04-translations/translations/v3/hooks.ts: -------------------------------------------------------------------------------- 1 | import { sbvrUtils } from '@balena/pinejs'; 2 | 3 | const addHook = ( 4 | methods: Array[0]>, 5 | resource: string, 6 | hook: sbvrUtils.Hooks, 7 | ) => { 8 | methods.map((method) => { 9 | sbvrUtils.addPureHook(method, 'v3', resource, hook); 10 | }); 11 | }; 12 | 13 | addHook(['PUT', 'POST', 'PATCH'], 'student', { 14 | async POSTPARSE({ request, api }) { 15 | const campusId = request.values.studies_at__campus; 16 | 17 | const resinApi = sbvrUtils.api.university.clone({ 18 | passthrough: api.passthrough, 19 | }); 20 | 21 | if (Object.hasOwn(request.values, 'studies_at__campus')) { 22 | const faculty = await resinApi.get({ 23 | resource: 'faculty', 24 | id: campusId, 25 | }); 26 | delete request.values.studies_at__campus; 27 | request.values.studies_at__faculty = faculty?.id; 28 | } 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /test/fixtures/04-translations/translations/v3/index.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | import { getAbstractSqlModelFromFile } from '@balena/pinejs/out/bin/utils.js'; 3 | import type { AbstractSqlQuery } from '@balena/abstract-sql-compiler'; 4 | 5 | export const v3AbstractSqlModel = await getAbstractSqlModelFromFile( 6 | import.meta.dirname + '/university.sbvr', 7 | undefined, 8 | ); 9 | 10 | export const toVersion = 'v4'; 11 | 12 | v3AbstractSqlModel.tables['student'].fields.push({ 13 | fieldName: 'computed field', 14 | dataType: 'Text', 15 | required: false, 16 | computed: ['EmbeddedText', 'v3_computed_field'] as AbstractSqlQuery, 17 | }); 18 | 19 | v3AbstractSqlModel.relationships['version'] = { v3: {} }; 20 | 21 | export const v3Translations: ConfigLoader.Model['translations'] = { 22 | campus: { 23 | $toResource: 'faculty', 24 | }, 25 | student: { 26 | 'studies at-campus': 'studies at-faculty', 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /test/fixtures/04-translations/translations/v3/university.sbvr: -------------------------------------------------------------------------------- 1 | Vocabulary: university 2 | 3 | Term: name 4 | Concept Type: Short Text (Type) 5 | 6 | Term: last name 7 | Concept Type: Short Text (Type) 8 | 9 | Term: matrix number 10 | Concept Type: Integer (Type) 11 | 12 | Term: campus 13 | 14 | Fact Type: campus has name 15 | Necessity: each campus has exactly one name 16 | Necessity: each name is of exactly one campus 17 | 18 | Term: student 19 | 20 | Fact Type: student has matrix number 21 | Necessity: each student has exactly one matrix number 22 | Necessity: each matrix number is of exactly one student 23 | 24 | Fact Type: student has name 25 | Necessity: each student has exactly one name 26 | 27 | Fact Type: student has last name 28 | Necessity: each student has exactly one last name 29 | 30 | Fact Type: student studies at campus 31 | Necessity: each student studies at exactly one campus -------------------------------------------------------------------------------- /test/fixtures/04-translations/translations/v4/index.ts: -------------------------------------------------------------------------------- 1 | import { getAbstractSqlModelFromFile } from '@balena/pinejs/out/bin/utils.js'; 2 | import type { AbstractSqlQuery } from '@balena/abstract-sql-compiler'; 3 | 4 | export const v4AbstractSqlModel = await getAbstractSqlModelFromFile( 5 | import.meta.dirname + '/university.sbvr', 6 | undefined, 7 | ); 8 | 9 | export const toVersion = 'v5'; 10 | 11 | v4AbstractSqlModel.tables['student'].fields.push({ 12 | fieldName: 'computed field', 13 | dataType: 'Text', 14 | required: false, 15 | computed: ['EmbeddedText', 'v4_computed_field'] as AbstractSqlQuery, 16 | }); 17 | 18 | v4AbstractSqlModel.relationships['version'] = { v4: {} }; 19 | -------------------------------------------------------------------------------- /test/fixtures/04-translations/translations/v4/university.sbvr: -------------------------------------------------------------------------------- 1 | Vocabulary: university 2 | 3 | Term: name 4 | Concept Type: Short Text (Type) 5 | 6 | Term: last name 7 | Concept Type: Short Text (Type) 8 | 9 | Term: matrix number 10 | Concept Type: Integer (Type) 11 | 12 | Term: faculty 13 | 14 | Fact Type: faculty has name 15 | Necessity: each faculty has exactly one name 16 | Necessity: each name is of exactly one faculty 17 | 18 | Term: student 19 | 20 | Fact Type: student has matrix number 21 | Necessity: each student has exactly one matrix number 22 | Necessity: each matrix number is of exactly one student 23 | 24 | Fact Type: student has name 25 | Necessity: each student has exactly one name 26 | 27 | Fact Type: student has last name 28 | Necessity: each student has exactly one last name 29 | 30 | Fact Type: student studies at faculty 31 | Necessity: each student studies at exactly one faculty 32 | -------------------------------------------------------------------------------- /test/fixtures/04-translations/translations/v5/index.ts: -------------------------------------------------------------------------------- 1 | import { getAbstractSqlModelFromFile } from '@balena/pinejs/out/bin/utils.js'; 2 | import type { AbstractSqlQuery } from '@balena/abstract-sql-compiler'; 3 | 4 | export const v5AbstractSqlModel = await getAbstractSqlModelFromFile( 5 | import.meta.dirname + '/university.sbvr', 6 | undefined, 7 | ); 8 | 9 | export const toVersion = 'university'; 10 | 11 | v5AbstractSqlModel.tables['student'].fields.push({ 12 | fieldName: 'computed field', 13 | dataType: 'Text', 14 | required: false, 15 | computed: ['EmbeddedText', 'v5_computed_field'] as AbstractSqlQuery, 16 | }); 17 | 18 | v5AbstractSqlModel.relationships['version'] = { v5: {} }; 19 | -------------------------------------------------------------------------------- /test/fixtures/04-translations/translations/v5/university.sbvr: -------------------------------------------------------------------------------- 1 | Vocabulary: university 2 | 3 | Term: name 4 | Concept Type: Short Text (Type) 5 | 6 | Term: last name 7 | Concept Type: Short Text (Type) 8 | 9 | Term: matrix number 10 | Concept Type: Integer (Type) 11 | 12 | Term: faculty 13 | 14 | Fact Type: faculty has name 15 | Necessity: each faculty has exactly one name 16 | Necessity: each name is of exactly one faculty 17 | 18 | Term: student 19 | 20 | Fact Type: student has matrix number 21 | Necessity: each student has exactly one matrix number 22 | Necessity: each matrix number is of exactly one student 23 | 24 | Fact Type: student has name 25 | Necessity: each student has exactly one name 26 | 27 | Fact Type: student has last name 28 | Necessity: each student has exactly one last name 29 | 30 | Fact Type: student studies at faculty 31 | Necessity: each student studies at exactly one faculty 32 | -------------------------------------------------------------------------------- /test/fixtures/04-translations/university.sbvr: -------------------------------------------------------------------------------- 1 | Vocabulary: university 2 | 3 | Term: name 4 | Concept Type: Short Text (Type) 5 | 6 | Term: last name 7 | Concept Type: Short Text (Type) 8 | 9 | Term: matrix number 10 | Concept Type: Integer (Type) 11 | 12 | Term: faculty 13 | 14 | Fact Type: faculty has name 15 | Necessity: each faculty has exactly one name 16 | Necessity: each name is of exactly one faculty 17 | 18 | Term: student 19 | 20 | Fact Type: student has matrix number 21 | Necessity: each student has exactly one matrix number 22 | Necessity: each matrix number is of exactly one student 23 | 24 | Fact Type: student has name 25 | Necessity: each student has exactly one name 26 | 27 | Fact Type: student has last name 28 | Necessity: each student has exactly one last name 29 | 30 | Fact Type: student studies at faculty 31 | Necessity: each student studies at exactly one faculty -------------------------------------------------------------------------------- /test/fixtures/05-request-cancellation/config.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | 3 | const apiRoot = 'example'; 4 | const modelName = 'example'; 5 | const modelFile = import.meta.dirname + '/example.sbvr'; 6 | 7 | export default { 8 | models: [ 9 | { 10 | modelName, 11 | modelFile, 12 | apiRoot, 13 | }, 14 | ], 15 | users: [ 16 | { 17 | username: 'guest', 18 | password: ' ', 19 | permissions: ['resource.all'], 20 | }, 21 | ], 22 | } as ConfigLoader.Config; 23 | -------------------------------------------------------------------------------- /test/fixtures/05-request-cancellation/example.sbvr: -------------------------------------------------------------------------------- 1 | Vocabulary: example 2 | 3 | Term: name 4 | Concept Type: Short Text (Type) 5 | 6 | Term: note 7 | Concept Type: Text (Type) 8 | 9 | Term: content 10 | Concept Type: Text (Type) 11 | 12 | Term: slow resource 13 | 14 | Fact Type: slow resource has name 15 | Necessity: each slow resource has at most one name. 16 | Fact Type: slow resource has note 17 | Necessity: each slow resource has at most one note. 18 | 19 | Term: log 20 | 21 | Fact Type: log has content 22 | Necessity: each log has exactly one content. 23 | -------------------------------------------------------------------------------- /test/fixtures/05-request-cancellation/hooks.ts: -------------------------------------------------------------------------------- 1 | import { setTimeout } from 'timers/promises'; 2 | import { sbvrUtils } from '@balena/pinejs'; 3 | import { track } from './util.js'; 4 | 5 | sbvrUtils.addPureHook('POST', 'example', 'slow_resource', { 6 | async POSTRUN({ request, api }) { 7 | await track('POST slow_resource POSTRUN started'); 8 | 9 | await api.patch({ 10 | resource: 'slow_resource', 11 | options: { 12 | $filter: { id: { $in: request.affectedIds! } }, 13 | }, 14 | body: { 15 | note: 'I got updated after the slow POSTRUN', 16 | }, 17 | }); 18 | await track('POST slow_resource POSTRUN updated the note once'); 19 | 20 | await setTimeout(300); 21 | await track('POST slow_resource POSTRUN spent some time waiting'); 22 | 23 | await api.patch({ 24 | resource: 'slow_resource', 25 | options: { 26 | $filter: { id: { $in: request.affectedIds! } }, 27 | }, 28 | body: { 29 | note: 'I got updated twice after the slow POSTRUN', 30 | }, 31 | }); 32 | await track('POST slow_resource POSTRUN updated the note again'); 33 | 34 | await track('POST slow_resource POSTRUN finished'); 35 | }, 36 | async PRERESPOND() { 37 | await track('POST slow_resource PRERESPOND'); 38 | }, 39 | async 'POSTRUN-ERROR'() { 40 | await track('POST slow_resource POSTRUN-ERROR'); 41 | }, 42 | }); 43 | -------------------------------------------------------------------------------- /test/fixtures/05-request-cancellation/routes.ts: -------------------------------------------------------------------------------- 1 | import type express from 'express'; 2 | import onFinished from 'on-finished'; 3 | import { sbvrUtils, errors } from '@balena/pinejs'; 4 | import { setTimeout } from 'timers/promises'; 5 | import { track } from './util.js'; 6 | 7 | export const initRoutes = (app: express.Express) => { 8 | app.post('/slow-custom-endpoint', async (req, res) => { 9 | try { 10 | const response = await sbvrUtils.db.transaction(async (tx) => { 11 | await track('POST /slow-custom-endpoint tx started'); 12 | const tryCancelRequest = () => { 13 | if (!tx.isClosed()) { 14 | void tx.rollback(); 15 | } 16 | }; 17 | switch (req.query.event) { 18 | case 'on-close': 19 | res.on('close', tryCancelRequest); 20 | break; 21 | case 'on-finished': 22 | onFinished(res, tryCancelRequest); 23 | break; 24 | default: 25 | throw new errors.BadRequestError(`query.event: ${req.query.event}`); 26 | } 27 | 28 | const apiTx = sbvrUtils.api.example.clone({ 29 | passthrough: { req, tx }, 30 | }); 31 | const slowResource = await apiTx.post({ 32 | resource: 'slow_resource', 33 | body: { 34 | name: req.body.name, 35 | }, 36 | }); 37 | await track('POST /slow-custom-endpoint POST-ed slow_resource record'); 38 | 39 | await setTimeout(300); 40 | await track('POST /slow-custom-endpoint spent some time waiting'); 41 | 42 | await apiTx.patch({ 43 | resource: 'slow_resource', 44 | id: slowResource.id, 45 | body: { 46 | note: 'I am a note from the custom endpoint', 47 | }, 48 | }); 49 | await track( 50 | 'POST /slow-custom-endpoint PATCH-ed the slow_resource note', 51 | ); 52 | 53 | const result = await apiTx.get({ 54 | resource: 'slow_resource', 55 | id: slowResource.id, 56 | }); 57 | await track('POST /slow-custom-endpoint re-GET result finished'); 58 | 59 | return result; 60 | }); 61 | await track('POST /slow-custom-endpoint tx finished'); 62 | 63 | res.status(201).json(response); 64 | } catch (err) { 65 | if (err instanceof Error && sbvrUtils.handleHttpErrors(req, res, err)) { 66 | await track(`POST /slow-custom-endpoint caught: ${err.name}`); 67 | return; 68 | } 69 | res.status(500).send(); 70 | } 71 | }); 72 | }; 73 | -------------------------------------------------------------------------------- /test/fixtures/05-request-cancellation/util.ts: -------------------------------------------------------------------------------- 1 | import { sbvrUtils } from '@balena/pinejs'; 2 | 3 | // Since pine runs in a different process than the tests, we can't use spies, 4 | // so we use a resource as a workaround for persistence outside of TXs. 5 | export const track = async (content: string) => { 6 | return await sbvrUtils.api.example.post({ 7 | resource: 'log', 8 | body: { 9 | content, 10 | }, 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /test/fixtures/06-webresource/config.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | import { S3Handler } from '@balena/pinejs-webresource-s3'; 3 | import { v1AbstractSqlModel, v1Translations } from './translations/v1/index.js'; 4 | import { requiredVar, intVar } from '@balena/env-parsing'; 5 | 6 | const apiRoot = 'example'; 7 | const modelName = 'example'; 8 | const modelFile = import.meta.dirname + '/example.sbvr'; 9 | 10 | const s3Handler = new S3Handler({ 11 | bucket: requiredVar('S3_STORAGE_ADAPTER_BUCKET'), 12 | region: requiredVar('S3_REGION'), 13 | accessKey: requiredVar('S3_ACCESS_KEY'), 14 | secretKey: requiredVar('S3_SECRET_KEY'), 15 | endpoint: requiredVar('S3_ENDPOINT'), 16 | maxSize: intVar('PINEJS_WEBRESOURCE_MAXFILESIZE'), 17 | }); 18 | 19 | export default { 20 | models: [ 21 | { 22 | modelName, 23 | modelFile, 24 | apiRoot, 25 | }, 26 | { 27 | apiRoot: 'v1', 28 | modelName: 'v1', 29 | abstractSql: v1AbstractSqlModel, 30 | translateTo: 'example', 31 | translations: v1Translations, 32 | }, 33 | ], 34 | users: [ 35 | { 36 | username: 'guest', 37 | password: ' ', 38 | permissions: ['resource.all'], 39 | }, 40 | ], 41 | webResourceHandler: s3Handler, 42 | } as ConfigLoader.Config; 43 | -------------------------------------------------------------------------------- /test/fixtures/06-webresource/example.sbvr: -------------------------------------------------------------------------------- 1 | Vocabulary: example 2 | 3 | Term: name 4 | Concept Type: Short Text (Type) 5 | 6 | Term: logo image 7 | Concept Type: WebResource (Type) 8 | 9 | Term: not translated webresource 10 | Concept Type: WebResource (Type) 11 | 12 | Term: public artifacts 13 | Concept Type: WebResource (Type) 14 | 15 | Term: private artifacts 16 | Concept Type: WebResource (Type) 17 | 18 | Term: unrestricted artifact 19 | Concept Type: WebResource (Type) 20 | 21 | Term: organization 22 | 23 | Fact Type: organization has name 24 | Necessity: each organization has exactly one name 25 | Necessity: each organization that has a name, has a name that has a Length (Type) that is greater than 0 and is less than or equal to 5 26 | 27 | Fact Type: organization has not translated webresource 28 | Necessity: each organization has at most one not translated webresource 29 | Necessity: each not translated webresource is of exactly one organization. 30 | Necessity: each organization that has a not translated webresource, has a not translated webresource that has a Content Type (Type) that is equal to "image/png" or "image/jpg" or "image/jpeg" and has a Size (Type) that is less than 540000000. 31 | 32 | Fact Type: organization has logo image 33 | Necessity: each organization has at most one logo image 34 | Necessity: each logo image is of exactly one organization. 35 | Necessity: each organization that has a logo image, has a logo image that has a Content Type (Type) that is equal to "image/png" or "image/jpg" or "image/jpeg" and has a Size (Type) that is less than 540000000. 36 | 37 | Fact Type: organization releases public artifacts 38 | Fact Type: organization has private artifacts 39 | Synonymous Form: private artifacts belongs to organization 40 | Term Form: organization private artifacts 41 | Database Table Name: organization private artifacts 42 | 43 | Fact Type: organization has unrestricted artifact 44 | Necessity: each organization has at most one unrestricted artifact 45 | Necessity: each unrestricted artifact is of exactly one organization 46 | -------------------------------------------------------------------------------- /test/fixtures/06-webresource/resources/avatar-profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io/pinejs/197b1226d597af1f79face9f62d47e72c7c91d0e/test/fixtures/06-webresource/resources/avatar-profile.png -------------------------------------------------------------------------------- /test/fixtures/06-webresource/resources/other-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io/pinejs/197b1226d597af1f79face9f62d47e72c7c91d0e/test/fixtures/06-webresource/resources/other-image.png -------------------------------------------------------------------------------- /test/fixtures/06-webresource/translations/hooks.ts: -------------------------------------------------------------------------------- 1 | import './v1/hooks.js'; 2 | -------------------------------------------------------------------------------- /test/fixtures/06-webresource/translations/v1/example.sbvr: -------------------------------------------------------------------------------- 1 | Vocabulary: example 2 | 3 | Term: name 4 | Concept Type: Short Text (Type) 5 | 6 | Term: other image 7 | Concept Type: WebResource (Type) 8 | 9 | Term: not translated webresource 10 | Concept Type: WebResource (Type) 11 | 12 | Term: organization 13 | 14 | Fact Type: organization has name 15 | Necessity: each organization has exactly one name 16 | Necessity: each organization that has a name, has a name that has a Length (Type) that is greater than 0 and is less than or equal to 5 17 | 18 | Fact Type: organization has not translated webresource 19 | Necessity: each organization has at most one not translated webresource 20 | Necessity: each not translated webresource is of exactly one organization. 21 | Necessity: each organization that has a not translated webresource, has a not translated webresource that has a Content Type (Type) that is equal to "image/png" or "image/jpg" or "image/jpeg" and has a Size (Type) that is less than 540000000. 22 | 23 | Fact Type: organization has other image 24 | Necessity: each organization has at most one other image 25 | Necessity: each other image is of exactly one organization. 26 | Necessity: each organization that has a other image, has a other image that has a Content Type (Type) that is equal to "image/png" or "image/jpg" or "image/jpeg" and has a Size (Type) that is less than 540000000. 27 | -------------------------------------------------------------------------------- /test/fixtures/06-webresource/translations/v1/hooks.ts: -------------------------------------------------------------------------------- 1 | import { sbvrUtils } from '@balena/pinejs'; 2 | 3 | const addHook = ( 4 | methods: Array[0]>, 5 | resource: string, 6 | hook: sbvrUtils.Hooks, 7 | ) => { 8 | methods.map((method) => { 9 | sbvrUtils.addPureHook(method, 'v1', resource, hook); 10 | }); 11 | }; 12 | 13 | addHook(['PUT', 'POST', 'PATCH'], 'organization', { 14 | POSTPARSE({ request }) { 15 | if (request.values.other_image !== undefined) { 16 | request.values.logo_image = request.values.other_image; 17 | delete request.values.other_image; 18 | } 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /test/fixtures/06-webresource/translations/v1/index.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | import { getAbstractSqlModelFromFile } from '@balena/pinejs/out/bin/utils.js'; 3 | 4 | export const toVersion = 'example'; 5 | 6 | export const v1AbstractSqlModel = await getAbstractSqlModelFromFile( 7 | import.meta.dirname + '/example.sbvr', 8 | undefined, 9 | ); 10 | 11 | v1AbstractSqlModel.relationships['version'] = { v1: {} }; 12 | 13 | export const v1Translations: ConfigLoader.Model['translations'] = { 14 | organization: { 15 | 'other image': 'logo image', 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /test/fixtures/07-permissions/config.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | 3 | const apiRoot = 'university'; 4 | const modelName = 'university'; 5 | const modelFile = import.meta.dirname + '/university.sbvr'; 6 | 7 | export default { 8 | models: [ 9 | { 10 | modelName, 11 | modelFile, 12 | apiRoot, 13 | }, 14 | ], 15 | users: [ 16 | { 17 | username: 'guest', 18 | password: ' ', 19 | permissions: ['university.student.read'], 20 | }, 21 | { 22 | username: 'admin', 23 | password: 'admin', 24 | permissions: ['resource.all'], 25 | }, 26 | ], 27 | } as ConfigLoader.Config; 28 | -------------------------------------------------------------------------------- /test/fixtures/07-permissions/university.sbvr: -------------------------------------------------------------------------------- 1 | Vocabulary: university 2 | 3 | Term: name 4 | Concept Type: Short Text (Type) 5 | 6 | Term: lastname 7 | Concept Type: Short Text (Type) 8 | 9 | Term: birthday 10 | Concept Type: Date Time (Type) 11 | 12 | Term: semester credits 13 | Concept Type: Integer (Type) 14 | 15 | Term: matrix number 16 | Concept Type: Integer (Type) 17 | 18 | Term: badge key 19 | Concept Type: Short Text (Type) 20 | 21 | Term: badge value 22 | Concept Type: Short Text (Type) 23 | 24 | Term: student 25 | 26 | Fact Type: student has matrix number 27 | Necessity: each student has exactly one matrix number 28 | Necessity: each matrix number is of exactly one student 29 | 30 | Fact Type: student has name 31 | Necessity: each student has exactly one name 32 | 33 | Fact Type: student has lastname 34 | Necessity: each student has exactly one lastname 35 | 36 | Fact Type: student has birthday 37 | Necessity: each student has exactly one birthday 38 | 39 | Fact Type: student has semester credits 40 | Necessity: each student has at most one semester credits 41 | Necessity: each student that has a semester credits, has a semester credits that is greater than or equal to 4 and is less than or equal to 16. 42 | 43 | Fact Type: student has badge key 44 | Term Form: student badge 45 | Database Table Name: student badge 46 | 47 | Fact type: student badge has badge value 48 | Necessity: each student badge has exactly one badge value. 49 | -------------------------------------------------------------------------------- /test/fixtures/08-tasks/config.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | import { fileURLToPath } from 'node:url'; 3 | 4 | export default { 5 | models: [ 6 | { 7 | modelName: 'Auth', 8 | modelFile: fileURLToPath( 9 | import.meta.resolve('@balena/pinejs/out/sbvr-api/user.sbvr'), 10 | ), 11 | apiRoot: 'Auth', 12 | }, 13 | { 14 | modelName: 'tasks', 15 | modelFile: fileURLToPath( 16 | import.meta.resolve('@balena/pinejs/out/tasks/tasks.sbvr'), 17 | ), 18 | apiRoot: 'tasks', 19 | }, 20 | { 21 | modelName: 'example', 22 | modelFile: import.meta.dirname + '/example.sbvr', 23 | apiRoot: 'example', 24 | }, 25 | ], 26 | users: [ 27 | { 28 | username: 'guest', 29 | password: ' ', 30 | permissions: ['resource.all'], 31 | }, 32 | ], 33 | } as ConfigLoader.Config; 34 | -------------------------------------------------------------------------------- /test/fixtures/08-tasks/example.sbvr: -------------------------------------------------------------------------------- 1 | Vocabulary: example 2 | 3 | Term: name 4 | Concept Type: Short Text (Type) 5 | 6 | Term: note 7 | Concept Type: Text (Type) 8 | 9 | Term: type 10 | Concept Type: Short Text (Type) 11 | 12 | Term: count 13 | Concept Type: Integer (Type) 14 | 15 | Term: device 16 | 17 | Fact Type: device has name 18 | Necessity: each device has at most one name. 19 | 20 | Fact Type: device has note 21 | Necessity: each device has at most one note. 22 | 23 | Fact Type: device has type 24 | Necessity: each device has exactly one type. 25 | 26 | Fact Type: device has count 27 | Necessity: each device has at most one count. -------------------------------------------------------------------------------- /test/fixtures/08-tasks/task-handlers.ts: -------------------------------------------------------------------------------- 1 | import type { FromSchema } from 'json-schema-to-ts'; 2 | import { sbvrUtils, tasks } from '@balena/pinejs'; 3 | 4 | // Define JSON schema for accepted parameters 5 | const createDeviceParamsSchema = { 6 | type: 'object', 7 | properties: { 8 | name: { 9 | type: 'string', 10 | }, 11 | type: { 12 | type: 'string', 13 | }, 14 | }, 15 | required: ['name', 'type'], 16 | additionalProperties: false, 17 | } as const; 18 | 19 | const incrementDeviceCountParamsSchema = { 20 | type: 'object', 21 | properties: { 22 | deviceId: { 23 | type: 'number', 24 | }, 25 | }, 26 | required: ['deviceId'], 27 | additionalProperties: false, 28 | } as const; 29 | 30 | // Generate type from schema and export for callers to use 31 | export type CreateDeviceParams = FromSchema; 32 | export type IncrementDeviceCountParams = FromSchema< 33 | typeof incrementDeviceCountParamsSchema 34 | >; 35 | 36 | export const initTaskHandlers = () => { 37 | tasks.addTaskHandler( 38 | 'create_device', 39 | async (options) => { 40 | try { 41 | const params = options.params; 42 | await options.api.post({ 43 | apiPrefix: '/example/', 44 | resource: 'device', 45 | body: { 46 | name: params.name, 47 | type: params.type, 48 | }, 49 | }); 50 | return { 51 | status: 'succeeded', 52 | }; 53 | } catch (err: any) { 54 | return { 55 | status: 'failed', 56 | error: err.message, 57 | }; 58 | } 59 | }, 60 | createDeviceParamsSchema, 61 | ); 62 | 63 | tasks.addTaskHandler('will_fail', () => { 64 | try { 65 | throw new Error('This task is supposed to fail'); 66 | } catch (err: any) { 67 | return { 68 | status: 'failed', 69 | error: err.message, 70 | }; 71 | } 72 | }); 73 | 74 | tasks.addTaskHandler( 75 | 'increment_device_count', 76 | async (options) => { 77 | const deviceId = options.params.deviceId; 78 | await sbvrUtils.db.executeSql( 79 | 'UPDATE device SET count = count + 1 WHERE id = $1', 80 | [deviceId], 81 | ); 82 | return { status: 'succeeded' }; 83 | }, 84 | incrementDeviceCountParamsSchema, 85 | ); 86 | 87 | void tasks.worker?.start(); 88 | }; 89 | -------------------------------------------------------------------------------- /test/fixtures/09-actions/actions-university.ts: -------------------------------------------------------------------------------- 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 Student { 6 | Read: { 7 | created_at: Types['Date Time']['Read']; 8 | modified_at: Types['Date Time']['Read']; 9 | id: Types['Serial']['Read']; 10 | name: Types['Short Text']['Read']; 11 | semester: Types['Integer']['Read']; 12 | is_repeating: Types['Boolean']['Read']; 13 | previous_year_grade: Types['Short Text']['Read'] | null; 14 | }; 15 | Write: { 16 | created_at: Types['Date Time']['Write']; 17 | modified_at: Types['Date Time']['Write']; 18 | id: Types['Serial']['Write']; 19 | name: Types['Short Text']['Write']; 20 | semester: Types['Integer']['Write']; 21 | is_repeating: Types['Boolean']['Write']; 22 | previous_year_grade: Types['Short Text']['Write'] | null; 23 | }; 24 | } 25 | 26 | export default interface $Model { 27 | student: Student; 28 | } 29 | -------------------------------------------------------------------------------- /test/fixtures/09-actions/actions.ts: -------------------------------------------------------------------------------- 1 | import { actions, errors } from '@balena/pinejs'; 2 | import type ActionsUniversityModel from './actions-university.js'; 3 | import { assertExists } from '../../lib/common.js'; 4 | import './translations/v1/actions.js'; 5 | 6 | declare module '../../../out/sbvr-api/sbvr-utils.js' { 7 | export interface API { 8 | actionsUniversity: PinejsClient; 9 | } 10 | } 11 | 12 | actions.addAction( 13 | 'actionsUniversity', 14 | 'student', 15 | 'promoteToNextSemester', 16 | async ({ id, api, request }) => { 17 | if (id == null) { 18 | throw new errors.BadRequestError( 19 | 'Can only promote one student at a time', 20 | ); 21 | } 22 | 23 | const { grades } = request.values; 24 | 25 | if (!Array.isArray(grades)) { 26 | throw new errors.BadRequestError('Invalid payload'); 27 | } 28 | 29 | const average = 30 | grades.reduce((sum, grade) => sum + grade, 0) / grades.length; 31 | 32 | if (average < 7) { 33 | await api.patch({ 34 | resource: 'student', 35 | id, 36 | body: { 37 | is_repeating: true, 38 | previous_year_grade: `${average}`, 39 | }, 40 | }); 41 | } else { 42 | const student = await api.get({ 43 | resource: 'student', 44 | id, 45 | options: { 46 | $select: ['semester'], 47 | }, 48 | }); 49 | 50 | assertExists(student); 51 | 52 | await api.patch({ 53 | resource: 'student', 54 | id, 55 | body: { 56 | semester: student.semester + 1, 57 | is_repeating: false, 58 | previous_year_grade: `${average}`, 59 | }, 60 | }); 61 | } 62 | 63 | if (request.values.dryRun) { 64 | throw new errors.UnprocessableEntityError('Dry run completed'); 65 | } 66 | 67 | return { 68 | statusCode: 200, 69 | }; 70 | }, 71 | ); 72 | -------------------------------------------------------------------------------- /test/fixtures/09-actions/actionsUniversity.sbvr: -------------------------------------------------------------------------------- 1 | Vocabulary: actionsUniversity 2 | 3 | Term: name 4 | Concept Type: Short Text (Type) 5 | 6 | Term: semester 7 | Concept Type: Integer (Type) 8 | 9 | Term: previous year grade 10 | Concept Type: Short Text (Type) 11 | 12 | Term: student 13 | 14 | Fact Type: student has name 15 | Necessity: each student has exactly one name 16 | Necessity: each name is of exactly one student 17 | 18 | Fact Type: student has semester 19 | Necessity: each student has exactly one semester 20 | Necessity: each student has a semester that is greater than 0 and is less than 12 21 | 22 | Fact Type: student is repeating 23 | Fact type: student has previous year grade 24 | Necessity: each student has at most one previous year grade 25 | -------------------------------------------------------------------------------- /test/fixtures/09-actions/config.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | import { v1AbstractSqlModel, v1Translations } from './translations/v1/index.js'; 3 | 4 | const apiRoot = 'actionsUniversity'; 5 | const modelName = 'actionsUniversity'; 6 | const modelFile = import.meta.dirname + '/actionsUniversity.sbvr'; 7 | 8 | export default { 9 | models: [ 10 | { 11 | modelName, 12 | modelFile, 13 | apiRoot, 14 | }, 15 | { 16 | apiRoot: 'v1actionsUniversity', 17 | modelName: 'v1actionsUniversity', 18 | abstractSql: v1AbstractSqlModel, 19 | translateTo: 'actionsUniversity', 20 | translations: v1Translations, 21 | }, 22 | ], 23 | users: [ 24 | { 25 | username: 'teacher', 26 | password: 'teacher', 27 | permissions: [ 28 | 'actionsUniversity.student.read', 29 | 'actionsUniversity.student.create', 30 | 'actionsUniversity.student.update', 31 | 'actionsUniversity.student.delete', 32 | ], 33 | }, 34 | { 35 | username: 'admin', 36 | password: 'admin', 37 | permissions: [ 38 | 'actionsUniversity.student.read', 39 | 'actionsUniversity.student.create', 40 | 'actionsUniversity.student.update', 41 | 'actionsUniversity.student.delete', 42 | 'actionsUniversity.student.promoteToNextSemester', 43 | ], 44 | }, 45 | { 46 | username: 'guest', 47 | password: ' ', 48 | permissions: [], 49 | }, 50 | ], 51 | } as ConfigLoader.Config; 52 | -------------------------------------------------------------------------------- /test/fixtures/09-actions/translations/hooks.ts: -------------------------------------------------------------------------------- 1 | import './v1/hooks.js'; 2 | -------------------------------------------------------------------------------- /test/fixtures/09-actions/translations/v1/actions.ts: -------------------------------------------------------------------------------- 1 | import { actions, errors } from '@balena/pinejs'; 2 | import type V1ActionsUniversityModel from './v1-actions-university.js'; 3 | import { assertExists } from '../../../../lib/common.js'; 4 | 5 | declare module '../../../../../out/sbvr-api/sbvr-utils.js' { 6 | export interface API { 7 | v1actionsUniversity: PinejsClient; 8 | } 9 | } 10 | 11 | actions.addAction( 12 | 'v1actionsUniversity', 13 | 'student', 14 | 'promoteToNextSemester', 15 | async ({ id, api, request }) => { 16 | if (id == null) { 17 | throw new errors.BadRequestError( 18 | 'Can only promote one student at a time', 19 | ); 20 | } 21 | 22 | const { grades } = request.values; 23 | 24 | if (!Array.isArray(grades)) { 25 | throw new errors.BadRequestError('Invalid payload'); 26 | } 27 | 28 | // V1 grants students one extra mark 29 | const average = 30 | grades.reduce((sum, grade) => sum + grade, 0) / grades.length + 1; 31 | 32 | if (average < 7) { 33 | await api.patch({ 34 | resource: 'student', 35 | id, 36 | body: { 37 | is_repeating: true, 38 | previous_year_grade: `${average}`, 39 | }, 40 | }); 41 | } else { 42 | const student = await api.get({ 43 | resource: 'student', 44 | id, 45 | options: { 46 | $select: ['current_semester'], 47 | }, 48 | }); 49 | 50 | assertExists(student); 51 | 52 | await api.patch({ 53 | resource: 'student', 54 | id, 55 | body: { 56 | current_semester: student.current_semester + 1, 57 | is_repeating: false, 58 | previous_year_grade: `${average}`, 59 | }, 60 | }); 61 | } 62 | 63 | if (request.values.dryRun) { 64 | throw new errors.UnprocessableEntityError('Dry run completed'); 65 | } 66 | 67 | return { 68 | statusCode: 200, 69 | }; 70 | }, 71 | ); 72 | -------------------------------------------------------------------------------- /test/fixtures/09-actions/translations/v1/hooks.ts: -------------------------------------------------------------------------------- 1 | import { sbvrUtils } from '@balena/pinejs'; 2 | 3 | const addHook = ( 4 | methods: Array[0]>, 5 | resource: string, 6 | hook: sbvrUtils.Hooks, 7 | ) => { 8 | methods.map((method) => { 9 | sbvrUtils.addPureHook(method, 'v1actionsUniversity', resource, hook); 10 | }); 11 | }; 12 | 13 | addHook(['PUT', 'POST', 'PATCH'], 'student', { 14 | POSTPARSE({ request }) { 15 | if (request.values.current_semester !== undefined) { 16 | request.values.semester = request.values.current_semester; 17 | delete request.values.current_semester; 18 | } 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /test/fixtures/09-actions/translations/v1/index.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigLoader } from '@balena/pinejs'; 2 | import { getAbstractSqlModelFromFile } from '@balena/pinejs/out/bin/utils.js'; 3 | 4 | export const toVersion = 'actionsUniversity'; 5 | 6 | export const v1AbstractSqlModel = await getAbstractSqlModelFromFile( 7 | import.meta.dirname + '/v1actionsUniversity.sbvr', 8 | undefined, 9 | ); 10 | 11 | v1AbstractSqlModel.relationships['version'] = { v1actionsUniversity: {} }; 12 | 13 | export const v1Translations: ConfigLoader.Model['translations'] = { 14 | student: { 15 | 'current semester': 'semester', 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /test/fixtures/09-actions/translations/v1/v1-actions-university.ts: -------------------------------------------------------------------------------- 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 Student { 6 | Read: { 7 | created_at: Types['Date Time']['Read']; 8 | modified_at: Types['Date Time']['Read']; 9 | id: Types['Serial']['Read']; 10 | name: Types['Short Text']['Read']; 11 | current_semester: Types['Integer']['Read']; 12 | is_repeating: Types['Boolean']['Read']; 13 | previous_year_grade: Types['Short Text']['Read'] | null; 14 | }; 15 | Write: { 16 | created_at: Types['Date Time']['Write']; 17 | modified_at: Types['Date Time']['Write']; 18 | id: Types['Serial']['Write']; 19 | name: Types['Short Text']['Write']; 20 | current_semester: Types['Integer']['Write']; 21 | is_repeating: Types['Boolean']['Write']; 22 | previous_year_grade: Types['Short Text']['Write'] | null; 23 | }; 24 | } 25 | 26 | export default interface $Model { 27 | student: Student; 28 | } 29 | -------------------------------------------------------------------------------- /test/fixtures/09-actions/translations/v1/v1actionsUniversity.sbvr: -------------------------------------------------------------------------------- 1 | Vocabulary: actionsUniversity 2 | 3 | Term: name 4 | Concept Type: Short Text (Type) 5 | 6 | Term: current semester 7 | Concept Type: Integer (Type) 8 | 9 | Term: previous year grade 10 | Concept Type: Short Text (Type) 11 | 12 | Term: student 13 | 14 | Fact Type: student has name 15 | Necessity: each student has exactly one name 16 | Necessity: each name is of exactly one student 17 | 18 | Fact Type: student has current semester 19 | Necessity: each student has exactly one current semester 20 | Necessity: each student has a current semester that is greater than 0 and is less than 12 21 | 22 | Fact Type: student is repeating 23 | Fact type: student has previous year grade 24 | Necessity: each student has at most one previous year grade 25 | 26 | -------------------------------------------------------------------------------- /test/lib/common.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | export function assertExists(v: unknown): asserts v is NonNullable { 4 | expect(v).to.exist; 5 | } 6 | 7 | export const PINE_TEST_SIGNALS = { 8 | STOP_TASK_WORKER: 'PINEJS_TEST_STOP_TASK_WORKER', 9 | START_TASK_WORKER: 'PINEJS_TEST_START_TASK_WORKER', 10 | }; 11 | -------------------------------------------------------------------------------- /test/lib/pine-in-process.ts: -------------------------------------------------------------------------------- 1 | import { exit } from 'process'; 2 | import cluster from 'node:cluster'; 3 | import type { PineTestOptions } from './pine-init.js'; 4 | import { init } from './pine-init.js'; 5 | import { tasks } from '@balena/pinejs'; 6 | import { PINE_TEST_SIGNALS } from './common.js'; 7 | import { type Serializable } from 'child_process'; 8 | 9 | const createWorker = ( 10 | readyWorkers: Set, 11 | processArgs: PineTestOptions, 12 | ) => { 13 | const worker = cluster.fork(process.env); 14 | worker.on('message', (msg) => { 15 | if ('init' in msg && msg.init === 'ready') { 16 | readyWorkers.add(worker.id); 17 | if (readyWorkers.size === processArgs.clusterInstances && process.send) { 18 | process.send({ init: 'success' }); 19 | } 20 | } 21 | }); 22 | }; 23 | 24 | export async function forkInit() { 25 | const processArgs: PineTestOptions = JSON.parse(process.argv[2]); 26 | 27 | if (cluster.isPrimary) { 28 | const readyWorkers = new Set(); 29 | process.on('message', (message: Serializable) => { 30 | console.log('Received message in primary process', message); 31 | for (const id of readyWorkers.keys()) { 32 | cluster.workers?.[id]?.send(message); 33 | } 34 | }); 35 | if (processArgs.clusterInstances && processArgs.clusterInstances > 1) { 36 | for (let i = 0; i < processArgs.clusterInstances; i++) { 37 | createWorker(readyWorkers, processArgs); 38 | } 39 | cluster.on('exit', (worker) => { 40 | // While pine is initializing on empty db a worker might die 41 | // as several instances try at the same time to create tables etc 42 | // This is not a problem as the worker just tries to recreate until 43 | // everything syncs up 44 | console.info(`Worker ${worker.process.pid} died`); 45 | createWorker(readyWorkers, processArgs); 46 | }); 47 | } 48 | } 49 | 50 | if (!cluster.isPrimary || processArgs.clusterInstances === 1) { 51 | await runApp(processArgs); 52 | } 53 | 54 | if ( 55 | cluster.isPrimary && 56 | process.send && 57 | (!processArgs.clusterInstances || processArgs.clusterInstances === 1) 58 | ) { 59 | // If single instance or no clustering, send success directly 60 | process.send({ init: 'success' }); 61 | } 62 | } 63 | 64 | async function runApp(processArgs: PineTestOptions) { 65 | console.error('Running app in', processArgs); 66 | try { 67 | const { default: initConfig } = await import(processArgs.configPath); 68 | console.info(`listenPort: ${processArgs.listenPort}`); 69 | const app = await init( 70 | initConfig, 71 | processArgs.listenPort, 72 | processArgs.withLoginRoute, 73 | ); 74 | 75 | // load hooks 76 | if (processArgs.hooksPath) { 77 | await import(processArgs.hooksPath); 78 | } 79 | 80 | // load actions 81 | if (processArgs.actionsPath) { 82 | await import(processArgs.actionsPath); 83 | } 84 | 85 | // load task handlers 86 | if (processArgs.taskHandlersPath) { 87 | const { initTaskHandlers } = await import(processArgs.taskHandlersPath); 88 | initTaskHandlers(); 89 | } 90 | 91 | if (processArgs.routesPath) { 92 | const { initRoutes } = await import(processArgs.routesPath); 93 | initRoutes(app); 94 | } 95 | 96 | if (process.send) { 97 | process.send({ init: 'ready' }); 98 | } 99 | 100 | process.on('message', async (message) => { 101 | if (message === PINE_TEST_SIGNALS.STOP_TASK_WORKER) { 102 | // This avoids the worker from picking up any new tasks 103 | // Useful for stopping running process on a sigterm, for example 104 | tasks.worker?.stop(); 105 | } 106 | 107 | if (message === PINE_TEST_SIGNALS.START_TASK_WORKER) { 108 | await tasks.worker?.start(); 109 | } 110 | }); 111 | } catch (e) { 112 | console.error(`init pine in process failed ${e}`); 113 | exit(1); 114 | } 115 | } 116 | 117 | void forkInit(); 118 | -------------------------------------------------------------------------------- /test/lib/pine-init.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { exit } from 'process'; 3 | import * as pine from '@balena/pinejs'; 4 | 5 | export type PineTestOptions = { 6 | configPath: string; 7 | hooksPath?: string; 8 | actionsPath?: string; 9 | taskHandlersPath?: string; 10 | routesPath?: string; 11 | withLoginRoute?: boolean; 12 | deleteDb: boolean; 13 | listenPort: number; 14 | clusterInstances?: number; 15 | }; 16 | 17 | export async function init( 18 | initConfig: pine.ConfigLoader.Config, 19 | initPort: number, 20 | withLoginRoute = false, 21 | ) { 22 | const app = express(); 23 | app.use(express.urlencoded({ extended: true })); 24 | app.use(express.json()); 25 | 26 | if (withLoginRoute) { 27 | const { default: expressSession } = await import('express-session'); 28 | const { default: passport } = await import('passport'); 29 | 30 | app.use( 31 | expressSession({ 32 | secret: 'A pink cat jumped over a rainbow', 33 | store: new pine.PinejsSessionStore(), 34 | }), 35 | ); 36 | app.use(passport.initialize()); 37 | app.use(passport.session()); 38 | } 39 | 40 | app.use('/ping', (_req, res) => { 41 | res.sendStatus(200); 42 | }); 43 | 44 | process.on('SIGUSR2', () => { 45 | console.info( 46 | `Received SIGUSR2 to toggle async migration execution enabled from ${ 47 | pine.env.migrator.asyncMigrationIsEnabled 48 | } to ${!pine.env.migrator.asyncMigrationIsEnabled} `, 49 | ); 50 | pine.env.migrator.asyncMigrationIsEnabled = 51 | !pine.env.migrator.asyncMigrationIsEnabled; 52 | }); 53 | 54 | try { 55 | const loader = await pine.init(app, initConfig); 56 | if (withLoginRoute) { 57 | await pine.mountLoginRouter(loader, app); 58 | } 59 | await new Promise((resolve) => { 60 | app.listen(initPort, () => { 61 | resolve('server started'); 62 | }); 63 | }); 64 | return app; 65 | } catch (e) { 66 | console.log(`pineInit ${e}`); 67 | exit(1); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /test/lib/test-init.ts: -------------------------------------------------------------------------------- 1 | import type { ChildProcess } from 'child_process'; 2 | import { fork } from 'child_process'; 3 | import { boolVar } from '@balena/env-parsing'; 4 | import type { types } from '@balena/pinejs'; 5 | import { dbModule } from '@balena/pinejs'; 6 | import type { PineTestOptions } from './pine-init.js'; 7 | export const listenPortDefault = 1337; 8 | export const testLocalServer = `http://localhost:${listenPortDefault}`; 9 | 10 | export async function testInit( 11 | options: types.OptionalField, 12 | ): Promise { 13 | try { 14 | const processArgs: PineTestOptions = { 15 | listenPort: options.listenPort ?? listenPortDefault, 16 | deleteDb: options.deleteDb ?? boolVar('DELETE_DB', false), 17 | configPath: options.configPath, 18 | hooksPath: options.hooksPath, 19 | actionsPath: options.actionsPath, 20 | taskHandlersPath: options.taskHandlersPath, 21 | routesPath: options.routesPath, 22 | withLoginRoute: options.withLoginRoute, 23 | clusterInstances: options.clusterInstances ?? 1, 24 | }; 25 | if (processArgs.deleteDb) { 26 | await cleanDb(); 27 | } 28 | const testServer = fork( 29 | import.meta.dirname + '/pine-in-process.ts', 30 | [JSON.stringify(processArgs)], 31 | { 32 | detached: false, 33 | execArgv: [ 34 | '--require', 35 | 'ts-node/register/transpile-only', 36 | '--loader', 37 | 'ts-node/esm/transpile-only', 38 | ], 39 | }, 40 | ); 41 | await new Promise((resolve, reject) => { 42 | testServer.on('message', (msg: { init: string }) => { 43 | console.info(`init pine in separate process`); 44 | if ('init' in msg && msg.init === 'success') { 45 | resolve(msg.init); 46 | } 47 | }); 48 | testServer.on('error', () => { 49 | reject(new Error('error')); 50 | }); 51 | testServer.on('exit', () => { 52 | reject(new Error('exit')); 53 | }); 54 | }); 55 | return testServer; 56 | } catch (err: any) { 57 | console.error(`TestServer wasn't created properly: ${err}`); 58 | throw err; 59 | } 60 | } 61 | 62 | export function testDeInit(testServer: ChildProcess) { 63 | testServer?.kill(); 64 | } 65 | 66 | async function cleanDb() { 67 | try { 68 | const initDbOptions = { 69 | engine: 70 | process.env.DATABASE_URL?.slice( 71 | 0, 72 | process.env.DATABASE_URL?.indexOf(':'), 73 | ) ?? 'postgres', 74 | params: process.env.DATABASE_URL ?? 'localhost', 75 | }; 76 | const initDb = dbModule.connect(initDbOptions); 77 | await initDb.executeSql( 78 | 'DROP SCHEMA "public" CASCADE; CREATE SCHEMA "public";', 79 | ); 80 | console.info(`Postgres database dropped`); 81 | } catch (e) { 82 | console.error(`Error during dropping postgres database: ${e}`); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tsconfig.dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "allowImportingTsExtensions": true 6 | }, 7 | "include": [ 8 | "build/**/*", 9 | "bin/**/*", 10 | "src/**/*", 11 | "test/**/*", 12 | "typings/**/*.d.ts", 13 | "Gruntfile.cts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "NodeNext", 4 | "strict": true, 5 | "strictFunctionTypes": false, 6 | "noImplicitThis": false, 7 | "noUnusedParameters": true, 8 | "noUnusedLocals": true, 9 | "outDir": "out/", 10 | "preserveConstEnums": true, 11 | "removeComments": true, 12 | "rootDir": "src", 13 | "sourceMap": true, 14 | "target": "es2022", 15 | "declaration": true, 16 | "skipLibCheck": true, 17 | "resolveJsonModule": true, 18 | "allowJs": true, 19 | "checkJs": true 20 | }, 21 | "include": [ 22 | "src/**/*", 23 | "typings/**/*.d.ts" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /typings/lf-to-abstract-sql.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@balena/lf-to-abstract-sql' { 2 | import type sbvrTypes from '@balena/sbvr-types'; 3 | import type { LFModel } from '@balena/sbvr-parser'; 4 | import type { AbstractSqlModel } from '@balena/abstract-sql-compiler'; 5 | export const LF2AbstractSQL: { 6 | createInstance: () => { 7 | match: (lfModel: LFModel, rule: 'Process') => AbstractSqlModel; 8 | addTypes: (types: typeof sbvrTypes.default) => void; 9 | reset: () => void; 10 | }; 11 | }; 12 | export const LF2AbstractSQLPrep: { 13 | match: (lfModel: LFModel, rule: 'Process') => LFModel; 14 | _extend(obj: object): typeof LF2AbstractSQLPrep; 15 | }; 16 | export const createTranslator: ( 17 | types: typeof sbvrTypes.default, 18 | ) => (lfModel: LFModel, rule: 'Process') => AbstractSqlModel; 19 | } 20 | -------------------------------------------------------------------------------- /typings/memoizee.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'memoizee/weak.js' { 2 | import type Memoize from 'memoizee'; 3 | 4 | type FirstArg = T extends (arg1: infer U) => any ? U : any; 5 | type RestArgs = T extends (arg1: any, ...args: infer U) => any ? U : any[]; 6 | 7 | export interface MemoizeWeakOptions any> { 8 | length?: number | false; 9 | maxAge?: number; 10 | max?: number; 11 | preFetch?: number | true; 12 | promise?: boolean; 13 | dispose?(value: any): void; 14 | async?: boolean; 15 | primitive?: boolean; 16 | normalizer?(firstArg: FirstArg, restArgs: RestArgs): any; 17 | resolvers?: Array<(arg: any) => any>; 18 | } 19 | 20 | function memoizeWeak any>( 21 | f: F, 22 | options?: MemoizeWeakOptions, 23 | ): F & Memoize.Memoized; 24 | export = memoizeWeak; 25 | } 26 | -------------------------------------------------------------------------------- /typings/sbvr-parser.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@balena/sbvr-parser' { 2 | export type LFModel = Array; 3 | export const SBVRParser: { 4 | matchAll: (seModel: string, rule: string) => LFModel; 5 | _extend(extension: T): typeof SBVRParser & T; 6 | initialize(): void; 7 | }; 8 | } 9 | --------------------------------------------------------------------------------