Skip to content
32 changes: 22 additions & 10 deletions lib/idb.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Meteor } from 'meteor/meteor';
import { EJSON } from 'meteor/ejson';
import { offlineCollections } from './mongo';
import { openDB as open } from 'idb';
import { Offline } from './config';
Expand Down Expand Up @@ -28,17 +29,18 @@ const cleanSyncs = async () => { // clean up stale syncs keys when making change
const setupStores = (db, collections) => {
const existingStores = new Set(db.objectStoreNames);

for (const [ name, { sort }] of collections) {
for (const [name, { sort }] of collections) {
if (!existingStores.has(name)) {
const store = db.createObjectStore(name, { keyPath: '_id' });
if (sort) {
const [ key ] = Object.keys(sort);
store.createIndex(key, key, { unique: false });
const [key] = Object.keys(sort);
const newKey = key + 'Offline';
store.createIndex(newKey, newKey, { unique: false });
}
}
}

for (const name of existingStores) {
for (const name of existingStores) {
if (!collections.has(name) && !defaultStores.has(name)) {
db.deleteObjectStore(name);
}
Expand Down Expand Up @@ -107,7 +109,16 @@ export async function put(name, doc, { added = false } = {}) {
};

const finishPut = async () => {
await store.put(doc);
let newDoc;
if (sort) {
const [[key, value]] = Object.entries(sort);
const newKey = key + 'Offline';
newDoc = EJSON.toJSONValue(doc);
newDoc = { ...newDoc, [newKey]: doc[key] };
await store.put(newDoc);
} else {
await store.put(EJSON.toJSONValue(doc));
}
await tx.done;

return handleOffline();
Expand All @@ -124,16 +135,17 @@ export async function put(name, doc, { added = false } = {}) {
return finishPut();
}

const [[ key, value ]] = Object.entries(sort);
let [[ key, value] ] = Object.entries(sort);
const newKey = key + 'Offline';
const direction = value === -1 ? 'next' : 'prev'; // this feels counterintuitive but it works
const cursor = await store.index(key).openCursor(null, direction);
const cursor = await store.index(newKey).openCursor(null, direction);

const isCurrentUser = name === 'users' && ((cursor ? cursor.primaryKey : doc._id) === Meteor.userId()); // this prevents deleting the current user if publishing a bunch of users to the client
const isCurrentUser = name === 'users' && (cursor ? cursor.primaryKey : doc._id) === Meteor.userId(); // this prevents deleting the current user if publishing a bunch of users to the client
if (isCurrentUser) {
return finishPut();
}

const shouldEnd = !cursor || (direction === 'prev' ? doc[key] > cursor.value[key] : doc[key] < cursor.value[key]);
const shouldEnd = !cursor || (direction === 'prev' ? doc[key] > cursor.value[newKey] : doc[key] < cursor.value[newKey]);
if (shouldEnd) { // we don't put the doc but still need to handle the offline case
await tx.done;
return handleOffline();
Expand Down Expand Up @@ -215,7 +227,7 @@ export async function clearAll() {
const db = await dbPromise;
const names = [...db.objectStoreNames];

return Promise.allSettled(names.map(n => db.clear(n, names)));
return Promise.allSettled(names.map(n => db.clear(n)));
}

export function canQueue(methodName) { // we should only queue the method if the collection has been made available offline
Expand Down
10 changes: 9 additions & 1 deletion lib/load.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Meteor } from 'meteor/meteor';
import { EJSON } from 'meteor/ejson';
import { getAll, put, remove } from './idb';
import { offlineCollections } from './mongo';
import { noSub } from './ddp';
Expand All @@ -14,11 +15,18 @@ async function load({ offline = false } = {}) {
const localCollection = localCollections[name];
if (offline && localCollection.find(filter).count() > 0) return; // the app went offline, we don't need to load data we already have in minimongo

const { sort } = offlineCollections.get(name) || {};
// load into minimongo
const docs = await getAll(name);
for (const doc of docs) {
doc._id = parseId(doc._id); // used to support Mongo.ObjectID
localCollection.insert(doc);
// used to support Mongo.ObjectID on the other fields
if (sort) {
const [[key, value]] = Object.entries(sort);
const newKey = key + 'Offline';
delete doc[newKey];
}
localCollection.insert(EJSON.fromJSONValue(doc));
}

if (offline) { // the app was online and went offline, so we don't need start observing again
Expand Down
4 changes: 2 additions & 2 deletions lib/sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function pauseObservers(collectionNames) {
const localCollection = localCollections[name];

if (localCollection) localCollection.pauseObservers();
if (store) {
if (store && typeof store.endUpdate === 'function') {
store._originalEnd = store.endUpdate;
store.endUpdate = () => {}; // we'll resume observers after the queued methods have executed so we suppress the built in resumption here
}
Expand All @@ -61,7 +61,7 @@ export function resumeObservers(collectionNames) {
const localCollection = localCollections[name];

if (localCollection) localCollection[Meteor.isFibersDisabled ? 'resumeObserversClient' : 'resumeObservers']();
if (store) {
if (store && typeof store._originalEnd === 'function') {
store.endUpdate = store._originalEnd;
delete store._originalEnd;
}
Expand Down
84 changes: 76 additions & 8 deletions tests.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
// Import Tinytest from the tinytest Meteor package.
import { Tinytest } from 'meteor/tinytest';
import { Mongo } from 'meteor/mongo';
import { MongoID } from 'meteor/mongo-id';
import { Random } from 'meteor/random';
import { Tracker } from 'meteor/tracker';
import { offlineCollections } from './lib/mongo';
import { Offline, clearAll, queueMethod } from 'meteor/jam:offline';
import { deepReplace, deepContains } from './lib/utils/shared';
const { getAll, canQueue, removeWithRetry } = Meteor.isClient && require('./lib/idb');

const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
const wait = ms => new Promise((resolve) => setTimeout(resolve, ms));

const applyOptions = {
returnStubValue: true,
Expand Down Expand Up @@ -49,6 +50,9 @@ Notes.keep({}, { limit: 2 });
const Dogs = new Mongo.Collection('dogs');
Dogs.keep({});

const Cats = new Mongo.Collection('cats');
Cats.keep({});

const Cars = new Mongo.Collection('cars');
Cars.keep({});

Expand All @@ -74,6 +78,10 @@ if (Meteor.isServer) {
return Dogs.find({});
});

Meteor.publish('cats', function() {
return Cats.find({});
});

Meteor.publish('cars', function() {
return Cars.find({});
});
Expand All @@ -100,6 +108,9 @@ const resetNotes = async () => {
const resetDogs = async () => {
return Dogs.removeAsync({});
}
const resetCats = async () => {
return Cats.removeAsync({});
}
const resetCars = async () => {
return Cars.removeAsync({});
}
Expand Down Expand Up @@ -149,6 +160,22 @@ const removeDog = async ({ _id }) => {
return Dogs.removeAsync({ _id });
}

const insertCat = async ({ text, hexString }) => {
return Cats.insertAsync({ text, id: new MongoID.ObjectID(hexString), createdAt: new Date(), updatedAt: new Date() });
}

const upsertCat = async ({ _id, text, hexString }) => {
return Cats.upsertAsync(_id, { text, id: new MongoID.ObjectID(hexString), createdAt: new Date(), updatedAt: new Date() });
}

const updateCat = async ({ _id, text, hexString }) => {
return Cats.updateAsync(_id, { $set: { text, id: new MongoID.ObjectID(hexString), createdAt: new Date(), updatedAt: new Date() }});
}

const removeCat = async ({ _id }) => {
return Cats.removeAsync({ _id });
}

const insertCar = async ({ text }) => {
return Cars.insertAsync({ text, createdAt: new Date(), updatedAt: new Date() });
}
Expand Down Expand Up @@ -181,10 +208,10 @@ const updateBook = async ({ _id, title }) => {
return Books.updateAsync(_id, { $set: { title, createdAt: new Date(), updatedAt: new Date() }});
}

Meteor.methods({ insertThing, updateThing, insertNote, updateNote, removeNote, insertDog, removeDog, upsertDog, updateDog, insertCar, insertOrder, updateOrder, removeOrder, insertItem, updateItem, insertBook, updateBook });
Meteor.methods({ insertThing, updateThing, insertNote, updateNote, removeNote, insertDog, removeDog, upsertDog, updateDog, insertCat, removeCat, upsertCat, updateCat, insertCar, insertOrder, updateOrder, removeOrder, insertItem, updateItem, insertBook, updateBook });

if (Meteor.isServer) {
Meteor.methods({ resetThings, resetNotes, resetDogs, resetCars, resetOrders, resetItems, resetBooks })
Meteor.methods({ resetThings, resetNotes, resetDogs, resetCats, resetCars, resetOrders, resetItems, resetBooks })
}

// Client only tests
Expand Down Expand Up @@ -375,6 +402,47 @@ if (Meteor.isClient) {
comp.stop();
});

Tinytest.addAsync('ObjectID type', async (test) => {
await wait(100);
await Meteor.callAsync('resetCats');
await Cats.clear();
await wait(100);

let sub;
const comp = Tracker.autorun(() => {
sub = Meteor.subscribe('cats');
});


const cat1 = await Meteor.callAsync('insertCat', {text: 'stuff', hexString: '123456789012345678901234'});
await wait(100)
const cat2 = await Meteor.callAsync('insertCat', {text: 'stuff', hexString: '123412345678901234567890'});
await wait(100)

const cats = await getAll('cats');
test.equal(cats.length, 2);

test.isTrue(cats.map(t => t._id).includes(cat1))
test.isTrue(cats.map(t => t._id).includes(cat2))

test.isTrue(typeof Cats.find({ _id: cat1 }).fetch().map(d => d.id) === 'object')

const id1 = Cats.find({ _id: cat1 }).fetch()[0].id
const id1ToCompare = new MongoID.ObjectID('123456789012345678901234')

const id2 = Cats.find({ _id: cat2 }).fetch()[0].id
const id2ToCompare = new MongoID.ObjectID('123412345678901234567890')

test.isTrue(id1.equals(id1ToCompare))
test.isTrue(id2.equals(id2ToCompare))


await Cats.clear();

sub.stop();
comp.stop();
});


Tinytest.addAsync('brief disconnect', async (test) => { // these tests can be a little flaky due to timing issues in simulating the methods and reconnecting etc.
await wait(200);
Expand Down Expand Up @@ -438,7 +506,7 @@ if (Meteor.isClient) {

Meteor.reconnect();

await wait(50);
await wait(100);

const notes = await getAll('notes');

Expand Down Expand Up @@ -478,7 +546,7 @@ if (Meteor.isClient) {

Meteor.reconnect();

await wait(100);
await wait(200);

const notes = await getAll('notes');

Expand Down Expand Up @@ -522,7 +590,7 @@ if (Meteor.isClient) {
await wait(10);

const dogs = await getAll('dogs');
test.isTrue(dogs.map(d => d._id).includes(dog3)) // it should be preserved
test.isTrue(dogs.map(d => d._id).includes(dog3)); // it should be preserved

test.equal(Dogs.find().fetch().length, 3)
test.isTrue(Dogs.find().fetch().map(d => d.text).includes('hello'))
Expand Down Expand Up @@ -562,7 +630,7 @@ if (Meteor.isClient) {

Meteor.reconnect();

await wait(100);
await wait(200);

const notes = await getAll('notes');
test.isFalse(notes.map(n => n._id).includes(note4)) // it should be swapped with the result of the queued method
Expand Down Expand Up @@ -607,7 +675,7 @@ if (Meteor.isClient) {

Meteor.reconnect();

await wait(100);
await wait(200);

const orders = await getAll('orders');

Expand Down