@remy/origin-sql
Advanced tools
@@ -1,2 +0,2 @@ | ||
| /* origin-sql 0.1.0 — browser-side SQLite with optional libSQL sync */ | ||
| /* origin-sql 0.2.0 — browser-side SQLite with optional libSQL sync */ | ||
| /* Homepage: https://github.com/ */ | ||
@@ -73,7 +73,11 @@ /* License: MIT */ | ||
| }); | ||
| function call(op, payload) { | ||
| function call(op, payload, transfer) { | ||
| const id = nextId++; | ||
| return new Promise((resolve, reject) => { | ||
| pending.set(id, { resolve, reject }); | ||
| port.postMessage({ id, op, payload }); | ||
| if (transfer && transfer.length) { | ||
| port.postMessage({ id, op, payload }, transfer); | ||
| } else { | ||
| port.postMessage({ id, op, payload }); | ||
| } | ||
| }); | ||
@@ -473,2 +477,3 @@ } | ||
| let intervalTimer = null; | ||
| let inFlightSync = null; | ||
| function deliverStatus() { | ||
@@ -498,21 +503,29 @@ for (const cb of Array.from(statusListeners)) { | ||
| await setStatus({ state: "syncing", lastError: void 0 }); | ||
| const promise = (async () => { | ||
| try { | ||
| const result = await syncer.sync(); | ||
| if (result.pull?.pulled > 0) { | ||
| const { tables } = await rpc.call("sync:user-schema", {}); | ||
| notifier.emit(tables.map((t) => t.name)); | ||
| } | ||
| backoffFailures = 0; | ||
| pausedByAuth = false; | ||
| await setStatus({ | ||
| state: "idle", | ||
| lastSyncedAt: Date.now(), | ||
| lastError: void 0 | ||
| }); | ||
| return result; | ||
| } catch (err) { | ||
| if (err instanceof AuthError) pausedByAuth = true; | ||
| else backoffFailures++; | ||
| await setStatus({ state: "error", lastError: err }); | ||
| throw err; | ||
| } | ||
| })(); | ||
| inFlightSync = promise; | ||
| try { | ||
| const result = await syncer.sync(); | ||
| if (result.pull?.pulled > 0) { | ||
| const { tables } = await rpc.call("sync:user-schema", {}); | ||
| notifier.emit(tables.map((t) => t.name)); | ||
| } | ||
| backoffFailures = 0; | ||
| pausedByAuth = false; | ||
| await setStatus({ | ||
| state: "idle", | ||
| lastSyncedAt: Date.now(), | ||
| lastError: void 0 | ||
| }); | ||
| return result; | ||
| } catch (err) { | ||
| if (err instanceof AuthError) pausedByAuth = true; | ||
| else backoffFailures++; | ||
| await setStatus({ state: "error", lastError: err }); | ||
| throw err; | ||
| return await promise; | ||
| } finally { | ||
| if (inFlightSync === promise) inFlightSync = null; | ||
| } | ||
@@ -568,2 +581,53 @@ } | ||
| } | ||
| async function exportDatabase() { | ||
| assertOpen(); | ||
| if (inFlightSync) { | ||
| try { | ||
| await inFlightSync; | ||
| } catch { | ||
| } | ||
| } | ||
| const bytes = await rpc.call("export-file", {}); | ||
| return new Blob([bytes], { type: "application/vnd.sqlite3" }); | ||
| } | ||
| async function importDatabase(blob) { | ||
| assertOpen(); | ||
| if (!(blob instanceof Blob) && !(blob instanceof ArrayBuffer) && !(blob instanceof Uint8Array)) { | ||
| throw new OriginSqlError("import requires a Blob, ArrayBuffer, or Uint8Array"); | ||
| } | ||
| if (intervalTimer) { | ||
| clearTimeout(intervalTimer); | ||
| intervalTimer = null; | ||
| } | ||
| pausedByAuth = false; | ||
| backoffFailures = 0; | ||
| if (inFlightSync) { | ||
| try { | ||
| await inFlightSync; | ||
| } catch { | ||
| } | ||
| } | ||
| let buffer; | ||
| if (blob instanceof Blob) buffer = await blob.arrayBuffer(); | ||
| else if (blob instanceof Uint8Array) buffer = blob.slice().buffer; | ||
| else buffer = blob.slice(0); | ||
| const bytes = new Uint8Array(buffer); | ||
| await rpc.call("import-replace", { bytes }, [buffer]); | ||
| if (schema) { | ||
| try { | ||
| await rpc.call("exec", { sql: schema, params: [] }); | ||
| } catch (err) { | ||
| throw new SchemaError(err.message, err); | ||
| } | ||
| } | ||
| if (sync) { | ||
| await rpc.call("sync:setup", {}); | ||
| } | ||
| const { tables } = await rpc.call("sync:user-schema", {}).catch(() => ({ tables: [] })); | ||
| if (tables?.length) notifier.emit(tables.map((t) => t.name)); | ||
| if (sync) { | ||
| await refreshPending(); | ||
| if (sync.interval) scheduleIntervalTick(); | ||
| } | ||
| } | ||
| async function close() { | ||
@@ -585,7 +649,17 @@ if (closed) return; | ||
| } | ||
| return { exec, query, transaction, subscribe, sync: syncNow, onSyncStatus, close }; | ||
| return { | ||
| exec, | ||
| query, | ||
| transaction, | ||
| subscribe, | ||
| sync: syncNow, | ||
| onSyncStatus, | ||
| export: exportDatabase, | ||
| import: importDatabase, | ||
| close | ||
| }; | ||
| } | ||
| // src/bundle-entry.js | ||
| var WORKER_SOURCE = '/* origin-sql 0.1.0 \u2014 browser-side SQLite with optional libSQL sync */\n/* Homepage: https://github.com/ */\n/* License: MIT */\n\n// src/worker.js\nimport sqlite3InitModule from "https://esm.sh/@sqlite.org/sqlite-wasm@3.46.1-build3";\n\n// src/errors.js\nvar OriginSqlError = class extends Error {\n constructor(message, cause) {\n super(message);\n this.name = "OriginSqlError";\n if (cause !== void 0) this.cause = cause;\n }\n};\nvar SchemaError = class extends OriginSqlError {\n constructor(message, cause) {\n super(message, cause);\n this.name = "SchemaError";\n }\n};\n\n// src/rpc.js\nfunction serveRpc(port, handlers) {\n port.addEventListener("message", async (event) => {\n const { id, op, payload } = event.data ?? {};\n if (id == null || !op) return;\n const handler = handlers[op];\n if (!handler) {\n port.postMessage({ id, error: { message: `unknown op: ${op}` } });\n return;\n }\n try {\n const result = await handler(payload);\n port.postMessage({ id, result });\n } catch (err) {\n port.postMessage({\n id,\n error: { message: err?.message ?? String(err), name: err?.name }\n });\n }\n });\n}\n\n// src/sql-parse.js\nvar IDENT = \'(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|\\\\[[^\\\\]]+\\\\]|[\\\\w]+)\';\nvar QUALIFIED = `(?:${IDENT}\\\\s*\\\\.\\\\s*)?${IDENT}`;\nvar WRITE_PATTERNS = [\n new RegExp(`\\\\bINSERT\\\\s+(?:OR\\\\s+\\\\w+\\\\s+)?INTO\\\\s+(${QUALIFIED})`, "gi"),\n new RegExp(`\\\\bREPLACE\\\\s+INTO\\\\s+(${QUALIFIED})`, "gi"),\n new RegExp(`\\\\bUPDATE\\\\s+(?:OR\\\\s+\\\\w+\\\\s+)?(${QUALIFIED})\\\\s+SET`, "gi"),\n new RegExp(`\\\\bDELETE\\\\s+FROM\\\\s+(${QUALIFIED})`, "gi")\n];\nvar DDL_PATTERNS = [\n new RegExp(\n `\\\\b(?:CREATE|DROP|ALTER)\\\\s+TABLE\\\\s+(?:IF\\\\s+(?:NOT\\\\s+)?EXISTS\\\\s+)?(${QUALIFIED})`,\n "gi"\n )\n];\nvar COMMENT_BLOCK = /\\/\\*[\\s\\S]*?\\*\\//g;\nvar COMMENT_LINE = /--[^\\n]*/g;\nvar STRING_LITERAL = /\'(?:[^\']|\'\')*\'/g;\nfunction stripNoise(sql) {\n return sql.replace(COMMENT_BLOCK, " ").replace(COMMENT_LINE, " ").replace(STRING_LITERAL, "\'\'");\n}\nfunction unquote(ident) {\n const t = ident.trim();\n if (t.length >= 2) {\n const first = t[0];\n const last = t[t.length - 1];\n if (first === \'"\' && last === \'"\' || first === "`" && last === "`") {\n return t.slice(1, -1);\n }\n if (first === "[" && last === "]") return t.slice(1, -1);\n }\n return t;\n}\nfunction normalize(ident) {\n const stripped = unquote(ident);\n const parts = stripped.split(".").map(unquote);\n return parts[parts.length - 1].toLowerCase();\n}\nfunction extractTouchedTables(sql) {\n if (typeof sql !== "string" || sql.length === 0) return /* @__PURE__ */ new Set();\n const cleaned = stripNoise(sql);\n const tables = /* @__PURE__ */ new Set();\n for (const pattern of WRITE_PATTERNS) {\n pattern.lastIndex = 0;\n for (const match of cleaned.matchAll(pattern)) {\n tables.add(normalize(match[1]));\n }\n }\n return tables;\n}\nfunction isDdl(sql) {\n if (typeof sql !== "string" || sql.length === 0) return false;\n const cleaned = stripNoise(sql);\n return DDL_PATTERNS.some((pattern) => {\n pattern.lastIndex = 0;\n return pattern.test(cleaned);\n });\n}\nfunction extractDdlTables(sql) {\n if (typeof sql !== "string" || sql.length === 0) return /* @__PURE__ */ new Set();\n const cleaned = stripNoise(sql);\n const tables = /* @__PURE__ */ new Set();\n for (const pattern of DDL_PATTERNS) {\n pattern.lastIndex = 0;\n for (const match of cleaned.matchAll(pattern)) {\n tables.add(normalize(match[1]));\n }\n }\n return tables;\n}\n\n// src/sync-triggers.js\nvar SYNC_META_SCHEMA = `\nCREATE TABLE IF NOT EXISTS _sync_meta (\n seq INTEGER PRIMARY KEY AUTOINCREMENT,\n table_name TEXT NOT NULL,\n row_id TEXT NOT NULL,\n op TEXT NOT NULL CHECK (op IN (\'I\',\'U\',\'D\')),\n changed_at INTEGER NOT NULL,\n synced_at INTEGER\n);\nCREATE INDEX IF NOT EXISTS _sync_meta_pending\n ON _sync_meta(seq) WHERE synced_at IS NULL;\nCREATE TABLE IF NOT EXISTS _sync_cursor (\n remote_url TEXT PRIMARY KEY,\n last_seen INTEGER NOT NULL\n);\nCREATE TABLE IF NOT EXISTS _sync_suspend (\n id INTEGER PRIMARY KEY CHECK (id = 1)\n);\n`.trim();\nvar IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;\nfunction assertSafeIdent(name) {\n if (typeof name !== "string" || !IDENT_RE.test(name)) {\n throw new SchemaError(\n `sync is only supported for tables whose names match ${IDENT_RE} (got ${JSON.stringify(name)})`\n );\n }\n return name;\n}\nvar CHANGED_AT = `CAST(unixepoch(\'subsec\') * 1000 AS INTEGER)`;\nfunction insertMeta(table, rowRef, op) {\n return `INSERT INTO _sync_meta(table_name, row_id, op, changed_at) VALUES (\'${table}\', CAST(${rowRef}.rowid AS TEXT), \'${op}\', ${CHANGED_AT})`;\n}\nfunction triggerSql(tableName) {\n const t = assertSafeIdent(tableName);\n const suspended = `EXISTS (SELECT 1 FROM _sync_suspend)`;\n return [\n `CREATE TRIGGER IF NOT EXISTS "_sync_trg_${t}_ins" AFTER INSERT ON "${t}" WHEN NOT ${suspended} BEGIN ${insertMeta(t, "NEW", "I")}; END`,\n `CREATE TRIGGER IF NOT EXISTS "_sync_trg_${t}_upd" AFTER UPDATE ON "${t}" WHEN NOT ${suspended} BEGIN ${insertMeta(t, "NEW", "U")}; END`,\n `CREATE TRIGGER IF NOT EXISTS "_sync_trg_${t}_del" AFTER DELETE ON "${t}" WHEN NOT ${suspended} BEGIN ${insertMeta(t, "OLD", "D")}; END`\n ];\n}\nvar LIST_USER_TABLES_SQL = `SELECT name FROM sqlite_master WHERE type = \'table\' AND name NOT LIKE \'\\\\_sync\\\\_%\' ESCAPE \'\\\\\' AND name NOT LIKE \'sqlite\\\\_%\' ESCAPE \'\\\\\'`;\n\n// src/worker-core.js\nfunction installWorker(sqlite32, scope = self) {\n let poolUtilPromise;\n let db;\n let txnDepth = 0;\n let syncEnabled = false;\n function listUserTables() {\n return db.selectValues(LIST_USER_TABLES_SQL);\n }\n function installTriggersForAllUserTables() {\n for (const name of listUserTables()) {\n for (const stmt of triggerSql(name)) db.exec(stmt);\n }\n }\n function runBound(sql, params) {\n const s = db.prepare(sql);\n try {\n if (params && params.length) s.bind(params);\n while (s.step()) {\n }\n } finally {\n s.finalize();\n }\n }\n function selectAll(sql, params) {\n const s = db.prepare(sql);\n try {\n if (params && params.length) s.bind(params);\n const cols = s.getColumnNames();\n const out = [];\n while (s.step()) {\n const vals = s.get([]);\n const obj = {};\n for (let i = 0; i < cols.length; i++) obj[cols[i]] = vals[i];\n out.push(obj);\n }\n return out;\n } finally {\n s.finalize();\n }\n }\n async function ensureInit(name) {\n if (db) return;\n if (!poolUtilPromise) {\n poolUtilPromise = sqlite32.installOpfsSAHPoolVfs({ name: "origin-sql-pool" });\n }\n const poolUtil = await poolUtilPromise;\n db = new poolUtil.OpfsSAHPoolDb(`/${name}.sqlite`);\n db.exec("PRAGMA foreign_keys = ON");\n }\n function runQuery(sql, params) {\n const stmt = db.prepare(sql);\n try {\n if (params && params.length) stmt.bind(params);\n const columns = stmt.getColumnNames();\n const rows = [];\n while (stmt.step()) rows.push(stmt.get([]));\n return { columns, rows };\n } finally {\n stmt.finalize();\n }\n }\n function runExec(sql, params) {\n const stmt = db.prepare(sql);\n try {\n if (params && params.length) stmt.bind(params);\n while (stmt.step()) {\n }\n } finally {\n stmt.finalize();\n }\n const tables = extractTouchedTables(sql);\n if (isDdl(sql)) for (const t of extractDdlTables(sql)) tables.add(t);\n return {\n changes: db.changes(),\n lastInsertRowid: Number(sqlite32.capi.sqlite3_last_insert_rowid(db.pointer)),\n tables: Array.from(tables)\n };\n }\n const handlers = {\n async open({ name }) {\n await ensureInit(name);\n return { ok: true };\n },\n query({ sql, params }) {\n return runQuery(sql, params);\n },\n exec({ sql, params }) {\n const result = runExec(sql, params);\n if (syncEnabled && isDdl(sql)) installTriggersForAllUserTables();\n return result;\n },\n "sync:setup"() {\n if (!db) throw new Error("database not opened");\n if (!syncEnabled) {\n db.exec(SYNC_META_SCHEMA);\n syncEnabled = true;\n }\n installTriggersForAllUserTables();\n return { ok: true };\n },\n "sync:user-schema"() {\n return {\n tables: selectAll(\n LIST_USER_TABLES_SQL.replace("SELECT name", "SELECT name, sql")\n ).map((r) => ({ name: r.name, createSql: r.sql }))\n };\n },\n "sync:prepare-push"() {\n db.exec("BEGIN");\n try {\n const collapsed = selectAll(`\n SELECT table_name, row_id, op, changed_at, seq\n FROM _sync_meta m\n WHERE synced_at IS NULL\n AND seq = (\n SELECT MAX(seq) FROM _sync_meta\n WHERE table_name = m.table_name\n AND row_id = m.row_id\n AND synced_at IS NULL\n )\n ORDER BY seq ASC\n `);\n let watermark = 0;\n const ops = [];\n for (const row of collapsed) {\n if (row.seq > watermark) watermark = row.seq;\n if (row.op === "D") {\n ops.push({ table: row.table_name, rowId: row.row_id, op: "D", changedAt: row.changed_at });\n continue;\n }\n const live = selectAll(\n `SELECT rowid AS __rowid, * FROM "${row.table_name}" WHERE rowid = ?`,\n [row.row_id]\n );\n if (live.length) {\n ops.push({\n table: row.table_name,\n rowId: row.row_id,\n op: "U",\n changedAt: row.changed_at,\n payload: live[0]\n });\n } else {\n ops.push({ table: row.table_name, rowId: row.row_id, op: "D", changedAt: row.changed_at });\n }\n }\n db.exec("COMMIT");\n return { ops, watermark };\n } catch (e) {\n try {\n db.exec("ROLLBACK");\n } catch {\n }\n throw e;\n }\n },\n "sync:mark-synced"({ watermark, syncedAt }) {\n runBound(\n "UPDATE _sync_meta SET synced_at = ? WHERE seq <= ? AND synced_at IS NULL",\n [syncedAt, watermark]\n );\n return { ok: true };\n },\n "sync:cursor:get"({ url }) {\n const rows = selectAll(\n "SELECT last_seen FROM _sync_cursor WHERE remote_url = ?",\n [url]\n );\n return { lastSeen: rows[0]?.last_seen ?? 0 };\n },\n "sync:cursor:set"({ url, lastSeen }) {\n runBound(\n "INSERT OR REPLACE INTO _sync_cursor(remote_url, last_seen) VALUES (?, ?)",\n [url, lastSeen]\n );\n return { ok: true };\n },\n "sync:pending-count"() {\n const rows = selectAll(\n "SELECT COUNT(*) AS n FROM _sync_meta WHERE synced_at IS NULL",\n []\n );\n return { count: rows[0]?.n ?? 0 };\n },\n "sync:apply"({ ops, cursorUrl, cursorLastSeen }) {\n db.exec("BEGIN");\n try {\n runBound("INSERT OR IGNORE INTO _sync_suspend(id) VALUES (1)", []);\n for (const op of ops) {\n if (op.op === "D") {\n runBound(`DELETE FROM "${op.table}" WHERE rowid = ?`, [op.rowId]);\n } else {\n const cols = Object.keys(op.payload).filter((k) => k !== "__rowid");\n const values = cols.map((k) => op.payload[k]);\n const colList = cols.map((c) => `"${c}"`).join(", ");\n const phList = ["?", ...cols.map(() => "?")].join(", ");\n runBound(\n `INSERT OR REPLACE INTO "${op.table}" (rowid, ${colList}) VALUES (${phList})`,\n [op.rowId, ...values]\n );\n }\n }\n db.exec("DELETE FROM _sync_suspend");\n if (cursorUrl && cursorLastSeen != null) {\n runBound(\n "INSERT OR REPLACE INTO _sync_cursor(remote_url, last_seen) VALUES (?, ?)",\n [cursorUrl, cursorLastSeen]\n );\n }\n db.exec("COMMIT");\n return { applied: ops.length };\n } catch (e) {\n try {\n db.exec("ROLLBACK");\n } catch {\n }\n throw e;\n }\n },\n "txn:begin"() {\n if (txnDepth > 0) throw new Error("nested transactions are not supported in v1");\n db.exec("BEGIN");\n txnDepth = 1;\n return { ok: true };\n },\n "txn:commit"() {\n if (txnDepth === 0) throw new Error("no active transaction");\n db.exec("COMMIT");\n txnDepth = 0;\n return { ok: true };\n },\n "txn:rollback"() {\n if (txnDepth === 0) return { ok: true };\n db.exec("ROLLBACK");\n txnDepth = 0;\n return { ok: true };\n },\n close() {\n if (db) {\n if (txnDepth > 0) {\n try {\n db.exec("ROLLBACK");\n } catch {\n }\n txnDepth = 0;\n }\n db.close();\n db = null;\n }\n return { ok: true };\n }\n };\n serveRpc(scope, handlers);\n scope.postMessage({ id: 0, event: "ready" });\n}\n\n// src/worker.js\nvar sqlite3 = await sqlite3InitModule({\n print: () => {\n },\n printErr: (...args) => console.error("[origin-sql]", ...args)\n});\ninstallWorker(sqlite3);\n'; | ||
| var WORKER_SOURCE = '/* origin-sql 0.2.0 \u2014 browser-side SQLite with optional libSQL sync */\n/* Homepage: https://github.com/ */\n/* License: MIT */\n\n// src/worker.js\nimport sqlite3InitModule from "https://esm.sh/@sqlite.org/sqlite-wasm@3.46.1-build3";\n\n// src/errors.js\nvar OriginSqlError = class extends Error {\n constructor(message, cause) {\n super(message);\n this.name = "OriginSqlError";\n if (cause !== void 0) this.cause = cause;\n }\n};\nvar SchemaError = class extends OriginSqlError {\n constructor(message, cause) {\n super(message, cause);\n this.name = "SchemaError";\n }\n};\n\n// src/rpc.js\nvar TRANSFER = /* @__PURE__ */ Symbol.for("origin-sql.rpc.transfer");\nfunction withTransfer(value, transfer) {\n return { [TRANSFER]: transfer, value };\n}\nfunction serveRpc(port, handlers) {\n port.addEventListener("message", async (event) => {\n const { id, op, payload } = event.data ?? {};\n if (id == null || !op) return;\n const handler = handlers[op];\n if (!handler) {\n port.postMessage({ id, error: { message: `unknown op: ${op}` } });\n return;\n }\n try {\n const result = await handler(payload);\n if (result && typeof result === "object" && TRANSFER in result) {\n port.postMessage({ id, result: result.value }, result[TRANSFER]);\n } else {\n port.postMessage({ id, result });\n }\n } catch (err) {\n port.postMessage({\n id,\n error: { message: err?.message ?? String(err), name: err?.name }\n });\n }\n });\n}\n\n// src/sql-parse.js\nvar IDENT = \'(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|\\\\[[^\\\\]]+\\\\]|[\\\\w]+)\';\nvar QUALIFIED = `(?:${IDENT}\\\\s*\\\\.\\\\s*)?${IDENT}`;\nvar WRITE_PATTERNS = [\n new RegExp(`\\\\bINSERT\\\\s+(?:OR\\\\s+\\\\w+\\\\s+)?INTO\\\\s+(${QUALIFIED})`, "gi"),\n new RegExp(`\\\\bREPLACE\\\\s+INTO\\\\s+(${QUALIFIED})`, "gi"),\n new RegExp(`\\\\bUPDATE\\\\s+(?:OR\\\\s+\\\\w+\\\\s+)?(${QUALIFIED})\\\\s+SET`, "gi"),\n new RegExp(`\\\\bDELETE\\\\s+FROM\\\\s+(${QUALIFIED})`, "gi")\n];\nvar DDL_PATTERNS = [\n new RegExp(\n `\\\\b(?:CREATE|DROP|ALTER)\\\\s+TABLE\\\\s+(?:IF\\\\s+(?:NOT\\\\s+)?EXISTS\\\\s+)?(${QUALIFIED})`,\n "gi"\n )\n];\nvar COMMENT_BLOCK = /\\/\\*[\\s\\S]*?\\*\\//g;\nvar COMMENT_LINE = /--[^\\n]*/g;\nvar STRING_LITERAL = /\'(?:[^\']|\'\')*\'/g;\nfunction stripNoise(sql) {\n return sql.replace(COMMENT_BLOCK, " ").replace(COMMENT_LINE, " ").replace(STRING_LITERAL, "\'\'");\n}\nfunction unquote(ident) {\n const t = ident.trim();\n if (t.length >= 2) {\n const first = t[0];\n const last = t[t.length - 1];\n if (first === \'"\' && last === \'"\' || first === "`" && last === "`") {\n return t.slice(1, -1);\n }\n if (first === "[" && last === "]") return t.slice(1, -1);\n }\n return t;\n}\nfunction normalize(ident) {\n const stripped = unquote(ident);\n const parts = stripped.split(".").map(unquote);\n return parts[parts.length - 1].toLowerCase();\n}\nfunction extractTouchedTables(sql) {\n if (typeof sql !== "string" || sql.length === 0) return /* @__PURE__ */ new Set();\n const cleaned = stripNoise(sql);\n const tables = /* @__PURE__ */ new Set();\n for (const pattern of WRITE_PATTERNS) {\n pattern.lastIndex = 0;\n for (const match of cleaned.matchAll(pattern)) {\n tables.add(normalize(match[1]));\n }\n }\n return tables;\n}\nfunction isDdl(sql) {\n if (typeof sql !== "string" || sql.length === 0) return false;\n const cleaned = stripNoise(sql);\n return DDL_PATTERNS.some((pattern) => {\n pattern.lastIndex = 0;\n return pattern.test(cleaned);\n });\n}\nfunction extractDdlTables(sql) {\n if (typeof sql !== "string" || sql.length === 0) return /* @__PURE__ */ new Set();\n const cleaned = stripNoise(sql);\n const tables = /* @__PURE__ */ new Set();\n for (const pattern of DDL_PATTERNS) {\n pattern.lastIndex = 0;\n for (const match of cleaned.matchAll(pattern)) {\n tables.add(normalize(match[1]));\n }\n }\n return tables;\n}\n\n// src/sync-triggers.js\nvar SYNC_META_SCHEMA = `\nCREATE TABLE IF NOT EXISTS _sync_meta (\n seq INTEGER PRIMARY KEY AUTOINCREMENT,\n table_name TEXT NOT NULL,\n row_id TEXT NOT NULL,\n op TEXT NOT NULL CHECK (op IN (\'I\',\'U\',\'D\')),\n changed_at INTEGER NOT NULL,\n synced_at INTEGER\n);\nCREATE INDEX IF NOT EXISTS _sync_meta_pending\n ON _sync_meta(seq) WHERE synced_at IS NULL;\nCREATE TABLE IF NOT EXISTS _sync_cursor (\n remote_url TEXT PRIMARY KEY,\n last_seen INTEGER NOT NULL\n);\nCREATE TABLE IF NOT EXISTS _sync_suspend (\n id INTEGER PRIMARY KEY CHECK (id = 1)\n);\n`.trim();\nvar IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;\nfunction assertSafeIdent(name) {\n if (typeof name !== "string" || !IDENT_RE.test(name)) {\n throw new SchemaError(\n `sync is only supported for tables whose names match ${IDENT_RE} (got ${JSON.stringify(name)})`\n );\n }\n return name;\n}\nvar CHANGED_AT = `CAST(unixepoch(\'subsec\') * 1000 AS INTEGER)`;\nfunction insertMeta(table, rowRef, op) {\n return `INSERT INTO _sync_meta(table_name, row_id, op, changed_at) VALUES (\'${table}\', CAST(${rowRef}.rowid AS TEXT), \'${op}\', ${CHANGED_AT})`;\n}\nfunction triggerSql(tableName) {\n const t = assertSafeIdent(tableName);\n const suspended = `EXISTS (SELECT 1 FROM _sync_suspend)`;\n return [\n `CREATE TRIGGER IF NOT EXISTS "_sync_trg_${t}_ins" AFTER INSERT ON "${t}" WHEN NOT ${suspended} BEGIN ${insertMeta(t, "NEW", "I")}; END`,\n `CREATE TRIGGER IF NOT EXISTS "_sync_trg_${t}_upd" AFTER UPDATE ON "${t}" WHEN NOT ${suspended} BEGIN ${insertMeta(t, "NEW", "U")}; END`,\n `CREATE TRIGGER IF NOT EXISTS "_sync_trg_${t}_del" AFTER DELETE ON "${t}" WHEN NOT ${suspended} BEGIN ${insertMeta(t, "OLD", "D")}; END`\n ];\n}\nvar LIST_USER_TABLES_SQL = `SELECT name FROM sqlite_master WHERE type = \'table\' AND name NOT LIKE \'\\\\_sync\\\\_%\' ESCAPE \'\\\\\' AND name NOT LIKE \'sqlite\\\\_%\' ESCAPE \'\\\\\'`;\n\n// src/worker-core.js\nfunction installWorker(sqlite32, scope = self) {\n let poolUtilPromise;\n let poolUtil;\n let db;\n let dbPath;\n let txnDepth = 0;\n let syncEnabled = false;\n function listUserTables() {\n return db.selectValues(LIST_USER_TABLES_SQL);\n }\n function installTriggersForAllUserTables() {\n for (const name of listUserTables()) {\n for (const stmt of triggerSql(name)) db.exec(stmt);\n }\n }\n function runBound(sql, params) {\n const s = db.prepare(sql);\n try {\n if (params && params.length) s.bind(params);\n while (s.step()) {\n }\n } finally {\n s.finalize();\n }\n }\n function selectAll(sql, params) {\n const s = db.prepare(sql);\n try {\n if (params && params.length) s.bind(params);\n const cols = s.getColumnNames();\n const out = [];\n while (s.step()) {\n const vals = s.get([]);\n const obj = {};\n for (let i = 0; i < cols.length; i++) obj[cols[i]] = vals[i];\n out.push(obj);\n }\n return out;\n } finally {\n s.finalize();\n }\n }\n async function ensureInit(name) {\n if (db) return;\n if (!poolUtilPromise) {\n poolUtilPromise = sqlite32.installOpfsSAHPoolVfs({ name: "origin-sql-pool" });\n }\n poolUtil = await poolUtilPromise;\n dbPath = `/${name}.sqlite`;\n db = new poolUtil.OpfsSAHPoolDb(dbPath);\n db.exec("PRAGMA foreign_keys = ON");\n }\n function closeDbHandle() {\n if (!db) return;\n if (txnDepth > 0) {\n try {\n db.exec("ROLLBACK");\n } catch {\n }\n txnDepth = 0;\n }\n db.close();\n db = null;\n }\n function runQuery(sql, params) {\n const stmt = db.prepare(sql);\n try {\n if (params && params.length) stmt.bind(params);\n const columns = stmt.getColumnNames();\n const rows = [];\n while (stmt.step()) rows.push(stmt.get([]));\n return { columns, rows };\n } finally {\n stmt.finalize();\n }\n }\n function runExec(sql, params) {\n const stmt = db.prepare(sql);\n try {\n if (params && params.length) stmt.bind(params);\n while (stmt.step()) {\n }\n } finally {\n stmt.finalize();\n }\n const tables = extractTouchedTables(sql);\n if (isDdl(sql)) for (const t of extractDdlTables(sql)) tables.add(t);\n return {\n changes: db.changes(),\n lastInsertRowid: Number(sqlite32.capi.sqlite3_last_insert_rowid(db.pointer)),\n tables: Array.from(tables)\n };\n }\n const handlers = {\n async open({ name }) {\n await ensureInit(name);\n return { ok: true };\n },\n query({ sql, params }) {\n return runQuery(sql, params);\n },\n exec({ sql, params }) {\n const result = runExec(sql, params);\n if (syncEnabled && isDdl(sql)) installTriggersForAllUserTables();\n return result;\n },\n "sync:setup"() {\n if (!db) throw new Error("database not opened");\n if (!syncEnabled) {\n db.exec(SYNC_META_SCHEMA);\n syncEnabled = true;\n }\n installTriggersForAllUserTables();\n return { ok: true };\n },\n "sync:user-schema"() {\n return {\n tables: selectAll(\n LIST_USER_TABLES_SQL.replace("SELECT name", "SELECT name, sql")\n ).map((r) => ({ name: r.name, createSql: r.sql }))\n };\n },\n "sync:prepare-push"() {\n db.exec("BEGIN");\n try {\n const collapsed = selectAll(`\n SELECT table_name, row_id, op, changed_at, seq\n FROM _sync_meta m\n WHERE synced_at IS NULL\n AND seq = (\n SELECT MAX(seq) FROM _sync_meta\n WHERE table_name = m.table_name\n AND row_id = m.row_id\n AND synced_at IS NULL\n )\n ORDER BY seq ASC\n `);\n let watermark = 0;\n const ops = [];\n for (const row of collapsed) {\n if (row.seq > watermark) watermark = row.seq;\n if (row.op === "D") {\n ops.push({ table: row.table_name, rowId: row.row_id, op: "D", changedAt: row.changed_at });\n continue;\n }\n const live = selectAll(\n `SELECT rowid AS __rowid, * FROM "${row.table_name}" WHERE rowid = ?`,\n [row.row_id]\n );\n if (live.length) {\n ops.push({\n table: row.table_name,\n rowId: row.row_id,\n op: "U",\n changedAt: row.changed_at,\n payload: live[0]\n });\n } else {\n ops.push({ table: row.table_name, rowId: row.row_id, op: "D", changedAt: row.changed_at });\n }\n }\n db.exec("COMMIT");\n return { ops, watermark };\n } catch (e) {\n try {\n db.exec("ROLLBACK");\n } catch {\n }\n throw e;\n }\n },\n "sync:mark-synced"({ watermark, syncedAt }) {\n runBound(\n "UPDATE _sync_meta SET synced_at = ? WHERE seq <= ? AND synced_at IS NULL",\n [syncedAt, watermark]\n );\n return { ok: true };\n },\n "sync:cursor:get"({ url }) {\n const rows = selectAll(\n "SELECT last_seen FROM _sync_cursor WHERE remote_url = ?",\n [url]\n );\n return { lastSeen: rows[0]?.last_seen ?? 0 };\n },\n "sync:cursor:set"({ url, lastSeen }) {\n runBound(\n "INSERT OR REPLACE INTO _sync_cursor(remote_url, last_seen) VALUES (?, ?)",\n [url, lastSeen]\n );\n return { ok: true };\n },\n "sync:pending-count"() {\n const rows = selectAll(\n "SELECT COUNT(*) AS n FROM _sync_meta WHERE synced_at IS NULL",\n []\n );\n return { count: rows[0]?.n ?? 0 };\n },\n "sync:apply"({ ops, cursorUrl, cursorLastSeen }) {\n db.exec("BEGIN");\n try {\n runBound("INSERT OR IGNORE INTO _sync_suspend(id) VALUES (1)", []);\n for (const op of ops) {\n if (op.op === "D") {\n runBound(`DELETE FROM "${op.table}" WHERE rowid = ?`, [op.rowId]);\n } else {\n const cols = Object.keys(op.payload).filter((k) => k !== "__rowid");\n const values = cols.map((k) => op.payload[k]);\n const colList = cols.map((c) => `"${c}"`).join(", ");\n const phList = ["?", ...cols.map(() => "?")].join(", ");\n runBound(\n `INSERT OR REPLACE INTO "${op.table}" (rowid, ${colList}) VALUES (${phList})`,\n [op.rowId, ...values]\n );\n }\n }\n db.exec("DELETE FROM _sync_suspend");\n if (cursorUrl && cursorLastSeen != null) {\n runBound(\n "INSERT OR REPLACE INTO _sync_cursor(remote_url, last_seen) VALUES (?, ?)",\n [cursorUrl, cursorLastSeen]\n );\n }\n db.exec("COMMIT");\n return { applied: ops.length };\n } catch (e) {\n try {\n db.exec("ROLLBACK");\n } catch {\n }\n throw e;\n }\n },\n async "export-file"() {\n if (!db || !poolUtil || !dbPath) throw new Error("database not opened");\n if (txnDepth > 0) throw new Error("cannot export during a transaction");\n const bytes = await poolUtil.exportFile(dbPath);\n return withTransfer(bytes, [bytes.buffer]);\n },\n async "import-replace"({ bytes }) {\n if (!poolUtil || !dbPath) throw new Error("database not opened");\n if (txnDepth > 0) throw new Error("cannot import during a transaction");\n closeDbHandle();\n try {\n await poolUtil.importDb(dbPath, bytes);\n } catch (err) {\n db = new poolUtil.OpfsSAHPoolDb(dbPath);\n db.exec("PRAGMA foreign_keys = ON");\n throw err;\n }\n db = new poolUtil.OpfsSAHPoolDb(dbPath);\n db.exec("PRAGMA foreign_keys = ON");\n syncEnabled = false;\n return { ok: true };\n },\n "txn:begin"() {\n if (txnDepth > 0) throw new Error("nested transactions are not supported in v1");\n db.exec("BEGIN");\n txnDepth = 1;\n return { ok: true };\n },\n "txn:commit"() {\n if (txnDepth === 0) throw new Error("no active transaction");\n db.exec("COMMIT");\n txnDepth = 0;\n return { ok: true };\n },\n "txn:rollback"() {\n if (txnDepth === 0) return { ok: true };\n db.exec("ROLLBACK");\n txnDepth = 0;\n return { ok: true };\n },\n close() {\n closeDbHandle();\n return { ok: true };\n }\n };\n serveRpc(scope, handlers);\n scope.postMessage({ id: 0, event: "ready" });\n}\n\n// src/worker.js\nvar sqlite3 = await sqlite3InitModule({\n print: () => {\n },\n printErr: (...args) => console.error("[origin-sql]", ...args)\n});\ninstallWorker(sqlite3);\n'; | ||
| var workerBlobUrl = null; | ||
@@ -592,0 +666,0 @@ function getWorkerUrl() { |
@@ -1,2 +0,2 @@ | ||
| /* origin-sql 0.1.0 — browser-side SQLite with optional libSQL sync */ | ||
| /* origin-sql 0.2.0 — browser-side SQLite with optional libSQL sync */ | ||
| /* Homepage: https://github.com/ */ | ||
@@ -24,2 +24,6 @@ /* License: MIT */ | ||
| // src/rpc.js | ||
| var TRANSFER = /* @__PURE__ */ Symbol.for("origin-sql.rpc.transfer"); | ||
| function withTransfer(value, transfer) { | ||
| return { [TRANSFER]: transfer, value }; | ||
| } | ||
| function serveRpc(port, handlers) { | ||
@@ -36,3 +40,7 @@ port.addEventListener("message", async (event) => { | ||
| const result = await handler(payload); | ||
| port.postMessage({ id, result }); | ||
| if (result && typeof result === "object" && TRANSFER in result) { | ||
| port.postMessage({ id, result: result.value }, result[TRANSFER]); | ||
| } else { | ||
| port.postMessage({ id, result }); | ||
| } | ||
| } catch (err) { | ||
@@ -165,3 +173,5 @@ port.postMessage({ | ||
| let poolUtilPromise; | ||
| let poolUtil; | ||
| let db; | ||
| let dbPath; | ||
| let txnDepth = 0; | ||
@@ -209,6 +219,19 @@ let syncEnabled = false; | ||
| } | ||
| const poolUtil = await poolUtilPromise; | ||
| db = new poolUtil.OpfsSAHPoolDb(`/${name}.sqlite`); | ||
| poolUtil = await poolUtilPromise; | ||
| dbPath = `/${name}.sqlite`; | ||
| db = new poolUtil.OpfsSAHPoolDb(dbPath); | ||
| db.exec("PRAGMA foreign_keys = ON"); | ||
| } | ||
| function closeDbHandle() { | ||
| if (!db) return; | ||
| if (txnDepth > 0) { | ||
| try { | ||
| db.exec("ROLLBACK"); | ||
| } catch { | ||
| } | ||
| txnDepth = 0; | ||
| } | ||
| db.close(); | ||
| db = null; | ||
| } | ||
| function runQuery(sql, params) { | ||
@@ -384,2 +407,24 @@ const stmt = db.prepare(sql); | ||
| }, | ||
| async "export-file"() { | ||
| if (!db || !poolUtil || !dbPath) throw new Error("database not opened"); | ||
| if (txnDepth > 0) throw new Error("cannot export during a transaction"); | ||
| const bytes = await poolUtil.exportFile(dbPath); | ||
| return withTransfer(bytes, [bytes.buffer]); | ||
| }, | ||
| async "import-replace"({ bytes }) { | ||
| if (!poolUtil || !dbPath) throw new Error("database not opened"); | ||
| if (txnDepth > 0) throw new Error("cannot import during a transaction"); | ||
| closeDbHandle(); | ||
| try { | ||
| await poolUtil.importDb(dbPath, bytes); | ||
| } catch (err) { | ||
| db = new poolUtil.OpfsSAHPoolDb(dbPath); | ||
| db.exec("PRAGMA foreign_keys = ON"); | ||
| throw err; | ||
| } | ||
| db = new poolUtil.OpfsSAHPoolDb(dbPath); | ||
| db.exec("PRAGMA foreign_keys = ON"); | ||
| syncEnabled = false; | ||
| return { ok: true }; | ||
| }, | ||
| "txn:begin"() { | ||
@@ -404,13 +449,3 @@ if (txnDepth > 0) throw new Error("nested transactions are not supported in v1"); | ||
| close() { | ||
| if (db) { | ||
| if (txnDepth > 0) { | ||
| try { | ||
| db.exec("ROLLBACK"); | ||
| } catch { | ||
| } | ||
| txnDepth = 0; | ||
| } | ||
| db.close(); | ||
| db = null; | ||
| } | ||
| closeDbHandle(); | ||
| return { ok: true }; | ||
@@ -417,0 +452,0 @@ } |
+1
-1
| { | ||
| "name": "@remy/origin-sql", | ||
| "version": "0.1.0", | ||
| "version": "0.2.0", | ||
| "description": "Browser-side SQLite with OPFS persistence and optional libSQL sync", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+87
-0
@@ -103,2 +103,4 @@ # origin-sql | ||
| - `db.onSyncStatus(cb): () => void` — subscribe to sync state transitions. `cb` is called synchronously once at registration and on every transition with `{ state: 'idle'|'syncing'|'error', pendingPush, lastSyncedAt, lastError }`. Returns an unsubscribe. | ||
| - `db.export(): Promise<Blob>` — dump the entire SQLite file as a `Blob` tagged `application/vnd.sqlite3`. Useful for user-initiated backups or "download my data" flows. See [Backup and restore](#backup-and-restore). | ||
| - `db.import(source): Promise<void>` — replace the entire database with the contents of `source` (a `Blob`, `ArrayBuffer`, or `Uint8Array`). The handle stays usable: the schema is re-applied and sync is re-initialised if configured. Throws if a transaction is in flight. | ||
| - `db.close(): Promise<void>` — flushes, terminates the worker, rejects pending calls, cancels any sync interval. | ||
@@ -118,2 +120,32 @@ | ||
| ### Backup and restore | ||
| `db.export()` hands you the whole database as a standard SQLite file, suitable for saving to disk, uploading to S3, or round-tripping through `sqlite3` on the command line: | ||
| ```js | ||
| const blob = await db.export(); | ||
| const url = URL.createObjectURL(blob); | ||
| const a = document.createElement('a'); | ||
| a.href = url; | ||
| a.download = 'my-app.sqlite'; | ||
| a.click(); | ||
| URL.revokeObjectURL(url); | ||
| ``` | ||
| `db.import(blob)` replaces the current database with an uploaded file. The in-memory `db` handle stays valid — schema and sync setup are re-applied automatically — so the usual pattern is a single file input: | ||
| ```js | ||
| fileInput.addEventListener('change', async (e) => { | ||
| const file = e.target.files[0]; | ||
| if (!file) return; | ||
| await db.import(file); // file is a Blob | ||
| // subscribers fire for every user table; re-render your UI | ||
| }); | ||
| ``` | ||
| Notes: | ||
| - Import rejects if a transaction is in flight. Await any `db.transaction(...)` first. | ||
| - When `sync` is configured, importing effectively "forks" the local clone. The imported file's `_sync_meta` state governs what gets pushed next; rows from a non-sync export won't be flagged for push. Expect the next `db.sync()` to behave as if the imported data had always been local. | ||
| - The exported file contains every table, including the `_sync_*` internal tables when sync is configured. | ||
| ### Known limitations | ||
@@ -212,2 +244,57 @@ | ||
| ## Recipes | ||
| Things that are trivial in user-land and therefore deliberately not part of the core API. | ||
| ### Export as JSON | ||
| `db.export()` returns a binary SQLite file — the universal, lossless format. When you need a JSON dump instead (product requirement to "download as JSON", uploading to a JSON-native endpoint, diffable snapshots in tests), iterate `sqlite_master` yourself: | ||
| ```js | ||
| async function exportJson(db) { | ||
| const tables = await db.query( | ||
| `SELECT name FROM sqlite_master | ||
| WHERE type = 'table' | ||
| AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' | ||
| AND name NOT LIKE '\\_sync\\_%' ESCAPE '\\'`, | ||
| ); | ||
| const dump = { version: 1, exportedAt: new Date().toISOString(), tables: {} }; | ||
| for (const { name } of tables) { | ||
| dump.tables[name] = await db.query(`SELECT * FROM "${name}"`); | ||
| } | ||
| return new Blob([JSON.stringify(dump, null, 2)], { type: 'application/json' }); | ||
| } | ||
| ``` | ||
| Caveats you own when you do this: | ||
| - **BLOB columns** need base64 (`btoa(String.fromCharCode(...row.col))`) or they'll JSON-stringify to `{}`. | ||
| - **Integers past `Number.MAX_SAFE_INTEGER`** lose precision — cast to TEXT in the query (`CAST(big_id AS TEXT) AS big_id`) and parse client-side. | ||
| - **Schema is not preserved.** Types, constraints, indexes, triggers, and views are gone. Re-importing requires you to recreate the schema separately. | ||
| - **Not round-trip-symmetric with `db.import()`.** Use `db.export()` for true backup/restore; use JSON for "send a copy of my data" and similar one-way flows. | ||
| ### Restore from JSON | ||
| The mirror of above — rebuild a database from a JSON dump: | ||
| ```js | ||
| async function importJson(db, dump) { | ||
| await db.transaction(async (tx) => { | ||
| for (const [table, rows] of Object.entries(dump.tables)) { | ||
| if (!rows.length) continue; | ||
| const cols = Object.keys(rows[0]); | ||
| const placeholders = cols.map(() => '?').join(', '); | ||
| const colList = cols.map((c) => `"${c}"`).join(', '); | ||
| for (const row of rows) { | ||
| await tx.exec( | ||
| `INSERT OR REPLACE INTO "${table}" (${colList}) VALUES (${placeholders})`, | ||
| cols.map((c) => row[c]), | ||
| ); | ||
| } | ||
| } | ||
| }); | ||
| } | ||
| ``` | ||
| Assumes the tables already exist (run your `schema` first, or pass it when opening the DB). | ||
| ## Running the notes demo | ||
@@ -214,0 +301,0 @@ |
+84
-19
@@ -145,2 +145,3 @@ import { createRpc } from './rpc.js'; | ||
| let intervalTimer = null; | ||
| let inFlightSync = null; | ||
@@ -171,21 +172,29 @@ function deliverStatus() { | ||
| await setStatus({ state: 'syncing', lastError: undefined }); | ||
| const promise = (async () => { | ||
| try { | ||
| const result = await syncer.sync(); | ||
| if (result.pull?.pulled > 0) { | ||
| const { tables } = await rpc.call('sync:user-schema', {}); | ||
| notifier.emit(tables.map((t) => t.name)); | ||
| } | ||
| backoffFailures = 0; | ||
| pausedByAuth = false; | ||
| await setStatus({ | ||
| state: 'idle', | ||
| lastSyncedAt: Date.now(), | ||
| lastError: undefined, | ||
| }); | ||
| return result; | ||
| } catch (err) { | ||
| if (err instanceof AuthError) pausedByAuth = true; | ||
| else backoffFailures++; | ||
| await setStatus({ state: 'error', lastError: err }); | ||
| throw err; | ||
| } | ||
| })(); | ||
| inFlightSync = promise; | ||
| try { | ||
| const result = await syncer.sync(); | ||
| if (result.pull?.pulled > 0) { | ||
| const { tables } = await rpc.call('sync:user-schema', {}); | ||
| notifier.emit(tables.map((t) => t.name)); | ||
| } | ||
| backoffFailures = 0; | ||
| pausedByAuth = false; | ||
| await setStatus({ | ||
| state: 'idle', | ||
| lastSyncedAt: Date.now(), | ||
| lastError: undefined, | ||
| }); | ||
| return result; | ||
| } catch (err) { | ||
| if (err instanceof AuthError) pausedByAuth = true; | ||
| else backoffFailures++; | ||
| await setStatus({ state: 'error', lastError: err }); | ||
| throw err; | ||
| return await promise; | ||
| } finally { | ||
| if (inFlightSync === promise) inFlightSync = null; | ||
| } | ||
@@ -242,2 +251,48 @@ } | ||
| async function exportDatabase() { | ||
| assertOpen(); | ||
| if (inFlightSync) { try { await inFlightSync; } catch {} } | ||
| const bytes = await rpc.call('export-file', {}); | ||
| return new Blob([bytes], { type: 'application/vnd.sqlite3' }); | ||
| } | ||
| async function importDatabase(blob) { | ||
| assertOpen(); | ||
| if (!(blob instanceof Blob) && !(blob instanceof ArrayBuffer) && !(blob instanceof Uint8Array)) { | ||
| throw new OriginSqlError('import requires a Blob, ArrayBuffer, or Uint8Array'); | ||
| } | ||
| if (intervalTimer) { clearTimeout(intervalTimer); intervalTimer = null; } | ||
| pausedByAuth = false; | ||
| backoffFailures = 0; | ||
| if (inFlightSync) { try { await inFlightSync; } catch {} } | ||
| let buffer; | ||
| if (blob instanceof Blob) buffer = await blob.arrayBuffer(); | ||
| else if (blob instanceof Uint8Array) buffer = blob.slice().buffer; | ||
| else buffer = blob.slice(0); | ||
| const bytes = new Uint8Array(buffer); | ||
| await rpc.call('import-replace', { bytes }, [buffer]); | ||
| if (schema) { | ||
| try { | ||
| await rpc.call('exec', { sql: schema, params: [] }); | ||
| } catch (err) { | ||
| throw new SchemaError(err.message, err); | ||
| } | ||
| } | ||
| if (sync) { | ||
| await rpc.call('sync:setup', {}); | ||
| } | ||
| // Imported data can touch any table — fan out to all subscribers. | ||
| const { tables } = await rpc.call('sync:user-schema', {}).catch(() => ({ tables: [] })); | ||
| if (tables?.length) notifier.emit(tables.map((t) => t.name)); | ||
| if (sync) { | ||
| await refreshPending(); | ||
| if (sync.interval) scheduleIntervalTick(); | ||
| } | ||
| } | ||
| async function close() { | ||
@@ -254,3 +309,13 @@ if (closed) return; | ||
| return { exec, query, transaction, subscribe, sync: syncNow, onSyncStatus, close }; | ||
| return { | ||
| exec, | ||
| query, | ||
| transaction, | ||
| subscribe, | ||
| sync: syncNow, | ||
| onSyncStatus, | ||
| export: exportDatabase, | ||
| import: importDatabase, | ||
| close, | ||
| }; | ||
| } |
+17
-3
| import { RpcError } from './errors.js'; | ||
| const TRANSFER = Symbol.for('origin-sql.rpc.transfer'); | ||
| export function withTransfer(value, transfer) { | ||
| return { [TRANSFER]: transfer, value }; | ||
| } | ||
| export function createRpc(port) { | ||
@@ -20,7 +26,11 @@ const pending = new Map(); | ||
| function call(op, payload) { | ||
| function call(op, payload, transfer) { | ||
| const id = nextId++; | ||
| return new Promise((resolve, reject) => { | ||
| pending.set(id, { resolve, reject }); | ||
| port.postMessage({ id, op, payload }); | ||
| if (transfer && transfer.length) { | ||
| port.postMessage({ id, op, payload }, transfer); | ||
| } else { | ||
| port.postMessage({ id, op, payload }); | ||
| } | ||
| }); | ||
@@ -49,3 +59,7 @@ } | ||
| const result = await handler(payload); | ||
| port.postMessage({ id, result }); | ||
| if (result && typeof result === 'object' && TRANSFER in result) { | ||
| port.postMessage({ id, result: result.value }, result[TRANSFER]); | ||
| } else { | ||
| port.postMessage({ id, result }); | ||
| } | ||
| } catch (err) { | ||
@@ -52,0 +66,0 @@ port.postMessage({ |
+41
-11
@@ -1,2 +0,2 @@ | ||
| import { serveRpc } from './rpc.js'; | ||
| import { serveRpc, withTransfer } from './rpc.js'; | ||
| import { extractTouchedTables, isDdl, extractDdlTables } from './sql-parse.js'; | ||
@@ -11,3 +11,5 @@ import { | ||
| let poolUtilPromise; | ||
| let poolUtil; | ||
| let db; | ||
| let dbPath; | ||
| let txnDepth = 0; | ||
@@ -59,7 +61,18 @@ let syncEnabled = false; | ||
| } | ||
| const poolUtil = await poolUtilPromise; | ||
| db = new poolUtil.OpfsSAHPoolDb(`/${name}.sqlite`); | ||
| poolUtil = await poolUtilPromise; | ||
| dbPath = `/${name}.sqlite`; | ||
| db = new poolUtil.OpfsSAHPoolDb(dbPath); | ||
| db.exec('PRAGMA foreign_keys = ON'); | ||
| } | ||
| function closeDbHandle() { | ||
| if (!db) return; | ||
| if (txnDepth > 0) { | ||
| try { db.exec('ROLLBACK'); } catch {} | ||
| txnDepth = 0; | ||
| } | ||
| db.close(); | ||
| db = null; | ||
| } | ||
| function runQuery(sql, params) { | ||
@@ -230,2 +243,26 @@ const stmt = db.prepare(sql); | ||
| }, | ||
| async 'export-file'() { | ||
| if (!db || !poolUtil || !dbPath) throw new Error('database not opened'); | ||
| if (txnDepth > 0) throw new Error('cannot export during a transaction'); | ||
| const bytes = await poolUtil.exportFile(dbPath); | ||
| return withTransfer(bytes, [bytes.buffer]); | ||
| }, | ||
| async 'import-replace'({ bytes }) { | ||
| if (!poolUtil || !dbPath) throw new Error('database not opened'); | ||
| if (txnDepth > 0) throw new Error('cannot import during a transaction'); | ||
| closeDbHandle(); | ||
| try { | ||
| await poolUtil.importDb(dbPath, bytes); | ||
| } catch (err) { | ||
| // Pool may have released the slot; reopen a fresh handle on whatever | ||
| // is on disk (possibly the pre-import file) so the db stays usable. | ||
| db = new poolUtil.OpfsSAHPoolDb(dbPath); | ||
| db.exec('PRAGMA foreign_keys = ON'); | ||
| throw err; | ||
| } | ||
| db = new poolUtil.OpfsSAHPoolDb(dbPath); | ||
| db.exec('PRAGMA foreign_keys = ON'); | ||
| syncEnabled = false; | ||
| return { ok: true }; | ||
| }, | ||
| 'txn:begin'() { | ||
@@ -250,10 +287,3 @@ if (txnDepth > 0) throw new Error('nested transactions are not supported in v1'); | ||
| close() { | ||
| if (db) { | ||
| if (txnDepth > 0) { | ||
| try { db.exec('ROLLBACK'); } catch {} | ||
| txnDepth = 0; | ||
| } | ||
| db.close(); | ||
| db = null; | ||
| } | ||
| closeDbHandle(); | ||
| return { ok: true }; | ||
@@ -260,0 +290,0 @@ }, |
Network access
Supply chain riskThis module accesses the network.
Found 2 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 2 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
99993
13.77%2274
10.28%327
36.25%