mirror of
https://github.com/node-red/node-red.git
synced 2023-10-10 13:36:53 +02:00
Apply fGC to all global contexts for default values
This commit is contained in:
parent
14882bda78
commit
038d821a7c
@ -19,21 +19,23 @@ var log = require("../../log");
|
||||
var memory = require("./memory");
|
||||
|
||||
var settings;
|
||||
|
||||
// A map of scope id to context instance
|
||||
var contexts = {};
|
||||
var globalContext = null;
|
||||
var externalContexts = {};
|
||||
var hasExternalContext = false;
|
||||
|
||||
// A map of store name to instance
|
||||
var stores = {};
|
||||
|
||||
// Whether there context storage has been configured or left as default
|
||||
var hasConfiguredStore = false;
|
||||
|
||||
var defaultStore = "_";
|
||||
|
||||
function init(_settings) {
|
||||
settings = _settings;
|
||||
externalContexts = {};
|
||||
|
||||
// init memory plugin
|
||||
stores = {};
|
||||
var seed = settings.functionGlobalContext || {};
|
||||
externalContexts["_"] = memory();
|
||||
externalContexts["_"].setGlobalContext(seed);
|
||||
globalContext = createContext("global",seed);
|
||||
contexts['global'] = globalContext;
|
||||
contexts['global'] = createContext("global",seed);
|
||||
}
|
||||
|
||||
function load() {
|
||||
@ -43,6 +45,8 @@ function load() {
|
||||
var defaultIsAlias = false;
|
||||
var promises = [];
|
||||
if (plugins) {
|
||||
var hasDefault = plugins.hasOwnProperty('default');
|
||||
var defaultName;
|
||||
for (var pluginName in plugins) {
|
||||
if (plugins.hasOwnProperty(pluginName)) {
|
||||
// "_" is a reserved name - do not allow it to be overridden
|
||||
@ -59,6 +63,9 @@ function load() {
|
||||
defaultIsAlias = true;
|
||||
continue;
|
||||
}
|
||||
if (!hasDefault && !defaultName) {
|
||||
defaultName = pluginName;
|
||||
}
|
||||
var plugin;
|
||||
if (plugins[pluginName].hasOwnProperty("module")) {
|
||||
// Get the provided config and copy in the 'approved' top-level settings (eg userDir)
|
||||
@ -79,7 +86,7 @@ function load() {
|
||||
}
|
||||
try {
|
||||
// Create a new instance of the plugin by calling its module function
|
||||
externalContexts[pluginName] = plugin(config);
|
||||
stores[pluginName] = plugin(config);
|
||||
} catch(err) {
|
||||
return reject(new Error(log._("context.error-loading-module",{module:pluginName,message:err.toString()})));
|
||||
}
|
||||
@ -91,23 +98,39 @@ function load() {
|
||||
}
|
||||
|
||||
// Open all of the configured contexts
|
||||
for (var plugin in externalContexts) {
|
||||
if (externalContexts.hasOwnProperty(plugin)) {
|
||||
promises.push(externalContexts[plugin].open());
|
||||
for (var plugin in stores) {
|
||||
if (stores.hasOwnProperty(plugin)) {
|
||||
promises.push(stores[plugin].open());
|
||||
}
|
||||
}
|
||||
|
||||
// If 'default' is an alias, point it at the right module - we have already
|
||||
// checked that it exists
|
||||
if (defaultIsAlias) {
|
||||
externalContexts["default"] = externalContexts[plugins["default"]];
|
||||
}
|
||||
// There is a 'default' listed in the configuration
|
||||
if (hasDefault) {
|
||||
// If 'default' is an alias, point it at the right module - we have already
|
||||
// checked that it exists. If it isn't an alias, then it will
|
||||
// already be set to a configured store
|
||||
if (defaultIsAlias) {
|
||||
stores["default"] = stores[plugins["default"]];
|
||||
}
|
||||
stores["_"] = stores["default"];
|
||||
} else if (defaultName) {
|
||||
// No 'default' listed, so pick first in list as the default
|
||||
stores["default"] = stores[defaultName];
|
||||
stores["_"] = stores["default"];
|
||||
} // else there were no stores list the config object - fall through
|
||||
// to below where we default to a memory store
|
||||
}
|
||||
|
||||
if (promises.length === 0) {
|
||||
promises.push(externalContexts["_"].open())
|
||||
// No stores have been configured. Setup the default as an instance
|
||||
// of memory storage
|
||||
stores["_"] = memory();
|
||||
stores["default"] = stores["_"];
|
||||
promises.push(stores["_"].open())
|
||||
} else {
|
||||
hasExternalContext = true;
|
||||
// if there's configured storage then the lifecycle is slightly different
|
||||
// - specifically, we don't delete node context on redeploy
|
||||
hasConfiguredStore = true;
|
||||
}
|
||||
return resolve(Promise.all(promises));
|
||||
});
|
||||
@ -122,12 +145,12 @@ function copySettings(config, settings){
|
||||
}
|
||||
|
||||
function getContextStorage(storage) {
|
||||
if (externalContexts.hasOwnProperty(storage)) {
|
||||
if (stores.hasOwnProperty(storage)) {
|
||||
// A known context
|
||||
return externalContexts[storage];
|
||||
} else if (externalContexts.hasOwnProperty("default")) {
|
||||
return stores[storage];
|
||||
} else if (stores.hasOwnProperty("default")) {
|
||||
// Not known, but we have a default to fall back to
|
||||
return externalContexts["default"];
|
||||
return stores["default"];
|
||||
} else {
|
||||
// Not known and no default configured
|
||||
var contextError = new Error(log._("context.error-use-undefined-storage", {storage:storage}));
|
||||
@ -136,14 +159,19 @@ function getContextStorage(storage) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function createContext(id,seed) {
|
||||
// Seed is only set for global context - sourced from functionGlobalContext
|
||||
var scope = id;
|
||||
var obj = seed || {};
|
||||
|
||||
var seedKeys;
|
||||
if (seed) {
|
||||
seedKeys = Object.keys(seed);
|
||||
}
|
||||
obj.get = function(key, storage, callback) {
|
||||
var context;
|
||||
if (!storage && !callback) {
|
||||
context = externalContexts["_"];
|
||||
context = stores["_"];
|
||||
} else {
|
||||
if (typeof storage === 'function') {
|
||||
callback = storage;
|
||||
@ -154,12 +182,34 @@ function createContext(id,seed) {
|
||||
}
|
||||
context = getContextStorage(storage);
|
||||
}
|
||||
return context.get(scope, key, callback);
|
||||
if (seed) {
|
||||
// Get the value from the underlying store. If it is undefined,
|
||||
// check the seed for a default value.
|
||||
if (callback) {
|
||||
context.get(scope,key,function(err, v) {
|
||||
if (v === undefined) {
|
||||
callback(err, seed[key]);
|
||||
} else {
|
||||
callback(err, v);
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// No callback, attempt to do this synchronously
|
||||
var storeValue = context.get(scope,key);
|
||||
if (storeValue === undefined) {
|
||||
return seed[key];
|
||||
} else {
|
||||
return storeValue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return context.get(scope, key, callback);
|
||||
}
|
||||
};
|
||||
obj.set = function(key, value, storage, callback) {
|
||||
var context;
|
||||
if (!storage && !callback) {
|
||||
context = externalContexts["_"];
|
||||
context = stores["_"];
|
||||
} else {
|
||||
if (typeof storage === 'function') {
|
||||
callback = storage;
|
||||
@ -175,7 +225,7 @@ function createContext(id,seed) {
|
||||
obj.keys = function(storage, callback) {
|
||||
var context;
|
||||
if (!storage && !callback) {
|
||||
context = externalContexts["_"];
|
||||
context = stores["_"];
|
||||
} else {
|
||||
if (typeof storage === 'function') {
|
||||
callback = storage;
|
||||
@ -186,7 +236,18 @@ function createContext(id,seed) {
|
||||
}
|
||||
context = getContextStorage(storage);
|
||||
}
|
||||
return context.keys(scope, callback);
|
||||
if (seed) {
|
||||
if (callback) {
|
||||
context.keys(scope, function(err,keys) {
|
||||
callback(err,Array.from(new Set(seedKeys.concat(keys)).keys()));
|
||||
});
|
||||
} else {
|
||||
var keys = context.keys(scope);
|
||||
return Array.from(new Set(seedKeys.concat(keys)).keys())
|
||||
}
|
||||
} else {
|
||||
return context.keys(scope, callback);
|
||||
}
|
||||
};
|
||||
return obj;
|
||||
}
|
||||
@ -203,21 +264,20 @@ function getContext(localId,flowId) {
|
||||
if (flowId) {
|
||||
newContext.flow = getContext(flowId);
|
||||
}
|
||||
if (globalContext) {
|
||||
newContext.global = globalContext;
|
||||
}
|
||||
newContext.global = contexts['global'];
|
||||
contexts[contextId] = newContext;
|
||||
return newContext;
|
||||
}
|
||||
|
||||
function deleteContext(id,flowId) {
|
||||
if(!hasExternalContext){
|
||||
if(!hasConfiguredStore){
|
||||
// only delete context if there's no configured storage.
|
||||
var contextId = id;
|
||||
if (flowId) {
|
||||
contextId = id+":"+flowId;
|
||||
}
|
||||
delete contexts[contextId];
|
||||
return externalContexts["_"].delete(contextId);
|
||||
return stores["_"].delete(contextId);
|
||||
}else{
|
||||
return Promise.resolve();
|
||||
}
|
||||
@ -225,9 +285,9 @@ function deleteContext(id,flowId) {
|
||||
|
||||
function clean(flowConfig) {
|
||||
var promises = [];
|
||||
for(var plugin in externalContexts){
|
||||
if(externalContexts.hasOwnProperty(plugin)){
|
||||
promises.push(externalContexts[plugin].clean(Object.keys(flowConfig.allNodes)));
|
||||
for(var plugin in stores){
|
||||
if(stores.hasOwnProperty(plugin)){
|
||||
promises.push(stores[plugin].clean(Object.keys(flowConfig.allNodes)));
|
||||
}
|
||||
}
|
||||
for (var id in contexts) {
|
||||
@ -243,9 +303,9 @@ function clean(flowConfig) {
|
||||
|
||||
function close() {
|
||||
var promises = [];
|
||||
for(var plugin in externalContexts){
|
||||
if(externalContexts.hasOwnProperty(plugin)){
|
||||
promises.push(externalContexts[plugin].close());
|
||||
for(var plugin in stores){
|
||||
if(stores.hasOwnProperty(plugin)){
|
||||
promises.push(stores[plugin].close());
|
||||
}
|
||||
}
|
||||
return Promise.all(promises);
|
||||
|
@ -109,10 +109,6 @@ Memory.prototype.clean = function(activeNodes){
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
Memory.prototype.setGlobalContext= function(seed){
|
||||
this.data["global"] = seed;
|
||||
};
|
||||
|
||||
module.exports = function(config){
|
||||
return new Memory(config);
|
||||
};
|
@ -124,7 +124,7 @@ describe('context', function() {
|
||||
});
|
||||
});
|
||||
|
||||
it('enumerates context keys', function() {
|
||||
it('enumerates context keys - sync', function() {
|
||||
var context = Context.get("1","flowA");
|
||||
|
||||
var keys = context.keys();
|
||||
@ -142,15 +142,63 @@ describe('context', function() {
|
||||
keys[1].should.equal("abc");
|
||||
});
|
||||
|
||||
it('should enumerate only context keys when GlobalContext was given', function() {
|
||||
it('enumerates context keys - async', function(done) {
|
||||
var context = Context.get("1","flowA");
|
||||
|
||||
var keys = context.keys(function(err,keys) {
|
||||
keys.should.be.an.Array();
|
||||
keys.should.be.empty();
|
||||
context.set("foo","bar");
|
||||
keys = context.keys(function(err,keys) {
|
||||
keys.should.have.length(1);
|
||||
keys[0].should.equal("foo");
|
||||
|
||||
context.set("abc.def","bar");
|
||||
keys = context.keys(function(err,keys) {
|
||||
keys.should.have.length(2);
|
||||
keys[1].should.equal("abc");
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should enumerate only context keys when GlobalContext was given - sync', function() {
|
||||
Context.init({functionGlobalContext: {foo:"bar"}});
|
||||
return Context.load().then(function(){
|
||||
var context = Context.get("1","flowA");
|
||||
context.global.set("foo2","bar2");
|
||||
var keys = context.global.keys();
|
||||
keys.should.have.length(1);
|
||||
keys.should.have.length(2);
|
||||
keys[0].should.equal("foo");
|
||||
keys[1].should.equal("foo2");
|
||||
});
|
||||
});
|
||||
|
||||
it('should enumerate only context keys when GlobalContext was given - async', function(done) {
|
||||
Context.init({functionGlobalContext: {foo:"bar"}});
|
||||
return Context.load().then(function(){
|
||||
var context = Context.get("1","flowA");
|
||||
context.global.set("foo2","bar2");
|
||||
context.global.keys(function(err,keys) {
|
||||
keys.should.have.length(2);
|
||||
keys[0].should.equal("foo");
|
||||
keys[1].should.equal("foo2");
|
||||
done();
|
||||
});
|
||||
}).catch(done);
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('returns functionGlobalContext value if store value undefined', function() {
|
||||
Context.init({functionGlobalContext: {foo:"bar"}});
|
||||
return Context.load().then(function(){
|
||||
var context = Context.get("1","flowA");
|
||||
var v = context.global.get('foo');
|
||||
v.should.equal('bar');
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
describe('external context storage',function() {
|
||||
@ -215,6 +263,11 @@ describe('context', function() {
|
||||
config:{}
|
||||
}
|
||||
};
|
||||
var memoryStorage ={
|
||||
memory:{
|
||||
module: "memory"
|
||||
}
|
||||
};
|
||||
|
||||
afterEach(function() {
|
||||
sandbox.reset();
|
||||
@ -257,7 +310,7 @@ describe('context', function() {
|
||||
stubSet.calledWithExactly("1:flow","file","file2",cb).should.be.true();
|
||||
stubSet.calledWithExactly("1:flow","num","num3",cb).should.be.true();
|
||||
done();
|
||||
});
|
||||
}).catch(done);
|
||||
});
|
||||
it('should ignore reserved storage name `_`', function(done) {
|
||||
Context.init({contextStorage:{_:{module:testPlugin}}});
|
||||
@ -271,7 +324,7 @@ describe('context', function() {
|
||||
stubGet.called.should.be.false();
|
||||
stubKeys.called.should.be.false();
|
||||
done();
|
||||
});
|
||||
}).catch(done);
|
||||
});
|
||||
it('should fail when using invalid default context', function(done) {
|
||||
Context.init({contextStorage:{default:"noexist"}});
|
||||
@ -300,14 +353,15 @@ describe('context', function() {
|
||||
});
|
||||
|
||||
describe('close modules',function(){
|
||||
it('should call close()', function() {
|
||||
it('should call close()', function(done) {
|
||||
Context.init({contextStorage:contextDefaultStorage});
|
||||
return Context.load().then(function(){
|
||||
return Context.close().then(function(){
|
||||
stubClose.called.should.be.true();
|
||||
stubClose2.called.should.be.true();
|
||||
done();
|
||||
});
|
||||
});
|
||||
}).catch(done);
|
||||
});
|
||||
});
|
||||
|
||||
@ -324,7 +378,7 @@ describe('context', function() {
|
||||
stubGet.calledWithExactly("1:flow","foo",cb).should.be.true();
|
||||
stubKeys.calledWithExactly("1:flow",cb).should.be.true();
|
||||
done();
|
||||
});
|
||||
}).catch(done);
|
||||
});
|
||||
it('should store flow property to external context storage',function(done) {
|
||||
Context.init({contextStorage:contextStorage});
|
||||
@ -338,7 +392,7 @@ describe('context', function() {
|
||||
stubGet.calledWithExactly("flow","foo",cb).should.be.true();
|
||||
stubKeys.calledWithExactly("flow",cb).should.be.true();
|
||||
done();
|
||||
});
|
||||
}).catch(done);
|
||||
});
|
||||
it('should store global property to external context storage',function(done) {
|
||||
Context.init({contextStorage:contextStorage});
|
||||
@ -349,10 +403,10 @@ describe('context', function() {
|
||||
context.global.get("foo","test",cb);
|
||||
context.global.keys("test",cb);
|
||||
stubSet.calledWithExactly("global","foo","bar",cb).should.be.true();
|
||||
stubGet.calledWithExactly("global","foo",cb).should.be.true();
|
||||
stubKeys.calledWithExactly("global",cb).should.be.true();
|
||||
stubGet.calledWith("global","foo").should.be.true();
|
||||
stubKeys.calledWith("global").should.be.true();
|
||||
done();
|
||||
});
|
||||
}).catch(done);
|
||||
});
|
||||
it('should store data to the default context when non-existent context storage was specified', function(done) {
|
||||
Context.init({contextStorage:contextDefaultStorage});
|
||||
@ -369,7 +423,7 @@ describe('context', function() {
|
||||
stubGet2.calledWithExactly("1:flow","foo",cb).should.be.true();
|
||||
stubKeys2.calledWithExactly("1:flow",cb).should.be.true();
|
||||
done();
|
||||
});
|
||||
}).catch(done);
|
||||
});
|
||||
it('should use the default context', function(done) {
|
||||
Context.init({contextStorage:contextDefaultStorage});
|
||||
@ -386,7 +440,7 @@ describe('context', function() {
|
||||
stubGet2.calledWithExactly("1:flow","foo",cb).should.be.true();
|
||||
stubKeys2.calledWithExactly("1:flow",cb).should.be.true();
|
||||
done();
|
||||
});
|
||||
}).catch(done);
|
||||
});
|
||||
it('should use the alias of default context', function(done) {
|
||||
Context.init({contextStorage:contextDefaultStorage});
|
||||
@ -403,7 +457,7 @@ describe('context', function() {
|
||||
stubGet2.calledWithExactly("1:flow","foo",cb).should.be.true();
|
||||
stubKeys2.calledWithExactly("1:flow",cb).should.be.true();
|
||||
done();
|
||||
});
|
||||
}).catch(done);
|
||||
});
|
||||
it('should use default as the alias of other context', function(done) {
|
||||
Context.init({contextStorage:contextAlias});
|
||||
@ -417,22 +471,16 @@ describe('context', function() {
|
||||
stubGet.calledWithExactly("1:flow","foo",cb).should.be.true();
|
||||
stubKeys.calledWithExactly("1:flow",cb).should.be.true();
|
||||
done();
|
||||
});
|
||||
}).catch(done);
|
||||
});
|
||||
it('should throw an error using undefined storage for local context', function(done) {
|
||||
it('should not throw an error using undefined storage for local context', function(done) {
|
||||
Context.init({contextStorage:contextStorage});
|
||||
Context.load().then(function(){
|
||||
var context = Context.get("1","flow");
|
||||
var cb = function(){done("An error occurred")}
|
||||
context.get("local","nonexist",cb);
|
||||
should.fail(null, null, "An error was not thrown using undefined storage for local context");
|
||||
}).catch(function(err) {
|
||||
if (err.name === "ContextError") {
|
||||
done();
|
||||
} else {
|
||||
done(err);
|
||||
}
|
||||
});
|
||||
done()
|
||||
}).catch(done);
|
||||
});
|
||||
it('should throw an error using undefined storage for flow context', function(done) {
|
||||
Context.init({contextStorage:contextStorage});
|
||||
@ -440,39 +488,89 @@ describe('context', function() {
|
||||
var context = Context.get("1","flow");
|
||||
var cb = function(){done("An error occurred")}
|
||||
context.flow.get("flow","nonexist",cb);
|
||||
should.fail(null, null, "An error was not thrown using undefined storage for flow context");
|
||||
}).catch(function(err) {
|
||||
if (err.name === "ContextError") {
|
||||
done();
|
||||
} else {
|
||||
done(err);
|
||||
}
|
||||
});
|
||||
done();
|
||||
}).catch(done);
|
||||
});
|
||||
|
||||
it('should return functionGlobalContext value as a default - synchronous', function(done) {
|
||||
var fGC = { "foo": 456 };
|
||||
Context.init({contextStorage:memoryStorage, functionGlobalContext:fGC });
|
||||
Context.load().then(function() {
|
||||
var context = Context.get("1","flow");
|
||||
// Get foo - should be value from fGC
|
||||
var v = context.global.get("foo");
|
||||
v.should.equal(456);
|
||||
|
||||
// Update foo - should not touch fGC object
|
||||
context.global.set("foo","new value");
|
||||
fGC.foo.should.equal(456);
|
||||
|
||||
// Get foo - should be the updated value
|
||||
v = context.global.get("foo");
|
||||
v.should.equal("new value");
|
||||
done();
|
||||
}).catch(done);
|
||||
})
|
||||
|
||||
it('should return functionGlobalContext value as a default - async', function(done) {
|
||||
var fGC = { "foo": 456 };
|
||||
Context.init({contextStorage:memoryStorage, functionGlobalContext:fGC });
|
||||
Context.load().then(function() {
|
||||
var context = Context.get("1","flow");
|
||||
// Get foo - should be value from fGC
|
||||
context.global.get("foo", function(err, v) {
|
||||
if (err) {
|
||||
done(err)
|
||||
} else {
|
||||
v.should.equal(456);
|
||||
// Update foo - should not touch fGC object
|
||||
context.global.set("foo","new value", function(err) {
|
||||
if (err) {
|
||||
done(err)
|
||||
} else {
|
||||
fGC.foo.should.equal(456);
|
||||
// Get foo - should be the updated value
|
||||
context.global.get("foo", function(err, v) {
|
||||
if (err) {
|
||||
done(err)
|
||||
} else {
|
||||
v.should.equal("new value");
|
||||
done();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}).catch(done);
|
||||
})
|
||||
|
||||
});
|
||||
|
||||
describe('delete context',function(){
|
||||
it('should not call delete() when external context storage is used', function() {
|
||||
it('should not call delete() when external context storage is used', function(done) {
|
||||
Context.init({contextStorage:contextDefaultStorage});
|
||||
return Context.load().then(function(){
|
||||
Context.get("flowA");
|
||||
return Context.delete("flowA").then(function(){
|
||||
stubDelete.called.should.be.false();
|
||||
stubDelete2.called.should.be.false();
|
||||
done();
|
||||
});
|
||||
});
|
||||
}).catch(done);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clean context',function(){
|
||||
it('should call clean()', function() {
|
||||
it('should call clean()', function(done) {
|
||||
Context.init({contextStorage:contextDefaultStorage});
|
||||
return Context.load().then(function(){
|
||||
return Context.clean({allNodes:{}}).then(function(){
|
||||
stubClean.calledWithExactly([]).should.be.true();
|
||||
stubClean2.calledWithExactly([]).should.be.true();
|
||||
done();
|
||||
});
|
||||
});
|
||||
}).catch(done);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -103,19 +103,6 @@ describe('memory',function() {
|
||||
keysY[0].should.equal("hoge");
|
||||
});
|
||||
|
||||
it('should enumerate only context keys when GlobalContext was given', function() {
|
||||
var keys = context.keys("global");
|
||||
keys.should.be.an.Array();
|
||||
keys.should.be.empty();
|
||||
|
||||
var data = {
|
||||
foo: "bar"
|
||||
}
|
||||
context.setGlobalContext(data);
|
||||
keys = context.keys("global");
|
||||
keys.should.have.length(1);
|
||||
keys[0].should.equal("foo");
|
||||
});
|
||||
});
|
||||
|
||||
describe('#delete',function() {
|
||||
@ -163,17 +150,4 @@ describe('memory',function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setGlobalContext',function() {
|
||||
it('should initialize global context with argument', function() {
|
||||
var keys = context.keys("global");
|
||||
keys.should.be.an.Array();
|
||||
keys.should.be.empty();
|
||||
|
||||
var data = {
|
||||
foo: "bar"
|
||||
}
|
||||
context.setGlobalContext(data);
|
||||
context.get("global","foo").should.equal("bar");
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user