UNPKG

@instantdb/core

Version:
481 lines (393 loc) 11.2 kB
// https://www.npmjs.com/package/fake-indexeddb import 'fake-indexeddb/auto'; import { test, expect } from 'vitest'; import { setTimeout as sleep } from 'timers/promises'; import IndexedDBStorage from '../../src/IndexedDBStorage'; import Reactor from '../../src/Reactor'; import InMemoryStorage from '../../src/InMemoryStorage'; import * as instaml from '../../src/instaml'; import * as instatx from '../../src/instatx'; import zenecaAttrs from './data/zeneca/attrs.json'; import zenecaTriples from './data/zeneca/triples.json'; import uuid from '../../src/utils/id'; import { weakHash } from '../../src'; import { AttrsStoreClass } from '../../src/store'; const zenecaAttrsStore = new AttrsStoreClass( zenecaAttrs.reduce((res, x) => { res[x.id] = x; return res; }, {}), null, ); async function waitForLoaded(reactor) { await reactor.querySubs.waitForMetaToLoad(); await reactor.kv.waitForMetaToLoad(); await reactor.kv.waitForKeyToLoad('pendingMutations'); await reactor.querySubs.flush(); await reactor.kv.flush(); } test('querySubs round-trips', async () => { const appId = uuid(); const reactor = new Reactor({ appId }); reactor._initStorage(IndexedDBStorage); reactor._setAttrs(zenecaAttrs); const q = { users: {} }; await waitForLoaded(reactor); const resultOne = new Promise((resolve, reject) => { reactor.subscribeQuery(q, (res) => { if (res.error) { reject(res.error); } resolve(res); }); }); // Initialize the store reactor._handleReceive(0, { op: 'add-query-ok', q, 'processed-tx-id': 0, result: [ { data: { 'datalog-result': { 'join-rows': [zenecaTriples], }, }, 'child-nodes': [], }, ], }); const data1 = await resultOne; // Make sure the store has the data we expect expect(data1.data.users.map((x) => x.handle)).toEqual([ 'joe', 'alex', 'stopa', 'nicolegf', ]); await waitForLoaded(reactor); // Create a new reactor const reactor2 = new Reactor({ appId }); reactor2._initStorage(IndexedDBStorage); reactor2._setAttrs(zenecaAttrs); await waitForLoaded(reactor2); await reactor2.querySubs.waitForKeyToLoad(weakHash(q)); // Check that it pull the data from indexedDB const res = await new Promise((resolve, reject) => { reactor2.subscribeQuery(q, (res) => { if (res.error) { reject(res.error); } resolve(res); }); }); expect(res.data.users.map((x) => x.handle)).toEqual([ 'joe', 'alex', 'stopa', 'nicolegf', ]); }); test('rewrite mutations', () => { const appId = uuid(); const reactor = new Reactor({ appId }); const bookId = 'bookId'; const bookshelfId = 'bookshelfId'; const ops = [ instatx.tx.books[bookId].update({ title: 'title' }), instatx.tx.users[instatx.lookup('handle', 'stopa')].update({ email: 's@example.com', }), instatx.tx.bookshelves[bookshelfId].link({ users: { handle: 'stopa' }, }), instatx.tx.bookshelves[bookshelfId].unlink({ users: ['handle', 'joe'], }), instatx.tx.bookshelves[bookshelfId].unlink({ users: instatx.lookup('handle', 'joe'), }), ]; // create transactions without any attributes const optimisticSteps = instaml.transform( { attrsStore: new AttrsStoreClass({}, null) }, ops, ); const mutations = new Map([['k', { 'tx-steps': optimisticSteps }]]); const rewrittenWithoutAttrs = reactor ._rewriteMutations(new AttrsStoreClass({}, null), mutations) .get('k')['tx-steps']; // Check that we didn't clobber anything in our rewrite expect(rewrittenWithoutAttrs).toEqual(optimisticSteps); // rewrite them with the new server attributes const rewrittenSteps = reactor ._rewriteMutations(zenecaAttrsStore, mutations) .get('k')['tx-steps']; const serverSteps = instaml.transform({ attrsStore: zenecaAttrsStore }, ops); expect(rewrittenSteps).toEqual(serverSteps); }); test('rewrite mutations works with multiple transactions', () => { const appId = uuid(); const reactor = new Reactor({ appId }); reactor._initStorage(InMemoryStorage); const bookId = 'bookId'; const bookshelfId = 'bookshelfId'; const ops = [ instatx.tx.books[bookId].update({ title: 'title' }), instatx.tx.users[instatx.lookup('handle', 'stopa')].update({ email: 's@example.com', }), instatx.tx.bookshelves[bookshelfId].link({ users: { handle: 'stopa' }, }), instatx.tx.bookshelves[bookshelfId].unlink({ users: ['handle', 'joe'], }), instatx.tx.bookshelves[bookshelfId].unlink({ users: instatx.lookup('handle', 'joe'), }), ]; const keys = ['a', 'b', 'c', 'd']; for (const k of keys) { const attrs = reactor.optimisticAttrs(); const steps = instaml.transform({ attrsStore: attrs }, ops); const mut = { op: 'transact', 'tx-steps': steps, }; reactor._updatePendingMutations((prev) => { prev.set(k, mut); return prev; }); } // rewrite them with the new server attributes const rewrittenMutations = reactor._rewriteMutations( zenecaAttrsStore, reactor._pendingMutations(), ); const serverSteps = instaml.transform({ attrsStore: zenecaAttrsStore }, ops); for (const k of keys) { expect(rewrittenMutations.get(k)['tx-steps']).toEqual(serverSteps); } }); test('optimisticTx is not overwritten by refresh-ok', async () => { const appId = uuid(); const reactor = new Reactor({ appId }); reactor._initStorage(IndexedDBStorage); reactor._setAttrs(zenecaAttrs); const q = { users: {} }; const joe_id = 'ce942051-2d74-404a-9c7d-4aa3f2d54ae4'; await waitForLoaded(reactor); let data = null; reactor.subscribeQuery(q, (res) => { data = res; }); // Initialize the store reactor._handleReceive(0, { op: 'add-query-ok', q, 'processed-tx-id': 0, result: [ { data: { 'datalog-result': { 'join-rows': [zenecaTriples], }, }, 'child-nodes': [], }, ], }); await reactor.querySubs.flush(); expect(data.data.users.map((x) => x.handle)).toEqual([ 'joe', 'alex', 'stopa', 'nicolegf', ]); // first optimistic const ops2 = [ instatx.tx.users[joe_id].update({ handle: 'joe2', }), ]; reactor.pushTx(ops2); await reactor.querySubs.flush(); expect(data.data.users.map((x) => x.handle)).toEqual([ 'joe2', 'alex', 'stopa', 'nicolegf', ]); const [eventId2] = reactor._pendingMutations().keys(); // second optimistic const ops3 = [ instatx.tx.users[joe_id].update({ handle: 'joe3', }), ]; reactor.pushTx(ops3); await reactor.querySubs.flush(); expect(data.data.users.map((x) => x.handle)).toEqual([ 'joe3', 'alex', 'stopa', 'nicolegf', ]); const [_, eventId3] = reactor._pendingMutations().keys(); // confirmation from server for first optimistic reactor._handleReceive(1, { op: 'transact-ok', 'client-event-id': eventId2, 'tx-id': 100, }); await waitForLoaded(reactor); expect(data.data.users.map((x) => x.handle)).toEqual([ 'joe3', 'alex', 'stopa', 'nicolegf', ]); // query refresh after first tx reactor._handleReceive(3, { op: 'refresh-ok', 'processed-tx-id': 100, attrs: zenecaAttrs, computations: [ { 'instaql-query': q, 'instaql-result': [ { data: { 'datalog-result': { 'join-rows': [zenecaTriples], }, }, 'child-nodes': [], }, ], }, ], }); // make sure it doesn’t override local results await waitForLoaded(reactor); expect(data.data.users.map((x) => x.handle)).toEqual([ 'joe3', 'alex', 'stopa', 'nicolegf', ]); // confirmation from server for second optimistic reactor._handleReceive(2, { op: 'transact-ok', 'client-event-id': eventId3, 'tx-id': 101, }); await waitForLoaded(reactor); expect(data.data.users.map((x) => x.handle)).toEqual([ 'joe3', 'alex', 'stopa', 'nicolegf', ]); // make sure it still doesn’t override local results await waitForLoaded(reactor); expect(data.data.users.map((x) => x.handle)).toEqual([ 'joe3', 'alex', 'stopa', 'nicolegf', ]); }); test("we don't cleanup mutations we're still waiting on", async () => { const appId = uuid(); const reactor = new Reactor({ appId, pendingTxCleanupTimeout: 1, pendingMutationCleanupThreshold: 0, }); reactor._initStorage(IndexedDBStorage); reactor._setAttrs(zenecaAttrs); const q = { users: {} }; const joe_id = 'ce942051-2d74-404a-9c7d-4aa3f2d54ae4'; await waitForLoaded(reactor); // Add two transactions const ops1 = [ instatx.tx.users[joe_id].update({ handle: 'joe2', }), ]; reactor.pushTx(ops1); const ops2 = [ instatx.tx.users[joe_id].update({ handle: 'joe2', }), ]; reactor.pushTx(ops2); await reactor.kv.flush(); const [ev1, ev2] = reactor._pendingMutations().keys(); // Mark one as received reactor._handleReceive(1, { op: 'transact-ok', 'client-event-id': ev1, 'tx-id': 100, }); // Wait for the pendingTxCleanupTimeout to expire await sleep(10); // Cleanup shouldn't remove the mutation we're still waiting on await reactor._cleanupPendingMutationsTimeout(); const remainingKeys = new Array(...reactor._pendingMutations().keys()); expect(remainingKeys).toStrictEqual([ev2]); }); test('getLocalId always returns the same id', async () => { const appId = uuid(); const idbs = new Map(); // Class that reuses the same underlying storage for indexeddb. // This will make fake-indexeddb act more like the browser, otherwise // we get a fresh in-memory store each time we call indexedDB.open class IDBStorage { constructor(appId, dbName) { if (idbs.get(dbName)) { return idbs.get(dbName); } const idb = new IndexedDBStorage(appId, dbName); idbs.set(dbName, idb); return idb; } } const reactor = new Reactor({ appId, pendingTxCleanupTimeout: 1, pendingMutationCleanupThreshold: 0, }); reactor._initStorage(IDBStorage); const promises = []; const ids = new Set(); for (const _ of [...new Array(1000)]) { const p = reactor.getLocalId('id').then((id) => ids.add(id)); promises.push(p); } await Promise.all(promises); expect(ids.size).toBe(1); await reactor.querySubs.flush(); await reactor.kv.flush(); const reactor2 = new Reactor({ appId, pendingTxCleanupTimeout: 1, pendingMutationCleanupThreshold: 0, }); async function idbSnapshot(idb, { includeMeta }) { const keys = await idb.getAllKeys(); const res = {}; for (const key of keys) { if (key === '__meta' && !includeMeta) { continue; } res[key] = await idb.getItem(key); } return res; } reactor2._initStorage(IDBStorage); const id = await reactor2.getLocalId('id'); expect(id).toStrictEqual([...ids][0]); });