Project

General

Profile

Download (15.5 KB) Statistics
| Branch: | Tag: | Revision:

haketilo / common / indexeddb.js @ 4c6a2323

1 3a90084e Wojtek Kosior
/**
2
 * This file is part of Haketilo.
3
 *
4
 * Function: Facilitate use of IndexedDB within Haketilo.
5
 *
6
 * Copyright (C) 2021 Wojtek Kosior <koszko@koszko.org>
7
 *
8
 * This program is free software: you can redistribute it and/or modify
9
 * it under the terms of the GNU General Public License as published by
10
 * the Free Software Foundation, either version 3 of the License, or
11
 * (at your option) any later version.
12
 *
13
 * This program is distributed in the hope that it will be useful,
14
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16
 * GNU General Public License for more details.
17
 *
18
 * As additional permission under GNU GPL version 3 section 7, you
19
 * may distribute forms of that code without the copy of the GNU
20
 * GPL normally required by section 4, provided you include this
21
 * license notice and, in case of non-source distribution, a URL
22
 * through which recipients can access the Corresponding Source.
23
 * If you modify file(s) with this exception, you may extend this
24
 * exception to your version of the file(s), but you are not
25
 * obligated to do so. If you do not wish to do so, delete this
26
 * exception statement from your version.
27
 *
28
 * As a special exception to the GPL, any HTML file which merely
29
 * makes function calls to this code, and for that purpose
30
 * includes it by reference shall be deemed a separate work for
31
 * copyright law purposes. If you modify this code, you may extend
32
 * this exception to your version of the code, but you are not
33
 * obligated to do so. If you do not wish to do so, delete this
34
 * exception statement from your version.
35
 *
36
 * You should have received a copy of the GNU General Public License
37
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
38
 *
39
 * I, Wojtek Kosior, thereby promise not to sue for violation of this file's
40 372d24ea Wojtek Kosior
 * license. Although I request that you do not make use of this code in a
41 3a90084e Wojtek Kosior
 * proprietary program, I am not going to enforce this in court.
42
 */
