Releases

This is a reverse chronological summary of TinyJoin releases and their public compatibility boundaries. Every entry states what upgrading to it requires, so check the entries between the version in use and the target before upgrading. A release that needs no action says so explicitly.

v0.4.0

Persistent storage breaks compatibility with v0.3.0 and earlier. v0.4.0 introduces page format 3, which stores each row as packed binary columns rather than as JSON text, and encodes keys so that they sort in the same order SQL compares their values. Rows take less space, a query can read the columns it needs without parsing the rest of the row, and ALTER TABLE ... ADD COLUMN no longer rewrites every existing row.

v0.1.0 through v0.3.0 use page format 2. Opening a page format 2 database with v0.4.0 fails with UNSUPPORTED_PAGE, and so does opening a page format 3 database with an earlier release. There is no automatic migration, and changing the OPFS name does not copy existing data.

In-memory databases, including every tinyjoin/node database, are unaffected.

Object rows now list their columns in field order. A row's keys follow the result's fields, as in PostgreSQL, so SELECT title, id returns rows whose first key is title, and a changed key lists a composite primary key's columns in the order the key declares them. Earlier releases sorted both alphabetically. Code that reads a row or key by column name is unaffected; only code that depends on key order, such as Object.keys(row) or JSON.stringify(row), sees a difference.

The Worker protocol version moves from 8 to 10. Client and Worker ship together and are upgraded together by a normal install, so this affects only a deployment that pins or caches a Worker file independently of the client bundle. A mismatched pair fails cleanly with PROTOCOL_MISMATCH, and tabs running different releases fail with DATABASE_VERSION_MISMATCH, rather than misreading each other's results.

Numbers keep every bit. Numbers in JSON values and SQL text are now read with correct rounding. Earlier releases read about one in ten numbers that need all 17 significant digits, such as many results of Math.random(), as a neighboring value. In v0.3.0 and earlier, a row holding one could then not be read at all: reading it failed with PAGED_STORAGE_CORRUPT, because the row no longer matched its stored text.

Opening a database checks its catalog, not every row. Earlier releases read every row and index entry when opening a database, so opening took time in proportion to its size, and refused a database that failed. Each page is still checked when it is read, and the new check() reads the whole database on demand, rejecting with the first problem it finds. A database whose damage no statement reads now opens, and only check() reports it. See checking a database.

This release is also much faster. In the comparative benchmarks, most workloads in v0.3.0 took 10 to 1,400 times as long as the faster of SQLite and PGlite. None now takes as much as twice as long, and reopening a database, reading every row, LIKE scans, GROUP BY, joins, building indexes, and committing a single insert are quicker than in either.

The compressed download is now 301 KiB, up from 295 KiB in v0.3.0.

v0.3.0

This release closes some of the most commonly encountered gaps in the SQL dialect. Each form is new syntax that was previously rejected, so an existing statement keeps its meaning.

BETWEEN and NOT BETWEEN are accepted wherever a WHERE clause is, including in aggregates, joins, UPDATE, DELETE, and prepared statements. price BETWEEN $1 AND $2 means exactly price >= $1 AND price <= $2, with the same type checking and NULL behavior as those two comparisons, and it is inclusive at both ends. There is no SYMMETRIC form, so a range whose bounds are reversed matches nothing rather than being swapped.

A single-table SELECT can now rename its columns with AS, as aggregate and join queries already could: SELECT id AS task_id, title FROM tasks. The alias becomes the result field name, ORDER BY can refer to it, and one column may be returned under several names. As in PostgreSQL, an ORDER BY name matches an output alias before a source column of the same name. Output names must still be distinct.

SELECT DISTINCT removes duplicate rows from a single-table or join projection, which is especially useful for collapsing the rows a many-to-many join multiplies, as in SELECT DISTINCT tag.name FROM post_tags JOIN tags AS tag ON post_tags.tag_id = tag.id. NULLs compare as equal to one another, and LIMIT and OFFSET count distinct rows. As in PostgreSQL, ORDER BY must name projected columns. The projection must list its columns explicitly and cannot contain JSON columns or aggregates, and DISTINCT ON and aggregate DISTINCT, such as COUNT(DISTINCT column), remain unsupported. A column named distinct remains usable.

INSERT ... ON CONFLICT makes an upsert one atomic statement rather than an UPDATE followed by a conditional INSERT inside a transaction, which held every tab sharing the database for the duration. ON CONFLICT (id) DO NOTHING skips conflicting rows, and ON CONFLICT (id) DO UPDATE SET value = EXCLUDED.value rewrites the stored row. The target is the primary key or the columns of one unique index, and DO NOTHING may omit it to treat every unique constraint as an arbiter. Rows are handled one at a time as in PostgreSQL, so a statement that would update the same row twice fails with CONSTRAINT_VIOLATION. SET accepts literals, parameters, DEFAULT, and EXCLUDED columns, but cannot read the existing row or change its primary key, and DO UPDATE ... WHERE is not supported. RETURNING, the row count, and changed keys include only inserted and updated rows, so a statement that skips every row reports no change. See upserts.

