# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.1] - 2026-10-20

> Patch release. 0.3.0's wasm did not instantiate in a browser (so the
> playground never loaded) and `--json` was invalid for any DATE column;
> both are fixed here. No API changes — a 0.3.0 consumer upgrades by changing
> the version.

### Fixed

- 2026-10-20 the browser playground's wasm did not instantiate at all:
  `playground.wasm` imported the wasm host's `__moonbit_fs_unstable` module
  (12 functions) because the lazy-scan refactor routed `scan_string` and
  `scan_bytes` through the same `read_input` as their `scan_file`
  siblings, keeping `@fs` reachable. The page passes no imports, so it threw
  at `WebAssembly.instantiate` and never enabled Run. Scan inputs now carry
  their reader as a function field: a build that only scans in-memory content
  links no filesystem module (imports 12 → 0). CI builds the wasm and fails on
  any import beyond the js-string builtins
- 2026-10-20 `--json` emitted invalid JSON for DATE columns (a bare
  `1992-01-04` token); `SelectResult::to_json` now quotes a DATE as a calendar
  date, matching DuckDB's own JSON output, with a test for the quoted value
  and for `null` inside a DATE column
- 2026-10-20 the playground left the previous result's status on screen when a
  query threw or its result failed to parse; both paths now report `failed`
- 2026-10-20 the pages workflow only rebuilt on `playground/**` changes, so the
  deployed page still served `engine 0.1.0` while main carried 0.3.0. Engine,
  sources, types, sql, catalog, spill and the module manifests now trigger it

### Added

- 2026-10-20 `harness/bench_release.py`: one release performance record covering
  scan, join, gather and spill, with the toolchain, machine, git commit and
  binary hash alongside the numbers, stdout hashes to prove the work did not
  change, and `--compare` for release-to-release deltas. The first record is
  [`harness/baseline/2026-10-20-0.3.1.json`](harness/baseline/2026-10-20-0.3.1.json),
  with the 0.3.0-era record beside it, read in
  [`docs/bench-baseline.md`](docs/bench-baseline.md)
- 2026-10-20 `harness/playground_accept.cjs`: drives the playground in headless
  Chromium — CSV and Parquet uploads with DATE columns, two error paths, a
  cross-file JOIN (refused by name) and a same-file self-join

## [0.3.0] - 2026-10-20

> First release since `0.1.0` (published 2026-10-03). The `0.2.0` entries
> below record an internal development milestone that was never published on
> its own; everything since `0.1.0` ships in this version.

### Added

- 2026-10-20 the scan layer is lazy and pushdown-aware. A table entry now
  carries either materialized batches or a `ScanSource`, so the catalog needs
  the table's schema and nothing else. `@csv`/`@parquet` registration returns a
  source: `scan_file` keeps the path and re-reads it when a query opens the
  scan, `scan_string`/`scan_bytes` keep the caller's bytes, and typed columns
  exist only while a batch is live. `TableEntry::scan(ScanRequest)` is the new
  read path; `TableEntry::new(schema, batches)` builds a materialized entry as
  before
- 2026-10-20 scanners yield batches instead of a whole table: `BatchStream`
  produces `SCAN_BATCH_ROWS` (4096) row batches, CSV chunked by data rows and
  Parquet one batch per row group (sliced at 4096). An empty table still emits
  one zero-row batch so the engine sees the column layout
- 2026-10-20 column pruning reaches the sources. The engine passes the pruned
  column list it already computed (`table_cols`) as
  `ScanRequest.projection`; CSV parses and Parquet decodes only those columns,
  in projection order, and a zero-column projection (a bare `count(*)`) decodes
  nothing. A column outside the projection can no longer fail a query: a value
  that does not parse is only an error when the query reads that column