43
44 b590eaa2 Wojtek Kosior
#IMPORT common/entities.js
45
#IMPORT common/broadcast.js
46
47
let initial_data = (
48 01e977f9 Wojtek Kosior
#IF UNIT_TEST
49
    {}
50
#ELSE
51 4c6a2323 Wojtek Kosior
#INCLUDE default_settings.json
52 01e977f9 Wojtek Kosior
#ENDIF
53 b590eaa2 Wojtek Kosior
);
54 3a90084e Wojtek Kosior
55
/* Update when changes are made to database schema. Must have 3 elements */
56
const db_version = [1, 0, 0];
57
58
const nr_reductor = ([i, s], num) => [i - 1, s + num * 1024 ** i];
59
const version_nr = ver => Array.reduce(ver.slice(0, 3), nr_reductor, [2, 0])[1];
60
61
const stores = 	[
62 1e4ce148 Wojtek Kosior
    ["files",     {keyPath: "hash_key"}],
63
    ["file_uses", {keyPath: "hash_key"}],
64 7218849a Wojtek Kosior
    ["resource",  {keyPath: "identifier"}],
65
    ["mapping",   {keyPath: "identifier"}],
66 0feb9db2 Wojtek Kosior
    ["settings",  {keyPath: "name"}],
67
    ["blocking",  {keyPath: "pattern"}],
68
    ["repos",     {keyPath: "url"}]
69 3a90084e Wojtek Kosior
];
70
71
let db = null;
72
73
/* Generate a Promise that resolves when an IndexedDB request succeeds. */
74
async function wait_request(idb_request)
75
{
76
    let resolve, reject;
77
    const waiter = new Promise((...cbs) => [resolve, reject] = cbs);
78
    [idb_request.onsuccess, idb_request.onerror] = [resolve, reject];
79
    return waiter;
80
}
81
82
/* asynchronous wrapper for IDBObjectStore's get() method. */
83
async function idb_get(transaction, store_name, key)
84
{
85
    const req = transaction.objectStore(store_name).get(key);
86
    return (await wait_request(req)).target.result;
87
}
88 b590eaa2 Wojtek Kosior
#EXPORT idb_get
89 3a90084e Wojtek Kosior
90
/* asynchronous wrapper for IDBObjectStore's put() method. */
91
async function idb_put(transaction, store_name, object)
92
{
93
    return wait_request(transaction.objectStore(store_name).put(object));
94
}
95
96
/* asynchronous wrapper for IDBObjectStore's delete() method. */
97
async function idb_del(transaction, store_name, key)
98
{
99
    return wait_request(transaction.objectStore(store_name).delete(key));
100
}
101
102
/* Open haketilo database, asynchronously return an IDBDatabase object. */
103 9a7623de Wojtek Kosior
async function get_db()
104 3a90084e Wojtek Kosior
{
105
    if (db)
106
	return db;
107
108
    let resolve, reject;
109
    const waiter = new Promise((...cbs) => [resolve, reject] = cbs);
110
111
    const request = indexedDB.open("haketilo", version_nr(db_version));
112
    request.onsuccess       = resolve;
113
    request.onerror         = ev => reject("db error: " + ev.target.errorCode);
114
    request.onupgradeneeded = resolve;
115
116
    const event = await waiter;
117
    const opened_db = event.target.result;
118
119
    if (event instanceof IDBVersionChangeEvent) {
120
	/*
121
	 * When we move to a new database schema, we will add upgrade logic
122
	 * here.
123
	 */
124
	if (event.oldVersion > 0)
125
	    throw "bad db version: " + event.oldVersion;
126
127
	let store;
128
	for (const [store_name, key_mode] of stores)
129
	    store = opened_db.createObjectStore(store_name, key_mode);
130
131 9a7623de Wojtek Kosior
	const ctx = make_context(store.transaction, initial_data.files);
132
	await _save_items(initial_data.resources, initial_data.mappings, ctx);
133 3a90084e Wojtek Kosior
    }
134
135 b7378a99 Wojtek Kosior
    if (db)
136
	opened_db.close();
137
    else
138
	db = opened_db;
139 3a90084e Wojtek Kosior
140
    return db;
141
}
142 b590eaa2 Wojtek Kosior
#EXPORT  get_db  AS get
143 3a90084e Wojtek Kosior
144 b7378a99 Wojtek Kosior
/* Helper function used by make_context(). */
145
function reject_discard(context)
146
{
147
    broadcast.discard(context.sender);
148
    broadcast.close(context.sender);
149
    context.reject();
150
}
151
152
/* Helper function used by make_context(). */
153
function resolve_flush(context)
154
{
155
    broadcast.close(context.sender);
156
    context.resolve();
157
}
158
159 1e4ce148 Wojtek Kosior
/* Helper function used by start_items_transaction() and get_db(). */
160
function make_context(transaction, files)
161
{
162 b7378a99 Wojtek Kosior
    const sender = broadcast.sender_connection();
163 1e4ce148 Wojtek Kosior
164 b7378a99 Wojtek Kosior
    files = files || {};
165 1e4ce148 Wojtek Kosior
    let resolve, reject;
166 b7378a99 Wojtek Kosior
    const result = new Promise((...cbs) => [resolve, reject] = cbs);
167
168
    const context =
169
	  {sender, transaction, resolve, reject, result, files, file_uses: {}};
170 1e4ce148 Wojtek Kosior
171 b7378a99 Wojtek Kosior
    transaction.oncomplete = () => resolve_flush(context);
172
    transaction.onerror = () => reject_discard(context);
173 1e4ce148 Wojtek Kosior
174
    return context;
175
}
176
177
/*
178 7218849a Wojtek Kosior
 * item_store_names should be an array with either string "mapping", string
179
 * "resource" or both. files should be an object with values being contents of
180 1e4ce148 Wojtek Kosior
 * files that are to be possibly saved in this transaction and keys of the form
181
 * `sha256-<file's-sha256-sum>`.
182
 *
183
 * Returned is a context object wrapping the transaction and handling the
184
 * counting of file references in IndexedDB.
185
 */
