@proteinjs/db-settings 1.3.46 → 1.3.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +3 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/src/Settings.d.ts +10 -1
- package/dist/src/Settings.d.ts.map +1 -1
- package/dist/src/Settings.js +30 -9
- package/dist/src/Settings.js.map +1 -1
- package/dist/src/migrations/DeduplicateSettingRows.d.ts +34 -0
- package/dist/src/migrations/DeduplicateSettingRows.d.ts.map +1 -0
- package/dist/src/migrations/DeduplicateSettingRows.js +171 -0
- package/dist/src/migrations/DeduplicateSettingRows.js.map +1 -0
- package/dist/src/tables/SettingTable.d.ts +8 -0
- package/dist/src/tables/SettingTable.d.ts.map +1 -1
- package/dist/src/tables/SettingTable.js +10 -0
- package/dist/src/tables/SettingTable.js.map +1 -1
- package/dist/test/DeduplicateSettingRows.test.d.ts +3 -0
- package/dist/test/DeduplicateSettingRows.test.d.ts.map +1 -0
- package/dist/test/DeduplicateSettingRows.test.js +195 -0
- package/dist/test/DeduplicateSettingRows.test.js.map +1 -0
- package/dist/test/SettingsOneRowPerName.test.d.ts +3 -0
- package/dist/test/SettingsOneRowPerName.test.d.ts.map +1 -0
- package/dist/test/SettingsOneRowPerName.test.js +213 -0
- package/dist/test/SettingsOneRowPerName.test.js.map +1 -0
- package/dist/test/util/HeldFirstWriteDriver.d.ts +36 -0
- package/dist/test/util/HeldFirstWriteDriver.d.ts.map +1 -0
- package/dist/test/util/HeldFirstWriteDriver.js +130 -0
- package/dist/test/util/HeldFirstWriteDriver.js.map +1 -0
- package/dist/test/util/TestSession.d.ts +22 -0
- package/dist/test/util/TestSession.d.ts.map +1 -0
- package/dist/test/util/TestSession.js +49 -0
- package/dist/test/util/TestSession.js.map +1 -0
- package/generated/index.ts +3 -1
- package/index.ts +2 -0
- package/jest.config.js +2 -0
- package/package.json +9 -5
- package/src/Settings.ts +20 -5
- package/src/migrations/DeduplicateSettingRows.ts +78 -0
- package/src/tables/SettingTable.ts +11 -1
- package/test/DeduplicateSettingRows.test.ts +83 -0
- package/test/SettingsOneRowPerName.test.ts +104 -0
- package/test/setup.js +2 -0
- package/test/util/HeldFirstWriteDriver.ts +110 -0
- 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"}
|
package/generated/index.ts
CHANGED
|
@@ -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":"
|
|
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/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":"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"}],"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/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"}]}';
|
|
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
package/jest.config.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteinjs/db-settings",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.48",
|
|
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.
|
|
25
|
+
"@proteinjs/db": "^1.54.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.
|
|
29
|
+
"@proteinjs/user": "^1.8.0",
|
|
29
30
|
"moment": "2.29.4"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
33
|
+
"@proteinjs/db-driver-spanner": "^1.35.0",
|
|
34
|
+
"@proteinjs/db-transaction-context": "^0.5.45",
|
|
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": "
|
|
50
|
+
"gitHead": "856ac58e8bb61e5d46261ffb57434994fb808390"
|
|
47
51
|
}
|
package/src/Settings.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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,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
|
+
}
|