LP#1768947 Offline DB runs in shared web worker
[working/Evergreen.git] / Open-ILS / web / js / ui / default / staff / offline-db-worker.js
1 importScripts('/js/ui/default/staff/build/js/lovefield.min.js');
2
3 // Collection of schema tracking objects.
4 var schemas = {};
5
6 // Create the DB schema / tables
7 // synchronous
8 function createSchema(schemaName) {
9     if (schemas[schemaName]) return;
10
11     var meta = lf.schema.create(schemaName, 2);
12     schemas[schemaName] = {name: schemaName, meta: meta};
13
14     switch (schemaName) {
15         case 'cache':
16             createCacheTables(meta);
17             break;
18         case 'offline':
19             createOfflineTables(meta);
20             break;
21         default:
22             console.error('No schema definition for ' + schemaName);
23     }
24 }
25
26 // Offline cache tables are globally available in the staff client
27 // for on-demand caching.
28 function createCacheTables(meta) {
29
30     meta.createTable('Setting').
31         addColumn('name', lf.Type.STRING).
32         addColumn('value', lf.Type.STRING).
33         addPrimaryKey(['name']);
34
35     meta.createTable('Object').
36         addColumn('type', lf.Type.STRING).         // class hint
37         addColumn('id', lf.Type.STRING).           // obj id
38         addColumn('object', lf.Type.OBJECT).
39         addPrimaryKey(['type','id']);
40
41     meta.createTable('CacheDate').
42         addColumn('type', lf.Type.STRING).          // class hint
43         addColumn('cachedate', lf.Type.DATE_TIME).  // when was it last updated
44         addPrimaryKey(['type']);
45
46     meta.createTable('StatCat').
47         addColumn('id', lf.Type.INTEGER).
48         addColumn('value', lf.Type.OBJECT).
49         addPrimaryKey(['id']);
50 }
51
52 // Offline transaction and block list tables.  These can be bulky and
53 // are only used in the offline UI.
54 function createOfflineTables(meta) {
55
56     meta.createTable('OfflineXact').
57         addColumn('seq', lf.Type.INTEGER).
58         addColumn('value', lf.Type.OBJECT).
59         addPrimaryKey(['seq'], true);
60
61     meta.createTable('OfflineBlocks').
62         addColumn('barcode', lf.Type.STRING).
63         addColumn('reason', lf.Type.STRING).
64         addPrimaryKey(['barcode']);
65 }
66
67 // Connect to the database for a given schema
68 function connect(schemaName) {
69
70     var schema = schemas[schemaName];
71     if (!schema) {
72         return Promise.reject('createSchema(' +
73             schemaName + ') call required');
74     }
75
76     if (schema.db) { // already connected.
77         return Promise.resolve();
78     }
79
80     return new Promise(function(resolve, reject) {
81         try {
82             schema.meta.connect().then(
83                 function(db) {
84                     schema.db = db;
85                     resolve();
86                 },
87                 function(err) {
88                     reject('Error connecting to schema ' +
89                         schemaName + ' : ' + err);
90                 }
91             );
92         } catch (E) {
93             reject('Error connecting to schema ' + schemaName + ' : ' + E);
94         }
95     });
96 }
97
98 function getTableInfo(schemaName, tableName) {
99     var schema = schemas[schemaName];
100     var info = {};
101
102     if (!schema) {
103         info.error = 'createSchema(' + schemaName + ') call required';
104
105     } else if (!schema.db) {
106         info.error = 'connect(' + schemaName + ') call required';
107
108     } else {
109         info.schema = schema;
110         info.table = schema.meta.getSchema().table(tableName);
111
112         if (!info.table) {
113             info.error = 'no such table ' + tableName;
114         }
115     }
116
117     return info;
118 }
119
120 // Returns a promise resolved with true on success
121 // Note insert .exec() returns rows, but that can get bulky on large
122 // inserts, hence the boolean return;
123 function insertOrReplace(schemaName, tableName, objects) {
124
125     var info = getTableInfo(schemaName, tableName);
126     if (info.error) { return Promise.reject(info.error); }
127
128     var rows = objects.map(function(r) { return info.table.createRow(r) });
129     return info.schema.db.insertOrReplace().into(info.table)
130         .values(rows).exec().then(function() { return true; });
131 }
132
133 // Returns a promise resolved with true on success
134 // Note insert .exec() returns rows, but that can get bulky on large
135 // inserts, hence the boolean return;
136 function insert(schemaName, tableName, objects) {
137
138     var info = getTableInfo(schemaName, tableName);
139     if (info.error) { return Promise.reject(info.error); }
140
141     var rows = objects.map(function(r) { return info.table.createRow(r) });
142     return info.schema.db.insert().into(info.table)
143         .values(rows).exec().then(function() { return true; });
144 }
145
146 // Returns rows where the selected field equals the provided value.
147 function selectWhereEqual(schemaName, tableName, field, value) {
148
149     var info = getTableInfo(schemaName, tableName);
150     if (info.error) { return Promise.reject(info.error); }
151
152     return info.schema.db.select().from(info.table)
153         .where(info.table[field].eq(value)).exec();
154 }
155
156 // Returns rows where the selected field equals the provided value.
157 function selectWhereIn(schemaName, tableName, field, value) {
158
159     var info = getTableInfo(schemaName, tableName);
160     if (info.error) { return Promise.reject(info.error); }
161
162     return info.schema.db.select().from(info.table)
163         .where(info.table[field].in(value)).exec();
164 }
165
166 // Returns all rows in the selected table
167 function selectAll(schemaName, tableName) {
168
169     var info = getTableInfo(schemaName, tableName);
170     if (info.error) { return Promise.reject(info.error); }
171
172     return info.schema.db.select().from(info.table).exec();
173 }
174
175 // Deletes all rows in the selected table.
176 function deleteAll(schemaName, tableName) {
177
178     var info = getTableInfo(schemaName, tableName);
179     if (info.error) { return Promise.reject(info.error); }
180
181     return info.schema.db.delete().from(info.table).exec();
182 }
183
184 // Resolves to true if the selected table contains any rows.
185 function hasRows(schemaName, tableName) {
186
187     var info = getTableInfo(schemaName, tableName);
188     if (info.error) { return Promise.reject(info.error); }
189
190     return info.schema.db.select().from(info.table).limit(1).exec()
191         .then(function(rows) { return rows.length > 0 });
192 }
193
194
195 // Prevent parallel block list building calls, since it does a lot.
196 var buildingBlockList = false;
197
198 // Fetches the offline block list and rebuilds the offline blocks
199 // table from the new data.
200 function populateBlockList(authtoken) {
201     if (buildingBlockList) return;
202     buildingBlockList = true;
203
204     var url = '/standalone/list.txt?ses=' + 
205         authtoken + '&' + new Date().getTime();
206
207     console.debug('Fetching offline block list from: ' + url);
208
209     return new Promise(function(resolve, reject) {
210
211         var xhttp = new XMLHttpRequest();
212         xhttp.onreadystatechange = function() {
213             if (this.readyState === 4) {
214                 if (this.status === 200) {
215                     var blocks = xhttp.responseText;
216                     var lines = blocks.split('\n');
217                     insertOfflineBlocks(lines).then(
218                         function() {
219                             buildingBlockList = false;
220                             resolve();
221                         },
222                         function(e) {
223                             buildingBlockList = false;
224                             reject(e);
225                         }
226                     );
227                 } else {
228                     reject('Error fetching offline block list');
229                 }
230             }
231         };
232
233         xhttp.open('GET', url, true);
234         xhttp.send();
235     });
236 }
237
238 // Rebuild the offline blocks table with the provided blocks, one per line.
239 function insertOfflineBlocks(lines) {
240     console.debug('Fetched ' + lines.length + ' blocks');
241
242     // Clear the table first
243     return deleteAll('offline', 'OfflineBlocks').then(
244         function() { 
245
246             console.debug('Cleared existing offline blocks');
247
248             // Create a single batch of rows for insertion.
249             var chunks = [];
250             var currentChunk = [];
251             var chunkSize = 10000;
252             var seen = {bc: {}}; // for easier delete
253
254             chunks.push(currentChunk);
255             lines.forEach(function(line) {
256                 // slice/substring instead of split(' ') to handle barcodes
257                 // with trailing spaces.
258                 var barcode = line.slice(0, -2);
259                 var reason = line.substring(line.length - 1);
260                 
261                 // Trim duplicate barcodes, since only one version of each 
262                 // block per barcode is kept in the offline block list
263                 if (seen.bc[barcode]) return;
264                 seen.bc[barcode] = true;
265
266                 if (currentChunk.length >= chunkSize) {
267                     currentChunk = [];
268                     chunks.push(currentChunk);
269                 }
270
271                 currentChunk.push({barcode: barcode, reason: reason});
272             });
273
274             delete seen.bc; // allow this hunk to be reclaimed
275
276             console.debug('offline data broken into ' + 
277                 chunks.length + ' chunks of size ' + chunkSize);
278
279             return new Promise(function(resolve, reject) {
280                 insertOfflineChunks(chunks, 0, resolve, reject);
281             });
282         }, 
283
284         function(err) {
285             console.error('Error clearing offline table: ' + err);
286             return Promise.reject(err);
287         }
288     );
289 }
290
291 function insertOfflineChunks(chunks, offset, resolve, reject) {
292     var chunk = chunks[offset];
293     if (!chunk || chunk.length === 0) {
294         console.debug('Block list successfully stored');
295         return resolve();
296     }
297
298     insertOrReplace('offline', 'OfflineBlocks', chunk).then(
299         function() { 
300             console.debug('Block list successfully stored chunk ' + offset);
301             insertOfflineChunks(chunks, offset + 1, resolve, reject);
302         },
303         reject
304     );
305 }
306
307
308 // Routes inbound WebWorker message to the correct handler.
309 // Replies include the original request plus added response info.
310 function dispatchRequest(port, data) {
311
312     console.debug('Lovefield worker received', 
313         'action=' + (data.action || ''), 
314         'schema=' + (data.schema || ''), 
315         'table=' + (data.table || ''),
316         'field=' + (data.field || ''),
317         'value=' + (data.value || '')
318     );
319
320     function replySuccess(result) {
321         data.status = 'OK';
322         data.result = result;
323         port.postMessage(data);
324     }
325
326     function replyError(err) {
327         console.error('shared worker replying with error', err);
328         data.status = 'ERR';
329         data.error = err;
330         port.postMessage(data);
331     }
332
333     switch (data.action) {
334         case 'createSchema':
335             // Schema creation is synchronous and apparently throws
336             // no exceptions, at least until connect() is called.
337             createSchema(data.schema);
338             replySuccess();
339             break;
340
341         case 'connect':
342             connect(data.schema).then(replySuccess, replyError);
343             break;
344
345         case 'insertOrReplace':
346             insertOrReplace(data.schema, data.table, data.rows)
347                 .then(replySuccess, replyError);
348             break;
349
350         case 'insert':
351             insert(data.schema, data.table, data.rows)
352                 .then(replySuccess, replyError);
353             break;
354
355         case 'selectWhereEqual':
356             selectWhereEqual(data.schema, data.table, data.field, data.value)
357                 .then(replySuccess, replyError);
358             break;
359
360         case 'selectWhereIn':
361             selectWhereIn(data.schema, data.table, data.field, data.value)
362                 .then(replySuccess, replyError);
363             break;
364
365         case 'selectAll':
366             selectAll(data.schema, data.table).then(replySuccess, replyError);
367             break;
368
369         case 'deleteAll':
370             deleteAll(data.schema, data.table).then(replySuccess, replyError);
371             break;
372
373         case 'hasRows':
374             hasRows(data.schema, data.table).then(replySuccess, replyError);
375             break;
376
377         case 'populateBlockList':
378             populateBlockList(data.authtoken).then(replySuccess, replyError);
379             break;
380
381         default:
382             console.error('no such DB action ' + data.action);
383     }
384 }
385
386 onconnect = function(e) {
387     var port = e.ports[0];
388     port.addEventListener('message',
389         function(e) {dispatchRequest(port, e.data);});
390     port.start();
391 }
392
393
394