186
async function start_items_transaction(item_store_names, files)
187
{
188 b590eaa2 Wojtek Kosior
    const db = await get_db();
189 1e4ce148 Wojtek Kosior
    const scope = [...item_store_names, "files", "file_uses"];
190
    return make_context(db.transaction(scope, "readwrite"), files);
191
}
192 b590eaa2 Wojtek Kosior
#EXPORT start_items_transaction
193 1e4ce148 Wojtek Kosior
194
async function incr_file_uses(context, file_ref, by=1)
195
{
196
    const hash_key = file_ref.hash_key;
197
    let uses = context.file_uses[hash_key];
198
    if (uses === undefined) {
199
	uses = await idb_get(context.transaction, "file_uses", hash_key);
200
	if (uses)
201
	    [uses.new, uses.initial] = [false, uses.uses];
202
	else
203
	    uses = {hash_key, uses: 0, new: true, initial: 0};
204
205
	context.file_uses[hash_key] = uses;
206
    }
207
208
    uses.uses = uses.uses + by;
209
}
210
211
const decr_file_uses = (ctx, file_ref) => incr_file_uses(ctx, file_ref, -1);
212
213 702eefd2 Wojtek Kosior
async function finalize_transaction(context)
214 1e4ce148 Wojtek Kosior
{
215
    for (const uses of Object.values(context.file_uses)) {
216
	if (uses.uses < 0)
217
	    console.error("internal error: uses < 0 for file " + uses.hash_key);
218
219
	const is_new       = uses.new;
220
	const initial_uses = uses.initial;
221
	const hash_key     = uses.hash_key;
222
223
	delete uses.new;
224
	delete uses.initial;
225
226
	if (uses.uses < 1) {
227
	    if (!is_new) {
228
		idb_del(context.transaction, "file_uses", hash_key);
229
		idb_del(context.transaction, "files",     hash_key);
230
	    }
231
232
	    continue;
233
	}
234
235
	if (uses.uses === initial_uses)
236
	    continue;
237
238
	idb_put(context.transaction, "file_uses", uses);
239
240
	if (initial_uses > 0)
241
	    continue;
242
243
	const file = context.files[hash_key];
244
	if (file === undefined) {
245
	    context.transaction.abort();
246
	    throw "file not present: " + hash_key;
247
	}
248
249
	idb_put(context.transaction, "files", {hash_key, contents: file});
250
    }
251
252
    return context.result;
253
}
254 702eefd2 Wojtek Kosior
#EXPORT finalize_transaction
255 1e4ce148 Wojtek Kosior
256 3a90084e Wojtek Kosior
/*
257
 * How a sample data argument to the function below might look like:
258
 *
259
 * data = {
260
 *     resources: {
261
 *         "resource1": {
262
 *             "1": {
263
 *                 // some stuff
264
 *             },
265
 *             "1.1": {
266
 *                 // some stuff
267
 *             }
268
 *         },
269
 *         "resource2": {
270
 *             "0.4.3": {
271
 *                 // some stuff
272
 *             }
273
 *         },
274
 *     },
275
 *     mappings: {
276
 *         "mapping1": {
277
 *             "2": {
278
 *                 // some stuff
279
 *             }
280
 *         },
281
 *         "mapping2": {
282
 *             "0.1": {
283
 *                 // some stuff
284
 *             }
285
 *         },
286
 *     },
287
 *     files: {
288
 *         "sha256-f9444510dc7403e41049deb133f6892aa6a63c05591b2b59e4ee5b234d7bbd99": "console.log(\"hello\");\n",
289
 *         "sha256-b857cd521cc82fff30f0d316deba38b980d66db29a5388eb6004579cf743c6fd": "console.log(\"bye\");"
290
 *     }
291
 * }
292
 */
293 b7378a99 Wojtek Kosior
async function save_items(data)
294 3a90084e Wojtek Kosior
{
295 7218849a Wojtek Kosior
    const item_store_names = ["resource", "mapping"];
296 b7378a99 Wojtek Kosior
    const context = await start_items_transaction(item_store_names, data.files);
297 3a90084e Wojtek Kosior
298 1e4ce148 Wojtek Kosior
    return _save_items(data.resources, data.mappings, context);
299 3a90084e Wojtek Kosior
}
300 b590eaa2 Wojtek Kosior
#EXPORT save_items
301 3a90084e Wojtek Kosior
302 1e4ce148 Wojtek Kosior
async function _save_items(resources, mappings, context)
303 3a90084e Wojtek Kosior
{
304 1e4ce148 Wojtek Kosior
    resources = Object.values(resources || {}).map(entities.get_newest);
305
    mappings  = Object.values(mappings  || {}).map(entities.get_newest);
306 3a90084e Wojtek Kosior
307 1e4ce148 Wojtek Kosior
    for (const item of resources.concat(mappings))
308
	await save_item(item, context);
309 3a90084e Wojtek Kosior
310 702eefd2 Wojtek Kosior
    await finalize_transaction(context);
311 3a90084e Wojtek Kosior
}
312
313
/*
314
 * Save given definition of a resource/mapping to IndexedDB. If the definition
315
 * (passed as `item`) references files that are not already present in
316 19304cd1 Wojtek Kosior
 * IndexedDB, those files should be provided as values of the `files' object
317
 * used to create the transaction context.
318 1e4ce148 Wojtek Kosior
 *
319
 * context should be one returned from start_items_transaction() and should be
320 702eefd2 Wojtek Kosior
 * later passed to finalize_transaction() so that files depended on are added to
321
 * IndexedDB and files that are no longer depended on after this operation are
322
 * removed from IndexedDB.
323 3a90084e Wojtek Kosior
 */
