@balena/pinejs 15.0.0-true-boolean-911aca4062d3132ad3c34712014739b6849fa13a → 15.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (145) hide show
  1. package/.dockerignore +4 -0
  2. package/.github/workflows/flowzone.yml +21 -0
  3. package/.husky/pre-commit +4 -0
  4. package/.pinejs-cache.json +1 -0
  5. package/.resinci.yml +1 -0
  6. package/.versionbot/CHANGELOG.yml +9678 -2001
  7. package/CHANGELOG.md +2975 -2
  8. package/Dockerfile +14 -0
  9. package/Gruntfile.ts +3 -6
  10. package/README.md +10 -1
  11. package/VERSION +1 -0
  12. package/build/browser.ts +1 -1
  13. package/build/config.ts +0 -1
  14. package/docker-compose.npm-test.yml +11 -0
  15. package/docs/AdvancedUsage.md +77 -63
  16. package/docs/GettingStarted.md +90 -41
  17. package/docs/Migrations.md +102 -1
  18. package/docs/ProjectConfig.md +12 -21
  19. package/docs/Testing.md +7 -0
  20. package/out/bin/abstract-sql-compiler.js +17 -17
  21. package/out/bin/abstract-sql-compiler.js.map +1 -1
  22. package/out/bin/odata-compiler.js +23 -20
  23. package/out/bin/odata-compiler.js.map +1 -1
  24. package/out/bin/sbvr-compiler.js +22 -22
  25. package/out/bin/sbvr-compiler.js.map +1 -1
  26. package/out/bin/utils.d.ts +2 -2
  27. package/out/bin/utils.js +3 -3
  28. package/out/bin/utils.js.map +1 -1
  29. package/out/config-loader/config-loader.d.ts +9 -8
  30. package/out/config-loader/config-loader.js +135 -78
  31. package/out/config-loader/config-loader.js.map +1 -1
  32. package/out/config-loader/env.d.ts +41 -16
  33. package/out/config-loader/env.js +46 -2
  34. package/out/config-loader/env.js.map +1 -1
  35. package/out/data-server/sbvr-server.d.ts +2 -19
  36. package/out/data-server/sbvr-server.js +44 -38
  37. package/out/data-server/sbvr-server.js.map +1 -1
  38. package/out/database-layer/db.d.ts +32 -14
  39. package/out/database-layer/db.js +120 -41
  40. package/out/database-layer/db.js.map +1 -1
  41. package/out/express-emulator/express.js +10 -11
  42. package/out/express-emulator/express.js.map +1 -1
  43. package/out/http-transactions/transactions.d.ts +2 -18
  44. package/out/http-transactions/transactions.js +29 -21
  45. package/out/http-transactions/transactions.js.map +1 -1
  46. package/out/migrator/async.d.ts +7 -0
  47. package/out/migrator/async.js +168 -0
  48. package/out/migrator/async.js.map +1 -0
  49. package/out/migrator/migrations.sbvr +43 -0
  50. package/out/migrator/sync.d.ts +9 -0
  51. package/out/migrator/sync.js +106 -0
  52. package/out/migrator/sync.js.map +1 -0
  53. package/out/migrator/utils.d.ts +78 -0
  54. package/out/migrator/utils.js +283 -0
  55. package/out/migrator/utils.js.map +1 -0
  56. package/out/odata-metadata/odata-metadata-generator.js +10 -13
  57. package/out/odata-metadata/odata-metadata-generator.js.map +1 -1
  58. package/out/passport-pinejs/passport-pinejs.d.ts +1 -1
  59. package/out/passport-pinejs/passport-pinejs.js +8 -7
  60. package/out/passport-pinejs/passport-pinejs.js.map +1 -1
  61. package/out/pinejs-session-store/pinejs-session-store.d.ts +1 -1
  62. package/out/pinejs-session-store/pinejs-session-store.js +20 -6
  63. package/out/pinejs-session-store/pinejs-session-store.js.map +1 -1
  64. package/out/sbvr-api/abstract-sql.d.ts +3 -2
  65. package/out/sbvr-api/abstract-sql.js +9 -9
  66. package/out/sbvr-api/abstract-sql.js.map +1 -1
  67. package/out/sbvr-api/cached-compile.js +1 -1
  68. package/out/sbvr-api/cached-compile.js.map +1 -1
  69. package/out/sbvr-api/common-types.d.ts +6 -5
  70. package/out/sbvr-api/control-flow.d.ts +8 -1
  71. package/out/sbvr-api/control-flow.js +36 -9
  72. package/out/sbvr-api/control-flow.js.map +1 -1
  73. package/out/sbvr-api/errors.d.ts +47 -40
  74. package/out/sbvr-api/errors.js +78 -77
  75. package/out/sbvr-api/errors.js.map +1 -1
  76. package/out/sbvr-api/express-extension.d.ts +4 -0
  77. package/out/sbvr-api/hooks.d.ts +16 -15
  78. package/out/sbvr-api/hooks.js +74 -48
  79. package/out/sbvr-api/hooks.js.map +1 -1
  80. package/out/sbvr-api/odata-response.d.ts +2 -2
  81. package/out/sbvr-api/odata-response.js +28 -30
  82. package/out/sbvr-api/odata-response.js.map +1 -1
  83. package/out/sbvr-api/permissions.d.ts +17 -16
  84. package/out/sbvr-api/permissions.js +369 -304
  85. package/out/sbvr-api/permissions.js.map +1 -1
  86. package/out/sbvr-api/sbvr-utils.d.ts +33 -15
  87. package/out/sbvr-api/sbvr-utils.js +397 -235
  88. package/out/sbvr-api/sbvr-utils.js.map +1 -1
  89. package/out/sbvr-api/translations.d.ts +6 -0
  90. package/out/sbvr-api/translations.js +150 -0
  91. package/out/sbvr-api/translations.js.map +1 -0
  92. package/out/sbvr-api/uri-parser.d.ts +23 -17
  93. package/out/sbvr-api/uri-parser.js +33 -27
  94. package/out/sbvr-api/uri-parser.js.map +1 -1
  95. package/out/sbvr-api/user.sbvr +2 -0
  96. package/out/server-glue/module.d.ts +6 -6
  97. package/out/server-glue/module.js +4 -2
  98. package/out/server-glue/module.js.map +1 -1
  99. package/out/server-glue/server.js +5 -5
  100. package/out/server-glue/server.js.map +1 -1
  101. package/package.json +89 -73
  102. package/pinejs.png +0 -0
  103. package/repo.yml +9 -9
  104. package/src/bin/abstract-sql-compiler.ts +5 -7
  105. package/src/bin/odata-compiler.ts +11 -13
  106. package/src/bin/sbvr-compiler.ts +11 -17
  107. package/src/bin/utils.ts +3 -5
  108. package/src/config-loader/config-loader.ts +167 -53
  109. package/src/config-loader/env.ts +106 -6
  110. package/src/data-server/sbvr-server.js +44 -38
  111. package/src/database-layer/db.ts +205 -64
  112. package/src/express-emulator/express.js +10 -11
  113. package/src/http-transactions/transactions.js +29 -21
  114. package/src/migrator/async.ts +323 -0
  115. package/src/migrator/migrations.sbvr +43 -0
  116. package/src/migrator/sync.ts +152 -0
  117. package/src/migrator/utils.ts +458 -0
  118. package/src/odata-metadata/odata-metadata-generator.ts +12 -15
  119. package/src/passport-pinejs/passport-pinejs.ts +9 -7
  120. package/src/pinejs-session-store/pinejs-session-store.ts +15 -1
  121. package/src/sbvr-api/abstract-sql.ts +17 -14
  122. package/src/sbvr-api/common-types.ts +2 -1
  123. package/src/sbvr-api/control-flow.ts +45 -11
  124. package/src/sbvr-api/errors.ts +82 -77
  125. package/src/sbvr-api/express-extension.ts +6 -1
  126. package/src/sbvr-api/hooks.ts +123 -50
  127. package/src/sbvr-api/odata-response.ts +23 -28
  128. package/src/sbvr-api/permissions.ts +548 -415
  129. package/src/sbvr-api/sbvr-utils.ts +581 -259
  130. package/src/sbvr-api/translations.ts +248 -0
  131. package/src/sbvr-api/uri-parser.ts +63 -49
  132. package/src/sbvr-api/user.sbvr +2 -0
  133. package/src/server-glue/module.ts +16 -10
  134. package/src/server-glue/server.ts +5 -5
  135. package/tsconfig.dev.json +1 -0
  136. package/tsconfig.json +1 -2
  137. package/typings/lf-to-abstract-sql.d.ts +6 -9
  138. package/typings/memoizee.d.ts +1 -1
  139. package/.github/CODEOWNERS +0 -1
  140. package/circle.yml +0 -37
  141. package/docs/todo.txt +0 -22
  142. package/out/migrator/migrator.d.ts +0 -20
  143. package/out/migrator/migrator.js +0 -188
  144. package/out/migrator/migrator.js.map +0 -1
  145. package/src/migrator/migrator.ts +0 -286