- 2026-10-20 Parquet row-group skipping from column-chunk statistics:
  `min_value`/`max_value` and `null_count` decide whether a group can be ruled
  out before a page is read. Bounds are used only when provably usable
  (explicitly inexact bounds, the deprecated signed-order pair on BYTE_ARRAY
  and non-UTF-8 string bounds are all refused), an all-NULL chunk satisfies no
  comparison, and the operator is taken into account (a group whose column is
  constant `c` is skipped for `col <> c`, one whose range is below the constant
  for `col >= c`, and so on). Float columns only prune on the strict
  comparisons: this engine's `Scalar::compare` treats NaN as equal to every
  value, while writers leave NaN out of their statistics, so `=`, `<=`, `>=`
  and `<>` could otherwise drop a group that holds a matching NaN. Hints come
  only from the table's build filter —
  the expression the engine already applies to that table's rows before
  joining it — and the engine still applies its own filter, so a hint can cost
  work but never a row
- 2026-10-20 scan-time filtering: a lazy table's build filter runs on each
  incoming batch while it is drained, so a selective scan never accumulates
  the table's rows (`EXPLAIN` prints the pushed predicates and
  `row groups: N of M read (K skipped by statistics)`). On TPC-H SF0.01, best
  of 5 native debug runs against the previous commit: q1 130→94 ms, q6
  111→67 ms, q3 130→73 ms, `count(*)` over lineitem 105→48 ms, peak RSS
  42→36 MB (q3 46→34 MB). On a generated 1Mi-row, 8-row-group Parquet table:
  `count(*)` 446→57 ms, a filter keeping 6 groups 438→51 ms, one keeping a
  single group 424→47 ms, peak RSS 130→32-39 MB; `harness/bench_scan.py`
  reproduces the comparison

- 2026-10-19 the spill format is MLSPILL3: presence is a bitmap instead of one
  byte per row. A column with no NULLs costs a single mode byte and a nullable
  one `ceil(rows / 8)` bytes, so the bake-off file drops from 5.10 MB to 4.13 MB
  (-19%) with write and read inside noise (125 vs 130 ms, 52 vs 51 ms) and an
  18% lower write RSS; the checksum over every scalar is unchanged. The
  benchmark driver also stopped trusting a hardcoded release path — it locates
  the newest build and refuses to measure a binary older than its sources,
  which is how a first run reported the previous format's numbers
- 2026-10-17 the Parquet reader decodes the remaining page encodings through
  colenc: DELTA_BINARY_PACKED (INT32/INT64), DELTA_LENGTH_BYTE_ARRAY and
  DELTA_BYTE_ARRAY (BYTE_ARRAY), BYTE_STREAM_SPLIT (FLOAT/DOUBLE) and RLE
  boolean values. The encoding gate accepts them, decode errors surface as
  pointed scan errors with the colenc message, and eight small embedded
  fixtures pin each path on every target. colenc is published as
  `superbigcup325/colenc@0.1.0`, so the dependency resolves from the registry
  and a plain checkout builds
- 2026-10-17 a Parquet writer (`sources/parquet/write.mbt`): `write_bytes` and
  `write_file` turn a `TableEntry` into one row group per batch with one PLAIN
  data page per column, encoding page bodies through colenc. Scope matches the
  reader: flat columns, v1 pages, UNCOMPRESSED, INT32/INT64/DOUBLE/
  BYTE_ARRAY/BOOLEAN; a column that holds a NULL anywhere is written OPTIONAL
  with an RLE definition-level section per page, everything else REQUIRED.
  Round-trip tests cover every physical type, NULLs, and multiple row groups;
  DuckDB and pyarrow read the output back
- 2026-10-17 the spill format is MLSPILL2: each column is a dense colenc
  stream (non-null values only) and the writer picks the smallest of PLAIN,
  DELTA_BINARY_PACKED, DELTA_LENGTH_BYTE_ARRAY, DELTA_BYTE_ARRAY and
  DICTIONARY per column, with a sample deciding whether the expensive
  candidates are tried. On SF0.01 lineitem the file drops from 8.08 to
  4.86 MiB; write is 1.46× and read 1.16× the old cost, and both RSS peaks
  fall. `harness/spill_bench.py` reproduces the measurement

