@proteinjs/db-settings 1.3.45 → 1.3.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/generated/index.d.ts.map +1 -1
  3. package/dist/generated/index.js +3 -1
  4. package/dist/generated/index.js.map +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/src/Settings.d.ts +10 -1
  10. package/dist/src/Settings.d.ts.map +1 -1
  11. package/dist/src/Settings.js +30 -9
  12. package/dist/src/Settings.js.map +1 -1
  13. package/dist/src/migrations/DeduplicateSettingRows.d.ts +34 -0
  14. package/dist/src/migrations/DeduplicateSettingRows.d.ts.map +1 -0
  15. package/dist/src/migrations/DeduplicateSettingRows.js +171 -0
  16. package/dist/src/migrations/DeduplicateSettingRows.js.map +1 -0
  17. package/dist/src/tables/SettingTable.d.ts +8 -0
  18. package/dist/src/tables/SettingTable.d.ts.map +1 -1
  19. package/dist/src/tables/SettingTable.js +10 -0
  20. package/dist/src/tables/SettingTable.js.map +1 -1
  21. package/dist/test/DeduplicateSettingRows.test.d.ts +3 -0
  22. package/dist/test/DeduplicateSettingRows.test.d.ts.map +1 -0
  23. package/dist/test/DeduplicateSettingRows.test.js +195 -0
  24. package/dist/test/DeduplicateSettingRows.test.js.map +1 -0
  25. package/dist/test/SettingsOneRowPerName.test.d.ts +3 -0
  26. package/dist/test/SettingsOneRowPerName.test.d.ts.map +1 -0
  27. package/dist/test/SettingsOneRowPerName.test.js +213 -0
  28. package/dist/test/SettingsOneRowPerName.test.js.map +1 -0
  29. package/dist/test/util/HeldFirstWriteDriver.d.ts +36 -0
  30. package/dist/test/util/HeldFirstWriteDriver.d.ts.map +1 -0
  31. package/dist/test/util/HeldFirstWriteDriver.js +130 -0
  32. package/dist/test/util/HeldFirstWriteDriver.js.map +1 -0
  33. package/dist/test/util/TestSession.d.ts +22 -0
  34. package/dist/test/util/TestSession.d.ts.map +1 -0
  35. package/dist/test/util/TestSession.js +49 -0
  36. package/dist/test/util/TestSession.js.map +1 -0
  37. package/generated/index.ts +3 -1
  38. package/index.ts +2 -0
  39. package/jest.config.js +2 -0
  40. package/package.json +9 -5
  41. package/src/Settings.ts +20 -5
  42. package/src/migrations/DeduplicateSettingRows.ts +78 -0
  43. package/src/tables/SettingTable.ts +11 -1
  44. package/test/DeduplicateSettingRows.test.ts +83 -0
  45. package/test/SettingsOneRowPerName.test.ts +104 -0
  46. package/test/setup.js +2 -0
  47. package/test/util/HeldFirstWriteDriver.ts +110 -0
  48. package/test/util/TestSession.ts +35 -0
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ var __assign = (this && this.__assign) || function () {
3
+ __assign = Object.assign || function(t) {
4
+ for (var s, i = 1, n = arguments.length; i < n; i++) {
5
+ s = arguments[i];
6
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
7
+ t[p] = s[p];
8
+ }
9
+ return t;
10
+ };
11
+ return __assign.apply(this, arguments);
12
+ };
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.TestSession = void 0;
15
+ var reflection_1 = require("@proteinjs/reflection");
16
+ var server_api_1 = require("@proteinjs/server-api");
17
+ var user_1 = require("@proteinjs/user");
18
+ /**
19
+ * The one session a suite's calls run in: a process-wide session store (the async-context store a
20
+ * server uses does not survive jest's hook and test boundaries), registered where `Session` looks
21
+ * for one, carrying the user the scoped db reads its scope from. `as(user)` re-seats the session
22
+ * on another user, so a suite can act as two people.
23
+ */
24
+ var TestSession = /** @class */ (function () {
25
+ function TestSession() {
26
+ this.environment = 'node';
27
+ this.priority = 1;
28
+ }
29
+ /** Act as `user` from here on: the session's user, and the scope every scoped write and query carries. */
30
+ TestSession.as = function (user) {
31
+ TestSession.register();
32
+ server_api_1.Session.setData({ sessionId: 'settings-test', user: user.email, data: {} });
33
+ new user_1.UserRepo().setUser(__assign(__assign({}, user), { roles: [] }));
34
+ };
35
+ TestSession.prototype.setData = function (data) {
36
+ TestSession.data = data;
37
+ };
38
+ TestSession.prototype.getData = function () {
39
+ return TestSession.data;
40
+ };
41
+ TestSession.register = function () {
42
+ var repository = reflection_1.SourceRepository.get();
43
+ repository.objectCache['@proteinjs/server-api/SessionDataStorage'] = [new TestSession()];
44
+ };
45
+ TestSession.data = { sessionId: 'settings-test', user: undefined, data: {} };
46
+ return TestSession;
47
+ }());
48
+ exports.TestSession = TestSession;
49
+ //# sourceMappingURL=TestSession.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TestSession.js","sourceRoot":"","sources":["../../../test/util/TestSession.ts"],"names":[],"mappings":";;;;;;;;;;;;;;AAAA,oDAAyD;AACzD,oDAAiF;AACjF,wCAA2C;AAE3C;;;;;GAKG;AACH;IAAA;QACE,gBAAW,GAAG,MAAe,CAAC;QAC9B,aAAQ,GAAG,CAAC,CAAC;IAsBf,CAAC;IAnBC,0GAA0G;IACnG,cAAE,GAAT,UAAU,IAAiD;QACzD,WAAW,CAAC,QAAQ,EAAE,CAAC;QACvB,oBAAO,CAAC,OAAO,CAAC,EAAE,SAAS,EAAE,eAAe,EAAE,IAAI,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;QAC5E,IAAI,eAAQ,EAAE,CAAC,OAAO,CAAC,sBAAK,IAAI,KAAE,KAAK,EAAE,EAAE,GAAmD,CAAC,CAAC;IAClG,CAAC;IAED,6BAAO,GAAP,UAAQ,IAAiB;QACvB,WAAW,CAAC,IAAI,GAAG,IAAI,CAAC;IAC1B,CAAC;IAED,6BAAO,GAAP;QACE,OAAO,WAAW,CAAC,IAAI,CAAC;IAC1B,CAAC;IAEc,oBAAQ,GAAvB;QACE,IAAM,UAAU,GAAG,6BAAgB,CAAC,GAAG,EAA+D,CAAC;QACvG,UAAU,CAAC,WAAW,CAAC,0CAA0C,CAAC,GAAG,CAAC,IAAI,WAAW,EAAE,CAAC,CAAC;IAC3F,CAAC;IApBc,gBAAI,GAAgB,EAAE,SAAS,EAAE,eAAe,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,EAAE,EAAE,AAAzE,CAA0E;IAqB/F,kBAAC;CAAA,AAxBD,IAwBC;AAxBY,kCAAW"}
@@ -8,15 +8,17 @@ import '@proteinjs/user';
8
8
  /** Generate Source Graph */
