wasm_rails 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,413 @@
1
+ // Service Worker — Rails runs entirely here.
2
+ // SQLite via @sqlite.org/sqlite-wasm (synchronous OO1 API, OPFS-backed).
3
+ // Ruby+Rails via @ruby/wasm-wasi. No Web Worker relay, no SharedArrayBuffer.
4
+
5
+ import { DefaultRubyVM } from '@ruby/wasm-wasi/dist/browser';
6
+ import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
7
+
8
+ // Injected at build time by esbuild define — changes on every build.
9
+ // Used to detect when a new SW version has been deployed and force clients to reload.
10
+ const BUILD_ID = typeof __BUILD_ID__ !== 'undefined' ? __BUILD_ID__ : 'dev';
11
+ const CACHE_VERSION = `wasm-rails-sw-${BUILD_ID}`;
12
+
13
+ const BYPASS_PREFIXES = ['/wasm/', '/assets/', '/packs/', '/icon', '/favicon', '/robots.txt', '/wasm_shell', '/auth.html'];
14
+
15
+ function isAsset(pathname) {
16
+ return BYPASS_PREFIXES.some(p => pathname.startsWith(p));
17
+ }
18
+
19
+ function broadcast(msg) {
20
+ self.clients.matchAll({ includeUncontrolled: true }).then(cs => cs.forEach(c => c.postMessage(msg)));
21
+ }
22
+
23
+ // ── IndexedDB persistence (fallback when OPFS sync handles unavailable) ───────
24
+
25
+ const IDB_NAME = 'wasm-rails-sqlite';
26
+ const IDB_STORE = 'db';
27
+ const IDB_KEY = 'main';
28
+ const IDB_IMPORT_KEY = 'pending_import';
29
+
30
+ function idbOpen() {
31
+ return new Promise((resolve, reject) => {
32
+ const req = indexedDB.open(IDB_NAME, 1);
33
+ req.onupgradeneeded = e => e.target.result.createObjectStore(IDB_STORE);
34
+ req.onsuccess = e => resolve(e.target.result);
35
+ req.onerror = () => reject(req.error);
36
+ });
37
+ }
38
+
39
+ async function idbGet(key) {
40
+ try {
41
+ const idb = await idbOpen();
42
+ return new Promise(resolve => {
43
+ const tx = idb.transaction(IDB_STORE, 'readonly');
44
+ const req = tx.objectStore(IDB_STORE).get(key);
45
+ req.onsuccess = () => { idb.close(); resolve(req.result || null); };
46
+ req.onerror = () => { idb.close(); resolve(null); };
47
+ });
48
+ } catch { return null; }
49
+ }
50
+
51
+ async function idbSet(key, bytes) {
52
+ try {
53
+ const idb = await idbOpen();
54
+ await new Promise((resolve, reject) => {
55
+ const tx = idb.transaction(IDB_STORE, 'readwrite');
56
+ tx.objectStore(IDB_STORE).put(bytes, key);
57
+ tx.oncomplete = resolve;
58
+ tx.onerror = () => reject(tx.error);
59
+ });
60
+ idb.close();
61
+ } catch (e) {
62
+ console.warn('[sw] IDB set failed:', e.message);
63
+ }
64
+ }
65
+
66
+ async function idbDel(key) {
67
+ try {
68
+ const idb = await idbOpen();
69
+ await new Promise((resolve, reject) => {
70
+ const tx = idb.transaction(IDB_STORE, 'readwrite');
71
+ tx.objectStore(IDB_STORE).delete(key);
72
+ tx.oncomplete = resolve;
73
+ tx.onerror = () => reject(tx.error);
74
+ });
75
+ idb.close();
76
+ } catch {}
77
+ }
78
+
79
+ // Keep old names as aliases so existing call-sites don't change.
80
+ const idbLoad = () => idbGet(IDB_KEY);
81
+ const idbSaveBytes = (b) => idbSet(IDB_KEY, b);
82
+
83
+ // ── SQLite ────────────────────────────────────────────────────────────────────
84
+
85
+ // Set in initSQLite when using IDB-backed in-memory DB; null when OPFS handles it.
86
+ let persistDb = null;
87
+
88
+ async function initSQLite() {
89
+ broadcast({ type: 'progress', step: 'Initializing SQLite…' });
90
+
91
+ const sqlite3 = await sqlite3InitModule({
92
+ locateFile: (path) => `/wasm/${path}`,
93
+ print: () => {},
94
+ printErr: (msg) => { if (!msg.includes('pragma')) console.warn('[sqlite3]', msg); },
95
+ });
96
+
97
+ let db;
98
+ try {
99
+ // OPFS SAH Pool VFS — synchronous, truly persistent.
100
+ const pool = await sqlite3.installOpfsSAHPoolVfs({ directory: '.wasm-rails', initialCapacity: 6 });
101
+
102
+ // Apply pending import before opening — pool.importDb requires the db to be closed.
103
+ const importBytes = await idbGet(IDB_IMPORT_KEY);
104
+ if (importBytes) {
105
+ try {
106
+ await pool.importDb('/wasm-rails.db', importBytes);
107
+ await idbDel(IDB_IMPORT_KEY);
108
+ console.log('[sw] Applied pending import to OPFS');
109
+ } catch (ie) {
110
+ console.warn('[sw] Pending import failed:', ie.message);
111
+ }
112
+ }
113
+
114
+ db = new pool.OpfsSAHPoolDb('/wasm-rails.db');
115
+ console.log('[sw] SQLite OPFS SAH Pool opened (persistent)');
116
+ } catch (e) {
117
+ // OPFS sync handles unavailable — use in-memory DB with IndexedDB serialization.
118
+ console.warn('[sw] OPFS unavailable, using IndexedDB-backed in-memory DB');
119
+ db = new sqlite3.oo1.DB();
120
+
121
+ // Pending import takes priority over the regular IDB backup.
122
+ const importBytes = await idbGet(IDB_IMPORT_KEY);
123
+ if (importBytes) await idbDel(IDB_IMPORT_KEY);
124
+ const saved = importBytes || await idbLoad();
125
+ if (saved) {
126
+ try {
127
+ const pData = sqlite3.wasm.allocFromTypedArray(saved);
128
+ const rc = sqlite3.capi.sqlite3_deserialize(
129
+ db.pointer, 'main', pData, saved.byteLength, saved.byteLength, 1 | 2
130
+ );
131
+ if (rc !== 0) {
132
+ sqlite3.wasm.dealloc(pData);
133
+ console.warn(`[sw] SQLite restore failed (rc=${rc}), starting fresh`);
134
+ } else {
135
+ console.log(`[sw] SQLite restored from IndexedDB (${saved.byteLength} bytes)`);
136
+ }
137
+ } catch (de) {
138
+ console.warn('[sw] SQLite restore error:', de.message);
139
+ }
140
+ } else {
141
+ console.log('[sw] SQLite fresh in-memory DB');
142
+ }
143
+
144
+ persistDb = async () => {
145
+ try {
146
+ const bytes = sqlite3.capi.sqlite3_js_db_export(db.pointer);
147
+ await idbSaveBytes(bytes);
148
+ } catch (e) {
149
+ console.warn('[sw] SQLite export failed:', e.message);
150
+ }
151
+ };
152
+ }
153
+
154
+ exportDb = () => sqlite3.capi.sqlite3_js_db_export(db.pointer);
155
+
156
+ // Expose a synchronous interface Ruby calls via JS.global[:sqlite4rails]
157
+ self.sqlite4rails = {
158
+ exec: (sql) => {
159
+ const result = { cols: [], rows: [] };
160
+ db.exec({
161
+ sql: sql.toString(),
162
+ columnNames: result.cols,
163
+ resultRows: result.rows,
164
+ rowMode: 'array',
165
+ });
166
+ if (persistDb && db.changes() > 0) persistDb().catch(() => {});
167
+ return result;
168
+ },
169
+ changes: () => db.changes(),
170
+ };
171
+
172
+ console.log('[sw] SQLite ready');
173
+ }
174
+
175
+ // ── DB export ─────────────────────────────────────────────────────────────────
176
+
177
+ let exportDb = null;
178
+
179
+ async function handleExport() {
180
+ if (!exportDb) return new Response('Database not ready', { status: 503 });
181
+ try {
182
+ const bytes = exportDb();
183
+ return new Response(bytes, {
184
+ status: 200,
185
+ headers: {
186
+ 'Content-Type': 'application/x-sqlite3',
187
+ 'Content-Disposition': 'attachment; filename="wasm-rails.sqlite3"',
188
+ 'Content-Length': String(bytes.byteLength),
189
+ }
190
+ });
191
+ } catch (e) {
192
+ return new Response('Export failed: ' + e.message, { status: 500 });
193
+ }
194
+ }
195
+
196
+ async function handleImport(req) {
197
+ try {
198
+ const bytes = new Uint8Array(await req.arrayBuffer());
199
+ const magic = new TextDecoder().decode(bytes.slice(0, 15));
200
+ if (!magic.startsWith('SQLite format 3')) {
201
+ return new Response(JSON.stringify({ error: 'Not a valid SQLite file' }), {
202
+ status: 400, headers: { 'Content-Type': 'application/json' }
203
+ });
204
+ }
205
+ await idbSet(IDB_IMPORT_KEY, bytes);
206
+ return new Response(JSON.stringify({ ok: true }), {
207
+ status: 200, headers: { 'Content-Type': 'application/json' }
208
+ });
209
+ } catch (e) {
210
+ return new Response(JSON.stringify({ error: e.message }), {
211
+ status: 500, headers: { 'Content-Type': 'application/json' }
212
+ });
213
+ }
214
+ }
215
+
216
+ // ── Ruby / Rails ──────────────────────────────────────────────────────────────
217
+
218
+ let vm = null;
219
+
220
+ function buildMountScript(bundle) {
221
+ const entries = Object.entries(bundle).map(([path, b64]) => {
222
+ const escaped = b64.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
223
+ return `require 'base64'; File.write("${path}", Base64.decode64("${escaped}"))`;
224
+ }).join("\n");
225
+
226
+ const dirs = [...new Set(
227
+ Object.keys(bundle).map(p => p.split('/').slice(0, -1).join('/'))
228
+ )].sort().map(d => `FileUtils.mkdir_p("${d}")`).join("\n");
229
+
230
+ return `require 'fileutils'\n${dirs}\n${entries}`;
231
+ }
232
+
233
+ function escRuby(str) {
234
+ return String(str)
235
+ .replace(/\\/g, '\\\\')
236
+ .replace(/'/g, "\\'")
237
+ .replace(/\n/g, '\\n')
238
+ .replace(/\r/g, '\\r');
239
+ }
240
+
241
+ async function initRails() {
242
+ broadcast({ type: 'progress', step: 'Loading Ruby+Rails (this takes ~20s on first load)…' });
243
+
244
+ const rubyWasmUrl = typeof __RUBY_WASM_URL__ !== 'undefined' ? __RUBY_WASM_URL__ : '/wasm/ruby+stdlib.wasm';
245
+ const wasmModule = await WebAssembly.compileStreaming(fetch(rubyWasmUrl));
246
+ const { vm: rubyVM } = await DefaultRubyVM(wasmModule);
247
+
248
+ broadcast({ type: 'progress', step: 'Mounting app bundle…' });
249
+ const appBundleUrl = typeof __APP_BUNDLE_URL__ !== 'undefined' ? __APP_BUNDLE_URL__ : '/wasm/app_bundle.json';
250
+ const bundle = await (await fetch(appBundleUrl)).json();
251
+ rubyVM.eval(buildMountScript(bundle));
252
+
253
+ broadcast({ type: 'progress', step: 'Booting Rails…' });
254
+ rubyVM.eval(`
255
+ load '/wasm_setup.rb'
256
+
257
+ ENV['RAILS_ENV'] = 'production'
258
+ ENV['SECRET_KEY_BASE'] = 'wasm-local-secret-not-used-for-encryption'
259
+
260
+ Dir.chdir('/app')
261
+ require_relative '/app/config/environment'
262
+ RAILS_APP = Rails.application
263
+ RAILS_APP.initialize! unless RAILS_APP.initialized?
264
+ puts '[sw] Rails booted'
265
+
266
+ begin
267
+ unless ActiveRecord::Base.connection.table_exists?('users')
268
+ ActiveRecord::Schema.verbose = false
269
+ load('/app/db/schema.rb')
270
+ puts '[sw] Schema loaded'
271
+ else
272
+ ActiveRecord::Migration.verbose = false
273
+ ActiveRecord::MigrationContext.new(
274
+ Rails.root.join('db/migrate'),
275
+ ActiveRecord::SchemaMigration.new(ActiveRecord::Base.connection_pool)
276
+ ).migrate
277
+ puts '[sw] Migrations applied'
278
+ end
279
+ rescue => e
280
+ puts "[sw] Schema warning: \#{e.message}"
281
+ end
282
+ `);
283
+
284
+ // Persist schema to IDB — DDL (CREATE TABLE) doesn't bump db.changes()
285
+ // so the exec-level auto-save won't fire for the initial schema load.
286
+ if (persistDb) await persistDb();
287
+
288
+ vm = rubyVM;
289
+ console.log('[sw] Rails ready');
290
+ }
291
+
292
+ // ── Boot ──────────────────────────────────────────────────────────────────────
293
+
294
+ let bootPromise = null;
295
+
296
+ async function boot() {
297
+ if (bootPromise) return bootPromise;
298
+ bootPromise = (async () => {
299
+ try {
300
+ await initSQLite();
301
+ await initRails();
302
+ broadcast({ type: 'ready' });
303
+ } catch (e) {
304
+ console.error('[sw] Boot failed:', e);
305
+ broadcast({ type: 'error', message: e.message });
306
+ } finally {
307
+ bootPromise = null;
308
+ }
309
+ })();
310
+ return bootPromise;
311
+ }
312
+
313
+ self.addEventListener('install', (event) => {
314
+ event.waitUntil(
315
+ boot()
316
+ .then(() => caches.open(CACHE_VERSION))
317
+ .then(() => self.skipWaiting())
318
+ );
319
+ });
320
+
321
+ self.addEventListener('activate', (event) => {
322
+ event.waitUntil((async () => {
323
+ const allCaches = await caches.keys();
324
+ const staleCaches = allCaches.filter(k => k.startsWith('wasm-rails-sw-') && k !== CACHE_VERSION);
325
+
326
+ const isUpdate = staleCaches.length > 0;
327
+ await Promise.all(staleCaches.map(k => caches.delete(k)));
328
+
329
+ await self.clients.claim();
330
+
331
+ if (isUpdate) {
332
+ console.log(`[sw] Updated to ${BUILD_ID} — reloading all clients`);
333
+ const clients = await self.clients.matchAll({ type: 'window' });
334
+ await Promise.all(clients.map(c => c.navigate(c.url).catch(() => {})));
335
+ }
336
+ })());
337
+ });
338
+
339
+ // ── Request handling ──────────────────────────────────────────────────────────
340
+
341
+ self.addEventListener('fetch', (event) => {
342
+ const url = new URL(event.request.url);
343
+ if (url.origin !== self.location.origin || isAsset(url.pathname)) return;
344
+ if (url.pathname === '/data/export.sqlite3') {
345
+ event.respondWith(handleExport());
346
+ return;
347
+ }
348
+ if (url.pathname === '/data/import' && event.request.method === 'POST') {
349
+ event.respondWith(handleImport(event.request));
350
+ return;
351
+ }
352
+ event.respondWith(handleRequest(event.request));
353
+ });
354
+
355
+ async function handleRequest(req) {
356
+ if (!vm) {
357
+ boot();
358
+ return new Response(`<!DOCTYPE html>
359
+ <html><head><meta charset="utf-8"><title>Budget Clear</title>
360
+ <style>body{font-family:monospace;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}p{font-size:1rem}</style>
361
+ <script>navigator.serviceWorker.addEventListener('message',e=>{if(e.data?.type==='ready')location.reload()});<\/script>
362
+ </head><body><p>Restarting Budget Clear…</p></body></html>`,
363
+ { status: 503, headers: { 'Content-Type': 'text/html' } }
364
+ );
365
+ }
366
+
367
+ const body = ['GET', 'HEAD'].includes(req.method) ? '' : await req.text();
368
+ const url = new URL(req.url);
369
+ const headers = {};
370
+ req.headers.forEach((v, k) => { headers[k] = v; });
371
+
372
+ const httpHeaders = Object.entries(headers)
373
+ .filter(([k]) => !['host', 'content-type', 'content-length'].includes(k.toLowerCase()))
374
+ .map(([k, v]) => ` 'HTTP_${k.toUpperCase().replace(/-/g, '_')}' => '${escRuby(v)}'`)
375
+ .join(',\n');
376
+
377
+ const result = vm.eval(`
378
+ begin
379
+ env = {
380
+ 'REQUEST_METHOD' => '${escRuby(req.method)}',
381
+ 'PATH_INFO' => '${escRuby(url.pathname)}',
382
+ 'QUERY_STRING' => '${escRuby(url.search.slice(1))}',
383
+ 'HTTP_HOST' => '${escRuby(url.host)}',
384
+ ${headers['content-type'] ? `'CONTENT_TYPE' => '${escRuby(headers['content-type'])}',` : ''}
385
+ 'CONTENT_LENGTH' => '${escRuby(String(body.length))}',
386
+ 'rack.input' => StringIO.new('${escRuby(body)}'),
387
+ 'rack.errors' => $stderr,
388
+ 'rack.url_scheme' => '${escRuby(url.protocol.slice(0, -1))}',
389
+ 'rack.multithread' => false,
390
+ 'rack.multiprocess'=> false,
391
+ 'rack.run_once' => false,
392
+ ${httpHeaders}
393
+ }
394
+ status, headers, body_iter = RAILS_APP.call(env)
395
+ body_str = ''.dup; body_iter.each { |p| body_str << p.to_s }
396
+ body_iter.close if body_iter.respond_to?(:close)
397
+ JSON.generate({ status: status.to_i, headers: headers, body: body_str })
398
+ rescue => e
399
+ JSON.generate({ status: 500, headers: {}, body: e.message + "\\n" + e.backtrace.first(20).join("\\n") + (e.cause ? "\\nCaused by: \#{e.cause.message}\\n" + e.cause.backtrace.first(10).join("\\n") : "") })
400
+ end
401
+ `);
402
+
403
+ const response = JSON.parse(result.toString());
404
+
405
+ if (response.status !== 200) {
406
+ console.warn('[sw] non-200', response.status, response.body?.substring(0, 300));
407
+ }
408
+
409
+ const respHeaders = new Headers(response.headers || {});
410
+ if (!respHeaders.has('Content-Type')) respHeaders.set('Content-Type', 'text/html');
411
+
412
+ return new Response(response.body, { status: response.status, headers: respHeaders });
413
+ }
@@ -0,0 +1,97 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Loading…</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body {
10
+ font-family: monospace;
11
+ background: #fff;
12
+ color: #000;
13
+ display: flex;
14
+ flex-direction: column;
15
+ align-items: center;
16
+ justify-content: center;
17
+ min-height: 100vh;
18
+ padding: 2rem;
19
+ }
20
+ .box { border: 2px solid #000; padding: 2rem; width: 100%; max-width: 480px; }
21
+ .label { font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: #666; margin-bottom: 0.5rem; }
22
+ .status { font-size: 0.9rem; min-height: 1.4em; }
23
+ .bar-wrap { border: 2px solid #000; height: 10px; margin-top: 1.5rem; }
24
+ .bar { height: 100%; background: #000; width: 0%; transition: width 0.4s ease; }
25
+ .error { background: #f00; color: #fff; border: 2px solid #000; padding: 1rem; margin-top: 1rem;
26
+ font-size: 0.85rem; white-space: pre-wrap; display: none; }
27
+ </style>
28
+ </head>
29
+ <body>
30
+ <div class="box">
31
+ <div class="label">Status</div>
32
+ <div class="status" id="status">Initializing…</div>
33
+ <div class="bar-wrap"><div class="bar" id="bar"></div></div>
34
+ </div>
35
+ <div class="error" id="error"></div>
36
+
37
+ <script type="module">
38
+ const statusEl = document.getElementById('status');
39
+ const barEl = document.getElementById('bar');
40
+ const errorEl = document.getElementById('error');
41
+
42
+ function setStatus(msg, pct) {
43
+ statusEl.textContent = msg;
44
+ if (pct !== undefined) barEl.style.width = pct + '%';
45
+ }
46
+
47
+ function showError(msg) {
48
+ errorEl.style.display = 'block';
49
+ errorEl.textContent = 'Boot error:\n' + msg;
50
+ setStatus('Failed — see error below', 100);
51
+ barEl.style.background = '#f00';
52
+ }
53
+
54
+ async function boot() {
55
+ if (!window.crossOriginIsolated) {
56
+ showError(
57
+ 'Page is not cross-origin isolated.\n' +
58
+ 'Server must send:\n' +
59
+ ' Cross-Origin-Opener-Policy: same-origin\n' +
60
+ ' Cross-Origin-Embedder-Policy: credentialless'
61
+ );
62
+ return;
63
+ }
64
+
65
+ setStatus('Loading boot module…', 10);
66
+ const { bootWasm } = await import('/wasm/boot.js');
67
+
68
+ window.addEventListener('wasm-progress', ({ detail }) => {
69
+ setStatus(detail.step);
70
+ });
71
+
72
+ setStatus('Registering service worker…', 20);
73
+
74
+ let pct = 20;
75
+ const ticker = setInterval(() => {
76
+ if (pct < 90) { pct += 0.3; barEl.style.width = pct + '%'; }
77
+ }, 500);
78
+
79
+ try {
80
+ await bootWasm();
81
+ clearInterval(ticker);
82
+ setStatus('Rails booted. Entering app…', 100);
83
+ await new Promise(r => setTimeout(r, 200));
84
+ // Redirect to your app's root after boot.
85
+ // Override this to redirect to an auth page if needed.
86
+ window.location.href = '/';
87
+ } catch (e) {
88
+ clearInterval(ticker);
89
+ showError(e.message);
90
+ console.error('[wasm_shell] Boot failed:', e);
91
+ }
92
+ }
93
+
94
+ boot();
95
+ </script>
96
+ </body>
97
+ </html>
@@ -0,0 +1,160 @@
1
+ require 'js'
2
+
3
+ # wasm_stubs/sqlite3.rb (loaded via /stubs on $LOAD_PATH) provides the SQLite3
4
+ # module stubs. sqlite3_adapter.rb's `gem 'sqlite3'` and `require 'sqlite3'`
5
+ # both resolve there without the native C extension.
6
+ #
7
+ # We still need the fake gem spec so the `gem 'sqlite3', '>= 2.1'` version
8
+ # check in sqlite3_adapter.rb passes.
9
+ unless Gem.loaded_specs['sqlite3']
10
+ fake = Gem::Specification.new { |s| s.name = 'sqlite3'; s.version = Gem::Version.new('2.5.0') }
11
+ Gem.loaded_specs['sqlite3'] = fake
12
+ end
13
+
14
+ require 'active_record/connection_adapters/sqlite3_adapter'
15
+
16
+ module ActiveRecord
17
+ module ConnectionAdapters
18
+ # Thin JS-bridge wrapper around the real SQLite3Adapter.
19
+ # All query pipeline (perform_query, internal_exec_query, write_query?,
20
+ # column reflection, type mapping) is inherited from SQLite3Adapter.
21
+ # Only the raw connection object is replaced with a JS proxy.
22
+ class WasmSqlite3Adapter < SQLite3Adapter
23
+ class ExternalInterface
24
+ def initialize
25
+ @js = JS.global[:sqlite4rails]
26
+ end
27
+
28
+ # Called by SQLite3Adapter to execute SQL. Returns a Statement-like object.
29
+ def prepare(sql)
30
+ Statement.new(@js, sql)
31
+ end
32
+
33
+ def execute(sql)
34
+ stmt = prepare(sql)
35
+ stmt.result
36
+ end
37
+
38
+ # Compatibility shims SQLite3Adapter calls on the connection object.
39
+ def transaction(mode = nil)
40
+ mode = nil if mode == :deferred
41
+ execute("begin #{mode} transaction".strip)
42
+ if block_given?
43
+ begin
44
+ yield self
45
+ commit
46
+ rescue
47
+ rollback
48
+ raise
49
+ end
50
+ end
51
+ end
52
+
53
+ def commit = execute('commit transaction')
54
+ def rollback = execute('rollback transaction')
55
+
56
+ def changes = @js.call(:changes).to_i
57
+ def total_changes = @js.call(:changes).to_i
58
+
59
+ def busy_timeout(_t) = nil
60
+ def busy_handler_timeout=(_t); end
61
+ def closed? = false
62
+ def results_as_hash = true
63
+ def results_as_hash=(_v); end
64
+ end
65
+
66
+ class Statement
67
+ attr_reader :columns, :rows
68
+
69
+ def initialize(js_interface, sql)
70
+ @js = js_interface
71
+ @base_sql = sql.to_s
72
+ @sql = @base_sql
73
+ @executed = false
74
+ @columns = []
75
+ @rows = []
76
+ end
77
+
78
+ # Substitute bound parameters into the SQL (? placeholders).
79
+ def bind_params(*params)
80
+ params = params.flatten(1)
81
+ return if params.empty?
82
+ i = -1
83
+ @sql = @base_sql.gsub('?') do
84
+ i += 1
85
+ quote_value(params[i])
86
+ end
87
+ end
88
+
89
+ def step
90
+ execute
91
+ nil
92
+ end
93
+
94
+ def execute
95
+ return if @executed
96
+ @executed = true
97
+
98
+ res = @js.call(:exec, @sql)
99
+ @columns = res[:cols].to_a.map(&:to_s)
100
+ @rows = res[:rows].to_a.map do |row|
101
+ row.to_a.map do |val|
102
+ str = val.to_s
103
+ case val.typeof
104
+ when 'string' then str
105
+ when 'boolean' then str == 'true'
106
+ when 'number' then str.include?('.') ? val.to_f : val.to_i
107
+ else str == 'null' ? nil : str
108
+ end
109
+ end
110
+ end
111
+ end
112
+
113
+ def column_count = (execute; @columns.size)
114
+ def types = (execute; Array.new(@columns.size)) # nil → default type
115
+ def to_a = (execute; @rows)
116
+ def close; end
117
+ def reset!; @executed = false; @sql = @base_sql; end
118
+
119
+ private
120
+
121
+ def quote_value(v)
122
+ case v
123
+ when NilClass then 'NULL'
124
+ when TrueClass then '1'
125
+ when FalseClass then '0'
126
+ when Numeric then v.to_s
127
+ else "'#{v.to_s.gsub("'", "''")}'"
128
+ end
129
+ end
130
+ end
131
+
132
+ class << self
133
+ def database_exists?(_config) = true
134
+ def new_client(_config) = ExternalInterface.new
135
+ end
136
+
137
+ def initialize(...)
138
+ # Bypass SQLite3Adapter's native-gem constructor; use AbstractAdapter's.
139
+ AbstractAdapter.instance_method(:initialize).bind_call(self, ...)
140
+ @prepared_statements = false
141
+ @memory_database = false
142
+ @connection_parameters = @config.merge(
143
+ database: @config[:database].to_s,
144
+ results_as_hash: true
145
+ )
146
+ @use_insert_returning = @config.key?(:insert_returning) \
147
+ ? self.class.type_cast_config_to_boolean(@config[:insert_returning]) \
148
+ : true
149
+ end
150
+
151
+ def database_exists? = true
152
+ def database_version = SQLite3Adapter::Version.new('3.45.1')
153
+ end
154
+ end
155
+ end
156
+
157
+ ActiveRecord::ConnectionAdapters.register(
158
+ 'wasm_sqlite3',
159
+ 'ActiveRecord::ConnectionAdapters::WasmSqlite3Adapter'
160
+ ) { ActiveRecord::ConnectionAdapters::WasmSqlite3Adapter }
@@ -0,0 +1,23 @@
1
+ require "rails/railtie"
2
+
3
+ module WasmRails
4
+ class Railtie < Rails::Railtie
5
+ # In WASM mode, pre-require turbo-rails app-dir constants before Rails
6
+ # eager-loads ActionController::Base and fires the on_load hooks.
7
+ # Zeitwerk can't autoload from gem app/ dirs in WASM so we do it manually.
8
+ initializer "wasm_rails.turbo_namespaces", before: :load_config_initializers do
9
+ if WasmRails.wasm?
10
+ module Turbo
11
+ module Streams; end
12
+ module Frames; end
13
+ module Native; end
14
+ end
15
+ require "turbo/streams/action_helper"
16
+ require "turbo/streams/turbo_streams_tag_builder"
17
+ require "turbo/frames/frame_request"
18
+ require "turbo/native/navigation"
19
+ require "turbo/request_id_tracking"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,3 @@
1
+ module WasmRails
2
+ VERSION = "0.1.0"
3
+ end