WIP Generic data handling and caching

Add basic support for reimbursements via new, generic data handling
functions.
This commit is contained in:
2020-06-27 16:35:04 +02:00
parent 4a9dd5ff09
commit 95743330e4
8 changed files with 1409 additions and 519 deletions
+48
View File
@@ -0,0 +1,48 @@
import EmberObject, { computed } from '@ember/object';
import { isEmpty, isPresent } from '@ember/utils';
import moment from 'moment';
export default EmberObject.extend({
// Contract
id: null,
contributorId: null,
token: null,
amount: null,
confirmedAt: null,
vetoed: null,
ipfsHash: null,
// contributor model instance
contributor: null,
// TODO contributor who submitted the reimbursement
// recordedBy: null,
// IPFS
expenses: null, // Array of expense objects
pendingTx: null,
init () {
this._super(...arguments);
if (isEmpty(this.details)) this.set('details', {});
},
iso8601Date: computed('date', 'time', function() {
return this.time ? `${this.date}T${this.time}` : this.date;
}),
jsDate: computed('iso8601Date', function() {
return moment(this.iso8601Date).toDate();
}),
hasPendingChanges: computed('pendingTx', function() {
return isPresent(this.pendingTx);
}),
serialize () {
return JSON.stringify(this);
}
});
+15
View File
@@ -1,4 +1,19 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class BudgetRoute extends Route {
@service browserCache;
@service kredits;
async model () {
const numCachedReimbursements = await this.browserCache.reimbursements.length();
if (numCachedReimbursements > 0) {
await this.kredits.loadObjectsFromCache('Reimbursement');
this.kredits.set('reimbursementsNeedSync', true);
} else {
await this.kredits.fetchObjects('Reimbursement', { page: { size: 10 } });
}
}
}
+6 -1
View File
@@ -13,7 +13,8 @@ export default class BrowserCacheService extends Service {
super(...arguments);
this.stores = {
contributors: createStore('contributors'),
contributions: createStore('contributions')
contributions: createStore('contributions'),
reimbursements: createStore('reimbursements')
}
}
@@ -24,4 +25,8 @@ export default class BrowserCacheService extends Service {
get contributions() {
return this.stores.contributions;
}
get reimbursements() {
return this.stores.reimbursements;
}
}
+161 -11
View File
@@ -13,11 +13,15 @@ import { task, taskGroup } from 'ember-concurrency';
import groupBy from 'kredits-web/utils/group-by';
import processContributorData from 'kredits-web/utils/process-contributor-data';
import processContributionData from 'kredits-web/utils/process-contribution-data';
import processReimbursementData from 'kredits-web/utils/process-reimbursement-data';
import formatKredits from 'kredits-web/utils/format-kredits';
import config from 'kredits-web/config/environment';
import Contributor from 'kredits-web/models/contributor'
import Contribution from 'kredits-web/models/contribution'
import Contributor from 'kredits-web/models/contributor';
import Contribution from 'kredits-web/models/contribution';
import Reimbursement from 'kredits-web/models/reimbursement';
// Lets us access the model classes dynamically
const models = { Contributor, Contribution, Reimbursement }
export default Service.extend({
@@ -28,6 +32,7 @@ export default Service.extend({
currentUser: null,
contributors: null,
contributions: null,
reimbursements: null,
githubAccessToken: null,
currentUserIsContributor: notEmpty('currentUser'),
@@ -37,11 +42,13 @@ export default Service.extend({
// When data was loaded from cache, we need to fetch updates from the network
contributorsNeedSync: false,
contributionsNeedSync: false,
reimbursementsNeedSync: false,
init () {
this._super(...arguments);
this.set('contributors', []);
this.set('contributions', []);
this.set('reimbursements', []);
},
// This is called in the application route's beforeModel(). So it is
@@ -182,24 +189,31 @@ export default Service.extend({
}),
contributionsUnconfirmed: computed('contributions.[]', 'currentBlock', function() {
return this.contributions.filter(contribution => {
return contribution.confirmedAt > this.currentBlock;
});
return this.contributions
.filter(c => c.confirmedAt > this.currentBlock);
}),
contributionsConfirmed: computed('contributions.[]', 'currentBlock', function() {
return this.contributions
.filterBy('vetoed', false)
.filter(contribution => {
return contribution.confirmedAt <= this.currentBlock;
});
.filter(c => c.confirmedAt <= this.currentBlock);
}),
reimbursementsUnconfirmed: computed('reimbursements.[]', 'currentBlock', function() {
return this.reimbursements
.filter(r => r.confirmedAt > this.currentBlock);
}),
reimbursementsConfirmed: computed('reimbursements.[]', 'currentBlock', function() {
return this.reimbursements
.filterBy('vetoed', false)
.filter(r => r.confirmedAt <= this.currentBlock);
}),
async loadInitialData () {
const numCachedContributors = await this.browserCache.contributors.length();
if (numCachedContributors > 0) {
await this.loadContributorsFromCache();
await this.loadObjectsFromCache('Contributor');
this.set('contributorsNeedSync', true);
} else {
await this.fetchContributors();
@@ -207,7 +221,7 @@ export default Service.extend({
const numCachedContributions = await this.browserCache.contributions.length();
if (numCachedContributions > 0) {
await this.loadContributionsFromCache();
await this.loadObjectsFromCache('Contribution');
this.set('contributionsNeedSync', true);
} else {
await this.fetchContributions({ page: { size: 30 } });
@@ -353,7 +367,7 @@ export default Service.extend({
}).group('contributionTasks'),
fetchNewContributions: task(function * () {
const count = yield this.kredits.Contribution.count();
const count = yield this.kredits.Contribution.functions.contributionsCount();
const lastKnownContributionId = Math.max.apply(null, this.contributions.mapBy('id'));
const toFetch = count - lastKnownContributionId;
@@ -423,6 +437,142 @@ export default Service.extend({
});
},
//
// Generic data handling (for objects that can be vetoed)
//
fetchObjects(objectClass, options = { page: { size: 200 } }) {
const objectClassLowerCase = objectClass.toLowerCase();
console.debug(`[kredits] Fetching ${objectClassLowerCase}s from the network`);
return this.kredits[objectClass].all(options)
.then(objects => {
return objects.map(data => {
const classInstance = this[`load${objectClass}FromData`](data);
return classInstance;
});
})
.then(objects => {
const cacheWrites = objects.map(o => {
return this.browserCache[objectClassLowerCase+'s']
.setItem(o.id.toString(), o.serialize());
});
return Promise.all(cacheWrites).then(() => {
console.debug(`[kredits] Cached ${objects.length} ${objectClassLowerCase+'s'} in browser storage`);
});
});
},
removeObjectFromCollectionIfLoaded (collection, objectId) {
const loadedObj = this[collection].findBy('id', objectId);
if (loadedObj) { this[collection].removeObject(loadedObj); }
},
async cacheLoadedObjects (collection) {
for (const o of this[collection]) {
await this.browserCache[collection].setItem(o.id, o.serialize());
}
console.debug(`[kredits] Cached ${this[collection].length} ${collection} in browser storage`);
return Promise.resolve();
},
async loadObjectsFromCache (objectClass) {
const collection = objectClass.toLowerCase()+'s';
return this.browserCache[collection].iterate((value/*, key , iterationNumber */) => {
const obj = models[objectClass].create(JSON.parse(value));
this[collection].pushObject(obj);
}).then((/* result */) => {
console.debug(`[kredits] Loaded ${this[collection].length} ${collection} from cache`);
});
},
fetchNewObjects: task(function * (objectClass) {
const collection = objectClass.toLowerCase()+'s';
const count = yield this.kredits[objectClass].functions[`${collection}Count`]();
const lastKnownObjectId = Math.max.apply(null, this[collection].mapBy('id'));
const toFetch = count - lastKnownObjectId;
if (toFetch > 0) {
console.debug(`[kredits] Fetching ${toFetch} new ${collection}`);
for (let id = lastKnownObjectId; id <= count; id++) {
const data = yield this.kredits[objectClass].getById(id);
const o = this[`load${objectClass}FromData`](data);
yield this.browserCache[collection].setItem(o.id.toString(), o.serialize());
}
} else {
console.debug(`[kredits] No new ${collection} to fetch`);
}
}),
fetchMissingObjects: task(function * (objectClass) {
const collection = objectClass.toLowerCase()+'s';
const count = yield this.kredits[objectClass].count();
const allIds = [...Array(count+1).keys()];
allIds.shift(); // remove first item, which is 0
const loadedObjects = new Set(this[collection].mapBy('id'));
const toFetch = allIds.filter(id => !loadedObjects.has(id));
if (toFetch.length === 0) {
console.debug(`[kredits] No ${collection} left to fetch`);
return;
}
console.debug(`[kredits] Fetching ${toFetch.length} past ${collection}`);
let countFetched = 0;
for (let id = count; id > 0; id--) {
if (loadedObjects.has(id)) {
continue;
} else {
const data = yield this.kredits[objectClass].getById(id);
const o = this[`load${objectClass}fromData`](data);
yield this.browserCache[collection].setItem(o.id.toString(), o.serialize());
countFetched++;
if (countFetched % 20 === 0) {
console.debug(`[kredits] Fetched ${countFetched} more ${collection}`);
}
}
}
console.debug(`[kredits] Cached ${countFetched} past ${collection}`);
}).group('syncTaskGroup'),
syncUnconfirmedObjects: task(function * (objectClass) {
const collection = objectClass.toLowerCase()+'s';
if (this`${collection}Unconfirmed`.length > 0) {
console.debug(`[kredits] Syncing unconfirmed ${collection}`);
for (const o of this[`${collection}Unconfirmed`]) {
const data = yield this.kredits[objectClass].getById(o.id);
const object = this[`load${objectClass}FromData`](data);
yield this.browserCache[collection]
.setItem(o.id.toString(), object.serialize());
}
} else {
console.debug(`[kredits] No unconfirmed ${collection} to sync`);
}
}),
vetoAgainstObject (objectClass, objectId) {
console.debug(`[kredits] veto against ${objectClass.toLowerCase()}`, objectId);
const collection = objectClass.toLowerCase()+'s';
const object = this[collection].findBy('id', objectId);
return this.kredits[objectClass].functions.veto(objectId, { gasLimit: 300000 })
.then(data => {
console.debug('[kredits] veto response', data);
object.set('pendingTx', data);
return data;
});
},
//
// Reimbursements
//
loadReimbursementFromData(data) {
const obj = Reimbursement.create(processReimbursementData(data));
obj.set('contributor', this.contributors.findBy('id', data.contributorId.toString()));
this.removeObjectFromCollectionIfLoaded('reimbursements', obj.id);
this.reimbursements.pushObject(obj);
return obj;
},
//
// Contract events
//
+6 -1
View File
@@ -45,6 +45,11 @@
</div>
</section>
{{outlet}}
{{!-- TODO #if sub-route (i.e. filtered list) --}}
{{!-- {{outlet}} --}}
{{!-- {{else}} --}}
<section id="expenses">
</section>
</main>
+20
View File
@@ -0,0 +1,20 @@
export default function processReimbursementData(data) {
const processed = {}
if (data.confirmedAtBlock && (typeof data.confirmedAtBlock.toNumber === 'function')) {
processed.confirmedAt = data.confirmedAtBlock.toNumber();
} else if (data.confirmedAt !== 'undefined') {
processed.confirmedAt = data.confirmedAt;
}
const otherProperties = [
'id', 'contributorId', 'token', 'amount', 'vetoed', 'ipfsHash',
'expenses', 'pendingTx'
];
otherProperties.forEach(prop => {
processed[prop] = data[prop];
});
return processed;
}
+1151 -504
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -29,7 +29,7 @@
"@ember/optional-features": "^1.3.0",
"@glimmer/component": "^1.0.0",
"@glimmer/tracking": "^1.0.0",
"@kosmos/schemas": "^2.2.1",
"@kosmos/schemas": "github:67P/kosmos-schemas#feature/browser-compat",
"babel-eslint": "^10.1.0",
"babel-preset-es2015": "^6.22.0",
"babelify": "^7.3.0",
@@ -66,7 +66,7 @@
"eslint-plugin-ember": "^8.5.2",
"eslint-plugin-node": "^11.1.0",
"ethers": "^4.0.47",
"kredits-contracts": "^5.5.0",
"kredits-contracts": "github:67P/kredits-contracts#feature/expenses",
"loader.js": "^4.7.0",
"localforage": "^1.7.3",
"ndjson": "github:hugomrdias/ndjson#feat/readable-stream3",