9
9
 
10
10
  const sourceGraph =
11
- '{"options":{"directed":true,"multigraph":false,"compound":false},"nodes":[{"v":"@proteinjs/db-settings/Settings","value":{"packageName":"@proteinjs/db-settings","name":"Settings","filePath":"src/Settings.ts","qualifiedName":"@proteinjs/db-settings/Settings","isAbstract":false,"isStatic":false,"visibility":"public","properties":[{"name":"logger","type":null,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"private"},{"name":"serviceMetadata","type":null,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public"}],"methods":[{"name":"get","returnType":null,"isAsync":true,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public","parameters":[{"name":"name","type":{"packageName":"","name":"string","filePath":null,"qualifiedName":"/string","typeParameters":null,"directParents":null}},{"name":"defaultValue","type":{"packageName":"","name":"T","filePath":null,"qualifiedName":"/T","typeParameters":null,"directParents":null}}]},{"name":"set","returnType":null,"isAsync":true,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public","parameters":[{"name":"name","type":{"packageName":"","name":"string","filePath":null,"qualifiedName":"/string","typeParameters":null,"directParents":null}},{"name":"value","type":{"packageName":"","name":"any","filePath":null,"qualifiedName":"/any","typeParameters":null,"directParents":null}}]}],"typeParameters":[],"directParentInterfaces":[{"packageName":"@proteinjs/db-settings","name":"SettingsService","filePath":null,"qualifiedName":"@proteinjs/db-settings/SettingsService","properties":[],"methods":[],"typeParameters":[],"directParents":[]}],"directParentClasses":[],"sourceType":2}},{"v":"@proteinjs/db-settings/SettingsService","value":{"packageName":"@proteinjs/db-settings","name":"SettingsService","filePath":"src/services/SettingsService.ts","qualifiedName":"@proteinjs/db-settings/SettingsService","properties":[],"methods":[{"name":"get","returnType":{"packageName":"","name":"Promise<T>","filePath":null,"qualifiedName":"/Promise<T>","typeParameters":null,"directParents":null},"isAsync":false,"isOptional":false,"isAbstract":true,"isStatic":false,"visibility":"public","parameters":[{"name":"name","type":{"packageName":"","name":"string","filePath":null,"qualifiedName":"/string","typeParameters":null,"directParents":null}},{"name":"defaultValue","type":{"packageName":"","name":"T","filePath":null,"qualifiedName":"/T","typeParameters":null,"directParents":null}}]},{"name":"set","returnType":{"packageName":"","name":"Promise<void>","filePath":null,"qualifiedName":"/Promise<void>","typeParameters":null,"directParents":null},"isAsync":false,"isOptional":false,"isAbstract":true,"isStatic":false,"visibility":"public","parameters":[{"name":"name","type":{"packageName":"","name":"string","filePath":null,"qualifiedName":"/string","typeParameters":null,"directParents":null}},{"name":"value","type":{"packageName":"","name":"any","filePath":null,"qualifiedName":"/any","typeParameters":null,"directParents":null}}]}],"typeParameters":[],"directParents":[{"packageName":"@proteinjs/service","name":"Service","filePath":null,"qualifiedName":"@proteinjs/service/Service","properties":[],"methods":[],"typeParameters":[],"directParents":[]}],"sourceType":3}},{"v":"@proteinjs/service/Service"},{"v":"@proteinjs/db-settings/Setting","value":{"packageName":"@proteinjs/db-settings","name":"Setting","filePath":"src/tables/SettingTable.ts","qualifiedName":"@proteinjs/db-settings/Setting","properties":[{"name":"name","type":{"packageName":"","name":"string","filePath":null,"qualifiedName":"/string","typeParameters":null,"directParents":null},"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public"},{"name":"value","type":{"packageName":"","name":"any","filePath":null,"qualifiedName":"/any","typeParameters":null,"directParents":null},"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public"}],"methods":[],"typeParameters":[],"directParents":[{"packageName":"@proteinjs/user","name":"ScopedRecord","filePath":null,"qualifiedName":"@proteinjs/user/ScopedRecord","properties":[],"methods":[],"typeParameters":[],"directParents":[]}],"sourceType":3}},{"v":"@proteinjs/user/ScopedRecord"},{"v":"@proteinjs/db-settings/SettingTable","value":{"packageName":"@proteinjs/db-settings","name":"SettingTable","filePath":"src/tables/SettingTable.ts","qualifiedName":"@proteinjs/db-settings/SettingTable","isAbstract":false,"isStatic":false,"visibility":"public","properties":[{"name":"name","type":null,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public"},{"name":"auth","type":{"packageName":"","name":"Table<Setting>[\'auth\']","filePath":null,"qualifiedName":"/Table<Setting>[\'auth\']","typeParameters":null,"directParents":null},"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public"},{"name":"columns","type":null,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public"}],"methods":[],"typeParameters":[],"directParentInterfaces":[],"directParentClasses":[{"packageName":"@proteinjs/db","name":"Table","filePath":null,"qualifiedName":"@proteinjs/db/Table","isAbstract":false,"isStatic":false,"visibility":"public","properties":[],"methods":[],"typeParameters":["@proteinjs/db-settings/Setting"],"directParentInterfaces":[],"directParentClasses":[]}],"sourceType":2}},{"v":"@proteinjs/db/Table"}],"edges":[{"v":"@proteinjs/db-settings/Settings","w":"@proteinjs/db-settings/SettingsService","value":"implements interface"},{"v":"@proteinjs/db-settings/SettingsService","w":"@proteinjs/service/Service","value":"extends interface"},{"v":"@proteinjs/db-settings/Setting","w":"@proteinjs/user/ScopedRecord","value":"extends interface"},{"v":"@proteinjs/db-settings/SettingTable","w":"@proteinjs/db/Table","value":"extends class"}]}';
11
+ '{"options":{"directed":true,"multigraph":false,"compound":false},"nodes":[{"v":"@proteinjs/db-settings/Settings","value":{"packageName":"@proteinjs/db-settings","name":"Settings","filePath":"src/Settings.ts","qualifiedName":"@proteinjs/db-settings/Settings","isAbstract":false,"isStatic":false,"visibility":"public","properties":[{"name":"serviceMetadata","type":null,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public"}],"methods":[{"name":"get","returnType":null,"isAsync":true,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public","parameters":[{"name":"name","type":{"packageName":"","name":"string","filePath":null,"qualifiedName":"/string","typeParameters":null,"directParents":null}},{"name":"defaultValue","type":{"packageName":"","name":"T","filePath":null,"qualifiedName":"/T","typeParameters":null,"directParents":null}}]},{"name":"set","returnType":null,"isAsync":true,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public","parameters":[{"name":"name","type":{"packageName":"","name":"string","filePath":null,"qualifiedName":"/string","typeParameters":null,"directParents":null}},{"name":"value","type":{"packageName":"","name":"any","filePath":null,"qualifiedName":"/any","typeParameters":null,"directParents":null}}]}],"typeParameters":[],"directParentInterfaces":[{"packageName":"@proteinjs/db-settings","name":"SettingsService","filePath":null,"qualifiedName":"@proteinjs/db-settings/SettingsService","properties":[],"methods":[],"typeParameters":[],"directParents":[]}],"directParentClasses":[],"sourceType":2}},{"v":"@proteinjs/db-settings/SettingsService","value":{"packageName":"@proteinjs/db-settings","name":"SettingsService","filePath":"src/services/SettingsService.ts","qualifiedName":"@proteinjs/db-settings/SettingsService","properties":[],"methods":[{"name":"get","returnType":{"packageName":"","name":"Promise<T>","filePath":null,"qualifiedName":"/Promise<T>","typeParameters":null,"directParents":null},"isAsync":false,"isOptional":false,"isAbstract":true,"isStatic":false,"visibility":"public","parameters":[{"name":"name","type":{"packageName":"","name":"string","filePath":null,"qualifiedName":"/string","typeParameters":null,"directParents":null}},{"name":"defaultValue","type":{"packageName":"","name":"T","filePath":null,"qualifiedName":"/T","typeParameters":null,"directParents":null}}]},{"name":"set","returnType":{"packageName":"","name":"Promise<void>","filePath":null,"qualifiedName":"/Promise<void>","typeParameters":null,"directParents":null},"isAsync":false,"isOptional":false,"isAbstract":true,"isStatic":false,"visibility":"public","parameters":[{"name":"name","type":{"packageName":"","name":"string","filePath":null,"qualifiedName":"/string","typeParameters":null,"directParents":null}},{"name":"value","type":{"packageName":"","name":"any","filePath":null,"qualifiedName":"/any","typeParameters":null,"directParents":null}}]}],"typeParameters":[],"directParents":[{"packageName":"@proteinjs/service","name":"Service","filePath":null,"qualifiedName":"@proteinjs/service/Service","properties":[],"methods":[],"typeParameters":[],"directParents":[]}],"sourceType":3}},{"v":"@proteinjs/db-settings/DeduplicateSettingRows","value":{"packageName":"@proteinjs/db-settings","name":"DeduplicateSettingRows","filePath":"src/migrations/DeduplicateSettingRows.ts","qualifiedName":"@proteinjs/db-settings/DeduplicateSettingRows","isAbstract":false,"isStatic":false,"visibility":"public","properties":[{"name":"DELETE_BATCH","type":null,"isOptional":false,"isAbstract":false,"isStatic":true,"visibility":"private"},{"name":"table","type":null,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public"},{"name":"record","type":null,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public"}],"methods":[{"name":"surplusRowIds","returnType":{"packageName":"","name":"Promise<string[]>","filePath":null,"qualifiedName":"/Promise<string[]>","typeParameters":null,"directParents":null},"isAsync":true,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"private","parameters":[]},{"name":"newestFirst","returnType":{"packageName":"","name":"number","filePath":null,"qualifiedName":"/number","typeParameters":null,"directParents":null},"isAsync":false,"isOptional":false,"isAbstract":false,"isStatic":true,"visibility":"private","parameters":[{"name":"a","type":{"packageName":"@proteinjs/db-settings","name":"Setting","filePath":null,"qualifiedName":"@proteinjs/db-settings/Setting","typeParameters":null,"directParents":null}},{"name":"b","type":{"packageName":"@proteinjs/db-settings","name":"Setting","filePath":null,"qualifiedName":"@proteinjs/db-settings/Setting","typeParameters":null,"directParents":null}}]}],"typeParameters":[],"directParentInterfaces":[{"packageName":"@proteinjs/db","name":"SourceRecordLoader","filePath":null,"qualifiedName":"@proteinjs/db/SourceRecordLoader","properties":[],"methods":[],"typeParameters":["@proteinjs/db/Migration"],"directParents":[]}],"directParentClasses":[],"sourceType":2}},{"v":"@proteinjs/db/SourceRecordLoader"},{"v":"@proteinjs/db-settings/Setting","value":{"packageName":"@proteinjs/db-settings","name":"Setting","filePath":"src/tables/SettingTable.ts","qualifiedName":"@proteinjs/db-settings/Setting","properties":[{"name":"name","type":{"packageName":"","name":"string","filePath":null,"qualifiedName":"/string","typeParameters":null,"directParents":null},"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public"},{"name":"value","type":{"packageName":"","name":"any","filePath":null,"qualifiedName":"/any","typeParameters":null,"directParents":null},"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public"}],"methods":[],"typeParameters":[],"directParents":[{"packageName":"@proteinjs/user","name":"ScopedRecord","filePath":null,"qualifiedName":"@proteinjs/user/ScopedRecord","properties":[],"methods":[],"typeParameters":[],"directParents":[]}],"sourceType":3}},{"v":"@proteinjs/user/ScopedRecord"},{"v":"@proteinjs/db-settings/SettingTable","value":{"packageName":"@proteinjs/db-settings","name":"SettingTable","filePath":"src/tables/SettingTable.ts","qualifiedName":"@proteinjs/db-settings/SettingTable","isAbstract":false,"isStatic":false,"visibility":"public","properties":[{"name":"name","type":null,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public"},{"name":"indexes","type":{"packageName":"","name":"Table<Setting>[\'indexes\']","filePath":null,"qualifiedName":"/Table<Setting>[\'indexes\']","typeParameters":null,"directParents":null},"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public"},{"name":"auth","type":{"packageName":"","name":"Table<Setting>[\'auth\']","filePath":null,"qualifiedName":"/Table<Setting>[\'auth\']","typeParameters":null,"directParents":null},"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public"},{"name":"columns","type":null,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public"}],"methods":[],"typeParameters":[],"directParentInterfaces":[],"directParentClasses":[{"packageName":"@proteinjs/db","name":"Table","filePath":null,"qualifiedName":"@proteinjs/db/Table","isAbstract":false,"isStatic":false,"visibility":"public","properties":[],"methods":[],"typeParameters":["@proteinjs/db-settings/Setting"],"directParentInterfaces":[],"directParentClasses":[]}],"sourceType":2}},{"v":"@proteinjs/db/Table"},{"v":"@proteinjs/service/Service"}],"edges":[{"v":"@proteinjs/db-settings/Settings","w":"@proteinjs/db-settings/SettingsService","value":"implements interface"},{"v":"@proteinjs/db-settings/DeduplicateSettingRows","w":"@proteinjs/db/SourceRecordLoader","value":"implements interface"},{"v":"@proteinjs/db-settings/Setting","w":"@proteinjs/user/ScopedRecord","value":"extends interface"},{"v":"@proteinjs/db-settings/SettingTable","w":"@proteinjs/db/Table","value":"extends class"},{"v":"@proteinjs/db-settings/SettingsService","w":"@proteinjs/service/Service","value":"extends interface"}]}';
12
12
 
