Skip to content

feat: accept the .wasm bytes or a compiled module in loadModule() - #193

Merged
pyramation merged 2 commits into
mainfrom
devin/1793298512-load-module-wasm-main
Oct 29, 2026
Merged

pyramation merged 2 commits into
mainfrom
devin/1793298512-load-module-wasm-main

Conversation

@pyramation

Copy link
Copy Markdown
Collaborator

Summary

Some hosts can't fetch or read the package's own libpg-query.wasm, for example an Electron custom protocol registered without supportFetchAPI (Beekeeper Studio plugins) or a bundle that inlines its assets. Until now those hosts couldn't load the parser at all, because loadModule() ignored its arguments. With this change they hand the module over once:

import { loadModule, parseSync } from 'libpg-query';

await loadModule({ wasm }); // ArrayBuffer, typed array, or a compiled WebAssembly.Module
parseSync('SELECT 1');
  • The bytes are compiled once into provided. Each later instance is created from it through Emscripten's instantiateWasm, using async WebAssembly.instantiate and no fetch. That includes the replacement instance after a failed call (createModule() in instantiate()).
  • If an instance is already loaded from the file, or one is loading, it is swapped for one built from wasm.
  • Nothing else changes: import-time loading, parseSync and the stale-instance recovery all behave as before. loadModule.length stays 0, via a rest parameter.
  • The change goes in templates/index.ts (v13–17) and templates/full/index.ts (v18), and the generated versions/*/src/index.ts files were regenerated with copy:templates.

Builds on the approach in #191 by @Gldywn, who is credited as co-author.

Tests

versions/*/test/load-module.test.js runs the wrapper from a copy that has no libpg-query.wasm next to it:

  • On main: 3 of 6 fail.
  • With this change: all pass.

Every version's WASM was built locally and each full suite passed with 0 failures: v18 149/149, v13–17 78 pass / 20 skipped (the full-API tests, which are skipped in the slim builds).

I also checked it in Electron 37 on a plugin:// scheme without supportFetchAPI. A parse succeeded, a deeply nested query threw, and the next parse succeeded again: {"ok":true,"first":1,"threw":true,"afterRecovery":1}.

Link to Devin session: https://app.devin.ai/sessions/9bbf3e7a34e9477ea812446ccf8a5fb0
Open in Devin Desktop: https://app.devin.ai/desktop/session/9bbf3e7a34e9477ea812446ccf8a5fb0?variant=devin
Requested by: @pyramation

Hosts that cannot fetch or read libpg-query.wasm (a custom protocol without
fetch support, a bundle that inlines assets) can pass it once with
loadModule({ wasm }). Every later instance, including replacements after a
failed call, is created from it. Applies to v13-v18.

Co-authored-by: Gldywn <14254051+Gldywn@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@tenki-reviewer

tenki-reviewer Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Review complete. 🟠 1 high · 🟡 1 medium

💬 Inline comments (2)


The change threads a caller-provided WASM module through Emscripten's instantiateWasm hook and adds a module-wide load/recovery singleton (loadModule, stale handling, failure counters), mirrored byte-for-byte in templates/, templates/full/, and versions/13-18. READMEs and LOADING_WASM.md document the new option, and each version's package.json gains a load-module test that spawns a fresh node subprocess to exercise OOM/reload behavior.

Files Change
templates/index.ts, templates/full/index.ts, versions/*/src/index.ts Add loadModule accepting wasm bytes/WebAssembly.Module, wiring them into instantiateWasm and the stale-recovery loop
versions/*/package.json Add the test/load-module.test.js test target to each versioned package
versions/*/test/load-module.test.js New node:test harness spawning a subprocess to verify loadModule recovery under memory pressure
versions/*/README.md, LOADING_WASM.md Document the loadModule({ wasm }) option and its constraints

Note: the new wasm-supplied load path has a high-severity defect (the Emscripten receive callback gets the instantiate result record instead of the WebAssembly.Instance), so loads with a caller-provided module currently fail to initialize.

Reviewed commit: 4640ae9

@tenki-reviewer tenki-reviewer Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds a loadModule({ wasm }) API letting callers supply pre-compiled WASM bytes or a WebAssembly.Module, copied identically across all six versioned packages, plus a subprocess-based test harness for WASM load recovery.

Key findings

