I do all access control on the server, which is probably where it should be happening in any case. I never thought about the situation where a method gets called that requires a permission that the user loses while offline – I guess it would fail repeatedly, which is something I catch (using Meteor.apply()
instead of Meteor.call()
). Anyway, hasn’t come up for me yet but that’s a good point.
For what it’s worth, my offline implementation got a lot less messy when I moved everything into a wrapper class (one for each collection) that handles the data fetching and manipulation, and another class that specifically handles the offline stuff. For the most part, the rest of the app interacts with the wrapper class without caring whether it’s actually online or offline. The class figures out what to do with each function call based on whether we’re online or offline. Hence the references to this.groundedCollection
in the code above, for example. No other class on the client interacts with the collection or minimongo directly.
Here’s an example of one of those inner classes that deals with offline stuff. It’s a generic class that gets subclassed (further down in the paste) for specific collections. In reality I have a bit of other app-specific code in here which I’ve removed, so as written this code is UNTESTED and is given “as is”. But it is at least pretty close to what has been working well for me.
interface StorableType {
_id: string;
unsynced?: boolean | string;
createdAt: Date;
updatedAt: Date;
}
export abstract class OfflineCollectionBackendBase<T extends StorableType> {
groundedServerRecords: GroundedCollection<T>;
protected recordsToInsert: GroundedCollection<T>;
protected recordsToUpdate: GroundedCollection<T>;
protected recordsToRemove: GroundedCollection<{ _id: string }>;
constructor({
indexDBName,
insertionsDBName,
updatesDBname,
removalsDBName,
}: {
indexDBName: string;
insertionsDBName: string;
updatesDBname: string;
removalsDBName: string;
}) {
this.groundedServerRecords = new GroundedCollection<T>(indexDBName);
this.recordsToInsert = new GroundedCollection<T>(insertionsDBName);
this.recordsToUpdate = new GroundedCollection<T>(updatesDBname);
this.recordsToRemove = new GroundedCollection<{ _id: string }>(removalsDBName);
}
async groundWithLiveCollection(collection: Mongo.Collection<T>[]): Promise<void> {
await this.groundedServerRecords.waitUntilLoaded();
this.groundedServerRecords.observeSource(collection.find());
this.groundedServerRecords.keep(collection.find());
await Promise.all([
this.recordsToInsert.waitUntilLoaded(),
this.recordsToUpdate.waitUntilLoaded(),
this.recordsToRemove.waitUntilLoaded(),
]);
this.initUserChangesProcessingAutorun();
await waitUntilReactive(() => this.groundedServerRecords.pendingWrites.isZero());
this._offlineReady.set(true);
}
private _offlineReady = new ReactiveVar(false);
offlineReady(): boolean {
return this._offlineReady.get();
}
pendingChanges(): number {
return (
this.recordsToInsert.find().count() +
this.recordsToUpdate.find().count() +
this.recordsToRemove.find().count()
);
}
async insert(record: Mongo.OptionalId<T>): Promise<string> {
record.unsynced = 'new';
record.createdAt = record.updatedAt = new Date();
const newId = this.recordsToInsert.insert(record);
const newRecord = this.recordsToInsert.findOne(newId);
if (!newRecord) {
throw new Error('Could not find record that was just inserted offline');
}
this.groundedServerRecords.insert(newRecord as Mongo.OptionalId<T>);
return newId;
}
async update(record: T): Promise<T> {
record.updatedAt = new Date();
if (!record.unsynced) record.unsynced = true;
const unsyncedRecord = this.recordsToInsert.findOne(record._id);
if (unsyncedRecord) {
this.recordsToInsert.upsert(record._id, record);
} else {
this.recordsToUpdate.upsert(record._id, record);
}
this.groundedServerRecords.upsert(record._id, record);
return unsyncedRecord || record;
}
async remove(recordId: string): Promise<void> {
if (this.recordsToInsert.findOne(recordId)) {
this.recordsToInsert.remove(recordId);
} else {
this.recordsToRemove.upsert(recordId, { _id: recordId });
}
this.recordsToUpdate.remove(recordId);
this.groundedServerRecords.remove(recordId);
}
protected preventSync = new ReactiveVar(false);
private initUserChangesProcessingAutorun(): void {
const syncInProgress = new ReactiveVar(false);
Tracker.autorun(async () => {
if (syncInProgress.get()) return;
if (this.preventSync.get()) return;
if (!Meteor.status().connected) return; // can't do this offline!
if (!Meteor.userId()) return; // don't do this if not logged in
const toInsert = this.recordsToInsert.find().fetch();
const toUpdate = this.recordsToUpdate.find().fetch();
const toRemove = this.recordsToRemove.find().fetch();
if ([ toInsert, toUpdate, toRemove ].every((array) => array.length === 0)) return;
syncInProgress.set(true);
try {
await Promise.all([
...toInsert.map(async (record) => {
await this.doServerInsert(record);
this.recordsToInsert.remove(record._id);
}),
...toUpdate.map(async (record) => {
await this.doServerUpdate(record);
this.recordsToUpdate.remove(record._id);
}),
...toRemove.map(async (record) => {
await this.doServerRemove(record);
this.recordsToRemove.remove(record._id);
}),
]);
Meteor.setTimeout(() => syncInProgress.set(false), 1000);
} catch (error) {
console.error(error); // eslint-disable-line no-console
Sentry.captureException(error);
// Bert.alert('Error while syncing data to the cloud. Will retry in 20 seconds.');
Meteor.setTimeout(() => syncInProgress.set(false), 20000);
}
});
}
protected abstract doServerInsert(record: T): Promise<string>;
protected abstract doServerUpdate(record: T): Promise<void>;
protected abstract doServerRemove(record: { _id: string }): Promise<void>;
protected abstract getRecordsFromServer(recordIds: string[]): Promise<T[]>;
};
// ---------------------------------
export class OfflineWidgetsBackend extends OfflineCollectionBackendBase<any> {
constructor() {
super({
indexDBName: 'widgetsIndex',
insertionsDBName: 'widgetsInserted',
updatesDBname: 'widgetsUpdated',
removalsDBName: 'widgetsRemoved',
});
}
protected async doServerInsert(record: WidgetRecord): Promise<string> {
const oldId = record._id;
const newId = await callServerMethodWithoutRetry('widgets.insert', record);
if (oldId && oldId !== newId) {
// updateRouteId(oldId, newId);
this.groundedServerRecords.remove(oldId); // this will be resynced automatically from the publication
}
return newId;
}
protected async doServerUpdate(record: WidgetRecord): Promise<void> {
return await callServerMethodWithoutRetry('widgets.update', record);
}
protected async doServerRemove({ _id: songId }: { _id: string }): Promise<void> {
return await callServerMethodWithoutRetry('widgets.delete', songId);
}
protected async getRecordsFromServer(recordIds: string[]): Promise<WidgetRecord[]> {
if (recordIds.length === 0) return [];
const result = await callServerMethodWithoutRetry('widgets.fetchMultiple', recordIds);
return result.songs;
}
}
// ---------------------------------
export function callServerMethodWithoutRetry(methodName: string, ...args: any[]): Promise<any> {
return new Promise((resolve, reject) => {
Meteor.apply(
methodName,
args,
//@ts-expect-error Mistake in Meteor's typings
{ noRetry: true },
(error, result) => {
if (error) return reject(error);
resolve(result);
}
);
});
}