VelesQL in the browser (WASM)

August 17, 2026 · View on GitHub

Companion to crates/velesdb-wasm/README.md. VelesQL is parsed, validated and executed entirely client-side by @wiscale/velesdb-wasm. This guide states exactly what runs, what is rejected, and when to move to the REST server instead.

Parsing and validation

VelesQL is a static class — nothing to construct.

import init, { VelesQL } from '@wiscale/velesdb-wasm';

await init();

const parsed = VelesQL.parse('SELECT * FROM docs WHERE vector NEAR $v LIMIT 10');
console.log(parsed.tableName);        // "docs"
console.log(parsed.hasVectorSearch);  // true
console.log(parsed.limit);            // 10n   <- BigInt, not 10

console.log(VelesQL.isValid('SELECT * FROM docs'));   // true
console.log(VelesQL.isValid('SELEC * FROM docs'));    // false

const match = VelesQL.parse('MATCH (p:Person)-[:KNOWS]->(f:Person) RETURN f.name');
console.log(match.isMatch);                 // true
console.log(match.matchNodeCount);          // 2
console.log(match.matchRelationshipCount);  // 1

ParsedQuery also exposes isValid, isSelect, isDdl, isDml, collectionName, columns, hasDistinct, hasWhereClause, hasOrderBy, hasGroupBy, hasJoins, hasFusion, offset, orderBy, groupBy and joinCount — all properties, not calls. limit and offset are bigint | undefined.

Executing queries

Execution goes through WasmDatabase.executeQuery(sql, paramsJson). The second argument is a JSON string (or null), not an object. The result is a QueryResult with kind, message, rowCount and rowsJson.

import init, { WasmDatabase } from '@wiscale/velesdb-wasm';

await init();

const db = new WasmDatabase();
db.createMetadataCollection('docs');

let r = db.executeQuery("INSERT INTO docs (id, title, views) VALUES (1, 'hello', 5)", null);
console.log(r.kind, r.rowCount, r.message);   // mutation 1 1 row(s) affected

db.executeQuery("INSERT INTO docs (id, title, views) VALUES (2, 'world', 50)", null);

r = db.executeQuery('SELECT title FROM docs WHERE views > 10', null);
console.log(r.kind, r.rowCount, r.rowsJson);  // rows 1 [{"title":"world"}]