324 1e4ce148 Wojtek Kosior
async function save_item(item, context)
325 3a90084e Wojtek Kosior
{
326
    for (const file_ref of entities.get_files(item))
327 1e4ce148 Wojtek Kosior
	await incr_file_uses(context, file_ref);
328 3a90084e Wojtek Kosior
329 7218849a Wojtek Kosior
    broadcast.prepare(context.sender, `idb_changes_${item.type}`,
330 b7378a99 Wojtek Kosior
		      item.identifier);
331 7218849a Wojtek Kosior
    await _remove_item(item.type, item.identifier, context, false);
332
    await idb_put(context.transaction, item.type, item);
333 1e4ce148 Wojtek Kosior
}
334 b590eaa2 Wojtek Kosior
#EXPORT save_item
335 3a90084e Wojtek Kosior
336 1e4ce148 Wojtek Kosior
/* Helper function used by remove_item() and save_item(). */
337
async function _remove_item(store_name, identifier, context)
338
{
339
    const item = await idb_get(context.transaction, store_name, identifier);
340
    if (item !== undefined) {
341
	for (const file_ref of entities.get_files(item))
342
	    await decr_file_uses(context, file_ref);
343 3a90084e Wojtek Kosior
    }
344 1e4ce148 Wojtek Kosior
}
345 3a90084e Wojtek Kosior
346 1e4ce148 Wojtek Kosior
/*
347
 * Remove definition of a resource/mapping from IndexedDB.
348
 *
349
 * context should be one returned from start_items_transaction() and should be
350 702eefd2 Wojtek Kosior
 * later passed to finalize_transaction() so that files depended on are added to
351
 * IndexedDB and files that are no longer depended on after this operation are
352
 * removed from IndexedDB.
353 1e4ce148 Wojtek Kosior
 */
354
async function remove_item(store_name, identifier, context)
355
{
356 b7378a99 Wojtek Kosior
    broadcast.prepare(context.sender, `idb_changes_${store_name}`, identifier);
357 1e4ce148 Wojtek Kosior
    await _remove_item(store_name, identifier, context);
358
    await idb_del(context.transaction, store_name, identifier);
359 3a90084e Wojtek Kosior
}
360
361 7218849a Wojtek Kosior
const remove_resource = (id, ctx) => remove_item("resource", id, ctx);
362 b590eaa2 Wojtek Kosior
#EXPORT remove_resource
363
364 7218849a Wojtek Kosior
const remove_mapping = (id, ctx) => remove_item("mapping",  id, ctx);
365 b590eaa2 Wojtek Kosior
#EXPORT remove_mapping
366
367 0feb9db2 Wojtek Kosior
/* Function to retrieve all items from a given store. */
368
async function get_all(store_name)
369
{
370
    const transaction = (await get_db()).transaction([store_name]);
371
    const all_req = transaction.objectStore(store_name).getAll();
372
373
    return (await wait_request(all_req)).target.result;
374
}
375
#EXPORT get_all
376
377
/*
378
 * A simplified kind of transaction for modifying stores without special
379
 * inter-store integrity constraints ("settings", "blocking", "repos").
380
 */