@@ -12,7 +12,6 @@ declare global {
12
12
  }
13
13
  }
14
14
 
15
- import * as Bluebird from 'bluebird';
16
15
  import * as _ from 'lodash';
17
16
 
18
17
  import { TypedError } from 'typed-error';
@@ -24,17 +23,19 @@ import { version as AbstractSQLCompilerVersion } from '@balena/abstract-sql-comp
24
23
  import * as LF2AbstractSQL from '@balena/lf-to-abstract-sql';
25
24
 
26
25
  import {
26
+ ODataBinds,
27
27
  odataNameToSqlName,
28
28
  sqlNameToODataName,
29
29
  SupportedMethod,
30
30
  } from '@balena/odata-to-abstract-sql';
31
- import * as sbvrTypes from '@balena/sbvr-types';
31
+ import sbvrTypes from '@balena/sbvr-types';
32
32
  import deepFreeze = require('deep-freeze');
33
33
  import { PinejsClientCore, PromiseResultTypes } from 'pinejs-client-core';
34
34
 
35
35
  import { ExtendedSBVRParser } from '../extended-sbvr-parser/extended-sbvr-parser';
36
36
 
37
- import * as migrator from '../migrator/migrator';
37
+ import * as asyncMigrator from '../migrator/async';
38
+ import * as syncMigrator from '../migrator/sync';
38
39
  import { generateODataMetadata } from '../odata-metadata/odata-metadata-generator';
39
40
 
40
41
  // tslint:disable-next-line:no-var-requires
@@ -57,12 +58,14 @@ import {
57
58
  UnauthorizedError,
58
59
  } from './errors';
59
60
  import * as uriParser from './uri-parser';
61
+ export { ODataRequest } from './uri-parser';
60
62
  import {
61
63
  HookReq,
62
64
  HookArgs,
63
65
  rollbackRequestHooks,
64
66
  getHooks,
65
67
  runHooks,
68
+ InstantiatedHooks,
66
69
  } from './hooks';