LIKE, ILIKE, and their NOT forms match a text column against a pattern in which % matches any run of characters and _ exactly one, so a search box no longer has to filter rows in JavaScript: WHERE title ILIKE $1 with '%' + term + '%'. Backslash escapes a literal % or _ unless an ESCAPE clause names another character, and a pattern that ends in its escape character is rejected. A search term from user input should have its own %, _, and backslash characters escaped before being wrapped in wildcards. ILIKE folds only ASCII letters, as PostgreSQL does under the C locale, which is consistent with TinyJoin's code-point ordering, so 'É' ILIKE 'é' is false. Pattern matching always scans candidate rows rather than using an index.

Upgrading from v0.2.0 needs no action. Install, rebuild, and redeploy. The Worker protocol stays at version 8 and the page format stays at format 2, so an existing database opens with no migration and no OPFS namespace change. The new syntax was previously rejected, so statements that already worked keep their results.

The engine is now built with Rust 1.98.1 rather than 1.91.1. That more than offsets the new SQL, so the compressed WebAssembly engine is about 4 KiB smaller than in v0.2.0.

v0.2.0

Subscriptions and statement results now report which rows changed, not only which tables. A TablesChangedEvent and every Results carry a keys map of table name to the primary keys that changed, so a listener can refresh individual rows instead of re-reading a table. Keys are reported for writes from any connected Client, which makes a change made in another tab resolvable to individual rows. See refreshing individual rows.

Reporting is a bounded best effort, and the changed-table list stays authoritative. A table appears in keys only when every key it changed fits the bound of 1,000 keys per table; a table that changed more rows is absent rather than partially listed, so an absent table means its rows cannot be named and must be re-queried. keys is always empty alongside reset: true. Code that assumes keys are present will silently miss large writes, so handle the absent case.

The Worker protocol version moves from 7 to 8. Client and Worker ship together and are upgraded together by a normal install, so this affects only a deployment that pins or caches a Worker file independently of the client bundle. A mismatched pair fails cleanly with PROTOCOL_MISMATCH rather than misreading a result. Applications using tinyjoinOffline() are unaffected: the plugin revisions both files by content, so a rebuild replaces them together.

Upgrading from v0.1.0 needs no action. Install, rebuild, and redeploy. Persistent storage is unchanged: the page format remains format 2, so an existing database opens with no migration and no OPFS namespace change. The keys field is additive, so application code that ignores it behaves exactly as it did before.

Grouped and aggregate queries now use a secondary index when their predicate supplies every column of one, instead of always scanning the table. Results are unchanged, because the predicate is still evaluated for each candidate row. Because the scan limit counts candidate rows, a grouped query that previously exceeded QUERY_WORK_LIMIT_EXCEEDED on a large table may now succeed.

tinyjoin/node no longer fails to start when the parent process runs with a flag that Node rejects for a worker thread, such as the --stack-trace-limit that several test runners set. Inherited flags are dropped rather than the database failing to open.

v0.1.0

The new Node entry point, tinyjoin/node, opens in-memory databases in Node.js 22 or later. It constructs a Worker thread and loads WASM automatically, returning the same Client API without additional dependencies. Each Client owns an independent database; filesystem persistence is not included.

Persistent storage breaks compatibility with v0.0.5. The npm v0.0.5 release uses page format 1; v0.1.0 uses page format 2. Opening a v0.0.5 database with v0.1.0 fails with UNSUPPORTED_PAGE. There is no automatic migration, and changing the OPFS name does not copy existing data.

This release also makes predicate and assignment type validation consistent across reads and writes, compares heterogeneous JSON values consistently, and bounds shared parameter-graph traversal and expanded SQL bindings before copying values. Append-only transactions now validate new row statements incrementally, including unique-index checks; mixed writes retain complete staged write-set validation.

Selective multi-table joins now use their actual comparison count rather than a worst-case Cartesian estimate. Scan, build-row, result, memory, and shared script-work limits remain in force.

Page checksum calculation is faster while preserving the stored checksum values, storage format, and corruption checks. This improves mixed writes and populated startup; the repository's workload measurements retain the before/after samples and runtime hashes.

UPDATE and DELETE now use direct primary-key lookup for exact complete key predicates, including staged transaction rows. Other predicates retain their scan path, and statement validation and constraint checks remain in force.

Persistent startup avoids a redundant table scan for unique indexes whose columns are all NOT NULL. Table validation and complete index-entry checks remain in force; nullable indexes and indexes without uniqueness retain the existing count scan.

Duplicate output names in simple projections and RETURNING now fail consistently with INVALID_QUERY before execution. LIMIT and OFFSET reject nonnumeric parameter values consistently in ordinary, aggregate, and join queries, including JSON objects shaped like internal prepared placeholders.

Persistent Clients now coordinate automatically across tabs and within a page. One elected Worker owns the database; operations, prepared statements, and notifications follow it through handover. Callback transactions exclude other Clients for their duration. In-flight operations interrupted by owner loss reject without automatic replay, and incompatible releases reject explicitly.

The optional tinyjoin/vite build plugin precaches a complete production application, including lazy Worker, OPFS, and WASM assets. New starter apps enable it by default. Updates wait for old controlled tabs to close; existing service workers can use its generated manifest and helper instead. See the offline guide.

v0.0.5

This release establishes TinyJoin as a standalone, SQL-first browser database package.

TinyJoin remains experimental and deliberately smaller than PostgreSQL. This release does not include a PostgreSQL server or wire protocol, hosted service, remote replication, or offline-write synchronization.