Comment thread versions/15/src/index.ts Resolved
Comment thread versions/13/test/load-module.test.js Resolved
@pyramation
pyramation merged commit 2b4178e into main Oct 29, 2026
58 checks passed
devin-ai-integration Bot pushed a commit that referenced this pull request Oct 29, 2026
Provided wasm sets compiled, so the sync replaceInstance recovery path reuses it without fetching; load-module OOM test asserts the repeated-limit error.
Gldywn added a commit to Gldywn/gatekeeper that referenced this pull request Oct 31, 2026
…rst (#73)

* feat(plugin): load PostgreSQL's own grammar as WASM at startup

Adds libpg-query 18.1.5 and pgsql-deparser 18.3.8 (exact pins, operator-approved) and awaits the WASM grammar once before the app starts, so later parses stay synchronous. The build copies libpg-query.wasm next to the page, where its Emscripten loader fetches it.

* build(plugin): serve the PostgreSQL WASM grammar in the fixture preview

One Vite plugin, shared by the build and the preview, serves libpg-query.wasm in dev and emits it in the bundle. The preview loads the grammar before rendering, and its in-memory broker lets that one same-origin asset through while every other fetch stays blocked.

* build(plugin): ship the PostgreSQL parser's license notices

libpg-query.wasm embeds libpg_query (BSD-3) and PostgreSQL parser sources (PostgreSQL License), whose texts the npm packages do not carry. public/ lands in dist/, which the release zip takes whole. The two upstream texts are verbatim (pganalyze/libpg_query 18.0.0, postgres REL_18_STABLE).

* fix(plugin): log the parser load failure without promising a fallback

Classification still runs on node-sql-parser on this branch, so the message only states that the grammar failed to load.

* refactor(plugin): route SQL parsing through a per-engine registry

One SqlEngine interface and one engineFor(databaseType) replace mapDialect and the parser calls scattered over the gate, the schema annotations, the receipt and the row count. Every caller now carries Beekeeper's raw databaseType. The node-sql-parser code moves as is into sql/nsp/, the parser-independent parts stay in risk.ts and schema.ts, and the dispatching functions live in classify.ts and inspect.ts. No behaviour changes: every engine, PostgreSQL included, still reads SQL through node-sql-parser with the same grammar.

* feat(plugin): gate PostgreSQL on PostgreSQL's own grammar

A real PostgreSQL connection is now classified on libpg_query's parse tree: one statement, reads only for SELECT and SHOW, a lock, an INTO or a modifying statement escalates wherever it sits, and EXPLAIN carries its statement's risk unless every ANALYZE option is explicitly false. Text the parser cannot vouch for is blocked rather than read as text: a NUL ends its C string, and a backslash before a quote in an ordinary string splits statements differently when the server runs with standard_conforming_strings off. Other engines, including PostgreSQL look-alikes, keep node-sql-parser.

Gate tests: VACUUM and DROP DATABASE now parse (same class, approvable); a nested EXPLAIN, which PostgreSQL rejects, is destructive and approvable instead of blocked.

* feat(plugin): annotate PostgreSQL from its own parse tree

Tables, source columns, output names, JSON keys, sensitive literals, the decided receipt and the row-count rule now read libpg_query's tree on a real PostgreSQL connection. Literals come back as written, escapes included, so the query highlight still finds them, while their shape is checked on the decoded value. Three node-sql-parser defects the old tests pinned are gone: a CTE is no longer reported as a table, a quoted alias holding parentheses is no longer split into columns, and the value of IS DISTINCT FROM is now screened. Auto's backslash check reads PostgreSQL's scanner instead of the quote adapter.

* fix(plugin): close six gaps in the PostgreSQL extraction

From the cross-read of the native extraction: a CTE now hides a table only inside its own WITH scope, MERGE actions expose their columns and values, a composite field selection counts as the column compared, a join alias renames its columns, a JSON key written as a cast string is read, and a dollar tag is taken as written whatever its letters.

* feat(plugin): resolve Auto reads on PostgreSQL's own parse tree

The Auto walker reads libpg_query's tree with the same scoping, dependency and catalog resolution. Jev's SQL is rendered from the redacted tree by pgsql-deparser, then parsed again by PostgreSQL's grammar: any difference, beyond positions, keeps the query local, and every constant left in it must be syntax (LIMIT, ordinal, EXTRACT unit, typmod, boolean, NULL) or a screened JSON key. Now supported: NULLS FIRST/LAST, DISTINCT ON, bare USING keys merged per join input as PostgreSQL does, IS [NOT] DISTINCT FROM, set operations nested as written (mixed precedence, parenthesized branches, INTERSECT ALL and EXCEPT ALL), FETCH FIRST/NEXT, timetz and quoted type names.

Over all 753 corpus cases, 644 eligible inputs keep identical dependencies and withholding, 108 local cases keep their reason, and only the formatting of Jev's SQL changes (analysis shared-read-3). The one case that becomes eligible, mixed INTERSECT and UNION precedence on three status columns, was local only because of node-sql-parser and is relabelled allow with the operator's go.

* refactor(plugin): use node-sql-parser plainly for the other engines

PostgreSQL no longer goes through node-sql-parser, so the layers that
rewrote its input and recomposed what the parser lost have nothing left
to serve. The remaining engines now get the parser's own answer:

- classification walks the tree as parsed: no EXPLAIN stripping, row
  locks come from the parser's locking_read instead of a text regex
- MySQL and MariaDB refuse any statement holding /*!, whose body the
  parser drops; the strict text check then decides
- extraction reads names and literals as the tree gives them: no name
  placeholders, no E'' / dollar-quote / relation column-list readers

prepareSql, pg/syntax.ts and the node-sql-parser shape contract go.
The PostgreSQL name cases worth keeping move to pg/names.test.ts.

* docs: describe the per-engine parsers and native PostgreSQL analysis

The gate, the annotations and Auto mode now read PostgreSQL with its
own grammar and every other engine with node-sql-parser as it parses.
The docs still described the node-sql-parser adapter, its rewrites and
the shapes it could not represent.

- ARCHITECTURE and DEVELOPMENT: the engine registry and the libraries
- SECURITY: which parser reads what, the blocked backslash-quote string,
  the /*! refusal on MySQL and MariaDB
- AUTO-MODE: shared-read-3, set operations as PostgreSQL groups them,
  the newly supported shapes, the deparse-and-reparse rendering, the
  updated manual list
- AGENTS: the foundation example now refers to the removed adapter

* chore(server): note which plugins still send the mixed set operation hold

* fix(plugin): class an unread MySQL or MariaDB statement destructive

A statement holding an executable comment fell to the text check, which
strips comments without seeing strings: in
SELECT '/*!', '/*' INTO OUTFILE '/tmp/x' /* end */ it swallowed the
INTO OUTFILE and returned read, where the parsed tree had said
destructive.

An unread statement now takes the strictest class, approvable like any
statement Gatekeeper cannot read, and stays blocked when the parser or
the text check sees more than one statement. MariaDB's /*M! form, whose
body MariaDB also runs, is unread too. A harmless optimizer hint now
needs Destructive mode.

Also pins Auto's refusal of an explicit window frame, which the deleted
node-sql-parser shape contract used to cover.

* docs: state precisely which grammar reads what and what Jev sees

Corrections from the documentation review:

- engines without a node-sql-parser grammar use its PostgreSQL one
- a failed parse goes to the text check, which can return read; a
  PostgreSQL grammar that fails to load sends every statement there
- the analysis uses PostgreSQL 18's grammar with
  standard_conforming_strings on, not the server's own reading
- the deparser keeps an explicit ASC; NULL and booleans stay visible
  to Jev
- /*! is refused on MySQL, /*! and /*M! on MariaDB

* fix(plugin): stop listing CTE names as tables on the card

A query defining a CTE (pay, stuck) showed it among the tables it reads,
and the annotations looked its columns up as a table's.

- PostgreSQL: the CTE filter now scopes as PostgreSQL does. Without
  RECURSIVE a CTE body sees only the CTEs before it, so a real table
  read there under a later CTE's name was hidden.
- node-sql-parser: references to a name the statement defines as a CTE
  leave the table list. The list has no positions, so an unqualified
  real table sharing a CTE's name in the same statement is hidden too.

* build(plugin): move libpg-query to 18.2.1 with a loader patch

Beekeeper serves plugin pages from a scheme that cannot fetch the
plugin's own files, and libpg-query only loads its WASM through
Emscripten's fetch, started at import. The option that fixes it is
proposed upstream (constructive-io/libpg-query-node#191) but not
released.

Until then, patches/libpg-query@18.2.1.patch gives the published
wrapper the part of that API Gatekeeper relies on: nothing loads at
import, and loadModule({ instantiateWasm }) hands the hook to every
load, reloads after a WASM exception included. It is narrower than the
PR: a locateFile that re-enters loadModule() starts a second load,
options are not validated, and a failed load is retried as 18.2.1
already does. Gatekeeper passes only instantiateWasm. pnpm applies the
patch on every install, and a missing or changed patch fails a frozen
install.

18.2.1 brings the reload after a WASM exception and the NUL refusal.
The operator allowed it before the end of its quarantine (2026-11-01)
through an exception scoped to this version, to delete after that date.

* fix(plugin): load PostgreSQL's grammar from the bundle, not a fetch

The plugin fetched libpg-query.wasm next to its page, which works on
Vite's dev server but not from Beekeeper's plugin scheme, so a real
install had no PostgreSQL parser.

The WASM is now inlined in the bundle (sql/pg/wasm.ts) and handed to
the patched loader through instantiateWasm, which also serves every
reload. The plugin, the fixture preview and the tests load through the
same hook; the server's evals keep Node's load from disk.

The Vite plugin that served and emitted the file, and the preview
broker's exception for it, are gone. A test fails if libpg-query stops
honouring the options, which an unpatched release would silently do.

* refactor(plugin): read the load check through each engine's own parser

The load check (#55) read every statement with node-sql-parser through
the adapters this branch removed, so it no longer compiled.

- sql/load-plan.ts holds the plan in no parser's terms: a block keeps
  its conditions as the columns each AND part and each OR branch name,
  so usage() and checkLoad() never read a parse tree again.
- sql/pg/load.ts walks PostgreSQL's own tree. It reads FETCH, EXPLAIN
  options, CREATE TABLE AS, data-modifying CTEs, subqueries in every
  clause and the maintenance commands (VACUUM FULL, CLUSTER, REINDEX,
  REFRESH) from their nodes instead of their leading words. CTE
  visibility follows PostgreSQL, RECURSIVE included. Aggregates are
  told by their syntax and PostgreSQL 18's built-in names, a heuristic
  kept to this check.
- sql/nsp/load.ts is main's walker on node-sql-parser used plainly.
  MySQL and MariaDB statements holding an executable comment stay
  unread, as for every other answer of that engine.

Sizes, locks and the catalog read now follow Beekeeper's own type:
Redshift, CockroachDB, DuckDB and the other engines read with
node-sql-parser's PostgreSQL grammar no longer get the PostgreSQL
catalog query, which main sent them.

Two expectations described node-sql-parser's limits and now take
PostgreSQL's answer: a DELETE inside a CTE is reported, and a comma
after JOIN ... ON is one more FROM item.

* fix(plugin): no false heavy alert from the PostgreSQL load walker

Codex's cross-read reproduced alerts main never raised on statements it
could read, which matter because a heavy read holds Auto mode and can be
auto-denied:

- a constant compared with itself (1::integer = 1::integer, 1.0 = 1.00)
  is no WHERE again; two different untyped constants of one kind read
  nothing, while a cast or two kinds prove nothing
- an aliased join is read as one source, a block of its own like a
  derived table, instead of flattened under names its alias hides
- each side of a set operation keeps its own ORDER BY and LIMIT

Also bounds set-operation chains by the walk's depth and block limits,
and reads FETCH ... WITH TIES as no cap.

* fix(plugin): bound nested derived blocks and drop string contradictions

From Codex's second cross-read of the load walker:

- a block a filter bounds now bounds every block read through it, so a
  filter on an outer alias reaches a nested aliased join or derived
  table, on every engine
- an aliased join enters the walk's depth and block limits
- only two untyped numbers or two booleans that differ read nothing:
  PostgreSQL types a string as the other side ('true' = true holds),
  and a collation may equate two spellings

* fix(plugin): let an outer filter bound only what it reaches

The transitive bound from 63e4498 hid full reads: filtering the rows of
(SELECT * FROM r) y also silenced the count(*) r had to finish first.

- each block says whether a filter on its rows reaches what it reads
  (passes): not past an aggregate, a window, GROUP BY, DISTINCT, its
  own LIMIT, or a write; both the direct bound and its propagation
  require it, which also lifts main's older silence on
  (SELECT count(*) FROM t) y WHERE y.n = 1
- a bound goes on through a block only when its sources form one
  joined group
- on PostgreSQL a derived table or CTE exposes only its own-level
  blocks, not its sublinks', and a CTE PostgreSQL does not inline
  (MATERIALIZED, recursive, or used twice unless NOT MATERIALIZED)
  exposes none

* fix(plugin): carry each kind of bound only where PostgreSQL does

One flag could not tell the three ways an enclosing query bounds what a
block reads, so 50847af alerted on bounded reads and hid unbounded ones:

- a filter passes a sort but no aggregate, window, GROUP BY, DISTINCT
  or own LIMIT (filterable)
- a small enclosing LIMIT passes a plain inner LIMIT but no sort,
  unless an index leads on the order, which the catalog facts decide
  as for an ordered read (streams, ordered)
- an empty enclosing query runs nothing below it, writes aside

A set operation hands its sides its own LIMIT, deduplication and sort.

* fix(plugin): let a small LIMIT stop early through any join

The one-group rule kept a filter on one side of a cross product from
bounding the other, but it also stopped an enclosing LIMIT, so a LIMIT
over a cross product of two plain derived inputs reported both tables
read in full. A LIMIT now passes any join, as the preview rule already
held for direct tables.

Two narrower false alerts are left as noted limits: an index order is
not followed below the block that sorts, and a CTE used twice stays
read even when every use is empty.

* fix(plugin): let a UNION ALL's own small LIMIT stop its sides

The walker read a set operation's LIMIT only as a barrier to filters
on its rows. Without a sort or duplicates to remove, that LIMIT also
stops each side early, as main's preview rule held, so
"... UNION ALL SELECT id FROM b LIMIT 10" no longer reports b read in
full; a UNION or an ordered operation still does.

* docs(evals): record the shared-read-3 live pass and its paired rerun

* build(plugin): load the bundled grammar through libpg-query 18.2.2

Upstream released the option Gatekeeper patched in:
loadModule({ wasm }) (constructive-io/libpg-query-node#193, which
superseded our #191 and credits the operator) builds every instance,
reloads after a WASM exception included, from the bytes it is given.
18.2.2 ships the same libpg-query.wasm and Emscripten loader as 18.2.1;
only the wrapper changed.

The local patch and its patchedDependencies entry go. The plugin hands
the bundled bytes to loadModule, and the guard test now checks that
libpg-query compiles the bytes it is given and that bad bytes leave the
loaded parser intact.

Unlike the patch, 18.2.2 still starts its default load at import: in
Beekeeper that fetch fails and logs to the console before the bundled
bytes replace it.

The operator allowed 18.2.2 before the end of its quarantine
(2026-11-05 19:45 UTC) through an exception scoped to this version; the
main session has the cleanup.

* refactor(plugin): trace PostgreSQL output with its own grammar

The reviewer's flags on a PostgreSQL connection now come from
libpg_query's tree, which ends the stopgap of the merge commit.

- output-model.ts holds what both walkers share: slots, relations,
  documents, column lookup, joins and how a write fills a column.
  nsp/output.ts and pg/output.ts each read their own tree into it.
- pg/output.ts compares names exactly, reads Alias.colnames, joins as
  PostgreSQL scopes them (NATURAL from known columns, an aliased join
  hiding its tables, USING aliases), reads PG 18 RETURNING old/new
  names, MERGE, multiple assignment and SQL/JSON, and names an
  unaliased output as FigureColname does.
- Model fixes for every engine: CTEs differing only by case count as
  both where names compare without case; a partial write or a MERGE
  action keeps the column's old value; a name no known column has is
  untraced; a write without RETURNING still records what it reads.

Tests that encoded node-sql-parser's gaps now hold PostgreSQL's
behaviour, and new cases cover each fix. SECURITY.md records the
known limit left: a key read inside a part of a document is not
attached to that part when the statement returns it.

* test(plugin): pin the load check's two documented false alerts

A small LIMIT over an index-served sort, with a subquery that only
copies the table in between, and a CTE used twice whose every use is
empty both alert as a full read where PostgreSQL may read little.
The operator chose to keep them as documented limits: the tests make
lifting either a deliberate change, and SECURITY.md lists them next
to the opposite limit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant