outsourced Y.Map type
This commit is contained in:
parent
0db7fe5d46
commit
334db3234b
2
dist
2
dist
@ -1 +1 @@
|
|||||||
Subproject commit c44c86054ef1df42d782a67cba1f679207d07ed1
|
Subproject commit 5da36f07cebfdcad53b845c0c3924c22f4894f2f
|
@ -9,6 +9,7 @@
|
|||||||
var Y = require('./y.js')
|
var Y = require('./y.js')
|
||||||
require('../../y-memory/src/Memory.js')(Y)
|
require('../../y-memory/src/Memory.js')(Y)
|
||||||
require('../../y-array/src/Array.js')(Y)
|
require('../../y-array/src/Array.js')(Y)
|
||||||
|
require('../../y-map/src/Map.js')(Y)
|
||||||
require('../../y-indexeddb/src/IndexedDB.js')(Y)
|
require('../../y-indexeddb/src/IndexedDB.js')(Y)
|
||||||
module.exports = Y
|
module.exports = Y
|
||||||
|
|
||||||
|
314
src/Types/Map.js
314
src/Types/Map.js
@ -1,314 +0,0 @@
|
|||||||
/* @flow */
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
module.exports = function (Y /* :any */) {
|
|
||||||
class YMap {
|
|
||||||
/* ::
|
|
||||||
_model: Id;
|
|
||||||
os: Y.AbstractDatabase;
|
|
||||||
map: Object;
|
|
||||||
contents: any;
|
|
||||||
opContents: Object;
|
|
||||||
eventHandler: Function;
|
|
||||||
*/
|
|
||||||
constructor (os, model, contents, opContents) {
|
|
||||||
this._model = model.id
|
|
||||||
this.os = os
|
|
||||||
this.map = Y.utils.copyObject(model.map)
|
|
||||||
this.contents = contents
|
|
||||||
this.opContents = opContents
|
|
||||||
this.eventHandler = new Y.utils.EventHandler(ops => {
|
|
||||||
var userEvents = []
|
|
||||||
for (var i in ops) {
|
|
||||||
var op = ops[i]
|
|
||||||
var oldValue
|
|
||||||
// key is the name to use to access (op)content
|
|
||||||
var key = op.struct === 'Delete' ? op.key : op.parentSub
|
|
||||||
|
|
||||||
// compute oldValue
|
|
||||||
if (this.opContents[key] != null) {
|
|
||||||
let prevType = this.opContents[key]
|
|
||||||
oldValue = () => {// eslint-disable-line
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
this.os.requestTransaction(function *() {// eslint-disable-line
|
|
||||||
var type = yield* this.getType(prevType)
|
|
||||||
resolve(type)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
oldValue = this.contents[key]
|
|
||||||
}
|
|
||||||
// compute op event
|
|
||||||
if (op.struct === 'Insert') {
|
|
||||||
if (op.left === null) {
|
|
||||||
if (op.opContent != null) {
|
|
||||||
delete this.contents[key]
|
|
||||||
if (op.deleted) {
|
|
||||||
delete this.opContents[key]
|
|
||||||
} else {
|
|
||||||
this.opContents[key] = op.opContent
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
delete this.opContents[key]
|
|
||||||
if (op.deleted) {
|
|
||||||
delete this.contents[key]
|
|
||||||
} else {
|
|
||||||
this.contents[key] = op.content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.map[key] = op.id
|
|
||||||
var insertEvent
|
|
||||||
if (oldValue === undefined) {
|
|
||||||
insertEvent = {
|
|
||||||
name: key,
|
|
||||||
object: this,
|
|
||||||
type: 'add'
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
insertEvent = {
|
|
||||||
name: key,
|
|
||||||
object: this,
|
|
||||||
oldValue: oldValue,
|
|
||||||
type: 'update'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
userEvents.push(insertEvent)
|
|
||||||
}
|
|
||||||
} else if (op.struct === 'Delete') {
|
|
||||||
if (Y.utils.compareIds(this.map[key], op.target)) {
|
|
||||||
delete this.opContents[key]
|
|
||||||
delete this.contents[key]
|
|
||||||
var deleteEvent = {
|
|
||||||
name: key,
|
|
||||||
object: this,
|
|
||||||
oldValue: oldValue,
|
|
||||||
type: 'delete'
|
|
||||||
}
|
|
||||||
userEvents.push(deleteEvent)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error('Unexpected Operation!')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.eventHandler.callEventListeners(userEvents)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
get (key) {
|
|
||||||
// return property.
|
|
||||||
// if property does not exist, return null
|
|
||||||
// if property is a type, return a promise
|
|
||||||
if (key == null) {
|
|
||||||
throw new Error('You must specify key!')
|
|
||||||
}
|
|
||||||
if (this.opContents[key] == null) {
|
|
||||||
return this.contents[key]
|
|
||||||
} else {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
var oid = this.opContents[key]
|
|
||||||
this.os.requestTransaction(function *() {
|
|
||||||
var type = yield* this.getType(oid)
|
|
||||||
resolve(type)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
If there is a primitive (not a custom type), then return it.
|
|
||||||
Returns all primitive values, if propertyName is specified!
|
|
||||||
Note: modifying the return value could result in inconsistencies!
|
|
||||||
-- so make sure to copy it first!
|
|
||||||
*/
|
|
||||||
getPrimitive (key) {
|
|
||||||
if (key == null) {
|
|
||||||
return Y.utils.copyObject(this.contents)
|
|
||||||
} else {
|
|
||||||
return this.contents[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delete (key) {
|
|
||||||
var right = this.map[key]
|
|
||||||
if (right != null) {
|
|
||||||
var del = {
|
|
||||||
target: right,
|
|
||||||
struct: 'Delete'
|
|
||||||
}
|
|
||||||
var eventHandler = this.eventHandler
|
|
||||||
var modDel = Y.utils.copyObject(del)
|
|
||||||
modDel.key = key
|
|
||||||
eventHandler.awaitAndPrematurelyCall([modDel])
|
|
||||||
this.os.requestTransaction(function *() {
|
|
||||||
yield* this.applyCreatedOperations([del])
|
|
||||||
eventHandler.awaitedDeletes(1)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
set (key, value) {
|
|
||||||
// set property.
|
|
||||||
// if property is a type, return a promise
|
|
||||||
// if not, apply immediately on this type an call event
|
|
||||||
|
|
||||||
var right = this.map[key] || null
|
|
||||||
var insert /* :any */ = {
|
|
||||||
left: null,
|
|
||||||
right: right,
|
|
||||||
origin: null,
|
|
||||||
parent: this._model,
|
|
||||||
parentSub: key,
|
|
||||||
struct: 'Insert'
|
|
||||||
}
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (value instanceof Y.utils.CustomType) {
|
|
||||||
// construct a new type
|
|
||||||
this.os.requestTransaction(function *() {
|
|
||||||
var typeid = yield* value.createType.call(this)
|
|
||||||
var type = yield* this.getType(typeid)
|
|
||||||
insert.opContent = typeid
|
|
||||||
insert.id = this.store.getNextOpId()
|
|
||||||
yield* this.applyCreatedOperations([insert])
|
|
||||||
resolve(type)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
insert.content = value
|
|
||||||
insert.id = this.os.getNextOpId()
|
|
||||||
var eventHandler = this.eventHandler
|
|
||||||
eventHandler.awaitAndPrematurelyCall([insert])
|
|
||||||
this.os.requestTransaction(function *() {
|
|
||||||
yield* this.applyCreatedOperations([insert])
|
|
||||||
eventHandler.awaitedInserts(1)
|
|
||||||
})
|
|
||||||
resolve(value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
observe (f) {
|
|
||||||
this.eventHandler.addEventListener(f)
|
|
||||||
}
|
|
||||||
unobserve (f) {
|
|
||||||
this.eventHandler.removeEventListener(f)
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
Observe a path.
|
|
||||||
|
|
||||||
E.g.
|
|
||||||
```
|
|
||||||
o.set('textarea', Y.TextBind)
|
|
||||||
o.observePath(['textarea'], function(t){
|
|
||||||
// is called whenever textarea is replaced
|
|
||||||
t.bind(textarea)
|
|
||||||
})
|
|
||||||
|
|
||||||
returns a Promise that contains a function that removes the observer from the path.
|
|
||||||
*/
|
|
||||||
observePath (path, f) {
|
|
||||||
var self = this
|
|
||||||
function observeProperty (events) {
|
|
||||||
// call f whenever path changes
|
|
||||||
for (var i = 0; i < events.length; i++) {
|
|
||||||
var event = events[i]
|
|
||||||
if (event.name === propertyName) {
|
|
||||||
// call this also for delete events!
|
|
||||||
var property = self.get(propertyName)
|
|
||||||
if (property instanceof Promise) {
|
|
||||||
property.then(f)
|
|
||||||
} else {
|
|
||||||
f(property)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path.length < 1) {
|
|
||||||
throw new Error('Path must contain at least one element!')
|
|
||||||
} else if (path.length === 1) {
|
|
||||||
var propertyName = path[0]
|
|
||||||
var property = self.get(propertyName)
|
|
||||||
if (property instanceof Promise) {
|
|
||||||
property.then(f)
|
|
||||||
} else {
|
|
||||||
f(property)
|
|
||||||
}
|
|
||||||
this.observe(observeProperty)
|
|
||||||
return Promise.resolve(function () {
|
|
||||||
self.unobserve(f)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
var deleteChildObservers
|
|
||||||
var resetObserverPath = function () {
|
|
||||||
var promise = self.get(path[0])
|
|
||||||
if (!promise instanceof Promise) {
|
|
||||||
// its either not defined or a primitive value
|
|
||||||
promise = self.set(path[0], Y.Map)
|
|
||||||
}
|
|
||||||
return promise.then(function (map) {
|
|
||||||
return map.observePath(path.slice(1), f)
|
|
||||||
}).then(function (_deleteChildObservers) {
|
|
||||||
// update deleteChildObservers
|
|
||||||
deleteChildObservers = _deleteChildObservers
|
|
||||||
return Promise.resolve() // Promise does not return anything
|
|
||||||
})
|
|
||||||
}
|
|
||||||
var observer = function (events) {
|
|
||||||
for (var e in events) {
|
|
||||||
var event = events[e]
|
|
||||||
if (event.name === path[0]) {
|
|
||||||
if (deleteChildObservers != null) {
|
|
||||||
deleteChildObservers()
|
|
||||||
}
|
|
||||||
if (event.type === 'add' || event.type === 'update') {
|
|
||||||
resetObserverPath()
|
|
||||||
}
|
|
||||||
// TODO: what about the delete events?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.observe(observer)
|
|
||||||
return resetObserverPath().then(
|
|
||||||
// this promise contains a function that deletes all the child observers
|
|
||||||
// and how to unobserve the observe from this object
|
|
||||||
new Promise.resolve(function () { // eslint-disable-line
|
|
||||||
if (deleteChildObservers != null) {
|
|
||||||
deleteChildObservers()
|
|
||||||
}
|
|
||||||
self.unobserve(observer)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
* _changed (transaction, op) {
|
|
||||||
if (op.struct === 'Delete') {
|
|
||||||
var target = yield* transaction.getOperation(op.target)
|
|
||||||
op.key = target.parentSub
|
|
||||||
}
|
|
||||||
this.eventHandler.receivedOp(op)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Y.Map = new Y.utils.CustomType({
|
|
||||||
class: YMap,
|
|
||||||
createType: function * YMapCreator () {
|
|
||||||
var modelid = this.store.getNextOpId()
|
|
||||||
var model = {
|
|
||||||
map: {},
|
|
||||||
struct: 'Map',
|
|
||||||
type: 'Map',
|
|
||||||
id: modelid
|
|
||||||
}
|
|
||||||
yield* this.applyCreatedOperations([model])
|
|
||||||
return modelid
|
|
||||||
},
|
|
||||||
initType: function * YMapInitializer (os, model) {
|
|
||||||
var contents = {}
|
|
||||||
var opContents = {}
|
|
||||||
var map = model.map
|
|
||||||
for (var name in map) {
|
|
||||||
var op = yield* this.getOperation(map[name])
|
|
||||||
if (op.opContent != null) {
|
|
||||||
opContents[name] = op.opContent
|
|
||||||
} else {
|
|
||||||
contents[name] = op.content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new YMap(os, model, contents, opContents)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
@ -1,221 +0,0 @@
|
|||||||
/* global createUsers, databases, compareAllUsers, getRandomNumber, applyRandomTransactionsAllRejoinNoGC, applyRandomTransactionsWithGC, async, describeManyTimes */
|
|
||||||
/* eslint-env browser,jasmine */
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
var Y = require('../SpecHelper.js')
|
|
||||||
var numberOfYMapTests = 50
|
|
||||||
var repeatMapTeasts = 1
|
|
||||||
|
|
||||||
for (let database of databases) {
|
|
||||||
describe(`Map Type (DB: ${database})`, function () {
|
|
||||||
var y1, y2, y3, y4, flushAll
|
|
||||||
|
|
||||||
beforeEach(async(function * (done) {
|
|
||||||
yield createUsers(this, 5, database)
|
|
||||||
y1 = this.users[0].share.root
|
|
||||||
y2 = this.users[1].share.root
|
|
||||||
y3 = this.users[2].share.root
|
|
||||||
y4 = this.users[3].share.root
|
|
||||||
flushAll = Y.utils.globalRoom.flushAll
|
|
||||||
done()
|
|
||||||
}))
|
|
||||||
afterEach(async(function * (done) {
|
|
||||||
yield compareAllUsers(this.users)
|
|
||||||
done()
|
|
||||||
}), 5000)
|
|
||||||
|
|
||||||
describe('Basic tests', function () {
|
|
||||||
it('Basic get&set of Map property (converge via sync)', async(function * (done) {
|
|
||||||
y1.set('stuff', 'stuffy')
|
|
||||||
expect(y1.get('stuff')).toEqual('stuffy')
|
|
||||||
yield flushAll()
|
|
||||||
for (var key in this.users) {
|
|
||||||
var u = this.users[key].share.root
|
|
||||||
expect(u.get('stuff')).toEqual('stuffy')
|
|
||||||
}
|
|
||||||
done()
|
|
||||||
}))
|
|
||||||
it('Map can set custom types (Map)', async(function * (done) {
|
|
||||||
var map = yield y1.set('Map', Y.Map)
|
|
||||||
map.set('one', 1)
|
|
||||||
map = yield y1.get('Map')
|
|
||||||
expect(map.get('one')).toEqual(1)
|
|
||||||
done()
|
|
||||||
}))
|
|
||||||
it('Map can set custom types (Array)', async(function * (done) {
|
|
||||||
var array = yield y1.set('Array', Y.Array)
|
|
||||||
array.insert(0, [1, 2, 3])
|
|
||||||
array = yield y1.get('Array')
|
|
||||||
expect(array.toArray()).toEqual([1, 2, 3])
|
|
||||||
done()
|
|
||||||
}))
|
|
||||||
it('Basic get&set of Map property (converge via update)', async(function * (done) {
|
|
||||||
yield flushAll()
|
|
||||||
y1.set('stuff', 'stuffy')
|
|
||||||
expect(y1.get('stuff')).toEqual('stuffy')
|
|
||||||
|
|
||||||
yield flushAll()
|
|
||||||
for (var key in this.users) {
|
|
||||||
var r = this.users[key].share.root
|
|
||||||
expect(r.get('stuff')).toEqual('stuffy')
|
|
||||||
}
|
|
||||||
done()
|
|
||||||
}))
|
|
||||||
it('Basic get&set of Map property (handle conflict)', async(function * (done) {
|
|
||||||
yield flushAll()
|
|
||||||
y1.set('stuff', 'c0')
|
|
||||||
y2.set('stuff', 'c1')
|
|
||||||
|
|
||||||
yield flushAll()
|
|
||||||
for (var key in this.users) {
|
|
||||||
var u = this.users[key]
|
|
||||||
expect(u.share.root.get('stuff')).toEqual('c0')
|
|
||||||
}
|
|
||||||
done()
|
|
||||||
}))
|
|
||||||
it('Basic get&set&delete of Map property (handle conflict)', async(function * (done) {
|
|
||||||
yield flushAll()
|
|
||||||
y1.set('stuff', 'c0')
|
|
||||||
y1.delete('stuff')
|
|
||||||
y2.set('stuff', 'c1')
|
|
||||||
yield flushAll()
|
|
||||||
|
|
||||||
for (var key in this.users) {
|
|
||||||
var u = this.users[key]
|
|
||||||
expect(u.share.root.get('stuff')).toBeUndefined()
|
|
||||||
}
|
|
||||||
done()
|
|
||||||
}))
|
|
||||||
it('Basic get&set of Map property (handle three conflicts)', async(function * (done) {
|
|
||||||
yield flushAll()
|
|
||||||
y1.set('stuff', 'c0')
|
|
||||||
y2.set('stuff', 'c1')
|
|
||||||
y2.set('stuff', 'c2')
|
|
||||||
y3.set('stuff', 'c3')
|
|
||||||
yield flushAll()
|
|
||||||
|
|
||||||
for (var key in this.users) {
|
|
||||||
var u = this.users[key]
|
|
||||||
expect(u.share.root.get('stuff')).toEqual('c0')
|
|
||||||
}
|
|
||||||
done()
|
|
||||||
}))
|
|
||||||
it('Basic get&set&delete of Map property (handle three conflicts)', async(function * (done) {
|
|
||||||
yield flushAll()
|
|
||||||
y1.set('stuff', 'c0')
|
|
||||||
y2.set('stuff', 'c1')
|
|
||||||
y2.set('stuff', 'c2')
|
|
||||||
y3.set('stuff', 'c3')
|
|
||||||
yield flushAll()
|
|
||||||
y1.set('stuff', 'deleteme')
|
|
||||||
y1.delete('stuff')
|
|
||||||
y2.set('stuff', 'c1')
|
|
||||||
y3.set('stuff', 'c2')
|
|
||||||
y4.set('stuff', 'c3')
|
|
||||||
yield flushAll()
|
|
||||||
|
|
||||||
for (var key in this.users) {
|
|
||||||
var u = this.users[key]
|
|
||||||
expect(u.share.root.get('stuff')).toBeUndefined()
|
|
||||||
}
|
|
||||||
done()
|
|
||||||
}))
|
|
||||||
it('observePath properties', async(function * (done) {
|
|
||||||
y1.observePath(['map'], function (map) {
|
|
||||||
if (map != null) {
|
|
||||||
map.set('yay', 4)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
yield y2.set('map', Y.Map)
|
|
||||||
yield flushAll()
|
|
||||||
var map = yield y3.get('map')
|
|
||||||
expect(map.get('yay')).toEqual(4)
|
|
||||||
done()
|
|
||||||
}))
|
|
||||||
it('throws add & update & delete events (with type and primitive content)', async(function * (done) {
|
|
||||||
var event
|
|
||||||
yield flushAll()
|
|
||||||
y1.observe(function (e) {
|
|
||||||
event = e // just put it on event, should be thrown synchronously anyway
|
|
||||||
})
|
|
||||||
y1.set('stuff', 4)
|
|
||||||
expect(event).toEqual([{
|
|
||||||
type: 'add',
|
|
||||||
object: y1,
|
|
||||||
name: 'stuff'
|
|
||||||
}])
|
|
||||||
// update, oldValue is in contents
|
|
||||||
yield y1.set('stuff', Y.Array)
|
|
||||||
expect(event).toEqual([{
|
|
||||||
type: 'update',
|
|
||||||
object: y1,
|
|
||||||
name: 'stuff',
|
|
||||||
oldValue: 4
|
|
||||||
}])
|
|
||||||
y1.get('stuff').then(function (replacedArray) {
|
|
||||||
// update, oldValue is in opContents
|
|
||||||
y1.set('stuff', 5)
|
|
||||||
var getYArray = event[0].oldValue
|
|
||||||
expect(typeof getYArray.constructor === 'function').toBeTruthy()
|
|
||||||
getYArray().then(function (array) {
|
|
||||||
expect(array).toEqual(replacedArray)
|
|
||||||
|
|
||||||
// delete
|
|
||||||
y1.delete('stuff')
|
|
||||||
expect(event).toEqual([{
|
|
||||||
type: 'delete',
|
|
||||||
name: 'stuff',
|
|
||||||
object: y1,
|
|
||||||
oldValue: 5
|
|
||||||
}])
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
describeManyTimes(repeatMapTeasts, `${numberOfYMapTests} Random tests`, function () {
|
|
||||||
var randomMapTransactions = [
|
|
||||||
function set (map) {
|
|
||||||
map.set('somekey', getRandomNumber())
|
|
||||||
},
|
|
||||||
function delete_ (map) {
|
|
||||||
map.delete('somekey')
|
|
||||||
}
|
|
||||||
]
|
|
||||||
function compareMapValues (maps) {
|
|
||||||
var firstMap
|
|
||||||
for (var map of maps) {
|
|
||||||
var val = map.getPrimitive()
|
|
||||||
if (firstMap == null) {
|
|
||||||
firstMap = val
|
|
||||||
} else {
|
|
||||||
expect(val).toEqual(firstMap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
beforeEach(async(function * (done) {
|
|
||||||
yield y1.set('Map', Y.Map)
|
|
||||||
yield flushAll()
|
|
||||||
|
|
||||||
var promises = []
|
|
||||||
for (var u = 0; u < this.users.length; u++) {
|
|
||||||
promises.push(this.users[u].share.root.get('Map'))
|
|
||||||
}
|
|
||||||
this.maps = yield Promise.all(promises)
|
|
||||||
done()
|
|
||||||
}))
|
|
||||||
it(`succeed after ${numberOfYMapTests} actions, no GC, all users disconnecting/reconnecting`, async(function * (done) {
|
|
||||||
yield applyRandomTransactionsAllRejoinNoGC(this.users, this.maps, randomMapTransactions, numberOfYMapTests)
|
|
||||||
yield flushAll()
|
|
||||||
yield compareMapValues(this.maps)
|
|
||||||
done()
|
|
||||||
}))
|
|
||||||
it(`succeed after ${numberOfYMapTests} actions, GC, user[0] is not disconnecting`, async(function * (done) {
|
|
||||||
yield applyRandomTransactionsWithGC(this.users, this.maps, randomMapTransactions, numberOfYMapTests)
|
|
||||||
yield flushAll()
|
|
||||||
yield compareMapValues(this.maps)
|
|
||||||
done()
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
Loading…
x
Reference in New Issue
Block a user