### Changed

- 2026-10-20 the Parquet scan no longer concatenates row groups into one
  batch; the reader decodes one chunk of one row group at a time, which is
  what makes streaming and skipping possible. A full 16-column read (the shape
  of q6p's derived table) is 152→142 ms but needs ~3 MB more, because the
  scan's batches are what the engine then materializes
- 2026-10-20 `write_bytes`/`write_file` materialize a lazy entry before
  writing it, so a CSV or Parquet table can be written out without a separate
  copy step
- 2026-10-20 `moonbitlang/x` 0.5.5 → 0.5.6 (the latest `quickcheck`, 0.15.0,
  and `colenc` 0.1.0 are unchanged). Nothing in this tree changes behavior: it
  already uses `moonbitlang/core/encoding/utf8` rather than the now-deprecated
  `@unicode` helpers, and the new `@time` zone loading is unused here. The one
  upstream behavior change that reaches us is
  `@fs.read_file_to_string`, which the CLI uses for `--file <f>`: malformed
  UTF-8 now decodes to U+FFFD instead of dropping the trailing bytes. 200 tests
  on wasm-gc/native/js and the 27-query golden chain are unchanged

- 2026-10-19 `superbigcup325/colenc@0.1.0` is on mooncakes, so the module
  resolves its encoding kernel from the registry like any other dependency: a
  plain checkout builds after `moon update` (the dependency graph downloads on
  the first check/build/test), CI needs no workspace definition, and
  `moon publish --dry-run` validates a clean unpack again. The local-only
  `moon.work` + symlink setup is gone; the README keeps the recipe for editing
  both modules side by side

### Fixed

- 2026-10-20 a Parquet file that is both out of scope and internally
  inconsistent now reports the scope refusal: registering a table ran the
  footer row-count check before the schema gate, so a file with an unsupported
  schema *and* a wrong `num_rows` (apache/parquet-testing's
  `repeated_no_annotation.parquet` carries `num_rows = 0` over a 6-row group)
  failed with "row groups hold 6 rows, the file metadata says 0" instead of
  "nested schemas are not supported". The schema gate runs first again, so
  `harness/check_parquet_corpus.py` is back to 39 read / 29 refused by design /
  0 failing

- 2026-10-20 `harness/compare.py` judged the goldens too loosely in five ways,
  each able to pass a real divergence: an empty cell counted as SQL NULL (so
  `""` and `null` were interchangeable), every line was `strip()`ed (and TPC-H
  comments really do end in spaces, so whitespace at a row's edges was never
  compared), any numeric-looking cell was re-parsed as a double (`"001"`
  matched `"1"`, and 64-bit ids past 2^53 collapsed onto their neighbours),
  non-finite values fell through the tolerance and matched nothing — not even
  themselves — and *all* trailing blank lines were dropped although a
  single-column `""` row is a blank line. Cells now compare exact text first,
  integers exactly, decimals/exponents within 1e-9 with NaN and infinities
  handled as values, and only the terminating newline of the last row is
  dropped. `harness/check_compare.py` pins 27 cases (12 of which fail on the
  old comparator) and `check_all.sh` runs it before diffing a golden; the
  full sweep still passes

## [0.2.0] - 2026-10-16

> Not published on its own: this milestone's changes ship in `0.3.0`.

### Changed

- 2026-10-15 the Parquet path is in-house: `sources/parquet` decodes Parquet itself — Thrift compact-protocol footer and page headers, row groups, v1 data pages, PLAIN and dictionary (RLE/bit-packed hybrid) encodings, definition levels, `UNCOMPRESSED` and `SNAPPY` — and `mizchi/parquet`, together with the `mizchi/duckdb` it dragged in, is gone from the dependency graph. Scope is deliberate, not accidental: flat schemas, v1 pages, those codecs and encodings; nested fields, INT96, v2 pages, DELTA/byte-stream-split encodings and the other codecs raise pointed scan errors. Two reasons to write it: the transitive `mizchi/duckdb` shipped a legacy `moon.mod.json` that the current toolchain refuses, which made the module both unpublishable and uninstallable for consumers, and the reader was the last single-maintainer dependency in the graph. Dropping it also let `moonbitlang/quickcheck` move to 0.15.0, whose generator API now lives in `moonbitlang/core/quickcheck`
- 2026-10-13 the in-house SQL front-end (lexer, parser, AST) moved out of `engine` into its own `sql` package: the engine imports `@sql`, the AST types (`Select`, `LExpr`, `AggFn`, …) live at `@sql.*`, and the parser-side `contains_win_marker` helper is now public there; the unused `moonbit-community/sqlparser` dependency was dropped from `moon.mod`; the in-house front-end is the roadmap and sqlparser remains upstream cooperation (visibility issues/PR), not a planned swap-in
- Division and modulo follow DuckDB: `/` is true division and always yields DOUBLE (integer operands promote, `x/0` is ±inf, `0/0` is nan) instead of C-style integer division returning NULL on a zero divisor; `%` follows fmod — the result takes the dividend's sign (`-10.5 % 3` is `-1.5`) — and `x % 0` is NULL. `--json` renders non-finite floats as bare `Infinity`/`NaN` tokens, byte-identical to DuckDB's own JSON output (Python's `json` module round-trips them; strict RFC 8259 parsers do not)

### Added

- 2026-10-15 Parquet fixtures and their DuckDB goldens (`harness/gen_parquet_fixtures.py`, `harness/data/parquet`): one shape per type and coding mix — every physical type with NULLs, a low-cardinality shape that gets dictionary pages, a two-row-group shape — each generated in the codec variants the reader claims (plain/uncompressed, plain/snappy, dictionary/snappy, dictionary/uncompressed) and diffed in `check_all.sh` (now 27 goldens) so a decode bug fails the same gate the TPC-H answers do; plus fourteen decoder unit tests on hand-built buffers (compact-protocol integers and generic skipping, PLAIN layouts, the hybrid RLE/bit-packed stream including the byte-advance between runs, definition levels, malformed input raising scan errors instead of trapping)
- 2026-10-13 grace hash aggregation — when a GROUP BY's input exceeds the memory budget, the input partitions by the group-key hash into per-partition spill runs and the in-memory aggregate replays one partition at a time — equal keys land in the same partition, so each partition's group rows are final and non-combinable accumulators (median, string_agg, count-distinct) need no merge phase. Low-cardinality keys whose single partition still exceeds the budget fall back to an in-memory pass for that partition (group state cannot be split without a combine phase); over budget without a spill store is a clean error; global aggregates keep the streaming fold. Equivalence pinned by accumulator-family in-memory-vs-spilled tests over NULL-carrying keys, a 20k-row 97-group e2e with median, and a `spillagg` golden check in `check_all.sh`
- 2026-10-13 grace hash join — when a join's inputs exceed the memory budget, both sides partition by the join-key hash into per-partition spill runs and the in-memory join replays one partition at a time (all eight join flavors, the vectorized residual included); over budget without a spill store, key-less cross joins and single-key hash skew are clean errors pointing at `--memory`/`--spill-dir`. Equivalence pinned by seven-flavor in-memory-vs-spilled property tests, a 20k-row many-partition e2e, and a `spilljoin` golden check in `check_all.sh`
- 2026-10-13 CSV ingest pass — unchecked byte and array access in the scan and materialization hot paths (every index is scanner-constructed), a 9-digit int32 parse fast path with the int64 parser as fallback, single byte reads in the field loops and 64k-entry growable offset arrays: SF0.01 lineitem ingest 117 -> 105 ms (A/B, min of 9, scan phase 62 -> 42 ms), the TPC-H bench shifts down accordingly with all 27 goldens byte-identical
- 2026-10-13 join residual vectorization — a non-pushable join predicate (e.g. q19's three-way spanning OR) now decomposes into arms whose single-side conjuncts precompute Kleene true/unknown masks per joined batch pair through the vectorized kernels; only both-side conjuncts stay per-pair against a reduced row. q19 415 -> 162 ms, semantics pinned by the join oracle property, the semi/anti residual e2e battery, the 27 goldens and the duckdb-referenced pushdown fuzzer
- 2026-10-05 hot-path perf arc — typed group indexes for hash aggregation (single int/date keys hash raw `Int64`s, a single string key hashes the column's own strings, composite keys share one reusable `StringBuilder` and materialize the boxed key row once per group instead of once per row); mutable accumulator payloads allocated once per group instead of an enum rebuild per fold step; numeric aggregate inputs (sum/avg/count/stddev/variance/median over `INT`/`FLOAT64`) fold straight from the typed arrays with no boxed `Scalar` per cell; `ORDER BY` sorts the key `ColumnVector`s directly via typed comparators (no boxed decoration, no `Option` per comparison — `Scalar::compare_nn` is the new non-null total compare, also used by the spill merge and min/max folds). SF0.1 Q1 2015 -> ~1.6 s (-21%), SF0.01 Q1 187 -> 136 ms (-27%), raw-table ORDER BY -4% (scan-dominated); all 27 duckdb goldens byte-identical throughout
- 2026-10-04 external sort for ORDER BY — `ExecOptions { budget, spill }` (library `execute_with`, CLI `--memory <n>[K|M|G]` + `--spill-dir`) turns the blocking ORDER BY of non-aggregate queries into sorted runs on disk with a streaming k-way merge feeding projection/DISTINCT/OFFSET/LIMIT, so a LIMIT query's memory is the output window; engine stays fs-free via an injected SpillStore; equivalence tests (tiny budget vs in-memory, five shapes) plus a golden-chain self-consistency check pin correctness
- 2026-10-04 multi-row-group parquet coverage — the SF0.01 parquet export is a single row group, so the reader's multi-group path had never been exercised; `harness/gen_multigroup.py` writes a 4-group x 2048-row fixture with known per-group bounds (the same asset promised upstream in mizchi/parquet#4), four golden queries (full read, bounds-selective filter, aggregate over NULLs, window) run in `check_all.sh` (23 -> 27) and regenerate in both CI workflows
- 2026-10-04 window functions — `row_number()` / `rank()` / `dense_rank()` and every aggregate as `agg(expr) OVER (PARTITION BY ... ORDER BY ...) [NULLS FIRST|LAST]` in the select list, duckdb-diffed over a 29-query battery (ranking ties, NULL partition/order keys, running RANGE frames with peer sharing, GROUP BY interplay via `sum(sum(x)) OVER ()`, DISTINCT/WHERE/LIMIT composition); window markers compose inside item expressions (`v + sum(v) OVER (...)`) and outputs are referenceable by alias; explicit frame clauses and window placement outside the select list are pointed errors
- 2026-10-06 SQL-surface arc (audit gaps closed): `INTERSECT` / `EXCEPT` with ALL / DISTINCT (INTERSECT binds tighter, NULL-safe row comparison, multiset counts); correlated scalar subqueries in the select list; `EXISTS` / `NOT EXISTS` with a HAVING clause; `coalesce` / `nullif` / `greatest` / `least`; `date_trunc` / `date_diff`; `string_agg` (literal separator); `stddev` (+samp/pop), `variance` (+samp/pop), `median` (Welford); `GROUP BY` ordinals; `SELECT` without FROM; bare `NULL` select items as INTEGER
- 2026-10-06 usability arc — standalone-binary startup (`moon build cmd/main --target native --release` + one `install` line puts `moonlake` on PATH; README quickstart), `exec --file <q.sql>` to skip shell quoting entirely (trailing semicolons fine), a pointed hint when data flags are passed without the `exec` subcommand, and a CSV-reader perf pass: per-cell offsets moved from boxed arrays to trimmed FixedArrays — SF0.1 ingest -16% (1374 -> 1150 ms), end-to-end queries -11-18%, maxRSS below the previous baseline (370 -> 353 MB)
- 2026-10-06 verification arc — normalized correctness evidence beyond the SF0.01/1e-9 differential: a randomized vec-vs-scalar differential property over generated typed expression trees (batch sizes 0..4097, mutation-verified), a nested-loop hash-join oracle property across eight join flavors with hostile key distributions (mutation-verified), and `harness/fuzz_pushdown.py` (300 random predicate trees over INNER/LEFT joins, duckdb-refereed; catches WHERE-vs-key placement regressions at LEFT joins — verified by re-introducing the escape bug)
- 2026-10-05 acceptance arc — all 22 TPC-H queries now pass on their official texts (annex parameter values, duckdb-diffed within 1e-9 on SF0.01 and SF0.1; q15 in its sanctioned `WITH` form, see `harness/queries/q15.sql`):
  - `DATE +/- INTERVAL n day|month|year` arithmetic — calendar-aware month/year shifts with day-of-month clamping (`1996-03-31 + interval '1' month` = `1996-04-30`), vectorized, NULL-propagating; an `INTERVAL` literal outside date arithmetic is a bind error, and the TPC-H dbgen precision suffix (`interval '90' day (3)`) parses and is dropped
  - Correlated scalar subqueries (`expr <op> (SELECT agg(...) ... WHERE inner.col = outer.col ...)`) decorrelate into LEFT joins against the inner query grouped by the correlation keys — empty groups NULL-extend and the comparison filters, which is exactly scalar-subquery NULL semantics; the select item must contain an aggregate so groups stay single-row, and nesting works to the depth q20 asks (two correlation columns one level out)
  - Derived-table column alias lists (`FROM (SELECT ...) AS t (a, b)`) rename positionally, arity-checked
  - Non-recursive statement-level `WITH` CTEs: each body re-parses per reference (decorrelation rewrites the AST in place), a CTE name shadows catalog tables and qualifies its columns when no alias is given, later CTEs may reference earlier ones
  - The harness now carries the official TPC-H texts verbatim (`harness/queries/q1..q22.sql`), goldens are generated from the exact bytes moonlake runs, and `check_all.sh` covers 23 goldens (22 + the parquet variant) at any scale via `MOONLAKE_DATA`
- `IS NULL` / `IS NOT NULL` — the first way to test NULL-ness in a WHERE or CASE condition
- Columnar evaluation core (the first vectorization pass): `eval_vec` evaluates expressions over whole batches with typed kernels — column refs shared zero-copy, constant broadcast, same-type int32/int64/float64 arithmetic, three-valued comparison, NULL-aware AND/OR/NOT — with a per-row fallback for the remaining shapes; nine differential tests pin the vector path to the scalar interpreter cell-for-cell
- Filter, projection, aggregation inputs and ORDER BY run on the vector path: masks come from the vectorized boolean result, each output column is one pass, group keys and aggregate inputs are evaluated once per batch, and sort keys are materialized before the comparator runs
- Columnar hash join: both sides concatenate once into single batches, keys evaluate once per side, probe hits collect (left row, build row) integer pairs, and output assembles by typed per-column gathers — no row-major boxed Scalar arrays. NULL-key, residual and LEFT NULL-extension semantics are unchanged
- Bind-time projection pruning: the executor walks the bound plan, drops every unreferenced column from the pipeline (zero-copy vector subsets) and rewrites all column references. TPC-H lineitem scans narrow from 16 columns to the 2–6 the query touches; `--explain` reports the narrowed counts and renders the executed plan. `SELECT *` degenerates to an identity rewrite
- The NyaCSV-backed CSV scan is replaced by a built-in byte-level reader: one pass records per-cell offsets (RFC-4180 dialect — quoted fields with `""` escapes, commas and newlines inside quotes, LF/CRLF terminators, blank lines skipped, BOM stripped), and materialization parses numeric/boolean/date cells straight from their byte ranges, so text columns are the only ones that ever build Strings. Ingest of a 7.4 MB lineitem.csv drops 386 -> 132 ms and every bench query lands at 137–205 ms; the public `scan_string`/`scan_file` API and the wasm playground path are unchanged
- Performance pass over ingest and the join core: CSV cells materialize straight into typed FixedArrays (no boxed Scalar per cell — about a million temporaries per lineitem.csv load at SF0.01), `scan_file` reads bytes and hands them to NyaCSV without a full-file String decode, single-int join keys hash `Int64`s directly instead of building a key string per row, and row selection/batch concatenation copy FixedArray ranges instead of boxed cells. Best-of-3 on one machine: every bench query drops 16–29%, q7 (six-table join) 1040 → 483 ms cumulative against the row-at-a-time build; ingest is now the dominant remaining cost
- `NOT BETWEEN` (postfix form, three-valued negation of BETWEEN)
- `NULL` as a comparison or arithmetic operand: `v = NULL` is UNKNOWN per row, `NOT IN (1, NULL)` is empty, `v + NULL` propagates (the literal takes its type from the other operand; standalone `SELECT NULL` remains unsupported)
- `SELECT *` — expands to every FROM column, each table in FROM order; also inside derived tables

### Fixed

- 2026-10-05 spilled ORDER BY over more than 4096 result rows crashed on a destination overrun: `OutputBuilder::build` hands out capacity-sized vectors (4096) whose length exceeds `row_count`, and `copy_vec_range` copied by vector length, so `final_merge`'s multi-chunk `concat_batches` wrote past the destination (reproduced as a `check_range` trap; the existing spill fixtures stayed under one chunk, masking it). The copy now takes the live row count explicitly; regression tests cover the unit shape and a 5000-row spill merge with and without LIMIT
- 2026-10-04 mixed `ORDER BY output_col, expression` dropped the output-column key whenever an expression key was present (a two-phase sort applied only the expression keys); the binder now resolves both shapes into one written-order key list that sorts jointly — caught by the window-function tests
- 2026-10-06 silent-wrong-answer guards: `sum()` over int64 raises on overflow instead of wrapping (duckdb promotes to HUGEINT); ORDER BY / LIMIT / OFFSET on a UNION branch is a parse error instead of silently truncating that branch
- Playground: loading a CSV in the browser threw `csvText is not defined` (a leftover write to an undeclared variable in the module script), so the drag-and-drop / file-pick path silently never enabled the Run button; only the Parquet path worked. The dead store is gone and the CSV path runs end to end in the browser again
- Explicit `JOIN (SELECT ...) AS t (a, b)` syntax rejected derived tables ("expected table name after FROM") — only comma-list FROM accepted them; the shared table-reference parser now handles subqueries, WITH names, aliases and column lists on both paths
- LEFT JOIN pushdown soundness: WHERE conjuncts referencing only the build (right) table were applied as build filters, dropping build rows and letting NULL-extended probe rows survive a predicate they cannot satisfy (e.g. `... LEFT JOIN orders ON ... WHERE orders.amount > 15` returned the unmatched rows with NULL amounts). They now stay a deferred post-join filter; probe-side pushdown and ON-clause placement are unchanged
- CLI exit codes: every error path (bad SQL, unknown table/column, unreadable file, usage violation) exited 0. Errors now print a single-line message to stderr and exit 1 via a clean process exit (`moonbitlang/x/sys`), so no runtime abort stack trace trails the message; success, `--help` and `--version` remain 0
- Integer overflow wrapped silently: CSV inference and SQL literals shared a parser that ignored the int32 range, so `2147483648` came back as `-2147483648` and past int64 the sign flipped (`9223372036854775807` → `-1`). Integer parsing is now range-checked end to end: CSV columns widen int32 → int64 → float64 (DuckDB-style sniffing), SQL literals widen int32 → int64 → float64, sign-only inputs (`-`) no longer parse as `0`. The float parser's int64 digit accumulator had the same wrap on 20-digit inputs; it now accumulates in double precision (accuracy contract unchanged: 1e-9 relative)
- Keywords `year`/`month`/`day`/`date` were unusable as column names in any form (bare, qualified, aliased) and double-quoted identifiers were not supported at all — a CSV with a `day` column had no way to reference it. The DATE-part keywords now parse as names in name positions, and `"quoted"` identifiers (with `""` escape) reach every other reserved word
- A scalar subquery over zero rows raised a bind error; SQL semantics say it yields NULL, so only more than one row is now an error
- CLI: a `--date-col` naming no scanned column was a silent no-op (exit 0) that surfaced later as a confusing type mismatch — it is now a usage error; words after the quoted SQL silently glued onto the query (a stray word became a table alias) — the SQL must now be one quoted argument; a UTF-8 BOM glued onto the first CSV header cell's name — it is now stripped

## [0.1.0] - 2026-10-03

### Added

- MoonBit module skeleton (`superbigcup325/moonlake`): library package and CLI entry point (`cmd/main`)
- CLI `--version` / `--help` output
- Engine facade with version metadata and blackbox tests
- GitHub Actions CI: format check, `moon check`, tests across native / wasm-gc / js targets
- Apache-2.0 license
- W1: `types` (columnar vectors, scalars with three-valued NULL logic, epoch-day dates), `catalog`, CSV scan with type inference, engine subset parser/binder/executor, CLI `exec --csv`, TPC-H cross-validation harness (duckdb goldens, Q6 end to end)
- W2: GROUP BY hash aggregation with the v1 aggregate set (sum / avg / count / count(*) / count(distinct) / min / max), HAVING, ORDER BY (NULLS LAST) and LIMIT
- W2: CASE WHEN, LIKE and IN expressions; TRUE/FALSE literals
- W2: multi-table FROM with hash joins — comma lists, `JOIN ... ON`, `LEFT [OUTER] JOIN`, `CROSS JOIN`; WHERE/ON conjunct classification into build/probe filters, hash keys and per-pair residuals
- W2: Parquet scan adapter (`sources/parquet`) over mizchi/parquet with declared DATE columns; CLI `--parquet` / `--date-col`
- W2: TPC-H goldens for Q1 / Q3 / Q14 plus the parquet chain (q6p), `harness/check_all.sh`, and a CI job regenerating data + goldens via duckdb and cross-validating every query
- W3: TPC-H coverage to 14 of 22 queries (13 on the official text; Top Supplier rewritten — CTE inlined as a derived table), all duckdb-validated — `check_all.sh` runs 15 goldens
- W3: plain projection, table aliases with qualified columns, EXTRACT(year/month/day), derived tables (`FROM (SELECT ...) AS t`), NOT LIKE / NOT IN lists, non-correlated scalar subqueries and IN / NOT IN subqueries with SQL NULL semantics
- W3: greedy connected-first join ordering for inner-only chains; hash keys implied by OR-branch equalities (Q19)
- W3: string ordering fixed to lexicographic (`String::lexical_compare`) — MoonBit's `String::compare` is length-first and `<`/`>` are not content-ordered
- W3: CLI `--explain` (physical plan render) and `--json` (single-line JSON results); `harness/bench.py` (native vs duckdb, best of 3)
- W4: acceptance package — acceptance-facing README (ecosystem-boundary section, TPC-H scoreboard), `docs/DEMO.md` reproducible walkthrough, published to mooncakes.io as `superbigcup325/moonlake@0.1.0`
- W4: browser playground (`playground/`) — the whole engine as a wasm-gc foreign library, drop-a-CSV page with zero dependencies, GitHub Pages workflow
- W4: pre-commit gate (`hooks/`) — interface freshness, formatting, deny-warn check and tests run locally before every commit
- Polish: quickcheck property tests over the full pipeline (model-based group/aggregate oracle, join symmetry, pushdown equivalence, sum-over-groups; mutation-verified), SF0.1 cross-validation (all 15 goldens PASS on 600k+ rows) and scale-parameterized harness scripts, and Parquet drag-and-drop in the playground