EXPLAIN uses the same plan vocabulary as the REST server (core's QueryPlan::to_plan_steps()), so a plan captured in the browser is comparable to a server plan:

const plan = db.executeQuery('EXPLAIN SELECT title FROM docs WHERE views > 10', null);
console.log(plan.rowsJson);
// [{"description":"Scan collection 'docs'","estimated_rows":2,
//   "estimation_method":"row count","operation":"FullScan","step":1},
//  {"description":"Apply WHERE clause predicates","operation":"Filter","step":2}, …]

createMetadataCollection exists precisely so payload-only workloads can run VelesQL without declaring a vector column.

What runs in WASM

FeatureWASMREST server
Vector search (NEAR)yesyes
Metadata filteringyesyes
Hybrid search (vector + text)yesyes
Full-text searchyesyes
Multi-query fusion (MQG)yesyes
Batch searchyesyes
Sparse searchyesyes
Knowledge graph (nodes, edges, traversal)yesyes
Agent memory (MemoryService)yesyes
VelesQL parsing and validationyesyes
VelesQL executionyes, minus the carve-outs belowyes
Column projection / aliases / window functionsyesyes
GROUP BY / HAVING / aggregatesyesyes
Aggregate ORDER BY over a GROUP BYyesyes
UNION / INTERSECT / EXCEPTyesyes
JOININNER, LEFT onlyall
MATCH graph traversal1–2 hopsany depth
Cross-collection MATCH (@collection)noyes
EXPLAIN (core plan vocabulary)yesyes
PersistenceIndexedDBDisk (mmap)
Practical ceiling~100 K vectors (browser RAM)millions

The single-collection executor supports SELECT (with WHERE, NEAR, similarity()), projection / aliases / window functions (ROW_NUMBER, RANK, DENSE_RANK), GROUP BY / HAVING, aggregates, ORDER BY (payload columns, similarity(), arithmetic expressions, and aggregate ORDER BY over a GROUP BY), a default LIMIT 10, UNION / INTERSECT / EXCEPT, INNER / LEFT JOIN, INSERT / UPSERT / UPDATE / DELETE, DDL (CREATE / DROP / TRUNCATE COLLECTION), introspection (SHOW COLLECTIONS, DESCRIBE COLLECTION), admin (FLUSH, a no-op), and 1–2 hop MATCH.

What is rejected, and how

Every unsupported shape is a loud rejection. WASM never returns a quietly-wrong result for a query it cannot honour. The messages below are a historical reference from @wiscale/velesdb-wasm@4.0.0.

ShapeMessage
LET score bindingsLET bindings are not supported in WASM
Scalar subqueriesSubqueries are not supported in WASM
RIGHT JOINRIGHT JOIN is not supported in WASM (use LEFT JOIN)
FULL JOINFULL JOIN is not supported in WASM
MATCH beyond 2 hopsMATCH patterns with more than 2 hops are not yet supported in WASM (N nodes)
ALTER COLLECTIONALTER COLLECTION is not supported in WASM yet
Graph collections in DDLGraph collections are not supported in WASM (use GraphStore directly)
similarity() threshold in WHEREsimilarity() threshold filters are not supported in WASM
Graph MATCH predicate in WHEREGraph MATCH predicates are not supported in WASM
BM25 MATCH condition in WHEREMATCH (BM25) conditions are not supported in WASM
CONTAINS / CONTAINS_TEXTCONTAINS / CONTAINS_TEXT conditions are not supported in WASM
Geospatial conditionsGeospatial conditions are not supported in WASM
Inline vectors in INSERT… inline vectors are not supported in WASM INSERT
ORDER BY similarity(field, $v)ORDER BY similarity(field, $vec) is not supported in WASM: named/secondary …
ORDER BY in a MATCHMATCH ORDER BY <form> is not supported in WASM (use depth or alias.property)

Two cases deserve more than a one-liner:

  • Cross-collection MATCH (@collection) — the @ form is not even in the WASM grammar; it fails at parse time (expected identifier), not at execution. It requires Database-level query routing that only the server has.
  • TRAIN QUANTIZER — recognized for API parity but unavailable: training needs ndarray/persistence, which are compiled out for WASM. Product Quantization has the same constraint (it needs rayon).

Fusion strategies

USING FUSION(strategy=…) behaves differently depending on the query shape:

  • On a single-vector NEAR, WASM has no BM25 or graph branch to fuse against, so weight-sensitive strategies (weighted, rsf) are meaningless. Use rrf, maximum or average, or a plain metadata filter.
  • On a multi-vector NEAR_FUSED query the strategy is not rejected, but only rrf, average and maximum are honoured — weighted and rsf fall back to RRF, matching core's fused_config_to_strategy.

ORDER BY similarity()

WASM stores only the primary vector, so the named/secondary form ORDER BY similarity(field, $v) is rejected on both the SELECT and MATCH paths. Use ORDER BY similarity() (the search score itself) or a payload column. The MATCH path does no vector scoring at all, so it additionally rejects bare similarity() and arithmetic ORDER BY — order by depth or alias.property there.

Error surface: mixed, coerce before reading

In the historical 4.0.0 release, error reporting was partly structured. Some boundaries threw a real JS Error carrying a non-enumerable, machine-readable code; others still threw a bare string. This historical reference was verified against the historical @wiscale/velesdb-wasm@4.0.0 package:

OperationThrown value
store.search() with a wrong query lengthError, e.code === 'VELES-004', message [VELES-004] Vector dimension mismatch: expected 3, got 2
new VectorStore(3, 'nope')plain string, Unknown metric. Use: cosine, euclidean, l2, dot, dotproduct, inner, ip, hamming, jaccard
db.get_collection('nope')plain string, Collection 'nope' not found
db.executeQuery('SELECT * FROM missing')plain string, Collection 'missing' not found
A VelesQL syntax errorplain string, VelesQL parse error at position N: … with a caret-annotated excerpt

Write handlers that survive both:

try {
  db.executeQuery(sql, null);
} catch (e) {
  const code = e && typeof e === 'object' ? e.code : undefined;   // may be undefined
  console.error(code ?? 'UNKNOWN', String(e));
}

When to move to the REST server

Reach for the REST server when you need:

  • Cross-collection MATCH@collection routing lives on the server.
  • Multi-hop MATCH — traversals beyond 2 hops.
  • More than ~100 K vectors — browser RAM is the ceiling.
  • RIGHT/FULL JOIN, quantizer training, geospatial or BM25 predicates.
  • Centralized, shared state — WASM is per-tab and per-user by definition.

Migrating a query to REST

// Client-side (WASM)
import init, { VectorStore } from '@wiscale/velesdb-wasm';
await init();
const store = new VectorStore(768, 'cosine');
const wasmResults = store.search(query, 10);

// Server-side (REST) — vector search
const searchResponse = await fetch('http://localhost:8080/collections/docs/search', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ vector: Array.from(query), top_k: 10 }),
});
const restResults = await searchResponse.json();

// Server-side (REST) — VelesQL
const queryResponse = await fetch('http://localhost:8080/query', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    query: "SELECT * FROM docs WHERE vector NEAR $v AND category = 'tech' LIMIT 10",
    params: { v: Array.from(query) },
  }),
});
const restRows = await queryResponse.json();

The REST endpoints above are the server's documented surface; check docs/openapi.yaml for the authoritative contract of the version you run.


Last updated: 2026-08-13 · Applies to: velesdb-core 5.1.0