1/*
2  2022-05-20
3
4  The author disclaims copyright to this source code.  In place of a
5  legal notice, here is a blessing:
6
7  *   May you do good and not evil.
8  *   May you find forgiveness for yourself and forgive others.
9  *   May you share freely, never taking more than you give.
10
11  ***********************************************************************
12
13  This is the JS Worker file for the sqlite3 fiddle app. It loads the
14  sqlite3 wasm module and offers access to the db via the Worker
15  message-passing interface.
16
17  Forewarning: this API is still very much Under Construction and
18  subject to any number of changes as experience reveals what those
19  need to be.
20
21  Because we can have only a single message handler, as opposed to an
22  arbitrary number of discrete event listeners like with DOM elements,
23  we have to define a lower-level message API. Messages abstractly
24  look like:
25
26  { type: string, data: type-specific value }
27
28  Where 'type' is used for dispatching and 'data' is a
29  'type'-dependent value.
30
31  The 'type' values expected by each side of the main/worker
32  connection vary. The types are described below but subject to
33  change at any time as this experiment evolves.
34
35  Workers-to-Main types
36
37  - stdout, stderr: indicate stdout/stderr output from the wasm
38    layer. The data property is the string of the output, noting
39    that the emscripten binding emits these one line at a time. Thus,
40    if a C-side puts() emits multiple lines in a single call, the JS
41    side will see that as multiple calls. Example:
42
43    {type:'stdout', data: 'Hi, world.'}
44
45  - module: Status text. This is intended to alert the main thread
46    about module loading status so that, e.g., the main thread can
47    update a progress widget and DTRT when the module is finished
48    loading and available for work. Status messages come in the form
49
50    {type:'module', data:{
51        type:'status',
52        data: {text:string|null, step:1-based-integer}
53    }
54
55    with an incrementing step value for each subsequent message. When
56    the module loading is complete, a message with a text value of
57    null is posted.
58
59  - working: data='start'|'end'. Indicates that work is about to be
60    sent to the module or has just completed. This can be used, e.g.,
61    to disable UI elements which should not be activated while work
62    is pending. Example:
63
64    {type:'working', data:'start'}
65
66  Main-to-Worker types:
67
68  - shellExec: data=text to execute as if it had been entered in the
69    sqlite3 CLI shell app (as opposed to sqlite3_exec()). This event
70    causes the worker to emit a 'working' event (data='start') before
71    it starts and a 'working' event (data='end') when it finished. If
72    called while work is currently being executed it emits stderr
73    message instead of doing actual work, as the underlying db cannot
74    handle concurrent tasks. Example:
75
76    {type:'shellExec', data: 'select * from sqlite_master'}
77
78  - More TBD as the higher-level db layer develops.
79*/
80
81/*
82  Apparent browser(s) bug: console messages emitted may be duplicated
83  in the console, even though they're provably only run once. See:
84
85  https://stackoverflow.com/questions/49659464
86
87  Noting that it happens in Firefox as well as Chrome. Harmless but
88  annoying.
89*/
90"use strict";
91(function(){
92  /**
93     Posts a message in the form {type,data}. If passed more than 2
94     args, the 3rd must be an array of "transferable" values to pass
95     as the 2nd argument to postMessage(). */
96  const wMsg =
97        (type,data,transferables)=>{
98          postMessage({type, data}, transferables || []);
99        };
100  const stdout = (...args)=>wMsg('stdout', args);
101  const stderr = (...args)=>wMsg('stderr', args);
102  const toss = (...args)=>{
103    throw new Error(args.join(' '));
104  };
105  const fixmeOPFS = "(FIXME: won't work with OPFS-over-sqlite3_vfs.)";
106  let sqlite3 /* gets assigned when the wasm module is loaded */;
107
108  self.onerror = function(/*message, source, lineno, colno, error*/) {
109    const err = arguments[4];
110    if(err && 'ExitStatus'==err.name){
111      /* This is relevant for the sqlite3 shell binding but not the
112         lower-level binding. */
113      fiddleModule.isDead = true;
114      stderr("FATAL ERROR:", err.message);
115      stderr("Restarting the app requires reloading the page.");
116      wMsg('error', err);
117    }
118    console.error(err);
119    fiddleModule.setStatus('Exception thrown, see JavaScript console: '+err);
120  };
121
122  const Sqlite3Shell = {
123    /** Returns the name of the currently-opened db. */
124    dbFilename: function f(){
125      if(!f._) f._ = sqlite3.wasm.xWrap('fiddle_db_filename', "string", ['string']);
126      return f._(0);
127    },
128    dbHandle: function f(){
129      if(!f._) f._ = sqlite3.wasm.xWrap("fiddle_db_handle", "sqlite3*");
130      return f._();
131    },
132    dbIsOpfs: function f(){
133      return sqlite3.opfs && sqlite3.capi.sqlite3_js_db_uses_vfs(
134        this.dbHandle(), "opfs"
135      );
136    },
137    runMain: function f(){
138      if(f.argv) return 0===f.argv.rc;
139      const dbName = "/fiddle.sqlite3";
140      f.argv = [
141        'sqlite3-fiddle.wasm',
142        '-bail', '-safe',
143        dbName
144        /* Reminder: because of how we run fiddle, we have to ensure
145           that any argv strings passed to its main() are valid until
146           the wasm environment shuts down. */
147      ];
148      const capi = sqlite3.capi, wasm = sqlite3.wasm;
149      /* We need to call sqlite3_shutdown() in order to avoid numerous
150         legitimate warnings from the shell about it being initialized
151         after sqlite3_initialize() has been called. This means,
152         however, that any initialization done by the JS code may need
153         to be re-done (e.g.  re-registration of dynamically-loaded
154         VFSes). We need a more generic approach to running such
155         init-level code. */
156      capi.sqlite3_shutdown();
157      f.argv.pArgv = wasm.allocMainArgv(f.argv);
158      f.argv.rc = wasm.exports.fiddle_main(
159        f.argv.length, f.argv.pArgv
160      );
161      if(f.argv.rc){
162        stderr("Fatal error initializing sqlite3 shell.");
163        fiddleModule.isDead = true;
164        return false;
165      }
166      stdout("SQLite version", capi.sqlite3_libversion(),
167             capi.sqlite3_sourceid().substr(0,19));
168      stdout('Welcome to the "fiddle" shell.');
169      if(sqlite3.opfs){
170        stdout("\nOPFS is available. To open a persistent db, use:\n\n",
171               "  .open file:name?vfs=opfs\n\nbut note that some",
172               "features (e.g. upload) do not yet work with OPFS.");
173        sqlite3.opfs.registerVfs();
174      }
175      stdout('\nEnter ".help" for usage hints.');
176      this.exec([ // initialization commands...
177        '.nullvalue NULL',
178        '.headers on'
179      ].join('\n'));
180      return true;
181    },
182    /**
183       Runs the given text through the shell as if it had been typed
184       in by a user. Fires a working/start event before it starts and
185       working/end event when it finishes.
186    */
187    exec: function f(sql){
188      if(!f._){
189        if(!this.runMain()) return;
190        f._ = sqlite3.wasm.xWrap('fiddle_exec', null, ['string']);
191      }
192      if(fiddleModule.isDead){
193        stderr("shell module has exit()ed. Cannot run SQL.");
194        return;
195      }
196      wMsg('working','start');
197      try {
198        if(f._running){
199          stderr('Cannot run multiple commands concurrently.');
200        }else if(sql){
201          if(Array.isArray(sql)) sql = sql.join('');
202          f._running = true;
203          f._(sql);
204        }
205      }finally{
206        delete f._running;
207        wMsg('working','end');
208      }
209    },
210    resetDb: function f(){
211      if(!f._) f._ = sqlite3.wasm.xWrap('fiddle_reset_db', null);
212      stdout("Resetting database.");
213      f._();
214      stdout("Reset",this.dbFilename());
215    },
216    /* Interrupt can't work: this Worker is tied up working, so won't get the
217       interrupt event which would be needed to perform the interrupt. */
218    interrupt: function f(){
219      if(!f._) f._ = sqlite3.wasm.xWrap('fiddle_interrupt', null);
220      stdout("Requesting interrupt.");
221      f._();
222    }
223  };
224
225  self.onmessage = function f(ev){
226    ev = ev.data;
227    if(!f.cache){
228      f.cache = {
229        prevFilename: null
230      };
231    }
232    //console.debug("worker: onmessage.data",ev);
233    switch(ev.type){
234        case 'shellExec': Sqlite3Shell.exec(ev.data); return;
235        case 'db-reset': Sqlite3Shell.resetDb(); return;
236        case 'interrupt': Sqlite3Shell.interrupt(); return;
237          /** Triggers the export of the current db. Fires an
238              event in the form:
239
240              {type:'db-export',
241                data:{
242                  filename: name of db,
243                  buffer: contents of the db file (Uint8Array),
244                  error: on error, a message string and no buffer property.
245                }
246              }
247          */
248        case 'db-export': {
249          const fn = Sqlite3Shell.dbFilename();
250          stdout("Exporting",fn+".");
251          const fn2 = fn ? fn.split(/[/\\]/).pop() : null;
252          try{
253            if(!fn2) toss("DB appears to be closed.");
254            const buffer = sqlite3.capi.sqlite3_js_db_export(
255              Sqlite3Shell.dbHandle()
256            );
257            wMsg('db-export',{filename: fn2, buffer: buffer.buffer}, [buffer.buffer]);
258          }catch(e){
259            console.error("Export failed:",e);
260            /* Post a failure message so that UI elements disabled
261               during the export can be re-enabled. */
262            wMsg('db-export',{
263              filename: fn,
264              error: e.message
265            });
266          }
267          return;
268        }
269        case 'open': {
270          /* Expects: {
271               buffer: ArrayBuffer | Uint8Array,
272               filename: the filename for the db. Any dir part is
273                         stripped.
274              }
275          */
276          const opt = ev.data;
277          let buffer = opt.buffer;
278          stderr('open():',fixmeOPFS);
279          if(buffer instanceof ArrayBuffer){
280            buffer = new Uint8Array(buffer);
281          }else if(!(buffer instanceof Uint8Array)){
282            stderr("'open' expects {buffer:Uint8Array} containing an uploaded db.");
283            return;
284          }
285          const fn = (
286            opt.filename
287              ? opt.filename.split(/[/\\]/).pop().replace('"','_')
288              : ("db-"+((Math.random() * 10000000) | 0)+
289                 "-"+((Math.random() * 10000000) | 0)+".sqlite3")
290          );
291          try {
292            /* We cannot delete the existing db file until the new one
293               is installed, which means that we risk overflowing our
294               quota (if any) by having both the previous and current
295               db briefly installed in the virtual filesystem. */
296            const fnAbs = '/'+fn;
297            const oldName = Sqlite3Shell.dbFilename();
298            if(oldName && oldName===fnAbs){
299              /* We cannot create the replacement file while the current file
300                 is opened, nor does the shell have a .close command, so we
301                 must temporarily switch to another db... */
302              Sqlite3Shell.exec('.open :memory:');
303              fiddleModule.FS.unlink(fnAbs);
304            }
305            fiddleModule.FS.createDataFile("/", fn, buffer, true, true);
306            Sqlite3Shell.exec('.open "'+fnAbs+'"');
307            if(oldName && oldName!==fnAbs){
308              try{fiddleModule.fsUnlink(oldName)}
309              catch(e){/*ignored*/}
310            }
311            stdout("Replaced DB with",fn+".");
312          }catch(e){
313            stderr("Error installing db",fn+":",e.message);
314          }
315          return;
316        }
317    };
318    console.warn("Unknown fiddle-worker message type:",ev);
319  };
320
321  /**
322     emscripten module for use with build mode -sMODULARIZE.
323  */
324  const fiddleModule = {
325    print: stdout,
326    printErr: stderr,
327    /**
328       Intercepts status updates from the emscripting module init
329       and fires worker events with a type of 'status' and a
330       payload of:
331
332       {
333       text: string | null, // null at end of load process
334       step: integer // starts at 1, increments 1 per call
335       }
336
337       We have no way of knowing in advance how many steps will
338       be processed/posted, so creating a "percentage done" view is
339       not really practical. One can be approximated by giving it a
340       current value of message.step and max value of message.step+1,
341       though.
342
343       When work is finished, a message with a text value of null is
344       submitted.
345
346       After a message with text==null is posted, the module may later
347       post messages about fatal problems, e.g. an exit() being
348       triggered, so it is recommended that UI elements for posting
349       status messages not be outright removed from the DOM when
350       text==null, and that they instead be hidden until/unless
351       text!=null.
352    */
353    setStatus: function f(text){
354      if(!f.last) f.last = { step: 0, text: '' };
355      else if(text === f.last.text) return;
356      f.last.text = text;
357      wMsg('module',{
358        type:'status',
359        data:{step: ++f.last.step, text: text||null}
360      });
361    }
362  };
363
364  importScripts('fiddle-module.js'+self.location.search);
365  /**
366     initFiddleModule() is installed via fiddle-module.js due to
367     building with:
368
369     emcc ... -sMODULARIZE=1 -sEXPORT_NAME=initFiddleModule
370  */
371  sqlite3InitModule(fiddleModule).then((_sqlite3)=>{
372    sqlite3 = _sqlite3;
373    const dbVfs = sqlite3.wasm.xWrap('fiddle_db_vfs', "*", ['string']);
374    fiddleModule.fsUnlink = (fn)=>{
375      return sqlite3.wasm.sqlite3_wasm_vfs_unlink(dbVfs(0), fn);
376    };
377    wMsg('fiddle-ready');
378  })/*then()*/;
379})();
380