Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ permissions:
jobs:
release:
runs-on: ubuntu-latest
# Only run for stable version tags (vX.Y.Z). Pre-release tags such as
# v1.3.6-rc.1 contain a hyphen and are skipped here — they are published
# as GitHub pre-releases manually with the built .vsix attached.
if: ${{ !contains(github.ref_name, '-') }}
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,9 @@ Thumbs.db
*.zip
tmp/
.worktrees/

# Local browser/extension test harness (not part of the repo)
.playwright-mcp/
.vscode-test-web/
_repro_*.html
vscode-web-test-db.png
50 changes: 50 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,55 @@
# Changelog

## 1.3.6

### Bug Fixes

- **VS Code for Web (vscode.dev): databases stuck on the loading screen**: Fixed the browser SQLite worker never starting. The worker entry point calls Node's `parentPort.on('message', …)`, but inside a browser Web Worker the global scope only exposes `addEventListener`, not Node's `.on()` — so the worker threw `TypeError: parentPort.on is not a function` before wiring up its message handler, and every database operation silently timed out. A Node-style `parentPort` adapter is now provided for the browser runtime. This affected all database files (`.db`, `.sqlite`, `.gpkg`, …) in the web build; the desktop build and the website demo were unaffected because they use different worker paths (#418).
- **Error Cause Preservation**: The worker now preserves the original error `cause` when re-throwing a database open failure, so the underlying reason is no longer lost (#351).
- **Web Demo Query Parameterization**: Parameterized the filter queries in the web-demo worker, removing string-interpolated SQL in the demo data path (#401).

### Performance

- **Batched ALTER TABLE ADD COLUMN**: `undoColumnDrop` batches its `ADD COLUMN` statements into a single call instead of one per column (#405).
- **Batched ALTER TABLE DROP COLUMN**: `deleteColumns` batches its `DROP COLUMN` statements into a single call (#408).
- **Cached JSON Patches**: The `updateCells` JS fallback caches parsed JSON patches across a batch instead of re-parsing per row (#387).
- **updateCellBatch SAVEPOINT Fallback**: The sequential `hostBridge` cell-batch fallback is wrapped in a single `SAVEPOINT` to avoid per-row transaction overhead (#364).
- **Grid Selection Allocation**: Avoided string-key allocations in batch cell/column selection for large selections (#375).
- **Allocation Trimming**: Dropped `Object.keys` allocations in the JSON-merge-patch key iteration (#362) and in serialization, and tidied the `Uint8Array` marker check (#356).

### Improvements

- **Browser Worker Bundle Format**: The browser worker bundle (`out/worker-browser.js`) is now emitted as a classic-worker (IIFE) bundle to match how it is loaded (`new Worker(blobUrl)`), removing a latent module-vs-classic mismatch. Added regression tests that fail if the bundle reverts to ESM or if the browser `parentPort` adapter is removed (#418).
- **UI Module Extraction**: Extracted the pure batch-update logic out of `sidebar.js` into a DOM-free, unit-testable module (#416); split `renderSidebar` into helper functions; and modularized the grid render/events and viewer initialization paths.
- **Code Organization**: Extracted file-signature byte arrays into a shared `FILE_SIGNATURES` table (#374), per-format streaming into `getFormatHelper()` (#380), `maskSensitiveData()` into helpers (#398), a `requireEngine()` helper in `createWorkerEndpoint` (#357), and a `DatabaseMethodName` type alias (#386); converted panel-handler arrow-fields to methods (#383).
- **Type Safety**: Tightened the webview message-handler types (`any` → `unknown`) (#377).
- **Webview Hygiene**: Dropped a CSP-blocked inline `onerror` handler from the codicons `<link>` (#353), and removed several unused imports across `edit.js` (#359), `dnd.js` (#354), `blob-inspector.js` (#355), and `main.ts` (#352).

### Tests

- Expanded unit coverage: activation/deactivation entrypoint (#406); `disposeAll` null-skip and child `AggregateError` (#403); WASM `establishConnection` failure + worker termination (#402); RPC transfer-fallback warning args (#394); `doTry` null/non-error branches (#391); `deleteColumns` undo-history fetch-error path (#385); delegated worker-endpoint operations after init (#370); comprehensive virtual-filesystem `writeFile` + delete/rename/stat/watch coverage (#365); and consolidated overlapping error-path coverage.

### CI / Tooling

- **PR Workflow**: Added a pull-request workflow that builds, typechecks, and tests on every PR, and fixed the latent type errors it surfaced (#414); addressed follow-up review feedback (#417).

### Documentation

- Added GitHub community health files (#343); clarified the auto-save failure comment (#358) and rephrased the save-edit comment in `edit.js` (#382).

### Website

- Migrated the website to Tailwind CSS v4 (#415).
- Bumped `react`/`react-dom` to 19.2.6 (#344, #347), `@types/node` to 25.9.1 (#348), and `eslint` to 10.4.0 (#349).

### Dependencies

**Extension:**

- @types/node 25.9.0 → 25.9.1 (#345)
- tsx 4.21.0 → 4.22.3 (#346)
- tmp 0.2.5 → 0.2.7 (transitive, npm_and_yarn group) (#381)

## 1.3.5

### Security
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "sqlite-explorer",
"displayName": "SQLite Explorer",
"description": "A powerful SQLite database viewer and editor for VS Code",
"version": "1.3.5",
"version": "1.3.6",
"publisher": "zknpr",
"license": "MIT",
"repository": {
Expand Down
7 changes: 6 additions & 1 deletion scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,12 @@ const compileBrowserWorker = () =>
...baseWorkerConfig,
outfile: resolve(outDir, 'worker-browser.js'),
platform: 'browser',
format: 'esm',
// IIFE (not ESM): this bundle is loaded as a CLASSIC Web Worker via
// `new Worker(blobUrl)` in workerFactory.ts. An ESM bundle emits a top-level
// `export{...}` which a classic worker cannot parse ("SyntaxError: Unexpected
// token 'export'"), so the worker never boots and VS Code Web hangs on load.
// IIFE also keeps the classic-worker environment sql.js/emscripten expects.
format: 'iife',
mainFields: ['browser', 'module', 'main'],
external: ['fs/promises', 'path'],
define: {
Expand Down
80 changes: 64 additions & 16 deletions src/platform/threadPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,47 +14,95 @@
// ============================================================================

/**
* Minimal event receiver interface for message handling.
* Matches the common subset of browser EventTarget and Node.js EventEmitter.
* Node.js-style message port interface.
* Used for communication in Node.js worker_threads.
*/
type MessageReceiver = Pick<EventTarget, 'addEventListener' | 'removeEventListener'>;
interface NodeMessagePort {
postMessage(data: unknown, transfer?: unknown[]): void;
on(event: string, handler: EventListenerOrEventListenerObject, options?: object): void;
off(event: string, handler: EventListenerOrEventListenerObject, options?: object): void;
}

/**
* Browser-style message port interface.
* Used for communication in web worker environments.
* Minimal browser worker global scope surface used by the parentPort adapter.
* DedicatedWorkerGlobalScope provides these DOM APIs for communication with the
* spawning context.
*/
interface BrowserMessagePort extends MessageReceiver {
interface BrowserParentPortScope {
postMessage(data: unknown, transfer?: Transferable[]): void;
addEventListener: EventTarget['addEventListener'];
removeEventListener: EventTarget['removeEventListener'];
}

/**
* Node.js-style message port interface.
* Used for communication in Node.js worker_threads.
* Create a Node-style parentPort facade for browser workers.
*
* Node's parentPort.on('message', cb) delivers the message payload directly,
* while browser worker "message" listeners receive a MessageEvent whose data
* property contains the payload. The adapter preserves Node-style delivery for
* message events and forwards other events, such as "error", unchanged.
*/
interface NodeMessagePort {
postMessage(data: unknown, transfer?: unknown[]): void;
on(event: string, handler: EventListenerOrEventListenerObject, options?: object): void;
off(event: string, handler: EventListenerOrEventListenerObject, options?: object): void;
export function createBrowserParentPort(scope: BrowserParentPortScope): NodeMessagePort {
// Keyed by handler, then by event name. A single handler may be registered for
// more than one event (e.g. the same callback for 'message' and 'error'), so a
// flat handler->wrapped map would let the second registration overwrite the
// first — leaking the earlier DOM listener and making off() remove the wrong
// one. The per-event inner map keeps each (handler, event) wrapper distinct.
const browserListeners = new WeakMap<EventListenerOrEventListenerObject, Map<string, EventListener>>();

return {
postMessage: (data: unknown, transfer?: Transferable[]) =>
transfer ? scope.postMessage(data, transfer) : scope.postMessage(data),
on: (event: string, handler: EventListenerOrEventListenerObject) => {
// Node's parentPort.on('message', cb) passes the message DATA directly; the
// DOM 'message' event wraps it in event.data. For other events (e.g.
// 'error') pass the event through so consumers can read `.message`.
const cb = handler as (payload: unknown) => void;
const wrapped: EventListener = (e: Event) =>
cb(event === 'message' ? (e as MessageEvent).data : e);
let byEvent = browserListeners.get(handler);
if (!byEvent) {
byEvent = new Map<string, EventListener>();
browserListeners.set(handler, byEvent);
}
byEvent.set(event, wrapped);
scope.addEventListener(event, wrapped);
},
off: (event: string, handler: EventListenerOrEventListenerObject) => {
const byEvent = browserListeners.get(handler);
const wrapped = byEvent?.get(event);
if (wrapped) {
scope.removeEventListener(event, wrapped);
byEvent!.delete(event);
if (byEvent!.size === 0) browserListeners.delete(handler);
}
},
};
}

// ============================================================================
// Runtime Detection and API Export
// ============================================================================

const isBrowserRuntime = import.meta.env?.VSCODE_BROWSER_EXT;

let WorkerImpl: any;
let MessageChannelImpl: any;
let MessagePortImpl: any;
let BroadcastChannelImpl: any;
let parentPortImpl: any;

if (isBrowserRuntime) {
if (import.meta.env?.VSCODE_BROWSER_EXT) {
WorkerImpl = globalThis.Worker;
MessageChannelImpl = globalThis.MessageChannel;
MessagePortImpl = globalThis.MessagePort;
BroadcastChannelImpl = globalThis.BroadcastChannel;
parentPortImpl = globalThis;
// In a browser Web Worker the global scope (DedicatedWorkerGlobalScope) is the
// channel to the host, but it exposes addEventListener/postMessage — NOT the
// Node worker_threads `.on()` API that the worker entry (databaseWorker.ts) is
// written against. Without this adapter, `parentPort.on('message', ...)` throws
// `TypeError: parentPort.on is not a function`, so the worker never wires up its
// message handler and the host's RPC hangs forever (VS Code Web "stuck loading").
const workerScope = globalThis as unknown as BrowserParentPortScope;
parentPortImpl = createBrowserParentPort(workerScope);
} else {
// Node.js environment
try {
Expand Down
139 changes: 139 additions & 0 deletions tests/unit/threadPool_browser_parentport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* Regression tests for the browser parentPort adapter.
*
* databaseWorker.ts expects Node's worker_threads parentPort shape, while a
* DedicatedWorkerGlobalScope exposes DOM event APIs. These tests exercise the
* adapter against a fake worker global scope so the browser behavior is covered
* without depending on a real browser.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { createBrowserParentPort } from '../../src/platform/threadPool';
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe('createBrowserParentPort', () => {
it('adapts browser worker scope events to Node-style parentPort methods', () => {
const registeredListeners: Array<{
event: string;
listener: EventListenerOrEventListenerObject;
}> = [];
const removedListeners: Array<{
event: string;
listener: EventListenerOrEventListenerObject;
}> = [];
const postedMessages: Array<{ data: unknown; transfer?: Transferable[] }> = [];

const fakeScope = {
postMessage(data: unknown, transfer?: Transferable[]) {
// The fake scope records the exact payload and transfer list forwarded by
// the adapter so the test can verify postMessage does not transform them.
postedMessages.push({ data, transfer });
},
addEventListener(event: string, listener: EventListenerOrEventListenerObject) {
// The fake scope stores listeners exactly as registered, allowing the
// test to invoke the wrapped DOM listener with controlled event objects.
registeredListeners.push({ event, listener });
},
removeEventListener(event: string, listener: EventListenerOrEventListenerObject) {
// The fake scope records removals so off() can be checked against the
// wrapped listener that on() installed.
removedListeners.push({ event, listener });
},
};

const parentPort = createBrowserParentPort(fakeScope);

assert.strictEqual(typeof parentPort.postMessage, 'function');
assert.strictEqual(typeof parentPort.on, 'function');
assert.strictEqual(typeof parentPort.off, 'function');

const receivedMessages: unknown[] = [];
const messageHandler = (payload: unknown) => {
receivedMessages.push(payload);
};

parentPort.on('message', messageHandler);

assert.strictEqual(registeredListeners.length, 1);
assert.strictEqual(registeredListeners[0].event, 'message');

const messageListener = registeredListeners[0].listener as EventListener;
messageListener({ data: 'database-bytes' } as MessageEvent);

assert.deepStrictEqual(receivedMessages, ['database-bytes']);

const receivedErrors: unknown[] = [];
const errorHandler = (event: unknown) => {
receivedErrors.push(event);
};
const errorEvent = new Event('error');

parentPort.on('error', errorHandler);

assert.strictEqual(registeredListeners.length, 2);
assert.strictEqual(registeredListeners[1].event, 'error');

const errorListener = registeredListeners[1].listener as EventListener;
errorListener(errorEvent);

assert.deepStrictEqual(receivedErrors, [errorEvent]);

const transferBuffer = new ArrayBuffer(8);
parentPort.postMessage({ ready: true }, [transferBuffer]);

assert.deepStrictEqual(postedMessages, [
{ data: { ready: true }, transfer: [transferBuffer] },
]);

parentPort.off('message', messageHandler);

assert.strictEqual(removedListeners.length, 1);
assert.strictEqual(removedListeners[0].event, 'message');
assert.strictEqual(removedListeners[0].listener, registeredListeners[0].listener);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it('removes the correct wrapped listener when one handler is reused for two events', () => {
// Regression guard: the adapter must key wrapped listeners by (handler, event),
// not by handler alone. With a flat handler->wrapped map, registering the same
// handler for 'message' then 'error' would overwrite the first wrapper, so
// off('message', …) would remove the wrong (error) listener and leak the
// message one. This reuses a single handler across both events and asserts
// each off() removes the exact wrapper that on() installed for that event.
const registeredListeners: Array<{ event: string; listener: EventListener }> = [];
const removedListeners: Array<{ event: string; listener: EventListener }> = [];

const fakeScope = {
postMessage() {},
addEventListener(event: string, listener: EventListenerOrEventListenerObject) {
registeredListeners.push({ event, listener: listener as EventListener });
},
removeEventListener(event: string, listener: EventListenerOrEventListenerObject) {
removedListeners.push({ event, listener: listener as EventListener });
},
};

const parentPort = createBrowserParentPort(fakeScope);

// Same handler reference registered for two distinct events.
const sharedHandler = () => {};
parentPort.on('message', sharedHandler);
parentPort.on('error', sharedHandler);

assert.strictEqual(registeredListeners.length, 2);
const messageWrapped = registeredListeners[0].listener;
const errorWrapped = registeredListeners[1].listener;
// Each event must get its OWN wrapper (the bug would reuse/overwrite one).
assert.notStrictEqual(messageWrapped, errorWrapped);

parentPort.off('message', sharedHandler);
parentPort.off('error', sharedHandler);

assert.strictEqual(removedListeners.length, 2);
assert.deepStrictEqual(
removedListeners.map(r => r.event),
['message', 'error']
);
// The wrapper removed for each event matches the one registered for it.
assert.strictEqual(removedListeners[0].listener, messageWrapped);
assert.strictEqual(removedListeners[1].listener, errorWrapped);
});
});
43 changes: 43 additions & 0 deletions tests/unit/worker_browser_bundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Regression tests for the browser worker bundle format.
*
* VS Code Web loads out/worker-browser.js as a classic Web Worker from a blob
* URL. Classic workers parse scripts with the normal script grammar, so this
* test compiles the bundle with node:vm to catch module-only syntax such as a
* top-level export before it can ship.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe('browser worker bundle', () => {
it('parses as a classic worker script', (t) => {
const bundlePath = path.resolve(process.cwd(), 'out/worker-browser.js');

// This validates a build artifact, not source. `out/` is gitignored and the
// `npm test` script does not build first, so on a clean checkout the bundle
// may be absent — skip (don't fail the suite) with a clear hint. CI runs
// `node scripts/build.mjs` before tests, so there the assertions always run.
if (!existsSync(bundlePath)) {
t.skip('out/worker-browser.js not built — run `node scripts/build.mjs` first (CI builds before tests).');
return;
}

const source = readFileSync(bundlePath, 'utf8');

const isIifeBundle = /^(?:"use strict";)?\s*\(\s*(?:\(\)\s*=>|function\s*\()/.test(
source.trimStart()
);
assert.ok(
isIifeBundle,
'out/worker-browser.js must be emitted as an IIFE classic-worker bundle'
);

assert.doesNotThrow(
() => new vm.Script(source, { filename: bundlePath }),
'out/worker-browser.js must parse as a classic worker script'
);
});
});
Loading