67
70
  export {
68
71
  HookReq,
@@ -72,13 +75,11 @@ export {
72
75
  addPureHook,
73
76
  addSideEffectHook,
74
77
  } from './hooks';
75
- // TODO-MAJOR: Remove
76
- export type HookRequest = uriParser.ODataRequest;
77
78
 
78
79
  import memoizeWeak = require('memoizee/weak');
79
80
  import * as controlFlow from './control-flow';
80
81
 
81
- export let db = (undefined as any) as Db.Database;
82
+ export let db = undefined as any as Db.Database;
82
83
 
83
84
  export { sbvrTypes };
84
85
 
@@ -92,6 +93,7 @@ import {
92
93
  export { resolveOdataBind } from './abstract-sql';
93
94
  import * as odataResponse from './odata-response';
94
95
  import { env } from '../server-glue/module';
96
+ import { translateAbstractSqlModel } from './translations';
95
97
 
96
98
  const LF2AbstractSQLTranslator = LF2AbstractSQL.createTranslator(sbvrTypes);
97
99
  const LF2AbstractSQLTranslatorVersion = `${LF2AbstractSQLVersion}+${sbvrTypesVersion}`;
@@ -102,14 +104,18 @@ export type ExecutableModel =
102
104
 
103
105
  interface CompiledModel {
104
106
  vocab: string;
105
- se?: string;
106
- lf?: LFModel;
107
+ translateTo?: string;
108
+ resourceRenames?: ReturnType<typeof translateAbstractSqlModel>;
109
+ se?: string | undefined;
110
+ lf?: LFModel | undefined;
107
111
  abstractSql: AbstractSQLCompiler.AbstractSqlModel;
108
- sql: AbstractSQLCompiler.SqlModel;
112
+ sql?: AbstractSQLCompiler.SqlModel;
109
113
  odataMetadata: ReturnType<typeof generateODataMetadata>;
110
114
  }
111
115
  const models: {
112
- [vocabulary: string]: CompiledModel;
116
+ [vocabulary: string]: CompiledModel & {
117
+ versions: string[];
118
+ };
113
119
  } = {};
114
120
 
115
121
  export interface Actor {
@@ -123,14 +129,16 @@ export interface User extends Actor {
123
129
 
124
130
  export interface ApiKey extends Actor {
125
131
  key: string;
126
- actor?: number;
132
+ actor: number;
127
133
  }
128
134
 
129
- interface Response {
130
- status?: number;
131
- headers?: {
132
- [headerName: string]: any;
133
- };
135
+ export interface Response {
136
+ statusCode: number;
137
+ headers?:
138
+ | {
139
+ [headerName: string]: any;
140
+ }
141
+ | undefined;
134
142
  body?: AnyObject | string;
135
143
  }
136
144
 
@@ -170,13 +178,12 @@ const memoizedResolveNavigationResource = memoizeWeak(
170
178
  resourceName: string,
171
179
  navigationName: string,
172
180
  ): string => {
173
- const navigation = _(odataNameToSqlName(navigationName))
181
+ const navigation = odataNameToSqlName(navigationName)
174
182
  .split('-')
175
183
  .flatMap((namePart) =>
176
184
  memoizedResolvedSynonym(abstractSqlModel, namePart).split('-'),
177
- )
178
- .concat('$')
179
- .value();
185
+ );
186
+ navigation.push('$');
180
187
  const resolvedResourceName = memoizedResolvedSynonym(
181
188
  abstractSqlModel,
182
189
  resourceName,
@@ -226,13 +233,14 @@ const prettifyConstraintError = (
226
233
  if (err instanceof db.UniqueConstraintError) {
227
234
  switch (db.engine) {
228
235
  case 'mysql':
229
- matches = /ER_DUP_ENTRY: Duplicate entry '.*?[^\\]' for key '(.*?[^\\])'/.exec(
230
- err.message,
231
- );
236
+ matches =
237
+ /ER_DUP_ENTRY: Duplicate entry '.*?[^\\]' for key '(.*?[^\\])'/.exec(
238
+ err.message,
239
+ );
232
240
  break;
233
241
  case 'postgres':
234
242
  const resourceName = resolveSynonym(request);
235
- const abstractSqlModel = getAbstractSqlModel(request);
243
+ const abstractSqlModel = getFinalAbstractSqlModel(request);
236
244
  matches = new RegExp(
237
245
  '"' + abstractSqlModel.tables[resourceName].name + '_(.*?)_key"',
238
246
  ).exec(err.message);
@@ -258,13 +266,14 @@ const prettifyConstraintError = (
258
266
  if (err instanceof db.ForeignKeyConstraintError) {
259
267
  switch (db.engine) {
260
268
  case 'mysql':
261
- matches = /ER_ROW_IS_REFERENCED_: Cannot delete or update a parent row: a foreign key constraint fails \(".*?"\.(".*?").*/.exec(
262
- err.message,
263
- );
269
+ matches =
270
+ /ER_ROW_IS_REFERENCED_: Cannot delete or update a parent row: a foreign key constraint fails \(".*?"\.(".*?").*/.exec(
271
+ err.message,
272
+ );
264
273
  break;
265
274
  case 'postgres':
266
275
  const resourceName = resolveSynonym(request);
267
- const abstractSqlModel = getAbstractSqlModel(request);
276
+ const abstractSqlModel = getFinalAbstractSqlModel(request);
268
277
  const tableName = abstractSqlModel.tables[resourceName].name;
269
278
  matches = new RegExp(
270
279
  '"' +
@@ -296,7 +305,7 @@ const prettifyConstraintError = (
296
305
 
297
306
  if (err instanceof db.CheckConstraintError) {
298
307
  const resourceName = resolveSynonym(request);
299
- const abstractSqlModel = getAbstractSqlModel(request);
308
+ const abstractSqlModel = getFinalAbstractSqlModel(request);
300
309
  const table = abstractSqlModel.tables[resourceName];
301
310
  if (table.checks) {
302
311
  switch (db.engine) {
@@ -327,13 +336,104 @@ const prettifyConstraintError = (
327
336
  }
328
337
  };
329
338
 
339
+ let cachedIsModelNew: Set<string> | undefined;
340
+
341
+ /**
342
+ *
343
+ * @param tx database transaction - Needs to be executed in one contained config-loader transaction
344
+ * @param modelName name of the model to check the database for existence
345
+ * @returns true when the model was existing in database before pine config-loader is executed,
346
+ * false when the model was not existing before config-loader was executed.
347
+ *
348
+ * ATTENTION: This function needs to be executed in the same transaction as all model manipulating
349
+ * operations are executed in (as migration table operations). Otherwise it's possible that an INSERT INTO "model"
350
+ * statement executes and succeeds. Then afterwards execution fails and the isModelNew request would return `false`.
351
+ * In the case of migrations the table wouldn't have an entry and therefore it would try to run all the historical
352
+ * migrations on a new model.
353
+ */
354
+
355
+ export const isModelNew = async (
356
+ tx: Db.Tx,
357
+ modelName: string,
358
+ ): Promise<boolean> => {
359
+ const result = await tx.tableList("name = 'model'");
360
+ if (result.rows.length === 0) {
361
+ return true;
362
+ }
363
+ if (cachedIsModelNew == null) {
364
+ const { rows } = await tx.executeSql(
365
+ `SELECT "is of-vocabulary" FROM "model";`,
366
+ );
367
+ cachedIsModelNew = new Set<string>(
368
+ rows.map((row) => row['is of-vocabulary']),
369
+ );
370
+ }
371
+
372
+ return !cachedIsModelNew.has(modelName);
373
+ };
374
+
375
+ const bindsForAffectedIds = (
376
+ bindings: AbstractSQLCompiler.Binding[],
377
+ request?: Pick<
378
+ uriParser.ODataRequest,
379
+ | 'vocabulary'
380
+ | 'abstractSqlModel'
381
+ | 'method'
382
+ | 'resourceName'
383
+ | 'affectedIds'
384
+ >,
385
+ ) => {
386
+ if (request?.affectedIds == null) {
387
+ return {};
388
+ }
389
+
390
+ const tableName =
391
+ getAbstractSqlModel(request).tables[resolveSynonym(request)].name;
392
+
393
+ // If we're deleting the affected IDs then we can't narrow our rule to
394
+ // those IDs that are now missing
395
+ const isDelete = request.method === 'DELETE';
396
+
397
+ const odataBinds: { [key: string]: any } = {};
398
+ for (const bind of bindings) {
399
+ if (
400
+ bind.length !== 2 ||
401
+ bind[0] !== 'Bind' ||
402
+ typeof bind[1] !== 'string'
403
+ ) {
404
+ continue;
405
+ }
406
+
407
+ const bindName = bind[1];
408
+ if (!isDelete && bindName === tableName) {
409
+ odataBinds[bindName] = ['Text', `{${request.affectedIds}}`];
410
+ } else {
411
+ odataBinds[bindName] = ['Text', '{}'];
412
+ }
413
+ }
414
+
415
+ return odataBinds;
416
+ };
417
+
330
418
  export const validateModel = async (
331
419
  tx: Db.Tx,
332
420
  modelName: string,
333
- request?: uriParser.ODataRequest,
421
+ request?: Pick<
422
+ uriParser.ODataRequest,
423
+ | 'abstractSqlQuery'
424
+ | 'modifiedFields'
425
+ | 'method'
426
+ | 'vocabulary'
427
+ | 'resourceName'
428
+ | 'affectedIds'
429
+ >,
334
430
  ): Promise<void> => {
431
+ const { sql } = models[modelName];
432
+ if (!sql) {
433
+ throw new Error(`Tried to validate a virtual model: '${modelName}'`);
434
+ }
335
435
  await Promise.all(
336
- models[modelName].sql.rules.map(async (rule) => {
436
+ sql.rules.map(async (rule) => {
337
437
  if (!isRuleAffected(rule, request)) {
338
438
  // If none of the fields intersect we don't need to run the rule! :D
339
439
  return;
@@ -342,7 +442,16 @@ export const validateModel = async (
342
442
  const values = await getAndCheckBindValues(
343
443
  {
344
444
  vocabulary: modelName,
345
- odataBinds: [],
445
+ // TODO: `odataBinds` is of type `ODataBinds`, which is an
446
+ // array with extra arbitrary fields. `getAndCheckBindValues`
447
+ // accepts that and also a pure object form for this
448
+ // argument. Given how arrays have predefined symbols,
449
+ // `bindsForAffectedIds` cannot return an `ODataBinds`.
450
+ // Both `ODataBinds` and `getAndCheckBindValues` should be
451
+ // fixed to accept a pure object with both string and
452
+ // numerical keys, for named and positional binds
453
+ // respectively
454
+ odataBinds: bindsForAffectedIds(rule.bindings, request) as any,
346
455
  values: {},
347
456
  engine: db.engine,
348
457
  },
@@ -384,11 +493,19 @@ export const generateSqlModel = (
384
493
  () => AbstractSQLCompiler[targetDatabaseEngine].compileSchema(abstractSql),
385
494
  );
386
495
 
387
- export const generateModels = (
496
+ export function generateModels(
497
+ model: ExecutableModel & { translateTo?: undefined },
498
+ targetDatabaseEngine: AbstractSQLCompiler.Engines,
499
+ ): RequiredField<CompiledModel, 'sql'>;
500
+ export function generateModels(
501
+ model: ExecutableModel,
502
+ targetDatabaseEngine: AbstractSQLCompiler.Engines,
503
+ ): CompiledModel;
504
+ export function generateModels(
388
505
  model: ExecutableModel,
389
506
  targetDatabaseEngine: AbstractSQLCompiler.Engines,
390
- ): CompiledModel => {
391
- const { apiRoot: vocab, modelText: se } = model;
507
+ ): CompiledModel {
508
+ const { apiRoot: vocab, modelText: se, translateTo, translations } = model;
392
509
  let { abstractSql: maybeAbstractSql } = model;
393
510
 
394
511
  let lf: ReturnType<typeof generateLfModel> | undefined;
@@ -416,16 +533,41 @@ export const generateModels = (
416
533
  () => generateODataMetadata(vocab, abstractSql),
417
534
  );
418
535
 
419
- let sql: ReturnType<AbstractSQLCompiler.EngineInstance['compileSchema']>;
420
- try {
421
- sql = generateSqlModel(abstractSql, targetDatabaseEngine);
422
- } catch (e) {
423
- console.error(`Error compiling model '${vocab}':`, e);
424
- throw new Error(`Error compiling model '${vocab}': ` + e);
536
+ let sql: AbstractSQLCompiler.SqlModel | undefined;
537
+ let resourceRenames: ReturnType<typeof translateAbstractSqlModel> | undefined;
538
+
539
+ if (translateTo != null) {
540
+ resourceRenames = translateAbstractSqlModel(
541
+ abstractSql,
542
+ models[translateTo].abstractSql,
543
+ model.apiRoot,
544
+ translateTo,
545
+ translations,
546
+ );
547
+ } else {
548
+ for (const [key, table] of Object.entries(abstractSql.tables)) {
549
+ // Alias the current version so it can be explicitly referenced
550
+ abstractSql.tables[`${key}$${model.apiRoot}`] = { ...table };
551
+ }
552
+ try {
553
+ sql = generateSqlModel(abstractSql, targetDatabaseEngine);
554
+ } catch (e) {
555
+ console.error(`Error compiling model '${vocab}':`, e);
556
+ throw new Error(`Error compiling model '${vocab}': ` + e);
557
+ }
425
558
  }
426
559
 
427
- return { vocab, se, lf, abstractSql, sql, odataMetadata };
428
- };
560
+ return {
561
+ vocab,
562
+ translateTo,
563
+ resourceRenames,
564
+ se,
565
+ lf,
566
+ abstractSql,
567
+ sql,
568
+ odataMetadata,
569
+ };
570
+ }
429
571
 
430
572
  export const executeModel = (
431
573
  tx: Db.Tx,
@@ -441,46 +583,60 @@ export const executeModels = async (
441
583
  execModels.map(async (model) => {
442
584
  const { apiRoot } = model;
443
585
 
444
- await migrator.run(tx, model);
586
+ await syncMigrator.run(tx, model);
445
587
  const compiledModel = generateModels(model, db.engine);
446
588
 
447
- // Create tables related to terms and fact types
448
- // Run statements sequentially, as the order of the CREATE TABLE statements matters (eg. for foreign keys).
449
- for (const createStatement of compiledModel.sql.createSchema) {
450
- const promise = tx.executeSql(createStatement);
451
- if (db.engine === 'websql') {
452
- promise.catch((err) => {
453
- console.warn(
454
- "Ignoring errors in the create table statements for websql as it doesn't support CREATE IF NOT EXISTS",
455
- err,
456
- );
457
- });
589
+ if (compiledModel.sql) {
590
+ // Create tables related to terms and fact types
591
+ // Run statements sequentially, as the order of the CREATE TABLE statements matters (eg. for foreign keys).
592
+ for (const createStatement of compiledModel.sql.createSchema) {
593
+ const promise = tx.executeSql(createStatement);
594
+ if (db.engine === 'websql') {
595
+ promise.catch((err) => {
596
+ console.warn(
597
+ "Ignoring errors in the create table statements for websql as it doesn't support CREATE IF NOT EXISTS",
598
+ err,
599
+ );
600
+ });
601
+ }
602
+ await promise;
458
603
  }
459
- await promise;
460
604
  }
461
- await migrator.postRun(tx, model);
605
+ await syncMigrator.postRun(tx, model);
462
606
 
463
607
  odataResponse.prepareModel(compiledModel.abstractSql);
464
608
  deepFreeze(compiledModel.abstractSql);
465
- models[apiRoot] = compiledModel;
609
+
610
+ const versions = [apiRoot];
611
+ if (compiledModel.translateTo != null) {
612
+ versions.push(...models[compiledModel.translateTo].versions);
613
+ }
614
+ models[apiRoot] = {
615
+ ...compiledModel,
616
+ versions,
617
+ };
466
618
 
467
619
  // Validate the [empty] model according to the rules.
468
620
  // This may eventually lead to entering obligatory data.
469
621
  // For the moment it blocks such models from execution.
470
- await validateModel(tx, apiRoot);
622
+ if (compiledModel.sql) {
623
+ await validateModel(tx, apiRoot);
624
+ }
471
625
 
472
626
  // TODO: Can we do this without the cast?
473
627
  api[apiRoot] = new PinejsClient('/' + apiRoot + '/') as LoggingClient;
474
628
  api[apiRoot].logger = { ...console };
475
629
  if (model.logging != null) {
476
630
  const defaultSetting = model.logging?.default ?? true;
631
+ const { logger } = api[apiRoot];
477
632
  for (const k of Object.keys(model.logging)) {
478
633
  const key = k as keyof Console;
479
634
  if (
480
- typeof api[apiRoot].logger[key] === 'function' &&
635
+ key !== 'Console' &&
636
+ typeof logger[key] === 'function' &&
481
637
  !(model.logging?.[key] ?? defaultSetting)
482
638
  ) {
483
- api[apiRoot].logger[key] = _.noop;
639
+ logger[key] = _.noop;
484
640
  }
485
641
  }
486
642
  }
@@ -525,7 +681,10 @@ export const executeModels = async (
525
681
  let uri = '/dev/model';
526
682
  const body: AnyObject = {
527
683
  is_of__vocabulary: model.vocab,
528
- model_value: model[modelType],
684
+ model_value:
685
+ typeof model[modelType] === 'string'
686
+ ? { value: model[modelType] }
687
+ : model[modelType],
529
688
  model_type: modelType,
530
689
  };
531
690
  const id = result?.[0]?.id;
@@ -545,7 +704,13 @@ export const executeModels = async (
545
704
  );
546
705
  }),
547
706
  );
548
- } catch (err) {
707
+
708
+ await Promise.all(
709
+ execModels.map(async (model) => {
710
+ await asyncMigrator.run(tx, model);
711
+ }),
712
+ );
713
+ } catch (err: any) {
549
714
  await Promise.all(
550
715
  execModels.map(async ({ apiRoot }) => {
551
716
  await cleanupModel(apiRoot);
@@ -561,27 +726,30 @@ const cleanupModel = (vocab: string) => {
561
726
  };
562
727
 
563
728
  export const deleteModel = async (vocabulary: string) => {
564
- await db.transaction(async (tx) => {
565
- const dropStatements: Array<Promise<any>> = models[
566
- vocabulary
567
- ].sql.dropSchema.map((dropStatement) => tx.executeSql(dropStatement));
568
- await Promise.all(
569
- dropStatements.concat([
570
- api.dev.delete({
571
- resource: 'model',
572
- passthrough: {
573
- tx,
574
- req: permissions.root,
575
- },
576
- options: {
577
- $filter: {
578
- is_of__vocabulary: vocabulary,
729
+ const { sql } = models[vocabulary];
730
+ if (sql) {
731
+ await db.transaction(async (tx) => {
732
+ const dropStatements: Array<Promise<any>> = sql.dropSchema.map(
733
+ (dropStatement) => tx.executeSql(dropStatement),
734
+ );
735
+ await Promise.all(
736
+ dropStatements.concat([
737
+ api.dev.delete({
738
+ resource: 'model',
739
+ passthrough: {
740
+ tx,
741
+ req: permissions.root,
579
742
  },
580
- },
581
- }),
582
- ]),
583
- );
584
- });
743
+ options: {
744
+ $filter: {
745
+ is_of__vocabulary: vocabulary,
746
+ },
747
+ },
748
+ }),
749
+ ]),
750
+ );
751
+ });
752
+ }
585
753
  await cleanupModel(vocabulary);
586
754
  };
587
755
 
@@ -667,7 +835,7 @@ export const runRule = (() => {
667
835
  }
668
836
  const ruleBody = ruleAbs.find((node) => node[0] === 'Body') as [
669
837
  'Body',
670
- ...any[]
838
+ ...any[],
671
839
  ];
672
840
  if (
673
841
  ruleBody[1][0] === 'Not' &&
@@ -721,7 +889,7 @@ export const runRule = (() => {
721
889
  const values = await getAndCheckBindValues(
722
890
  {
723
891
  vocabulary: vocab,
724
- odataBinds: [],
892
+ odataBinds: [] as any as ODataBinds,
725
893
  values: {},
726
894
  engine: db.engine,
727
895
  },
@@ -767,6 +935,7 @@ export type Passthrough = AnyObject & {
767
935
  };
768
936
 
769
937
  export class PinejsClient extends PinejsClientCore<PinejsClient> {
938
+ // @ts-expect-error This is actually assigned by `super` so it is always declared but that isn't detected here
770
939
  public passthrough: Passthrough;
771
940
  public async _request({
772
941
  method,
@@ -811,7 +980,7 @@ export const runURI = async (
811
980
  let user: User | undefined;
812
981
  let apiKey: ApiKey | undefined;
813
982
 
814
- if (req != null && _.isObject(req)) {
983
+ if (req != null && typeof req === 'object') {
815
984
  user = req.user;
816
985
  apiKey = req.apiKey;
817
986
  } else {
@@ -853,15 +1022,15 @@ export const runURI = async (
853
1022
  throw response;
854
1023
  }
855
1024
 
856
- const { body: responseBody, status } = response as Response;
1025
+ const { body: responseBody, statusCode, headers } = response as Response;
857
1026
 
858
- if (status != null && status >= 400) {
1027
+ if (statusCode != null && statusCode >= 400) {
859
1028
  const ErrorClass =
860
- statusCodeToError[status as keyof typeof statusCodeToError];
1029
+ statusCodeToError[statusCode as keyof typeof statusCodeToError];
861
1030
  if (ErrorClass != null) {
862
- throw new ErrorClass(undefined, responseBody);
1031
+ throw new ErrorClass(undefined, responseBody, headers);
863
1032
  }
864
- throw new HttpError(status, undefined, responseBody);
1033
+ throw new HttpError(statusCode, undefined, responseBody, headers);
865
1034
  }
866
1035
 
867
1036
  return responseBody as AnyObject | undefined;
@@ -873,12 +1042,28 @@ export const getAbstractSqlModel = (
873
1042
  return (request.abstractSqlModel ??= models[request.vocabulary].abstractSql);
874
1043
  };
875
1044
 
1045
+ const getFinalAbstractSqlModel = (
1046
+ request: Pick<
1047
+ uriParser.ODataRequest,
1048
+ 'translateVersions' | 'finalAbstractSqlModel'
1049
+ >,
1050
+ ): AbstractSQLCompiler.AbstractSqlModel => {
1051
+ const finalModel = _.last(request.translateVersions)!;
1052
+ return (request.finalAbstractSqlModel ??= models[finalModel].abstractSql);
1053
+ };
1054
+
876
1055
  const getIdField = (
877
1056
  request: Pick<
878
1057
  uriParser.ODataRequest,
879
- 'vocabulary' | 'abstractSqlModel' | 'resourceName'
1058
+ | 'translateVersions'
1059
+ | 'finalAbstractSqlModel'
1060
+ | 'abstractSqlModel'
1061
+ | 'resourceName'
1062
+ | 'vocabulary'
880
1063
  >,
881
- ) => getAbstractSqlModel(request).tables[resolveSynonym(request)].idField;
1064
+ ) =>
1065
+ // TODO: Should resolveSynonym also be using the finalAbstractSqlModel?
1066
+ getFinalAbstractSqlModel(request).tables[resolveSynonym(request)].idField;
882
1067
 
883
1068
  export const getAffectedIds = async (
884
1069
  args: HookArgs & {
@@ -920,40 +1105,50 @@ const $getAffectedIds = async ({
920
1105
  }
921
1106
  // We reparse to make sure we get a clean odataQuery, without permissions already added
922
1107
  // And we use the request's url rather than the req for things like batch where the req url is ../$batch
923
- request = await uriParser.parseOData({
924
- method: request.method,
925
- url: `/${request.vocabulary}${request.url}`,
926
- });
1108
+ const parsedRequest: uriParser.ParsedODataRequest &
1109
+ Partial<Pick<uriParser.ODataRequest, 'engine' | 'translateVersions'>> =
1110
+ await uriParser.parseOData({
1111
+ method: request.method,
1112
+ url: `/${request.vocabulary}${request.url}`,
1113
+ });
927
1114
 
928
- request.engine = db.engine;
929
- const abstractSqlModel = getAbstractSqlModel(request);
930
- const resourceName = resolveSynonym(request);
1115
+ parsedRequest.engine = request.engine;
1116
+ parsedRequest.translateVersions = request.translateVersions;
1117
+ // Mark that the engine is required now that we've set it
1118
+ let affectedRequest: uriParser.ODataRequest = parsedRequest as RequiredField<
1119
+ typeof parsedRequest,
1120
+ 'engine' | 'translateVersions'
1121
+ >;
1122
+ const abstractSqlModel = getAbstractSqlModel(affectedRequest);
1123
+ const resourceName = resolveSynonym(affectedRequest);
931
1124
  const resourceTable = abstractSqlModel.tables[resourceName];
932
1125
  if (resourceTable == null) {
933
- throw new Error('Unknown resource: ' + request.resourceName);
1126
+ throw new Error('Unknown resource: ' + affectedRequest.resourceName);
934
1127
  }
935
1128
  const { idField } = resourceTable;
936
1129
 
937
- request.odataQuery.options ??= {};
938
- request.odataQuery.options.$select = {
1130
+ affectedRequest.odataQuery.options ??= {};
1131
+ affectedRequest.odataQuery.options.$select = {
939
1132
  properties: [{ name: idField }],
940
1133
  };
941
1134
 
942
1135
  // Delete any $expand that might exist as they're ignored on non-GETs but we're converting this request to a GET
943
- delete request.odataQuery.options.$expand;
1136
+ delete affectedRequest.odataQuery.options.$expand;
944
1137
 
945
- await permissions.addPermissions(req, request);
1138
+ await permissions.addPermissions(req, affectedRequest);
946
1139
 
947
- request.method = 'GET';
1140
+ affectedRequest.method = 'GET';
948
1141
 
949
- request = uriParser.translateUri(request);
950
- request = compileRequest(request);
1142
+ affectedRequest = uriParser.translateUri(affectedRequest);
1143
+ affectedRequest = compileRequest(affectedRequest);
951
1144
 
952
1145
  let result;
953
1146
  if (tx != null) {
954
- result = await runQuery(tx, request);
1147
+ result = await runQuery(tx, affectedRequest);
955
1148
  } else {
956
- result = await runTransaction(req, (newTx) => runQuery(newTx, request));
1149
+ result = await runTransaction(req, affectedRequest, (newTx) =>
1150
+ runQuery(newTx, affectedRequest),
1151
+ );
957
1152
  }
958
1153
  return result.rows.map((row) => row[idField]);
959
1154
  };
@@ -965,10 +1160,18 @@ const runODataRequest = (req: Express.Request, vocabulary: string) => {
965
1160
 
966
1161
  // Get the hooks for the current method/vocabulary as we know it,
967
1162
  // in order to run PREPARSE hooks, before parsing gets us more info
968
- const reqHooks = getHooks({
969
- method: req.method as SupportedMethod,
970
- vocabulary,
971
- });
1163
+ const { versions } = models[vocabulary];
1164
+ const reqHooks = versions.map((version): [string, InstantiatedHooks] => [
1165
+ version,
1166
+ getHooks(
1167
+ {
1168
+ method: req.method as SupportedMethod,
1169
+ vocabulary: version,
1170
+ },
1171
+ // Only include the `all` vocab for the first model version
1172
+ version === versions[0],
1173
+ ),
1174
+ ]);
972
1175
 
973
1176
  const transactions: Db.Tx[] = [];
974
1177
  const tryCancelRequest = () => {
@@ -994,11 +1197,11 @@ const runODataRequest = (req: Express.Request, vocabulary: string) => {
994
1197
  req.tx.on('rollback', tryCancelRequest);
995
1198
  }
996
1199
 
997
- const mapSeries = controlFlow.getMappingFn(req.headers);
1200
+ const mappingFn = controlFlow.getMappingFn(req.headers);
998
1201
 
999
1202
  return {
1000
1203
  tryCancelRequest,
1001
- promise: (async () => {
1204
+ promise: (async (): Promise<Array<Response | Response[] | HttpError>> => {
1002
1205
  await runHooks('PREPARSE', reqHooks, { req, tx: req.tx });
1003
1206
  let requests: uriParser.UnparsedRequest[];
1004
1207
  // Check if it is a single request or a batch
@@ -1009,20 +1212,54 @@ const runODataRequest = (req: Express.Request, vocabulary: string) => {
1009
1212
  requests = [{ method, url, data: body }];
1010
1213
  }
1011
1214
 
1012
- const prepareRequest = async ($request: uriParser.ODataRequest) => {
1013
- $request.engine = db.engine;
1014
- // Get the full hooks list now that we can.
1015
- $request.hooks = getHooks($request);
1215
+ const prepareRequest = async (
1216
+ parsedRequest: uriParser.ParsedODataRequest &
1217
+ Partial<Pick<uriParser.ODataRequest, 'engine' | 'translateVersions'>>,
1218
+ ): Promise<uriParser.ODataRequest> => {
1219
+ parsedRequest.engine = db.engine;
1220
+ parsedRequest.translateVersions = [...versions];
1221
+ // Mark that the engine/translateVersions is required now that we've set it
1222
+ const $request: uriParser.ODataRequest = parsedRequest as RequiredField<
1223
+ typeof parsedRequest,
1224
+ 'engine' | 'translateVersions'
1225
+ >;
1016
1226
  // Add/check the relevant permissions
1017
1227
  try {
1018
- await runHooks('POSTPARSE', $request.hooks, {
1019
- req,
1020
- request: $request,
1021
- tx: req.tx,
1022
- });
1228
+ $request.hooks = [];
1229
+ for (const version of versions) {
1230
+ // We get the hooks list between each `runHooks` so that any resource renames will be used
1231
+ // when getting hooks for later versions
1232
+ const hooks: [string, InstantiatedHooks] = [
1233
+ version,
1234
+ getHooks(
1235
+ {
1236
+ resourceName: $request.resourceName,
1237
+ vocabulary: version,
1238
+ method: $request.method,
1239
+ },
1240
+ // Only include the `all` vocab for the first model version
1241
+ version === versions[0],
1242
+ ),
1243
+ ];
1244
+ $request.hooks.push(hooks);
1245
+ await runHooks('POSTPARSE', [hooks], {
1246
+ req,
1247
+ request: $request,
1248
+ tx: req.tx,
1249
+ });
1250
+ const { resourceRenames } = models[version];
1251
+ if (resourceRenames) {
1252
+ const resourceName = resolveSynonym($request);
1253
+ if (resourceRenames[resourceName]) {
1254
+ $request.resourceName = sqlNameToODataName(
1255
+ resourceRenames[resourceName],
1256
+ );
1257
+ }
1258
+ }
1259
+ }
1023
1260
  const translatedRequest = await uriParser.translateUri($request);
1024
1261
  return await compileRequest(translatedRequest);
1025
- } catch (err) {
1262
+ } catch (err: any) {
1026
1263
  rollbackRequestHooks(reqHooks);
1027
1264
  rollbackRequestHooks($request.hooks);
1028
1265
  throw err;
@@ -1030,55 +1267,62 @@ const runODataRequest = (req: Express.Request, vocabulary: string) => {
1030
1267
  };
1031
1268
 
1032
1269
  // Parse the OData requests
1033
- const results = await mapSeries(requests, async (requestPart) => {
1034
- let request = await uriParser.parseOData(requestPart);
1270
+ const results = await mappingFn(requests, async (requestPart) => {
1271
+ const parsedRequest = await uriParser.parseOData(requestPart);
1035
1272
 
1036
- if (Array.isArray(request)) {
1037
- request = await Bluebird.mapSeries(request, prepareRequest);
1273
+ let request: uriParser.ODataRequest | uriParser.ODataRequest[];
1274
+ if (Array.isArray(parsedRequest)) {
1275
+ request = await controlFlow.mapSeries(parsedRequest, prepareRequest);
1038
1276
  } else {
1039
- request = await prepareRequest(request);
1277
+ request = await prepareRequest(parsedRequest);
1040
1278
  }
1041
1279
  // Run the request in its own transaction
1042
- return await runTransaction<Response | Response[]>(req, async (tx) => {
1043
- transactions.push(tx);
1044
- tx.on('rollback', () => {
1045
- rollbackRequestHooks(reqHooks);
1280
+ return await runTransaction<Response | Response[]>(
1281
+ req,
1282
+ request,
1283
+ async (tx) => {
1284
+ transactions.push(tx);
1285
+ tx.on('rollback', () => {
1286
+ rollbackRequestHooks(reqHooks);
1287
+ if (Array.isArray(request)) {
1288
+ for (const { hooks } of request) {
1289
+ rollbackRequestHooks(hooks);
1290
+ }
1291
+ } else {
1292
+ rollbackRequestHooks(request.hooks);
1293
+ }
1294
+ });
1046
1295
  if (Array.isArray(request)) {
1047
- request.forEach(({ hooks }) => {
1048
- rollbackRequestHooks(hooks);
1049
- });
1296
+ const changeSetResults = new Map<number, Response>();
1297
+ const changeSetRunner = runChangeSet(req, tx);
1298
+ for (const r of request) {
1299
+ await changeSetRunner(changeSetResults, r);
1300
+ }
1301
+ return Array.from(changeSetResults.values());
1050
1302
  } else {
1051
- rollbackRequestHooks(request.hooks);
1052
- }
1053
- });
1054
- if (Array.isArray(request)) {
1055
- const changeSetResults = new Map<number, Response>();
1056
- const changeSetRunner = runChangeSet(req, tx);
1057
- for (const r of request) {
1058
- await changeSetRunner(changeSetResults, r);
1303
+ return await runRequest(req, tx, request);
1059
1304
  }
1060
- return Array.from(changeSetResults.values());
1061
- } else {
1062
- return await runRequest(req, tx, request);
1063
- }
1064
- });
1305
+ },
1306
+ );
1065
1307
  });
1066
1308
 
1067
- const responses = results.map((result) => {
1068
- if (_.isError(result)) {
1069
- return convertToHttpError(result);
1070
- } else {
1071
- if (
1072
- !Array.isArray(result) &&
1073
- result.body == null &&
1074
- result.status == null
1075
- ) {
1076
- console.error('No status or body set', req.url, responses);
1077
- return new InternalRequestError();
1309
+ const responses = results.map(
1310
+ (result): Response | Response[] | HttpError => {
1311
+ if (_.isError(result)) {
1312
+ return convertToHttpError(result);
1313
+ } else {
1314
+ if (
1315
+ !Array.isArray(result) &&
1316
+ result.body == null &&
1317
+ result.statusCode == null
1318
+ ) {
1319
+ console.error('No status or body set', req.url, responses);
1320
+ return new InternalRequestError();
1321
+ }
1322
+ return result;
1078
1323
  }
1079
- return result;
1080
- }
1081
- });
1324
+ },
1325
+ );
1082
1326
  return responses;
1083
1327
  })(),
1084
1328
  };
@@ -1101,61 +1345,75 @@ export const handleODataRequest: Express.Handler = async (req, res, next) => {
1101
1345
  // If we are dealing with a single request unpack the response and respond normally
1102
1346
  if (req.batch == null || req.batch.length === 0) {
1103
1347
  let [response] = responses;
1104
- if (_.isError(response)) {
1105
- response = {
1106
- status: response.status,
1107
- body: response.getResponseBody(),
1108
- };
1109
- }
1110
- const { body, headers, status } = response as Response;
1111
- if (status) {
1112
- res.status(status);
1113
- }
1114
- _.forEach(headers, (headerValue, headerName) => {
1115
- res.set(headerName, headerValue);
1116
- });
1117
-
1118
- if (!body) {
1119
- res.sendStatus(status!);
1120
- } else {
1121
- if (status != null) {
1122
- res.status(status);
1123
- }
1124
- res.json(body);
1348
+ if (response instanceof HttpError) {
1349
+ response = httpErrorToResponse(response);
1125
1350
  }
1351
+ handleResponse(res, response as Response);
1126
1352
 
1127
1353
  // Otherwise its a multipart request and we reply with the appropriate multipart response
1128
1354
  } else {
1129
1355
  (res.status(200) as any).sendMulti(
1130
1356
  responses.map((response) => {
1131
- if (_.isError(response)) {
1132
- return {
1133
- status: response.status,
1134
- body: response.getResponseBody(),
1135
- };
1357
+ if (response instanceof HttpError) {
1358
+ response = httpErrorToResponse(response);
1136
1359
  } else {
1137
1360
  return response;
1138
1361
  }
1139
1362
  }),
1140
1363
  );
1141
1364
  }
1142
- } catch (e) {
1143
- if (e instanceof HttpError) {
1144
- const body = e.getResponseBody();
1145
- if (body) {
1146
- res.status(e.status).send(body);
1147
- } else {
1148
- res.sendStatus(e.status);
1149
- }
1365
+ } catch (e: any) {
1366
+ if (handleHttpErrors(req, res, e)) {
1150
1367
  return;
1151
1368
  }
1152
1369
  // If an error bubbles here it must have happened in the last then block
1153
1370
  // We just respond with 500 as there is probably not much we can do to recover
1154
1371
  console.error('An error occurred while constructing the response', e);
1155
- res.sendStatus(500);
1372
+ res.status(500).end();
1156
1373
  }
1157
1374
  };
1158
1375
 
1376
+ const handleErrorFns: Array<(req: Express.Request, err: HttpError) => void> =
1377
+ [];
1378
+ export const onHandleHttpError = (fn: (typeof handleErrorFns)[number]) => {
1379
+ handleErrorFns.push(fn);
1380
+ };
1381
+ export const handleHttpErrors = (
1382
+ req: Express.Request,
1383
+ res: Express.Response,
1384
+ err: Error,
1385
+ ): boolean => {
1386
+ if (err instanceof HttpError) {
1387
+ for (const handleErrorFn of handleErrorFns) {
1388
+ handleErrorFn(req, err);
1389
+ }
1390
+ const response = httpErrorToResponse(err);
1391
+ handleResponse(res, response);
1392
+ return true;
1393
+ }
1394
+ return false;
1395
+ };
1396
+ const handleResponse = (res: Express.Response, response: Response): void => {
1397
+ const { body, headers, statusCode } = response as Response;
1398
+ res.set(headers);
1399
+ res.status(statusCode);
1400
+ if (!body) {
1401
+ res.end();
1402
+ } else {
1403
+ res.json(body);
1404
+ }
1405
+ };
1406
+
1407
+ const httpErrorToResponse = (
1408
+ err: HttpError,
1409
+ ): RequiredField<Response, 'statusCode'> => {
1410
+ return {
1411
+ statusCode: err.status,
1412
+ body: err.getResponseBody(),
1413
+ headers: err.headers,
1414
+ };
1415
+ };
1416
+
1159
1417
  // Reject the error to use the nice catch syntax
1160
1418
  const convertToHttpError = (err: any): HttpError => {
1161
1419
  if (err instanceof HttpError) {
@@ -1217,7 +1475,7 @@ const runRequest = async (
1217
1475
  result = await runDelete(req, request, tx);
1218
1476
  break;
1219
1477
  }
1220
- } catch (err) {
1478
+ } catch (err: any) {
1221
1479
  if (err instanceof db.DatabaseError) {
1222
1480
  prettifyConstraintError(err, request);
1223
1481
  logger.error(err);
@@ -1241,7 +1499,7 @@ const runRequest = async (
1241
1499
  }
1242
1500
 
1243
1501
  await runHooks('POSTRUN', request.hooks, { req, request, result, tx });
1244
- } catch (err) {
1502
+ } catch (err: any) {
1245
1503
  await runHooks('POSTRUN-ERROR', request.hooks, {
1246
1504
  req,
1247
1505
  request,
@@ -1253,19 +1511,21 @@ const runRequest = async (
1253
1511
  return await prepareResponse(req, request, result, tx);
1254
1512
  };
1255
1513
 
1256
- const runChangeSet = (req: Express.Request, tx: Db.Tx) => async (
1257
- changeSetResults: Map<number, Response>,
1258
- request: uriParser.ODataRequest,
1259
- ): Promise<void> => {
1260
- request = updateBinds(changeSetResults, request);
1261
- const result = await runRequest(req, tx, request);
1262
- if (request.id == null) {
1263
- throw new Error('No request id');
1264
- }
1265
- result.headers ??= {};
1266
- result.headers['Content-Id'] = request.id;
1267
- changeSetResults.set(request.id, result);
1268
- };
1514
+ const runChangeSet =
1515
+ (req: Express.Request, tx: Db.Tx) =>
1516
+ async (
1517
+ changeSetResults: Map<number, Response>,
1518
+ request: uriParser.ODataRequest,
1519
+ ): Promise<void> => {
1520
+ request = updateBinds(changeSetResults, request);
1521
+ const result = await runRequest(req, tx, request);
1522
+ if (request.id == null) {
1523
+ throw new Error('No request id');
1524
+ }
1525
+ result.headers ??= {};
1526
+ result.headers['content-id'] = request.id;
1527
+ changeSetResults.set(request.id, result);
1528
+ };
1269
1529
 
1270
1530
  // Requests inside a changeset may refer to resources created inside the
1271
1531
  // changeset, the generation of the sql query for those requests must be
@@ -1276,7 +1536,8 @@ const updateBinds = (
1276
1536
  request: uriParser.ODataRequest,
1277
1537
  ) => {
1278
1538
  if (request._defer) {
1279
- request.odataBinds = request.odataBinds.map(([tag, id]) => {
1539
+ for (let i = 0; i < request.odataBinds.length; i++) {
1540
+ const [tag, id] = request.odataBinds[i];
1280
1541
  if (tag === 'ContentReference') {
1281
1542
  const ref = changeSetResults.get(id);
1282
1543
  if (
@@ -1288,10 +1549,9 @@ const updateBinds = (
1288
1549
  'Reference to a non existing resource in Changeset',
1289
1550
  );
1290
1551
  }
1291
- return uriParser.parseId(ref.body.id);
1552
+ request.odataBinds[i] = uriParser.parseId(ref.body.id);
1292
1553
  }
1293
- return [tag, id];
1294
- });
1554
+ }
1295
1555
  }
1296
1556
  return request;
1297
1557
  };
@@ -1320,18 +1580,45 @@ const prepareResponse = async (
1320
1580
  }
1321
1581
  };
1322
1582
 
1583
+ const checkReadOnlyRequests = (request: uriParser.ODataRequest) => {
1584
+ if (request.method !== 'GET') {
1585
+ // Only GET requests can be read-only
1586
+ return false;
1587
+ }
1588
+ const { hooks } = request;
1589
+ if (hooks == null) {
1590
+ // If there are no hooks then it's definitely read-only
1591
+ return true;
1592
+ }
1593
+ // If there are hooks then check that they're all read-only
1594
+ return hooks.every(([, versionedHooks]) =>
1595
+ Object.values(versionedHooks).every((hookTypeHooks) => {
1596
+ return (
1597
+ hookTypeHooks == null || hookTypeHooks.every((hook) => hook.readOnlyTx)
1598
+ );
1599
+ }),
1600
+ );
1601
+ };
1602
+
1323
1603
  // This is a helper method to handle using a passed in req.tx when available, or otherwise creating a new tx and cleaning up after we're done.
1324
1604
  const runTransaction = async <T>(
1325
1605
  req: HookReq,
1606
+ request: uriParser.ODataRequest | uriParser.ODataRequest[],
1326
1607
  callback: (tx: Db.Tx) => Promise<T>,
1327
1608
  ): Promise<T> => {
1328
1609
  if (req.tx != null) {
1329
1610
  // If an existing tx was passed in then use it.
1330
1611
  return await callback(req.tx);
1331
- } else {
1332
- // Otherwise create a new transaction and handle tidying it up.
1333
- return await db.transaction(callback);
1334
1612
  }
1613
+ if (Array.isArray(request)) {
1614
+ if (request.every(checkReadOnlyRequests)) {
1615
+ return await db.readTransaction(callback);
1616
+ }
1617
+ } else if (checkReadOnlyRequests(request)) {
1618
+ return await db.readTransaction(callback);
1619
+ }
1620
+ // Otherwise create a new write transaction and handle tidying it up.
1621
+ return await db.transaction(callback);
1335
1622
  };
1336
1623
 
1337
1624
  // This is a helper function that will check and add the bind values to the SQL query and then run it.
@@ -1339,7 +1626,7 @@ const runQuery = async (
1339
1626
  tx: Db.Tx,
1340
1627
  request: uriParser.ODataRequest,
1341
1628
  queryIndex?: number,
1342
- returningIdField?: string,
1629
+ addReturning: boolean = false,
1343
1630
  ): Promise<Db.Result> => {
1344
1631
  const { vocabulary } = request;
1345
1632
  let { sqlQuery } = request;
@@ -1368,10 +1655,14 @@ const runQuery = async (
1368
1655
  api[vocabulary].logger.log(query, values);
1369
1656
  }
1370
1657
 
1658
+ // We only add the returning clause if it's been requested and `affectedIds` hasn't been populated yet
1659
+ const returningIdField =
1660
+ addReturning && request.affectedIds == null ? getIdField(request) : false;
1661
+
1371
1662
  const sqlResult = await tx.executeSql(query, values, returningIdField);
1372
1663
 
1373
1664
  if (returningIdField) {
1374
- request.affectedIds = sqlResult.rows.map((row) => row[returningIdField]);
1665
+ request.affectedIds ??= sqlResult.rows.map((row) => row[returningIdField]);
1375
1666
  }
1376
1667
 
1377
1668
  return sqlResult;
@@ -1403,29 +1694,35 @@ const respondGet = async (
1403
1694
  const d = await odataResponse.process(
1404
1695
  vocab,
1405
1696
  getAbstractSqlModel(request),
1406
- request.resourceName,
1697
+ request.originalResourceName,
1407
1698
  result.rows,
1408
1699
  { includeMetadata: metadata === 'full' },
1409
1700
  );
1410
1701
 
1702
+ const response = {
1703
+ statusCode: 200,
1704
+ body: { d },
1705
+ headers: { 'content-type': 'application/json' },
1706
+ };
1411
1707
  await runHooks('PRERESPOND', request.hooks, {
1412
1708
  req,
1413
1709
  request,
1414
1710
  result,
1415
- data: d,
1711
+ response,
1416
1712
  tx,
1417
1713
  });
1418
- return { body: { d }, headers: { contentType: 'application/json' } };
1714
+ return response;
1419
1715
  } else {
1420
1716
  if (request.resourceName === '$metadata') {
1421
1717
  return {
1718
+ statusCode: 200,
1422
1719
  body: models[vocab].odataMetadata,
1423
- headers: { contentType: 'xml' },
1720
+ headers: { 'content-type': 'xml' },
1424
1721
  };
1425
1722
  } else {
1426
1723
  // TODO: request.resourceName can be '$serviceroot' or a resource and we should return an odata xml document based on that
1427
1724
  return {
1428
- status: 404,
1725
+ statusCode: 404,
1429
1726
  };
1430
1727
  }
1431
1728
  }
@@ -1440,12 +1737,12 @@ const runPost = async (
1440
1737
  tx,
1441
1738
  request,
1442
1739
  undefined,
1443
- getIdField(request),
1740
+ true,
1444
1741
  );
1445
1742
  if (rowsAffected === 0) {
1446
1743
  throw new PermissionError();
1447
1744
  }
1448
- await validateModel(tx, request.vocabulary, request);
1745
+ await validateModel(tx, _.last(request.translateVersions)!, request);
1449
1746
 
1450
1747
  return insertId;
1451
1748
  };
@@ -1457,7 +1754,11 @@ const respondPost = async (
1457
1754
  tx: Db.Tx,
1458
1755
  ): Promise<Response> => {
1459
1756
  const vocab = request.vocabulary;
1460
- const location = odataResponse.resourceURI(vocab, request.resourceName, id);
1757
+ const location = odataResponse.resourceURI(
1758
+ vocab,
1759
+ request.originalResourceName,
1760
+ id,
1761
+ );
1461
1762
  if (env.DEBUG) {
1462
1763
  api[vocab].logger.log('Insert ID: ', request.resourceName, id);
1463
1764
  }
@@ -1476,21 +1777,23 @@ const respondPost = async (
1476
1777
  }
1477
1778
  }
1478
1779
 
1780
+ const response = {
1781
+ statusCode: 201,
1782
+ body: result.d[0],
1783
+ headers: {
1784
+ 'content-type': 'application/json',
1785
+ location,
1786
+ },
1787
+ };
1479
1788
  await runHooks('PRERESPOND', request.hooks, {
1480
1789
  req,
1481
1790
  request,
1482
1791
  result,
1792
+ response,
1483
1793
  tx,
1484
1794
  });
1485
1795
 
1486
- return {
1487
- status: 201,
1488
- body: result.d[0],
1489
- headers: {
1490
- contentType: 'application/json',
1491
- Location: location,
1492
- },
1493
- };
1796
+ return response;
1494
1797
  };
1495
1798
 
1496
1799
  const runPut = async (
@@ -1498,22 +1801,20 @@ const runPut = async (
1498
1801
  request: uriParser.ODataRequest,
1499
1802
  tx: Db.Tx,
1500
1803
  ): Promise<undefined> => {
1501
- const idField = getIdField(request);
1502
-
1503
1804
  let rowsAffected: number;
1504
1805
  // If request.sqlQuery is an array it means it's an UPSERT, ie two queries: [InsertQuery, UpdateQuery]
1505
1806
  if (Array.isArray(request.sqlQuery)) {
1506
1807
  // Run the update query first
1507
- ({ rowsAffected } = await runQuery(tx, request, 1, idField));
1808
+ ({ rowsAffected } = await runQuery(tx, request, 1, true));
1508
1809
  if (rowsAffected === 0) {
1509
1810
  // Then run the insert query if nothing was updated
1510
- ({ rowsAffected } = await runQuery(tx, request, 0, idField));
1811
+ ({ rowsAffected } = await runQuery(tx, request, 0, true));
1511
1812
  }
1512
1813
  } else {
1513
- ({ rowsAffected } = await runQuery(tx, request, undefined, idField));
1814
+ ({ rowsAffected } = await runQuery(tx, request, undefined, true));
1514
1815
  }
1515
1816
  if (rowsAffected > 0) {
1516
- await validateModel(tx, request.vocabulary, request);
1817
+ await validateModel(tx, _.last(request.translateVersions)!, request);
1517
1818
  }
1518
1819
  return undefined;
1519
1820
  };
@@ -1524,16 +1825,17 @@ const respondPut = async (
1524
1825
  result: any,
1525
1826
  tx: Db.Tx,
1526
1827
  ): Promise<Response> => {
1828
+ const response = {
1829
+ statusCode: 200,
1830
+ };
1527
1831
  await runHooks('PRERESPOND', request.hooks, {
1528
1832
  req,
1529
1833
  request,
1530
1834
  result,
1835
+ response,
1531
1836
  tx,
1532
1837
  });
1533
- return {
1534
- status: 200,
1535
- headers: {},
1536
- };
1838
+ return response;
1537
1839
  };
1538
1840
  const respondDelete = respondPut;
1539
1841
  const respondOptions = respondPut;
@@ -1543,14 +1845,9 @@ const runDelete = async (
1543
1845
  request: uriParser.ODataRequest,
1544
1846
  tx: Db.Tx,
1545
1847
  ): Promise<undefined> => {
1546
- const { rowsAffected } = await runQuery(
1547
- tx,
1548
- request,
1549
- undefined,
1550
- getIdField(request),
1551
- );
1848
+ const { rowsAffected } = await runQuery(tx, request, undefined, true);
1552
1849
  if (rowsAffected > 0) {
1553
- await validateModel(tx, request.vocabulary, request);
1850
+ await validateModel(tx, _.last(request.translateVersions)!, request);
1554
1851
  }
1555
1852
 
1556
1853
  return undefined;
@@ -1570,11 +1867,36 @@ export const executeStandardModels = async (tx: Db.Tx): Promise<void> => {
1570
1867
  ALTER TABLE "model"
1571
1868
  ADD COLUMN IF NOT EXISTS "modified at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL;
1572
1869
  `,
1870
+ '15.0.0-data-types': async ($tx, sbvrUtils) => {
1871
+ switch (sbvrUtils.db.engine) {
1872
+ case 'mysql':
1873
+ await $tx.executeSql(`\
1874
+ ALTER TABLE "model"
1875
+ MODIFY "model value" JSON NOT NULL;
1876
+
1877
+ UPDATE "model"
1878
+ SET "model value" = CAST('{"value":' || CAST("model value" AS CHAR) || '}' AS JSON)
1879
+ WHERE "model type" IN ('se', 'odataMetadata')
1880
+ AND CAST("model value" AS CHAR) LIKE '"%';`);
1881
+ break;
1882
+ case 'postgres':
1883
+ await $tx.executeSql(`\
1884
+ ALTER TABLE "model"
1885
+ ALTER COLUMN "model value" SET DATA TYPE JSONB USING "model value"::JSONB;
1886
+
1887
+ UPDATE "model"
1888
+ SET "model value" = CAST('{"value":' || CAST("model value" AS TEXT) || '}' AS JSON)
1889
+ WHERE "model type" IN ('se', 'odataMetadata')
1890
+ AND CAST("model value" AS TEXT) LIKE '"%';`);
1891
+ break;
1892
+ // No need to migrate for websql
1893
+ }
1894
+ },
1573
1895
  },
1574
1896
  });
1575
1897
  await executeModels(tx, permissions.config.models);
1576
1898
  console.info('Successfully executed standard models.');
1577
- } catch (err) {
1899
+ } catch (err: any) {
1578
1900
  console.error('Failed to execute standard models.', err);
1579
1901
  throw err;
1580
1902
  }
@@ -1590,7 +1912,7 @@ export const setup = async (
1590
1912
  await executeStandardModels(tx);
1591
1913
  await permissions.setup();
1592
1914
  });
1593
- } catch (err) {
1915
+ } catch (err: any) {
1594
1916
  console.error('Could not execute standard models', err);
1595
1917
  process.exit(1);
1596
1918
  }