LP#1768947 Offline DB runs in shared web worker
authorBill Erickson <berickxx@gmail.com>
Fri, 8 Jun 2018 17:08:18 +0000 (13:08 -0400)
committerKathy Lussier <klussier@masslnc.org>
Wed, 8 Aug 2018 14:31:15 +0000 (10:31 -0400)
Move the lovefield database access logic into a shared web worker
script.  This ensures the only one connection (per schema) can exist,
avoiding data integrity problems caused by having multiple tabs writing
to the database at the same time.

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Jeff Davis <jdavis@sitka.bclibraries.ca>
Signed-off-by: Kathy Lussier <klussier@masslnc.org>
Open-ILS/src/templates/staff/base_js.tt2
Open-ILS/web/js/ui/default/staff/offline-db-worker.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/offline.js
Open-ILS/web/js/ui/default/staff/services/lovefield.js
Open-ILS/web/js/ui/default/staff/webpack.config.js

index 51d9c42..98650a7 100644 (file)
@@ -40,6 +40,7 @@ UpUp.start({
     '[% ctx.media_prefix %]/js/dojo/opensrf/md5.js',
     '[% ctx.media_prefix %]/js/ui/default/staff/build/js/moment-with-locales.min.js',
     '[% ctx.media_prefix %]/js/ui/default/staff/build/js/moment-timezone-with-data.min.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/js/lovefield.min.js',
     '[% ctx.media_prefix %]/js/ui/default/common/build/js/jquery.min.js',
     '[% ctx.media_prefix %]/js/ui/default/staff/build/js/vendor.bundle.js',
     '[% ctx.media_prefix %]/js/ui/default/staff/build/fonts/glyphicons-halflings-regular.woff',
@@ -83,6 +84,13 @@ UpUp.start({
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/moment-timezone-with-data.min.js"></script>
 
 <!--
+  Load the lovefield libs as a standaline file so both the main
+  application and the offline shared worker can reference (and cache)
+  the same file
+-->
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/lovefield.min.js"></script>
+
+<!--
   Load iframeResize via script tag
   https://bugs.launchpad.net/evergreen/+bug/1753008
 -->
diff --git a/Open-ILS/web/js/ui/default/staff/offline-db-worker.js b/Open-ILS/web/js/ui/default/staff/offline-db-worker.js
new file mode 100644 (file)
index 0000000..0107dfd
--- /dev/null
@@ -0,0 +1,394 @@
+importScripts('/js/ui/default/staff/build/js/lovefield.min.js');
+
+// Collection of schema tracking objects.
+var schemas = {};
+
+// Create the DB schema / tables
+// synchronous
+function createSchema(schemaName) {
+    if (schemas[schemaName]) return;
+
+    var meta = lf.schema.create(schemaName, 2);
+    schemas[schemaName] = {name: schemaName, meta: meta};
+
+    switch (schemaName) {
+        case 'cache':
+            createCacheTables(meta);
+            break;
+        case 'offline':
+            createOfflineTables(meta);
+            break;
+        default:
+            console.error('No schema definition for ' + schemaName);
+    }
+}
+
+// Offline cache tables are globally available in the staff client
+// for on-demand caching.
+function createCacheTables(meta) {
+
+    meta.createTable('Setting').
+        addColumn('name', lf.Type.STRING).
+        addColumn('value', lf.Type.STRING).
+        addPrimaryKey(['name']);
+
+    meta.createTable('Object').
+        addColumn('type', lf.Type.STRING).         // class hint
+        addColumn('id', lf.Type.STRING).           // obj id
+        addColumn('object', lf.Type.OBJECT).
+        addPrimaryKey(['type','id']);
+
+    meta.createTable('CacheDate').
+        addColumn('type', lf.Type.STRING).          // class hint
+        addColumn('cachedate', lf.Type.DATE_TIME).  // when was it last updated
+        addPrimaryKey(['type']);
+
+    meta.createTable('StatCat').
+        addColumn('id', lf.Type.INTEGER).
+        addColumn('value', lf.Type.OBJECT).
+        addPrimaryKey(['id']);
+}
+
+// Offline transaction and block list tables.  These can be bulky and
+// are only used in the offline UI.
+function createOfflineTables(meta) {
+
+    meta.createTable('OfflineXact').
+        addColumn('seq', lf.Type.INTEGER).
+        addColumn('value', lf.Type.OBJECT).
+        addPrimaryKey(['seq'], true);
+
+    meta.createTable('OfflineBlocks').
+        addColumn('barcode', lf.Type.STRING).
+        addColumn('reason', lf.Type.STRING).
+        addPrimaryKey(['barcode']);
+}
+
+// Connect to the database for a given schema
+function connect(schemaName) {
+
+    var schema = schemas[schemaName];
+    if (!schema) {
+        return Promise.reject('createSchema(' +
+            schemaName + ') call required');
+    }
+
+    if (schema.db) { // already connected.
+        return Promise.resolve();
+    }
+
+    return new Promise(function(resolve, reject) {
+        try {
+            schema.meta.connect().then(
+                function(db) {
+                    schema.db = db;
+                    resolve();
+                },
+                function(err) {
+                    reject('Error connecting to schema ' +
+                        schemaName + ' : ' + err);
+                }
+            );
+        } catch (E) {
+            reject('Error connecting to schema ' + schemaName + ' : ' + E);
+        }
+    });
+}
+
+function getTableInfo(schemaName, tableName) {
+    var schema = schemas[schemaName];
+    var info = {};
+
+    if (!schema) {
+        info.error = 'createSchema(' + schemaName + ') call required';
+
+    } else if (!schema.db) {
+        info.error = 'connect(' + schemaName + ') call required';
+
+    } else {
+        info.schema = schema;
+        info.table = schema.meta.getSchema().table(tableName);
+
+        if (!info.table) {
+            info.error = 'no such table ' + tableName;
+        }
+    }
+
+    return info;
+}
+
+// Returns a promise resolved with true on success
+// Note insert .exec() returns rows, but that can get bulky on large
+// inserts, hence the boolean return;
+function insertOrReplace(schemaName, tableName, objects) {
+
+    var info = getTableInfo(schemaName, tableName);
+    if (info.error) { return Promise.reject(info.error); }
+
+    var rows = objects.map(function(r) { return info.table.createRow(r) });
+    return info.schema.db.insertOrReplace().into(info.table)
+        .values(rows).exec().then(function() { return true; });
+}
+
+// Returns a promise resolved with true on success
+// Note insert .exec() returns rows, but that can get bulky on large
+// inserts, hence the boolean return;
+function insert(schemaName, tableName, objects) {
+
+    var info = getTableInfo(schemaName, tableName);
+    if (info.error) { return Promise.reject(info.error); }
+
+    var rows = objects.map(function(r) { return info.table.createRow(r) });
+    return info.schema.db.insert().into(info.table)
+        .values(rows).exec().then(function() { return true; });
+}
+
+// Returns rows where the selected field equals the provided value.
+function selectWhereEqual(schemaName, tableName, field, value) {
+
+    var info = getTableInfo(schemaName, tableName);
+    if (info.error) { return Promise.reject(info.error); }
+
+    return info.schema.db.select().from(info.table)
+        .where(info.table[field].eq(value)).exec();
+}
+
+// Returns rows where the selected field equals the provided value.
+function selectWhereIn(schemaName, tableName, field, value) {
+
+    var info = getTableInfo(schemaName, tableName);
+    if (info.error) { return Promise.reject(info.error); }
+
+    return info.schema.db.select().from(info.table)
+        .where(info.table[field].in(value)).exec();
+}
+
+// Returns all rows in the selected table
+function selectAll(schemaName, tableName) {
+
+    var info = getTableInfo(schemaName, tableName);
+    if (info.error) { return Promise.reject(info.error); }
+
+    return info.schema.db.select().from(info.table).exec();
+}
+
+// Deletes all rows in the selected table.
+function deleteAll(schemaName, tableName) {
+
+    var info = getTableInfo(schemaName, tableName);
+    if (info.error) { return Promise.reject(info.error); }
+
+    return info.schema.db.delete().from(info.table).exec();
+}
+
+// Resolves to true if the selected table contains any rows.
+function hasRows(schemaName, tableName) {
+
+    var info = getTableInfo(schemaName, tableName);
+    if (info.error) { return Promise.reject(info.error); }
+
+    return info.schema.db.select().from(info.table).limit(1).exec()
+        .then(function(rows) { return rows.length > 0 });
+}
+
+
+// Prevent parallel block list building calls, since it does a lot.
+var buildingBlockList = false;
+
+// Fetches the offline block list and rebuilds the offline blocks
+// table from the new data.
+function populateBlockList(authtoken) {
+    if (buildingBlockList) return;
+    buildingBlockList = true;
+
+    var url = '/standalone/list.txt?ses=' + 
+        authtoken + '&' + new Date().getTime();
+
+    console.debug('Fetching offline block list from: ' + url);
+
+    return new Promise(function(resolve, reject) {
+
+        var xhttp = new XMLHttpRequest();
+        xhttp.onreadystatechange = function() {
+            if (this.readyState === 4) {
+                if (this.status === 200) {
+                    var blocks = xhttp.responseText;
+                    var lines = blocks.split('\n');
+                    insertOfflineBlocks(lines).then(
+                        function() {
+                            buildingBlockList = false;
+                            resolve();
+                        },
+                        function(e) {
+                            buildingBlockList = false;
+                            reject(e);
+                        }
+                    );
+                } else {
+                    reject('Error fetching offline block list');
+                }
+            }
+        };
+
+        xhttp.open('GET', url, true);
+        xhttp.send();
+    });
+}
+
+// Rebuild the offline blocks table with the provided blocks, one per line.
+function insertOfflineBlocks(lines) {
+    console.debug('Fetched ' + lines.length + ' blocks');
+
+    // Clear the table first
+    return deleteAll('offline', 'OfflineBlocks').then(
+        function() { 
+
+            console.debug('Cleared existing offline blocks');
+
+            // Create a single batch of rows for insertion.
+            var chunks = [];
+            var currentChunk = [];
+            var chunkSize = 10000;
+            var seen = {bc: {}}; // for easier delete
+
+            chunks.push(currentChunk);
+            lines.forEach(function(line) {
+                // slice/substring instead of split(' ') to handle barcodes
+                // with trailing spaces.
+                var barcode = line.slice(0, -2);
+                var reason = line.substring(line.length - 1);
+                
+                // Trim duplicate barcodes, since only one version of each 
+                // block per barcode is kept in the offline block list
+                if (seen.bc[barcode]) return;
+                seen.bc[barcode] = true;
+
+                if (currentChunk.length >= chunkSize) {
+                    currentChunk = [];
+                    chunks.push(currentChunk);
+                }
+
+                currentChunk.push({barcode: barcode, reason: reason});
+            });
+
+            delete seen.bc; // allow this hunk to be reclaimed
+
+            console.debug('offline data broken into ' + 
+                chunks.length + ' chunks of size ' + chunkSize);
+
+            return new Promise(function(resolve, reject) {
+                insertOfflineChunks(chunks, 0, resolve, reject);
+            });
+        }, 
+
+        function(err) {
+            console.error('Error clearing offline table: ' + err);
+            return Promise.reject(err);
+        }
+    );
+}
+
+function insertOfflineChunks(chunks, offset, resolve, reject) {
+    var chunk = chunks[offset];
+    if (!chunk || chunk.length === 0) {
+        console.debug('Block list successfully stored');
+        return resolve();
+    }
+
+    insertOrReplace('offline', 'OfflineBlocks', chunk).then(
+        function() { 
+            console.debug('Block list successfully stored chunk ' + offset);
+            insertOfflineChunks(chunks, offset + 1, resolve, reject);
+        },
+        reject
+    );
+}
+
+
+// Routes inbound WebWorker message to the correct handler.
+// Replies include the original request plus added response info.
+function dispatchRequest(port, data) {
+
+    console.debug('Lovefield worker received', 
+        'action=' + (data.action || ''), 
+        'schema=' + (data.schema || ''), 
+        'table=' + (data.table || ''),
+        'field=' + (data.field || ''),
+        'value=' + (data.value || '')
+    );
+
+    function replySuccess(result) {
+        data.status = 'OK';
+        data.result = result;
+        port.postMessage(data);
+    }
+
+    function replyError(err) {
+        console.error('shared worker replying with error', err);
+        data.status = 'ERR';
+        data.error = err;
+        port.postMessage(data);
+    }
+
+    switch (data.action) {
+        case 'createSchema':
+            // Schema creation is synchronous and apparently throws
+            // no exceptions, at least until connect() is called.
+            createSchema(data.schema);
+            replySuccess();
+            break;
+
+        case 'connect':
+            connect(data.schema).then(replySuccess, replyError);
+            break;
+
+        case 'insertOrReplace':
+            insertOrReplace(data.schema, data.table, data.rows)
+                .then(replySuccess, replyError);
+            break;
+
+        case 'insert':
+            insert(data.schema, data.table, data.rows)
+                .then(replySuccess, replyError);
+            break;
+
+        case 'selectWhereEqual':
+            selectWhereEqual(data.schema, data.table, data.field, data.value)
+                .then(replySuccess, replyError);
+            break;
+
+        case 'selectWhereIn':
+            selectWhereIn(data.schema, data.table, data.field, data.value)
+                .then(replySuccess, replyError);
+            break;
+
+        case 'selectAll':
+            selectAll(data.schema, data.table).then(replySuccess, replyError);
+            break;
+
+        case 'deleteAll':
+            deleteAll(data.schema, data.table).then(replySuccess, replyError);
+            break;
+
+        case 'hasRows':
+            hasRows(data.schema, data.table).then(replySuccess, replyError);
+            break;
+
+        case 'populateBlockList':
+            populateBlockList(data.authtoken).then(replySuccess, replyError);
+            break;
+
+        default:
+            console.error('no such DB action ' + data.action);
+    }
+}
+
+onconnect = function(e) {
+    var port = e.ports[0];
+    port.addEventListener('message',
+        function(e) {dispatchRequest(port, e.data);});
+    port.start();
+}
+
+
+
index 947aa42..0e990a1 100644 (file)
@@ -17,8 +17,10 @@ function($routeProvider , $locationProvider , $compileProvider) {
      * Route resolvers allow us to run async commands
      * before the page controller is instantiated.
      */
-    var resolver = {delay : ['egCore', 
-        function(egCore) {
+    var resolver = {delay : ['egCore', 'egLovefield',
+        function(egCore, egLovefield) {
+            // the 'offline' schema is only active in the offline UI.
+            egLovefield.activeSchemas.push('offline');
             return egCore.startup.go();
         }
     ]};
@@ -251,8 +253,12 @@ function($routeProvider , $locationProvider , $compileProvider) {
 ])
 
 .controller('OfflineCtrl', 
-           ['$q','$scope','$window','$location','$rootScope','egCore','egLovefield','$routeParams','$timeout','$http','ngToast','egConfirmDialog','egUnloadPrompt',
-    function($q , $scope , $window , $location , $rootScope , egCore , egLovefield , $routeParams , $timeout , $http , ngToast , egConfirmDialog , egUnloadPrompt) {
+           ['$q','$scope','$window','$location','$rootScope','egCore',
+            'egLovefield','$routeParams','$timeout','$http','ngToast',
+            'egConfirmDialog','egUnloadPrompt','egProgressDialog',
+    function($q , $scope , $window , $location , $rootScope , egCore , 
+             egLovefield , $routeParams , $timeout , $http , ngToast , 
+             egConfirmDialog , egUnloadPrompt, egProgressDialog) {
 
         // Immediately redirect if we're really offline
         if (!$window.navigator.onLine) {
@@ -388,28 +394,16 @@ function($routeProvider , $locationProvider , $compileProvider) {
         });
 
         $scope.downloadBlockList = function () {
-            var url = '/standalone/list.txt?ses='
-                + egCore.auth.token()
-                + '&' + new Date().getTime();
-            return $http.get(url).then(
-                function (res) {
-                    if (res.data) {
-                        var lines = res.data.split('\n');
-                        egLovefield.destroyOfflineBlocks().then(function(){
-                            angular.forEach(lines, function (l) {
-                                var parts = l.split(' ');
-                                egLovefield.addOfflineBlock(parts[0], parts[1]);
-                            });
-                            return $q.when();
-                        }).then(function(){
-                            ngToast.create(egCore.strings.OFFLINE_BLOCKLIST_SUCCESS);
-                        });
-                    }
-                },function(){
+            egProgressDialog.open();
+            egLovefield.populateBlockList().then(
+                function(){
+                    ngToast.create(egCore.strings.OFFLINE_BLOCKLIST_SUCCESS);
+                },
+                function(){
                     ngToast.warning(egCore.strings.OFFLINE_BLOCKLIST_FAIL);
                     egCore.audio.play('warning.offline.blocklist_fail');
                 }
-            );
+            )['finally'](egProgressDialog.close);
         }
 
         $scope.createOfflineXactBlob = function () {
@@ -847,7 +841,7 @@ function($routeProvider , $locationProvider , $compileProvider) {
                         return egLovefield.reconstituteList('asva');
                     }).then(function() {
                         angular.forEach(egCore.env.asv.list, function (s) {
-                            s.questions( egCore.env.asva.list.filter( function (a) {
+                            s.questions( egCore.env.asvq.list.filter( function (q) {
                                 return q.survey().id == s.id();
                             }));
                         });
index d0cd9c1..b78d316 100644 (file)
@@ -1,36 +1,3 @@
-var osb = lf.schema.create('offline', 2);
-
-osb.createTable('Object').
-    addColumn('type', lf.Type.STRING).          // class hint
-    addColumn('id', lf.Type.STRING).           // obj id
-    addColumn('object', lf.Type.OBJECT).
-    addPrimaryKey(['type','id']);
-
-osb.createTable('CacheDate').
-    addColumn('type', lf.Type.STRING).          // class hint
-    addColumn('cachedate', lf.Type.DATE_TIME).  // when was it last updated
-    addPrimaryKey(['type']);
-
-osb.createTable('Setting').
-    addColumn('name', lf.Type.STRING).
-    addColumn('value', lf.Type.STRING).
-    addPrimaryKey(['name']);
-
-osb.createTable('StatCat').
-    addColumn('id', lf.Type.INTEGER).
-    addColumn('value', lf.Type.OBJECT).
-    addPrimaryKey(['id']);
-
-osb.createTable('OfflineXact').
-    addColumn('seq', lf.Type.INTEGER).
-    addColumn('value', lf.Type.OBJECT).
-    addPrimaryKey(['seq'], true);
-
-osb.createTable('OfflineBlocks').
-    addColumn('barcode', lf.Type.STRING).
-    addColumn('reason', lf.Type.STRING).
-    addPrimaryKey(['barcode']);
-
 /**
  * Core Service - egLovefield
  *
@@ -42,321 +9,349 @@ angular.module('egCoreMod')
 .factory('egLovefield', ['$q','$rootScope','egCore','$timeout', 
                  function($q , $rootScope , egCore , $timeout) { 
 
-    var service = {};
+    var service = {
+        autoId: 0, // each request gets a unique id.
+        cannotConnect: false,
+        pendingRequests: [],
+        activeSchemas: ['cache'], // add 'offline' in the offline UI
+        schemasInProgress: {},
+        connectedSchemas: [],
+        // TODO: relative path would be more portable
+        workerUrl: '/js/ui/default/staff/offline-db-worker.js'
+    };
 
-    function connectOrGo() {
+    service.connectToWorker = function() {
+        if (service.worker) return;
 
-        if (lf.offlineDB) { // offline DB connected
-            return $q.when();
+        try {
+            // relative path would be better...
+            service.worker = new SharedWorker(service.workerUrl);
+        } catch (E) {
+            console.error('SharedWorker() not supported', E);
+            service.cannotConnect = true;
+            return;
         }
 
-        if (service.cannotConnect) { // connection will never happen
-            return $q.reject();
+        service.worker.onerror = function(err) {
+            console.error('Error loading shared worker', err);
+            service.cannotConnect = true;
         }
 
-        if (service.connectPromise) { // connection in progress
-            return service.connectPromise;
-        }
+        // List for responses and resolve the matching pending request.
+        service.worker.port.addEventListener('message', function(evt) {
+            var response = evt.data;
+            var reqId = response.id;
+            var req = service.pendingRequests.filter(
+                function(r) { return r.id === reqId})[0];
 
-        // start a new connection attempt
-        
-        var deferred = $q.defer();
+            if (!req) {
+                console.error('Recieved response for unknown request ' + reqId);
+                return;
+            }
 
-        //console.debug('attempting offline DB connection');
-        try {
-            osb.connect().then(
-                function(db) {
-                    console.debug('successfully connected to offline DB');
-                    service.connectPromise = null;
-                    lf.offlineDB = db;
-                    deferred.resolve();
-                },
-                function(err) {
-                    // assumes that a single connection failure means
-                    // a connection will never succeed.
-                    service.cannotConnect = true;
-                    console.error('Cannot connect to offline DB: ' + err);
-                }
-            );
-        } catch (e) {
-            // .connect() will throw an error if it detects that a connection
-            // attempt is already in progress; this can happen with PhantomJS
-            console.error('Cannot connect to offline DB: ' + e);
-            service.cannotConnect = true;
-        }
+            if (response.status === 'OK') {
+                req.deferred.resolve(response.result);
+            } else {
+                console.error('worker request failed with ' + response.error);
+                req.deferred.reject(response.error);
+            }
+        });
 
-        service.connectPromise = deferred.promise;
-        return service.connectPromise;
+        service.worker.port.start();
     }
 
-    service.isCacheGood = function (type) {
+    service.connectToSchemas = function() {
 
-        return connectOrGo().then(function() {
-            var cacheDate = lf.offlineDB.getSchema().table('CacheDate');
+        if (service.cannotConnect) { 
+            // This can happen in certain environments
+            return $q.reject();
+        }
+        
+        service.connectToWorker(); // no-op if already connected
 
-            return lf.offlineDB.
-                select(cacheDate.cachedate).
-                from(cacheDate).
-                where(cacheDate.type.eq(type)).
-                exec().then(function(results) {
-                    if (results.length == 0) {
-                        return $q.when(false);
-                    }
+        var promises = [];
 
-                    var now = new Date();
-    
-                    // hard-coded 1 day offline cache timeout
-                    return $q.when((now.getTime() - results[0]['cachedate'].getTime()) <= 86400000);
-                })
+        service.activeSchemas.forEach(function(schema) {
+            promises.push(service.connectToSchema(schema));
         });
+
+        return $q.all(promises).then(
+            function() {},
+            function() {service.cannotConnect = true}
+        );
+    }
+
+    // Connects if necessary to the active schemas then relays the request.
+    service.request = function(args) {
+        return service.connectToSchemas().then(
+            function() {
+                return service.relayRequest(args);
+            }
+        );
+    }
+
+    // Send a request to the web worker and register the request for
+    // future resolution.
+    // Store the request ID in the request arguments, so it's included
+    // in the response, and in the pendingRequests list for linking.
+    service.relayRequest = function(args) {
+        var deferred = $q.defer();
+        var reqId = service.autoId++;
+        args.id = reqId;
+        service.pendingRequests.push({id : reqId, deferred: deferred});
+        service.worker.port.postMessage(args);
+        return deferred.promise;
+    }
+
+    // Create and connect to the give schema
+    service.connectToSchema = function(schema) {
+
+        if (service.connectedSchemas.includes(schema)) {
+            // already connected
+            return $q.when();
+        }
+
+        if (service.schemasInProgress[schema]) {
+            return service.schemasInProgress[schema];
+        }
+
+        var deferred = $q.defer();
+
+        service.relayRequest(
+            {schema: schema, action: 'createSchema'}) 
+        .then(
+            function() {
+                return service.relayRequest(
+                    {schema: schema, action: 'connect'});
+            },
+            deferred.reject
+        ).then(
+            function() { 
+                service.connectedSchemas.push(schema); 
+                delete service.schemasInProgress[schema];
+                deferred.resolve();
+            },
+            deferred.reject
+        );
+
+        return service.schemasInProgress[schema] = deferred.promise;
+    }
+
+    service.isCacheGood = function (type) {
+        return service.request({
+            schema: 'cache',
+            table: 'CacheDate',
+            action: 'selectWhereEqual',
+            field: 'type',
+            value: type
+        }).then(
+            function(result) {
+                var row = result[0];
+                if (!row) { return false; }
+                // hard-coded 1 day offline cache timeout
+                return (new Date().getTime() - row.cachedate.getTime()) <= 86400000;
+            }
+        );
     }
 
     service.destroyPendingOfflineXacts = function () {
-        return connectOrGo().then(function() {
-            var table = lf.offlineDB.getSchema().table('OfflineXact');
-            return lf.offlineDB.
-                delete().
-                from(table).
-                exec();
+        return service.request({
+            schema: 'offline',
+            table: 'OfflineXact',
+            action: 'deleteAll'
         });
     }
 
     service.havePendingOfflineXacts = function () {
-        return connectOrGo().then(function() {
-            var table = lf.offlineDB.getSchema().table('OfflineXact');
-            return lf.offlineDB.
-                select(table.reason).
-                from(table).
-                exec().
-                then(function(list) {
-                    return $q.when(Boolean(list.length > 0))
-                });
+        return service.request({
+            schema: 'offline',
+            table: 'OfflineXact',
+            action: 'hasRows'
         });
     }
 
     service.retrievePendingOfflineXacts = function () {
-        return connectOrGo().then(function() {
-            var table = lf.offlineDB.getSchema().table('OfflineXact');
-            return lf.offlineDB.
-                select(table.value).
-                from(table).
-                exec().
-                then(function(list) {
-                    return $q.when(list.map(function(x) { return x.value }))
-                });
-        });
-    }
-
-    service.destroyOfflineBlocks = function () {
-        return connectOrGo().then(function() {
-            var table = lf.offlineDB.getSchema().table('OfflineBlocks');
-            return $q.when(
-                lf.offlineDB.
-                    delete().
-                    from(table).
-                    exec()
-            );
+        return service.request({
+            schema: 'offline',
+            table: 'OfflineXact',
+            action: 'selectAll'
+        }).then(function(resp) {
+            return resp.map(function(x) { return x.value });
         });
     }
 
-    service.addOfflineBlock = function (barcode, reason) {
-        return connectOrGo().then(function() {
-            var table = lf.offlineDB.getSchema().table('OfflineBlocks');
-            return $q.when(
-                lf.offlineDB.
-                    insertOrReplace().
-                    into(table).
-                    values([ table.createRow({ barcode : barcode, reason : reason }) ]).
-                    exec()
-            );
+    service.populateBlockList = function() {
+        return service.request({
+            action: 'populateBlockList',
+            authtoken: egCore.auth.token()
         });
     }
 
     // Returns a promise with true for blocked, false for not blocked
     service.testOfflineBlock = function (barcode) {
-        return connectOrGo().then(function() {
-            var table = lf.offlineDB.getSchema().table('OfflineBlocks');
-            return lf.offlineDB.
-                select(table.reason).
-                from(table).
-                where(table.barcode.eq(barcode)).
-                exec().then(function(list) {
-                    if(list.length > 0) return $q.when(list[0].reason);
-                    return $q.when(null);
-                });
+        return service.request({
+            schema: 'offline',
+            table: 'OfflineBlocks',
+            action: 'selectWhereEqual',
+            field: 'barcode',
+            value: barcode
+        }).then(function(resp) {
+            if (resp.length === 0) return null;
+            return resp[0].reason;
         });
     }
 
     service.addOfflineXact = function (obj) {
-        return connectOrGo().then(function() {
-            var table = lf.offlineDB.getSchema().table('OfflineXact');
-            return $q.when(
-                lf.offlineDB.
-                    insertOrReplace().
-                    into(table).
-                    values([ table.createRow({ value : obj }) ]).
-                    exec()
-            );
+        return service.request({
+            schema: 'offline',
+            table: 'OfflineXact',
+            action: 'insertOrReplace',
+            rows: [{value: obj}]
         });
     }
 
     service.setStatCatsCache = function (statcats) {
-        if (lf.isOffline) return $q.when();
+        if (lf.isOffline || !statcats || statcats.length === 0) 
+            return $q.when();
 
-        return connectOrGo().then(function() {
-            var table = lf.offlineDB.getSchema().table('StatCat');
-            var rlist = [];
+        var rows = statcats.map(function(cat) {
+            return {id: cat.id(), value: egCore.idl.toHash(cat)}
+        });
 
-            angular.forEach(statcats, function (val) {
-                rlist.push(table.createRow({
-                    id    : val.id(),
-                    value : egCore.idl.toHash(val)
-                }));
-            });
-            return lf.offlineDB.
-                insertOrReplace().
-                into(table).
-                values(rlist).
-                exec();
+        return service.request({
+            schema: 'cache',
+            table: 'StatCat',
+            action: 'insertOrReplace',
+            rows: rows
         });
     }
 
     service.getStatCatsCache = function () {
-        return connectOrGo().then(function() {
 
-            var table = lf.offlineDB.getSchema().table('StatCat');
+        return service.request({
+            schema: 'cache',
+            table: 'StatCat',
+            action: 'selectAll'
+        }).then(function(list) {
             var result = [];
-            return lf.offlineDB.
-                select(table.value).
-                from(table).
-                exec().then(function(list) {
-                    angular.forEach(list, function (s) {
-                        var sc = egCore.idl.fromHash('actsc', s.value);
-    
-                        if (angular.isArray(sc.default_entries())) {
-                            sc.default_entries(
-                                sc.default_entries().map( function (k) {
-                                    return egCore.idl.fromHash('actsced', k);
-                                })
-                            );
-                        }
-    
-                        if (angular.isArray(sc.entries())) {
-                            sc.entries(
-                                sc.entries().map( function (k) {
-                                    return egCore.idl.fromHash('actsce', k);
-                                })
-                            );
-                        }
-    
-                        result.push(sc);
-                    });
-                    return $q.when(result);
-                });
-    
+            list.forEach(function(s) {
+                var sc = egCore.idl.fromHash('actsc', s.value);
+
+                if (Array.isArray(sc.default_entries())) {
+                    sc.default_entries(
+                        sc.default_entries().map( function (k) {
+                            return egCore.idl.fromHash('actsced', k);
+                        })
+                    );
+                }
+
+                if (Array.isArray(sc.entries())) {
+                    sc.entries(
+                        sc.entries().map( function (k) {
+                            return egCore.idl.fromHash('actsce', k);
+                        })
+                    );
+                }
+
+                result.push(sc);
+            });
+
+            return result;
         });
     }
 
     service.setSettingsCache = function (settings) {
         if (lf.isOffline) return $q.when();
 
-        return connectOrGo().then(function() {
-
-            var table = lf.offlineDB.getSchema().table('Setting');
-            var rlist = [];
-
-            angular.forEach(settings, function (val, key) {
-                rlist.push(
-                    table.createRow({
-                        name  : key,
-                        value : JSON.stringify(val)
-                    })
-                );
-            });
+        var rows = [];
+        angular.forEach(settings, function (val, key) {
+            rows.push({name  : key, value : JSON.stringify(val)});
+        });
 
-            return lf.offlineDB.
-                insertOrReplace().
-                into(table).
-                values(rlist).
-                exec();
+        return service.request({
+            schema: 'cache',
+            table: 'Setting',
+            action: 'insertOrReplace',
+            rows: rows
         });
     }
 
     service.getSettingsCache = function (settings) {
-        return connectOrGo().then(function() {
 
-            var table = lf.offlineDB.getSchema().table('Setting');
+        var promise;
+
+        if (settings && settings.length) {
+            promise = service.request({
+                schema: 'cache',
+                table: 'Setting',
+                action: 'selectWhereIn',
+                field: 'name',
+                value: settings
+            });
+        } else {
+            promise = service.request({
+                schema: 'cache',
+                table: 'Setting',
+                action: 'selectAll'
+            });
+        }
 
-            var search_pred = table.name.isNotNull();
-            if (settings && settings.length) {
-                search_pred = table.name.in(settings);
+        return promise.then(
+            function(resp) {
+                resp.forEach(function(s) { s.value = JSON.parse(s.value); });
+                return resp;
             }
-                
-            return lf.offlineDB.
-                select(table.name, table.value).
-                from(table).
-                where(search_pred).
-                exec().then(function(list) {
-                    angular.forEach(list, function (s) {
-                        s.value = JSON.parse(s.value)
-                    });
-                    return $q.when(list);
-                });
-        });
+        );
     }
 
     service.setListInOfflineCache = function (type, list) {
         if (lf.isOffline) return $q.when();
 
-        return connectOrGo().then(function() {
+        return service.isCacheGood(type).then(function(good) {
+            if (good) { return };  // already cached
 
-            service.isCacheGood(type).then(function(good) {
-                if (!good) {
-                    var object = lf.offlineDB.getSchema().table('Object');
-                    var cacheDate = lf.offlineDB.getSchema().table('CacheDate');
-                    var pkey = egCore.idl.classes[type].pkey;
-        
-                    angular.forEach(list, function(item) {
-                        var row = object.createRow({
-                            type    : type,
-                            id      : '' + item[pkey](),
-                            object  : egCore.idl.toHash(item)
-                        });
-                        lf.offlineDB.insertOrReplace().into(object).values([row]).exec();
-                    });
-        
-                    var row = cacheDate.createRow({
-                        type      : type,
-                        cachedate : new Date()
-                    });
-        
-                    console.log('egLovefield saving ' + type + ' list');
-                    lf.offlineDB.insertOrReplace().into(cacheDate).values([row]).exec();
-                }
-            })
+            var pkey = egCore.idl.classes[type].pkey;
+            var rows = Object.values(list).map(function(item) {
+                return {
+                    type: type, 
+                    id: '' + item[pkey](), 
+                    object: egCore.idl.toHash(item)
+                };
+            });
+
+            return service.request({
+                schema: 'cache',
+                table: 'Object',
+                action: 'insertOrReplace',
+                rows: rows
+            }).then(function(resp) {
+                return service.request({
+                    schema: 'cache',
+                    table: 'CacheDate',
+                    action: 'insertOrReplace',
+                    rows: [{type: type, cachedate : new Date()}]
+                });
+            });
         });
     }
 
     service.getListFromOfflineCache = function(type) {
-        return connectOrGo().then(function() {
-
-            var object = lf.offlineDB.getSchema().table('Object');
-
-            return lf.offlineDB.
-                select(object.object).
-                from(object).
-                where(object.type.eq(type)).
-                exec().then(function(results) {
-                    return $q.when(results.map(function(item) {
-                        return egCore.idl.fromHash(type,item['object'])
-                    }));
-                });
+        return service.request({
+            schema: 'cache',
+            table: 'Object',
+            action: 'selectWhereEqual',
+            field: 'type',
+            value: type
+        }).then(function(resp) {
+            return resp.map(function(item) {
+                return egCore.idl.fromHash(type,item['object']);
+            });
         });
     }
 
     service.reconstituteList = function(type) {
         if (lf.isOffline) {
-            console.log('egLovefield reading ' + type + ' list');
+            console.debug('egLovefield reading ' + type + ' list');
             return service.getListFromOfflineCache(type).then(function (list) {
                 egCore.env.absorbList(list, type, true)
                 return $q.when(true);
@@ -367,7 +362,7 @@ angular.module('egCoreMod')
 
     service.reconstituteTree = function(type) {
         if (lf.isOffline) {
-            console.log('egLovefield reading ' + type + ' tree');
+            console.debug('egLovefield reading ' + type + ' tree');
 
             var pkey = egCore.idl.classes[type].pkey;
             var parent_field = 'parent';
index 32e97b1..da66461 100644 (file)
@@ -44,7 +44,10 @@ const JS_FILES = [
   './node_modules/moment/min/moment-with-locales.min.js',
   './node_modules/moment-timezone/builds/moment-timezone-with-data.min.js',
   './node_modules/iframe-resizer/js/iframeResizer.contentWindow.min.js',
-  './node_modules/iframe-resizer/js/iframeResizer.min.js'
+  './node_modules/iframe-resizer/js/iframeResizer.min.js',
+  // lovefield is loaded from multiple locations.  Make it stand-alone
+  // so we only need a single copy.
+  './node_modules/lovefield/dist/lovefield.min.js'
 ]
 
 
@@ -102,7 +105,6 @@ const vendorJsFiles = [
   'angular-tree-control',
   'angular-tree-control/context-menu.js',
   'angular-order-object-by',
-  'lovefield',
   'angular-tablesort'
 ];