13
13
  /** Generate Source Links */
14
14
 
15
15
  import { Settings } from '../src/Settings';
16
+ import { DeduplicateSettingRows } from '../src/migrations/DeduplicateSettingRows';
16
17
  import { SettingTable } from '../src/tables/SettingTable';
17
18
 
18
19
  const sourceLinks = {
19
20
  '@proteinjs/db-settings/Settings': Settings,
21
+ '@proteinjs/db-settings/DeduplicateSettingRows': DeduplicateSettingRows,
20
22
  '@proteinjs/db-settings/SettingTable': SettingTable,
21
23
  };
22
24
 
package/index.ts CHANGED
@@ -2,4 +2,6 @@ export * from './src/Settings';
2
2
 
3
3
  export * from './src/tables/tables';
4
4
 
5
+ export * from './src/migrations/DeduplicateSettingRows';
6
+
5
7
  export * from './src/services/SettingsService';
package/jest.config.js CHANGED
@@ -6,4 +6,6 @@ module.exports = {
6
6
  testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
7
7
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
8
8
  testEnvironment: 'node',
9
+ testTimeout: 30000,
10
+ setupFiles: ['./test/setup'],
9
11
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db-settings",
3
- "version": "1.3.45",
3
+ "version": "1.3.47",
4
4
  "description": "Db settings",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -18,18 +18,22 @@
18
18
  "scripts": {
19
19
  "clean": "rm -rf dist/ node_modules/ generated/",
20
20
  "build": "reflection-build && tsc",
21
- "watch": "reflection-watch"
21
+ "watch": "reflection-watch",
22
+ "test": "jest --runInBand --passWithNoTests"
22
23
  },
23
24
  "dependencies": {
24
- "@proteinjs/db": "^1.51.0",
25
+ "@proteinjs/db": "^1.53.0",
25
26
  "@proteinjs/logger": "^1.0.21",
26
27
  "@proteinjs/reflection": "^1.2.0",
27
28
  "@proteinjs/service": "^1.6.0",
28
- "@proteinjs/user": "^1.7.1",
29
+ "@proteinjs/user": "^1.8.0",
29
30
  "moment": "2.29.4"
30
31
  },
31
32
  "devDependencies": {
33
+ "@proteinjs/db-driver-spanner": "^1.34.0",
34
+ "@proteinjs/db-transaction-context": "^0.5.44",
32
35
  "@proteinjs/reflection-build": "^2.0.1",
36
+ "@proteinjs/server-api": "^3.0.11",
33
37
  "@types/jest": "29.5.5",
34
38
  "@types/node": "14.0.27",
35
39
  "@typescript-eslint/eslint-plugin": "7.8.0",
@@ -43,5 +47,5 @@
43
47
  },
44
48
  "main": "./dist/generated/index.js",
45
49
  "types": "./dist/generated/index.d.ts",
46
- "gitHead": "1128b1e6374c7f52cd5de172620b619b8f92e119"
50
+ "gitHead": "480320d415088dd0d378bd0e9863dc70353fa92f"
47
51
  }
package/src/Settings.ts CHANGED
@@ -1,12 +1,21 @@
1
- import { Logger } from '@proteinjs/logger';
1
+ import { isDuplicateKeyError } from '@proteinjs/db';
2
2
  import { SettingsService, getSettingsService } from './services/SettingsService';
3
3
  import { tables } from './tables/tables';
4
4
  import { getScopedDb } from '@proteinjs/user';
5
5
 
6
6
  export const getSettings = () => (typeof self === 'undefined' ? new Settings() : (getSettingsService() as Settings));
7
7
 
8
+ /**
9
+ * The caller's named settings: one row per name within their scope (`SettingTable`'s unique
10
+ * index), so `get` reads the one row. `set` is one write that cannot mint a second row: it
11
+ * updates the row; when there is none it inserts; and when the index refuses that insert because
12
+ * another writer landed the row in between (the typed `DuplicateKeyError` — the race's only path)
13
+ * it updates the one row — the last writer's value is what the row holds. The update comes first
14
+ * because a rewrite of an existing name is the common act: insert-first made every rewrite a
15
+ * refused statement the driver reports as a failure. Without the index, two concurrent writers
16
+ * each updated nothing and each inserted, two rows of one name.
17
+ */
8
18
  export class Settings implements SettingsService {
9
- private logger = new Logger({ name: this.constructor.name });
10
19
  public serviceMetadata = {
11
20
  auth: {
12
21
  allUsers: true,
@@ -25,10 +34,16 @@ export class Settings implements SettingsService {
25
34
 
26
35
  async set(name: string, value: any) {
27
36
  const db = getScopedDb();
28
- const rowsUpdated = await db.update(tables.Setting, { value }, { name });
29
- if (rowsUpdated == 0) {
30
- this.logger.info({ message: `Creating new setting`, obj: { name, value } });
37
+ if ((await db.update(tables.Setting, { value }, { name })) > 0) {
38
+ return;
39
+ }
40
+ try {
31
41
  await db.insert(tables.Setting, { name, value });
42
+ } catch (error) {
43
+ if (!isDuplicateKeyError(error)) {
44
+ throw error;
45
+ }
46
+ await db.update(tables.Setting, { value }, { name });
32
47
  }
33
48
  }
34
49
  }
@@ -0,0 +1,78 @@
1
+ import { Migration, QueryBuilderFactory, SourceRecordLoader, getDbAsSystem, tables as dbTables } from '@proteinjs/db';
2
+ import { Logger } from '@proteinjs/logger';
3
+ import { Setting } from '../tables/SettingTable';
4
+ import { tables } from '../tables/tables';
5
+
6
+ /**
7
+ * The data step a database that predates the one-row-per-name invariant takes: before the unique
8
+ * index over (scope, name) lands (schema sync's preflight refuses the index over duplicate rows),
9
+ * keep ONE row of every (scope, name) — the newest, by `updated`, then `created`, then id — and
10
+ * delete the rest. The surplus rows are the ones an update-then-insert `set` created under
11
+ * concurrent writers; the newest holds the last value written, which is what the one row keeps.
12
+ *
13
+ * Pre-schema-sync (runs at `Db.init`, before the DDL), so by that contract: idempotent (a second
14
+ * run finds no duplicates), tolerant of concurrent duplicate runs (every actor computes the same
15
+ * survivors and deletes by id; a row already gone deletes nothing), and a no-op on a fresh
16
+ * database whose setting table does not exist yet.
17
+ */
18
+ export class DeduplicateSettingRows implements SourceRecordLoader<Migration> {
19
+ /** Spanner binds at most 950 parameters to one statement; a delete names fewer ids than that. */
20
+ private static readonly DELETE_BATCH = 500;
21
+
22
+ table = dbTables.Migration;
23
+ record = {
24
+ id: 'a95d9f1d-3760-43d3-af75-15b0f7964eb6',
25
+ description: 'Keep one setting row per scope and name (the newest) ahead of the unique index over them',
26
+ preSchemaSync: true,
27
+ estimate: async () => ({ units: (await this.surplusRowIds()).length, unit: 'rows' }),
28
+ run: async () => {
29
+ const surplus = await this.surplusRowIds();
30
+ const db = getDbAsSystem<Setting>();
31
+ for (let start = 0; start < surplus.length; start += DeduplicateSettingRows.DELETE_BATCH) {
32
+ const batch = surplus.slice(start, start + DeduplicateSettingRows.DELETE_BATCH);
33
+ const qb = new QueryBuilderFactory().getQueryBuilder(tables.Setting);
34
+ qb.condition({ field: 'id', operator: 'IN', value: batch });
35
+ await db.delete(tables.Setting, qb);
36
+ }
37
+ new Logger({ name: this.constructor.name }).info({
38
+ message: 'Deleted the surplus setting rows',
39
+ obj: { deleted: surplus.length },
40
+ });
41
+ return { deleted: surplus.length };
42
+ },
43
+ };
44
+
45
+ /** The ids of every row that is not the newest of its (scope, name) — none when the table does not exist. */
46
+ private async surplusRowIds(): Promise<string[]> {
47
+ const db = getDbAsSystem<Setting>();
48
+ if (!(await db.tableExists(tables.Setting))) {
49
+ return [];
50
+ }
51
+
52
+ const rowsByKey: { [key: string]: Setting[] } = {};
53
+ for (const row of await db.query(tables.Setting, {})) {
54
+ const key = JSON.stringify([row.scope ?? null, row.name]);
55
+ rowsByKey[key] = [...(rowsByKey[key] ?? []), row];
56
+ }
57
+
58
+ const surplus: string[] = [];
59
+ for (const key of Object.keys(rowsByKey)) {
60
+ const rows = rowsByKey[key];
61
+ if (rows.length < 2) {
62
+ continue;
63
+ }
64
+ rows.sort(DeduplicateSettingRows.newestFirst);
65
+ surplus.push(...rows.slice(1).map((row) => row.id));
66
+ }
67
+
68
+ return surplus;
69
+ }
70
+
71
+ private static newestFirst(a: Setting, b: Setting): number {
72
+ return (
73
+ b.updated.valueOf() - a.updated.valueOf() ||
74
+ b.created.valueOf() - a.created.valueOf() ||
75
+ (b.id > a.id ? 1 : b.id < a.id ? -1 : 0)
76
+ );
77
+ }
78
+ }
@@ -1,13 +1,23 @@
1
1
  import { Table, StringColumn, ObjectColumn } from '@proteinjs/db';
2
- import { ScopedRecord, withScopedRecordColumns } from '@proteinjs/user';
2
+ import { ScopedRecord, createScopedIndex, withScopedRecordColumns } from '@proteinjs/user';
3
3
 
4
4
  export interface Setting extends ScopedRecord {
5
5
  name: string;
6
6
  value: any;
7
7
  }
8
8
 
9
+ /**
10
+ * One row per name within a scope, by declaration: the unique index over (scope, name) is what
11
+ * keeps two writers from creating two rows of one name — `Settings.set` inserts, the index refuses
12
+ * the second writer's insert, and that writer updates the one row. The same name in another scope
13
+ * is another person's setting, its own row. A database that predates the index reaches the
14
+ * invariant through `DeduplicateSettingRows`, which runs before the index is added.
15
+ */
9
16
  export class SettingTable extends Table<Setting> {
10
17
  public name = 'setting';
18
+ public indexes: Table<Setting>['indexes'] = [
19
+ createScopedIndex<Setting>({ columns: ['name'], name: 'setting_scope_name_unique', unique: true }),
20
+ ];
11
21
  public auth: Table<Setting>['auth'] = {
12
22
  db: {
13
23
  all: 'authenticated',
@@ -0,0 +1,83 @@
1
+ import { Db, DuplicateValuesForUniqueIndexError, Record, Table, getDbAsSystem } from '@proteinjs/db';
2
+ import { SpannerDriver } from '@proteinjs/db-driver-spanner';
3
+ import { SpannerEmulatorProvisioner, getDropTestTable } from '@proteinjs/db-driver-spanner/test';
4
+ import moment from 'moment';
5
+ import { DeduplicateSettingRows } from '../src/migrations/DeduplicateSettingRows';
6
+ import { Setting, SettingTable } from '../src/tables/SettingTable';
7
+ import { tables } from '../src/tables/tables';
8
+ import '@proteinjs/db-transaction-context';
9
+ import '../generated/index';
10
+
11
+ /**
12
+ * The data step that carries a live database to one row per name: over a setting table as it
13
+ * stood before the unique index (duplicate rows of a (scope, name) in it), schema sync refuses to
14
+ * add the index; the migration keeps the newest row of every (scope, name) — by `updated`, then
15
+ * `created` — and deletes the rest, touching no other row; a second run deletes nothing; the index
16
+ * then lands. On a database without the table it does nothing.
17
+ */
18
+
19
+ const emulator = { projectId: 'proteinjs-test', instanceName: 'proteinjs-test', databaseName: 'test' };
20
+
21
+ describe('keeping one setting row per scope and name ahead of the unique index', () => {
22
+ const systemDb = getDbAsSystem();
23
+ const migration = new DeduplicateSettingRows().record;
24
+ let driver: SpannerDriver;
25
+ let dropTable: (table: Table<any>) => Promise<void>;
26
+
27
+ const minutesAgo = (minutes: number) => moment().subtract(minutes, 'minutes');
28
+ const seed = (row: Partial<Setting>) => systemDb.insert(tables.Setting, row as Omit<Setting, keyof Record>);
29
+ const valuesLeft = async () =>
30
+ (await systemDb.query(tables.Setting, {})).map((row) => `${row.scope}/${row.name}=${row.value}`).sort();
31
+
32
+ beforeAll(async () => {
33
+ await SpannerEmulatorProvisioner.ensureProvisioned(emulator);
34
+ driver = Db.getDefaultDbDriver() as SpannerDriver;
35
+ dropTable = getDropTestTable(driver);
36
+ await dropTable(tables.Setting);
37
+ // The table as a database that predates the invariant holds it: no unique index.
38
+ const beforeTheIndex = new SettingTable();
39
+ beforeTheIndex.indexes = [];
40
+ await driver.getTableManager().loadTable(beforeTheIndex);
41
+ }, 120000);
42
+
43
+ afterAll(async () => {
44
+ await dropTable(tables.Setting);
45
+ SpannerEmulatorProvisioner.release();
46
+ }, 120000);
47
+
48
+ test('keeps the newest row of each (scope, name), deletes the rest, and the index then lands', async () => {
49
+ // ada/theme three times over: the newest by `updated` is the one written last, not the one created last.
50
+ await seed({ scope: 'ada', name: 'theme', value: 'first', created: minutesAgo(30), updated: minutesAgo(30) });
51
+ await seed({ scope: 'ada', name: 'theme', value: 'newest', created: minutesAgo(20), updated: minutesAgo(5) });
52
+ await seed({ scope: 'ada', name: 'theme', value: 'middle', created: minutesAgo(10), updated: minutesAgo(10) });
53
+ // ada/font twice with one `updated`: the later `created` is the newest.
54
+ await seed({ scope: 'ada', name: 'font', value: 'serif', created: minutesAgo(20), updated: minutesAgo(1) });
55
+ await seed({ scope: 'ada', name: 'font', value: 'sans', created: minutesAgo(15), updated: minutesAgo(1) });
56
+ // One row each: untouched.
57
+ await seed({ scope: 'ada', name: 'lang', value: 'en' });
58
+ await seed({ scope: 'grace', name: 'theme', value: 'green' });
59
+
60
+ await expect(driver.getTableManager().loadTable(tables.Setting)).rejects.toThrow(
61
+ DuplicateValuesForUniqueIndexError
62
+ );
63
+
64
+ expect(await migration.estimate!()).toEqual({ units: 3, unit: 'rows' });
65
+ expect(await migration.run()).toEqual({ deleted: 3 });
66
+ expect(await valuesLeft()).toEqual(['ada/font=sans', 'ada/lang=en', 'ada/theme=newest', 'grace/theme=green']);
67
+
68
+ expect(await migration.estimate!()).toEqual({ units: 0, unit: 'rows' });
69
+ expect(await migration.run()).toEqual({ deleted: 0 });
70
+ expect(await valuesLeft()).toEqual(['ada/font=sans', 'ada/lang=en', 'ada/theme=newest', 'grace/theme=green']);
71
+
72
+ await driver.getTableManager().loadTable(tables.Setting);
73
+ expect(await driver.getTableManager().schemaMetadata.getIndexes(tables.Setting)).toMatchObject({
74
+ setting_scope_name_unique: ['scope', 'name'],
75
+ });
76
+ });
77
+
78
+ test('a database without the table: nothing to do', async () => {
79
+ await dropTable(tables.Setting);
80
+ expect(await migration.estimate!()).toEqual({ units: 0, unit: 'rows' });
81
+ expect(await migration.run()).toEqual({ deleted: 0 });
82
+ });
83
+ });
@@ -0,0 +1,104 @@
1
+ import { Db, Table, getDbAsSystem, isDuplicateKeyError } from '@proteinjs/db';
2
+ import { SpannerDriver } from '@proteinjs/db-driver-spanner';
3
+ import { SpannerEmulatorProvisioner, getDropTestTable } from '@proteinjs/db-driver-spanner/test';
4
+ import { Logger } from '@proteinjs/logger';
5
+ import { Settings } from '../src/Settings';
6
+ import { tables } from '../src/tables/tables';
7
+ import { HeldFirstWriteDriver } from './util/HeldFirstWriteDriver';
8
+ import { TestSession } from './util/TestSession';
9
+ import '@proteinjs/db-transaction-context';
10
+ import '../generated/index';
11
+
12
+ /**
13
+ * One setting row per name within a scope, on the real stack (the Spanner emulator, the default
14
+ * driver and transaction context resolved through reflection — the path `getScopedDb` takes in
15
+ * production): two concurrent `set`s of a fresh name — each observing no row before either
16
+ * writes, the interleaving that created two rows — leave ONE row, `get` reads its value, a later
17
+ * `set` rewrites that row without the driver reporting a refused statement (the common act stays
18
+ * quiet), the table's unique index refuses a second row of the name in the scope by its typed
19
+ * error, and the same name in another scope is another person's row.
20
+ */
21
+
22
+ const emulator = { projectId: 'proteinjs-test', instanceName: 'proteinjs-test', databaseName: 'test' };
23
+ const ada = { id: 'settings-test-ada', email: 'ada@settings.test', name: 'Ada' };
24
+ const grace = { id: 'settings-test-grace', email: 'grace@settings.test', name: 'Grace' };
25
+
26
+ describe('one setting row per name within a scope', () => {
27
+ const settings = new Settings();
28
+ const systemDb = getDbAsSystem();
29
+ let driver: SpannerDriver;
30
+ let dropTable: (table: Table<any>) => Promise<void>;
31
+
32
+ const rowsOf = (scope: string, name: string) => systemDb.query(tables.Setting, { scope, name });
33
+
34
+ beforeAll(async () => {
35
+ await SpannerEmulatorProvisioner.ensureProvisioned(emulator);
36
+ driver = Db.getDefaultDbDriver() as SpannerDriver;
37
+ dropTable = getDropTestTable(driver);
38
+ await dropTable(tables.Setting);
39
+ await driver.getTableManager().loadTable(tables.Setting);
40
+ TestSession.as(ada);
41
+ }, 120000);
42
+
43
+ afterAll(async () => {
44
+ await dropTable(tables.Setting);
45
+ SpannerEmulatorProvisioner.release();
46
+ }, 120000);
47
+
48
+ test('two concurrent sets of a fresh name, each observing no row before either writes, leave one row', async () => {
49
+ // Every `set` builds its db on the default driver: both writers' first writes are held until
50
+ // both have reached one, then run — neither can see the other's row before it writes.
51
+ const held = new HeldFirstWriteDriver(driver, 2);
52
+ const defaultDriver = jest.spyOn(Db, 'getDefaultDbDriver').mockImplementation(() => held);
53
+ try {
54
+ await Promise.all([settings.set('theme', 'dark'), settings.set('theme', 'light')]);
55
+ } finally {
56
+ defaultDriver.mockRestore();
57
+ }
58
+ const rows = await rowsOf(ada.id, 'theme');
59
+ expect(rows.length).toBe(1);
60
+ expect(['dark', 'light']).toContain(rows[0].value);
61
+ expect(await settings.get('theme')).toBe(rows[0].value);
62
+ });
63
+
64
+ test('a later set rewrites the one row, and the driver writes no error line for it', async () => {
65
+ // The driver's log lines, captured at its writer for the rewrite: a rewrite of an existing
66
+ // name is the common act (a setting written on every change) and must not be a refused
67
+ // statement the driver reports as a failure.
68
+ const internals = driver as unknown as { logger: Logger };
69
+ const driverLogger = internals.logger;
70
+ const lines: { logLevel: string; message?: string }[] = [];
71
+ internals.logger = new Logger({
72
+ name: 'SpannerDriver',
73
+ logLevel: 'info',
74
+ logWriter: { write: (line: { logLevel: string; message?: string }) => lines.push(line) } as any,
75
+ });
76
+ try {
77
+ await settings.set('theme', 'blue');
78
+ } finally {
79
+ internals.logger = driverLogger;
80
+ }
81
+ expect(lines.filter((line) => line.logLevel === 'error' || line.logLevel === 'warn')).toEqual([]);
82
+ expect((await rowsOf(ada.id, 'theme')).map((row) => row.value)).toEqual(['blue']);
83
+ expect(await settings.get('theme')).toBe('blue');
84
+ });
85
+
86
+ test('the unique index refuses a second row of the name in the scope by its typed error', async () => {
87
+ let refusal: unknown;
88
+ try {
89
+ await systemDb.insert(tables.Setting, { scope: ada.id, name: 'theme', value: 'red' });
90
+ } catch (error) {
91
+ refusal = error;
92
+ }
93
+ expect(isDuplicateKeyError(refusal)).toBe(true);
94
+ expect((await rowsOf(ada.id, 'theme')).map((row) => row.value)).toEqual(['blue']);
95
+ });
96
+
97
+ test('the same name in another scope is another row', async () => {
98
+ TestSession.as(grace);
99
+ await settings.set('theme', 'green');
100
+ expect(await settings.get('theme')).toBe('green');
101
+ expect((await rowsOf(grace.id, 'theme')).map((row) => row.value)).toEqual(['green']);
102
+ expect((await rowsOf(ada.id, 'theme')).map((row) => row.value)).toEqual(['blue']);
103
+ });
104
+ });
package/test/setup.js ADDED
@@ -0,0 +1,2 @@
1
+ process.env.SPANNER_EMULATOR_HOST = process.env.SPANNER_EMULATOR_HOST || 'localhost:9010';
2
+ process.env.DB_LOG_LEVEL = 'error';
@@ -0,0 +1,110 @@
1
+ import {
2
+ DbDriver,
3
+ DbDriverDmlStatementConfig,
4
+ DbDriverQueryStatementConfig,
5
+ SerializedRecord,
6
+ Statement,
7
+ TableManager,
8
+ } from '@proteinjs/db';
9
+
10
+ /**
11
+ * The real driver, with `writers` concurrent writers held in lockstep at their first write: each
12
+ * writer's first write is held until every writer has reached one, then all run; every later
13
+ * write waits until every first write has settled. The interleaving under which concurrent
14
+ * writers of one fresh name each observe no row before any of them writes one — what two requests
15
+ * produce when they write one setting at the same moment — made exact, so the suite does not
16
+ * rest on timing (the emulator serializes concurrent transactions with aborts and retries, which
17
+ * on their own let one writer's second write slip in before the other's first has settled).
18
+ * Reads go straight through. A barrier that never fills fails the held writers with a named
19
+ * error instead of hanging the suite.
20
+ */
21
+ export class HeldFirstWriteDriver implements DbDriver {
22
+ private arrivals = 0;
23
+ private release!: () => void;
24
+ private readonly released: Promise<void>;
25
+ private readonly firstWrites: Promise<void>[] = [];
26
+
27
+ constructor(
28
+ private readonly driver: DbDriver,
29
+ private readonly writers: number
30
+ ) {
31
+ this.released = new Promise<void>((resolve, reject) => {
32
+ this.release = resolve;
33
+ setTimeout(
34
+ () => reject(new Error(`Only ${this.arrivals} of ${writers} writers reached their first write`)),
35
+ 10000
36
+ ).unref();
37
+ });
38
+ }
39
+
40
+ async runDml(
41
+ generateStatement: (config: DbDriverDmlStatementConfig) => Statement,
42
+ transaction?: unknown
43
+ ): Promise<number> {
44
+ if (this.arrivals < this.writers) {
45
+ this.arrivals += 1;
46
+ if (this.arrivals === this.writers) {
47
+ this.release();
48
+ }
49
+ await this.released;
50
+ const write = this.driver.runDml(generateStatement, transaction);
51
+ this.firstWrites.push(
52
+ write.then(
53
+ () => undefined,
54
+ () => undefined
55
+ )
56
+ );
57
+ return await write;
58
+ }
59
+ await Promise.all(this.firstWrites);
60
+ return await this.driver.runDml(generateStatement, transaction);
61
+ }
62
+
63
+ runQuery(
64
+ generateStatement: (config: DbDriverQueryStatementConfig) => Statement,
65
+ transaction?: unknown
66
+ ): Promise<SerializedRecord[]> {
67
+ return this.driver.runQuery(generateStatement, transaction);
68
+ }
69
+
70
+ runTransaction<T>(fn: (transaction: unknown) => Promise<T>): Promise<T> {
71
+ return this.driver.runTransaction(fn);
72
+ }
73
+
74
+ isDuplicateKeyError(error: unknown): boolean {
75
+ return this.driver.isDuplicateKeyError?.(error) ?? false;
76
+ }
77
+
78
+ getDbName(): string {
79
+ return this.driver.getDbName();
80
+ }
81
+
82
+ createDbIfNotExists(): Promise<void> {
83
+ return this.driver.createDbIfNotExists();
84
+ }
85
+
86
+ createDb(name: string, options?: { ddl?: string[] }): Promise<void> {
87
+ return this.driver.createDb(name, options);
88
+ }
89
+
90
+ dropDb(name: string): Promise<void> {
91
+ return this.driver.dropDb(name);
92
+ }
93
+
94
+ getTableManager(): TableManager {
95
+ return this.driver.getTableManager();
96
+ }
97
+
98
+ getOperationDeadlineMs(): number {
99
+ return this.driver.getOperationDeadlineMs();
100
+ }
101
+
102
+ /** Forwarded; a driver that declares no bound would leave this undeclared, which a wrapper cannot express. */
103
+ getStatementParameterLimit(): number {
104
+ const limit = this.driver.getStatementParameterLimit?.();
105
+ if (limit === undefined) {
106
+ throw new Error('The wrapped driver declares no statement parameter limit');
107
+ }
108
+ return limit;
109
+ }
110
+ }