381
async function start_simple_transaction(store_name)
382 702eefd2 Wojtek Kosior
{
383
    const db = await get_db();
384 0feb9db2 Wojtek Kosior
    return make_context(db.transaction(store_name, "readwrite"), {});
385 702eefd2 Wojtek Kosior
}
386
387 0feb9db2 Wojtek Kosior
/* Functions to access the "settings" store. */
388 702eefd2 Wojtek Kosior
async function set_setting(name, value)
389
{
390 0feb9db2 Wojtek Kosior
    const context = await start_simple_transaction("settings");
391
    broadcast.prepare(context.sender, "idb_changes_settings", name);
392 702eefd2 Wojtek Kosior
    await idb_put(context.transaction, "settings", {name, value});
393
    return finalize_transaction(context);
394
}
395
#EXPORT set_setting
396
397
async function get_setting(name)
398
{
399
    const transaction = (await get_db()).transaction("settings");
400
    return ((await idb_get(transaction, "settings", name)) || {}).value;
401
}
402
#EXPORT get_setting
403
404 0feb9db2 Wojtek Kosior
/* Functions to access the "blocking" store. */
405
async function set_allowed(pattern, allow=true)
406
{
407
    const context = await start_simple_transaction("blocking");
408
    broadcast.prepare(context.sender, "idb_changes_blocking", pattern);
409
    if (allow === null)
410
	await idb_del(context.transaction, "blocking", pattern);
411
    else
412
	await idb_put(context.transaction, "blocking", {pattern, allow});
413
    return finalize_transaction(context);
414
}
415
#EXPORT set_allowed
416
417
const set_disallowed = pattern => set_allowed(pattern, false);
418
#EXPORT set_disallowed
419
420
const set_default_allowing = pattern => set_allowed(pattern, null);
421
#EXPORT set_default_allowing
422
423
async function get_allowing(pattern)
424
{
425
    const transaction = (await get_db()).transaction("blocking");
426
    return ((await idb_get(transaction, "blocking", pattern)) || {}).allow;
427
}
428
#EXPORT get_allowing
429
430
/* Functions to access the "repos" store. */
431
async function set_repo(url, remove=false)
432
{
433
    const context = await start_simple_transaction("repos");
434
    broadcast.prepare(context.sender, "idb_changes_repos", url);
435
    if (remove)
436
	await idb_del(context.transaction, "repos", url);
437
    else
438
	await idb_put(context.transaction, "repos", {url});
439
    return finalize_transaction(context);
440
}
441
#EXPORT set_repo
442
443
const del_repo = url => set_repo(url, true);
444
#EXPORT del_repo
445
446
const get_repos = () => get_all("repos").then(list => list.map(obj => obj.url));
447
#EXPORT get_repos
448
449 b7378a99 Wojtek Kosior
/* Callback used when listening to broadcasts while tracking db changes. */
450 702eefd2 Wojtek Kosior
async function track_change(tracking, key)
451 b7378a99 Wojtek Kosior
{
452 b590eaa2 Wojtek Kosior
    const transaction = (await get_db()).transaction([tracking.store_name]);
453 702eefd2 Wojtek Kosior
    const new_val = await idb_get(transaction, tracking.store_name, key);
454 b7378a99 Wojtek Kosior
455 702eefd2 Wojtek Kosior
    tracking.onchange({key, new_val});
456 b7378a99 Wojtek Kosior
}
457
458
/*
459
 * Monitor changes to `store_name` IndexedDB object store.
460
 *
461 7218849a Wojtek Kosior
 * `store_name` should be either "resource", "mapping", "settings", "blocking"
462
 * or "repos".
463 b7378a99 Wojtek Kosior
 *
464
 * `onchange` should be a callback that will be called when an item is added,
465
 * modified or removed from the store. The callback will be passed an object
466
 * representing the change as its first argument. This object will have the
467
 * form:
468
 * {
469 702eefd2 Wojtek Kosior
 *     key: "the identifier of modified resource/mapping or settings key",
470 b7378a99 Wojtek Kosior
 *     new_val: undefined // `undefined` if item removed, item object otherwise
471
 * }
472
 *
473
 * Returns a [tracking, all_current_items] array where `tracking` is an object
474 b590eaa2 Wojtek Kosior
 * that can be later passed to untrack() to stop tracking changes and
475 b7378a99 Wojtek Kosior
 * `all_current_items` is an array of items currently present in the object
476
 * store.
477
 *
478
 * It is possible that `onchange` gets spuriously fired even when an item is not
479
 * actually modified or that it only gets called once after multiple quick
480
 * changes to an item.
481
 */
482 702eefd2 Wojtek Kosior
async function start_tracking(store_name, onchange)
483 b7378a99 Wojtek Kosior
{
484
    const tracking = {store_name, onchange};
485
    tracking.listener =
486
	broadcast.listener_connection(msg => track_change(tracking, msg[1]));
487
    broadcast.subscribe(tracking.listener, `idb_changes_${store_name}`);
488
489 0feb9db2 Wojtek Kosior
    return [tracking, await get_all(store_name)];
490 b7378a99 Wojtek Kosior
}
491
492 702eefd2 Wojtek Kosior
const track = {};
493 7218849a Wojtek Kosior
const trackable = ["resource", "mapping", "settings", "blocking", "repos"];
494 0feb9db2 Wojtek Kosior
for (const store_name of trackable)
495 702eefd2 Wojtek Kosior
    track[store_name] = onchange => start_tracking(store_name, onchange);
496
#EXPORT track
497 b590eaa2 Wojtek Kosior
498
const untrack = tracking => broadcast.close(tracking.listener);
499
#EXPORT untrack