Elyra
Elyra The coding agent e The native code editor Elyra Grove Native local development environment Askr The real server for Laravel & PHP Elyra Framework Rust + Svelte 5 framework for desktop apps Elyra Conductor Local project conductor Elyra SQL Server MySQL-compatible SQL server in Rust Elyra Félagi Agents as teammates on one board Elyra SQL Client Native desktop SQL workbench Elyra SQL Anywhere Replication-ready SQL engine Elyra Sjá SEO & GEO workspace for macOS Elyra DataGrid Server-driven data grid for Laravel
Elyra

Changelog

All notable changes to Elyra DataGrid are documented here.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Packages are versioned together and released from git tags. A single tag (e.g. v0.1.3) publishes all four Composer packages — elyra/datagrid-protocol, elyra/datagrid-server, elyra/datagrid-livewire, and elyra/datagrid-js.

[Unreleased]

Removed

  • The npm publishing surface. scripts/publish-npm.sh and .npmrc.dist are deleted, the five publishConfig blocks are gone, and the JS packages are marked "private": true so npm publish refuses them outright.

    Nothing was ever published there: the registry the setup targeted (npm.packagist.com) does not exist, and the organisation has no npm registry. The clients ship inside the elyra/datagrid-js Composer package, which is the documented and working path.

    The package.json files stay — they are npm workspace manifests, and the symlinks in node_modules/@elyra/ are what make @elyra/* imports resolve for local development, typechecking and tests. "private": true is a stronger guard than the old publishConfig was: that only redirected the registry, so dropping it would have made npm publish fall back to public npmjs.org.

Internal

  • Release policy in docs/distribution.md: only tag when something under packages/ has actually changed. Tooling, CI and documentation work waits here until then. 0.6.0, 0.6.1 and 0.6.2 shipped byte-identical package content, which makes the version history say less than it should.

0.6.2 - 2026-08-23

No shipped code changed, as in 0.6.1 — nothing under packages/ differs, so the Composer packages are byte-identical apart from the version in their manifests. Release tooling only. Skip this one unless you cut releases.

Added

  • release.sh --dry-run runs every gate and the whole suite without bumping, committing or tagging. There was previously no way to check that a release would succeed except to cut one, which is a poor trade when a tag is public.

Changed

  • release.sh requires a reachable PostgreSQL, and refuses before touching anything if there is none. SQLite accepts MySQL-style backticks and coerces LIKE on numeric columns, so the request matrix passes on it while two classes of dialect bug go unseen — how the 0.5.0 grouping bug shipped, and nearly how 0.6.0 shipped too: no server happened to be running, the matrix fell back to SQLite-only, and said so in a NOTE that is easy to scroll past.

    The release now also runs test:postgres and test:postgres-engine, which are absent from npm test because they need a live server — but a release is exactly when they should run. ELYRA_RELEASE_WITHOUT_POSTGRES=1 overrides, loudly.

  • release.sh restores the tree when a release fails. The manifest bump and bundle rebuild happen before the suites, so any later failure — a lint, a test, a broken doc link — left the manifests bumped and the bundle rebuilt with no commit. The next attempt then refused as dirty, and the operator had to work out which files to revert. A failure now puts the manifests, lockfile and bundle back. --dry-run does the same on success, so it really leaves nothing behind.

    (The doc-link check itself was already covered: lint:docs is the third step of npm test, which the release runs. The problem was never coverage, it was what a failure left behind.)

0.6.1 - 2026-08-23

No shipped code changed. Nothing under packages/ differs from 0.6.0 — this release is repository tooling and documentation, and the Composer packages are byte-identical apart from the version in their manifests. There is nothing to gain by upgrading from 0.6.0 unless you work on the repo itself.

It exists to record the documentation corrections and to mark the state of the release tooling.

Added

  • Doc-link check in CI (scripts/check-doc-links.mjs, npm run lint:docs). Verifies every relative Markdown link in the repo — that the file exists, and that a #fragment matches a real heading slug, following GitHub's slug rules including -1 suffixes for duplicate headings. Fenced code blocks are skipped so a # inside an example is not mistaken for a heading. External URLs are deliberately not checked: CI should not depend on other people's uptime.

    118 links across 41 files. Two broken ones had already shipped before this existed, which is the argument for it — a renamed heading leaves a link that still looks correct in the source.

Fixed

  • The npm distribution surface is marked NOT PROVISIONED. No npm registry exists for the organisation — npm.packagist.com does not resolve, with a subscription active, while packagist.com and unrelated npm.* hosts do. So nothing has ever been published there and nothing can be.

    publish-npm.sh now refuses up front with that explanation rather than letting a DNS error read like an outage; ELYRA_NPM_PROVISIONED=1 lifts it once a real registry is in place. .npmrc.dist carries the same notice — it is the template customers are given, and it would have failed for them. docs/distribution.md has the provisioning checklist.

    Nothing is lost: the JavaScript clients ship inside the elyra/datagrid-js Composer package, which is the documented and working path.

  • docs/upgrading.md told people to npm update @elyra/*. They are not npm packages — they are Vite aliases onto vendor/elyra/datagrid-js/dist/, so composer update is the whole upgrade. Introduced with the guide in 0.6.0.

  • scripts/publish-npm.sh preflight now checks the registry. It verified the tree, the tag, the versions and the tests, but not that publishing could work at all — so a non-resolving registry host would surface as ENOTFOUND partway through, with the earlier packages already published and their version numbers burned. Preflight now also requires that every package pins publishConfig.registry with access: restricted (without it, npm falls back to the default registry — with a public npmjs token present that means publishing proprietary code publicly), and that the host resolves and answers.

    Which immediately establishes that the configured host, npm.packagist.com, does not exist. The npm packages have therefore never been published, and .npmrc.dist — the template customers are given — points at the same dead host. Unresolved: the real URL has to come from the registry dashboard.

0.6.0 - 2026-08-23

Three features — pivot mode, queued exports and live updates — plus a fix for the Livewire client failing to render at all on Livewire 4, and a test harness that turned up 36 PostgreSQL-only 500s reachable from ordinary requests.

Read Changed and Removed before upgrading: four defaults tightened, and the dead rollup surface is gone. The upgrade guide has the checklist.

Added

  • Pivot (cross-tab) mode in all three clientsDataPivot for Livewire, Vue and Svelte, plus a headless loadPivot() in @elyra/datagrid-client-core and GridPivot in the server package for PHP callers. Dimensions drag onto Rows and Columns, measures into Values; row and column subtotals, margins and a grand total come with it.

    No protocol change: a pivot is a re-presentation of the existing group + aggregates, so it works on every driver and inherits the same allow-list — grouping still requires filterable and a measure still requires aggregatable, checked server-side however the layout was edited.

    Every subtotal and margin is its own aggregate query, never a sum of the visible cells. That is the difference that makes avg/median/percentile subtotals correct: for Nord = {10, 20, 5} split by status the subtotal is 35/3, which is neither the sum (20) nor the mean (10) of the two cells.

    Cost is rows + 2 requests, issued in parallel — but one query per grouping level, so 4 queries for a 1×1 pivot and 7 for a 2×1. Documented in the guide; GROUPING SETS would fold them into one but only two of the five drivers support it.

  • maxGroupRows (10 000). The grouping queries had no LIMIT at all — the one remaining unbounded read path, and the number of group rows scales with a column's distinct values, not with the page size. Each level is now capped. Because this bounds a data-dependent result rather than an abusive request it clamps instead of throwing, and the response carries a new groupsTruncated: true so a partial grouping is never presented as complete. Every level is capped with the same bound and ordered by the same leading columns, which keeps the group tree well-formed under truncation (no orphaned children); asserted against a live server, including the case where parent and child levels both truncate.

  • Live updates. ->versionColumn('updated_at') plus meta: ['version'] returns a cheap dataVersion; clients poll that and re-fetch only the visible window when it moves (liveUpdates in Vue/Svelte/headless, $liveUpdates on a Livewire grid). revalidate() is the same code path, so a Laravel Echo event can drive it and polling can stay off — the package ships no transport, since broadcasting means the application emitting events on its own writes.

    The stamp is COUNT(*) and MAX(versionColumn), not the maximum alone: MAX(updated_at) is unchanged by a DELETE, so a removed row would stay on screen. It is still a heuristic — a delete plus an insert between two polls landing on the same maximum reads as unchanged, and a write that does not touch the column is invisible. Documented as such rather than sold as correctness.

    A poll is one aggregate query and no rows (take: 0). It is not free on a large filtered table, so the guide says to index the version column alongside whatever the grid filters on. Polling pauses on a hidden tab and never fires while a row is being edited.

  • Request matrix (tests/request-matrix.php, npm run test:matrix). An exhaustive request set derived from a GridDefinition — every operator against every column type, sorting, one- and two-level grouping, every aggregate function, facets, search modes, paging edges, and values that do not match their column's type — run against every reachable dialect. Add a column and the coverage extends on the next run.

    The oracle is not "no 500": a GridException is a correct answer (the allow-list working), a QueryException is always a bug (invalid SQL). It also asserts metamorphic invariants — eq(v) and in([v]) must agree, isNull and isNotNull must partition the table, group counts must sum to the total — because SQL that runs but answers wrongly passes a crash-only check.

    It runs against SQLite by default and adds PostgreSQL whenever one is reachable, and says so when none is. SQLite alone is not enough: it accepts MySQL-style backticks and coerces LIKE on numeric columns, so dialect bugs and type mismatches both pass silently there. 432 cases, 133 of them correct refusals.

  • Queued exports. A streamed export holds an HTTP response open for its whole duration — the 1.67M-row benchmark takes ~52 s against a 60 s ALB/nginx idle timeout — and occupies a PHP-FPM worker throughout. GridExportManager::queue() puts it on a queue instead; the toolbar shows progress and then a signed download link, in all three clients (queuedExport on a Livewire grid, queueExportUrl in Vue/Svelte, queueExportData() headless).

    Because authorize() holds a closure, a job cannot carry its definition: it stores a NAME and rebuilds it, via Grid::define() or any class implementing Contracts\ProvidesGridDefinition (which the Livewire components now do, by their own FQCN).

    Queueing fixes transport, not the read. The exporter's bounds still do that, and they matter more here — a queue worker usually has a laxer memory_limit and no max_execution_time — so the same reader and the same limits are used.

    The genuinely new surface is the file at rest, which streaming never had. Four gates on download, because a signed link is a bearer capability and Laravel's signed URLs do not authenticate: the export must be finished, unexpired, owned by the caller, and still permitted by the grid's export policy re-evaluated at download time. Plus elyra-datagrid:prune-exports — schedule it, nothing else cleans up.

    Routes stay opt-in: Grid::exportRoutes() mounts them under your middleware, so the package remains route-free by default.

  • GridRequest::toArray() (and on every nested protocol DTO), the inverse of fromArray(). A queued export persists its request so the download can re-authorize with the same context, and a stable array shape survives a deploy where serialize() would not.

  • groupView: { skip, take } pages the outermost grouping level, with groupTotal in the response for building a pager. Every descendant of a selected outer group comes with it, so the group tree stays whole — the deeper levels get an IN restriction on the paged values rather than their own LIMIT/OFFSET, and a NULL outer group gets its own IS NULL arm because IN would silently drop all of its children.

    The pivot uses it for the row axis (rowSkip/rowTake, rowAxisTotal, rowPageSize in all three clients, defaulting to 50), so a pivot is no longer limited to maxGroupRows row groups. Paging is applied to the body and subtotal queries only — they all group by rows[0] first, so they select the same page and their cells line up — never to the column axis.

  • A test suite for the Livewire client. orchestra/testbench and phpunit were already dev dependencies and entirely unused, so the Livewire components had no automated coverage at all. Added a Testbench harness (in-memory SQLite, no external service) and 12 tests for DataPivot, including that a field the component never offered cannot be forced onto an axis and that an unoffered measure is refused. npm run test:livewire, wired into npm test and CI.

Changed

  • The LIKE escape character is !, not \. Only visible if you inspect generated SQL or implement a custom driver: likeEscapeClause() returns ESCAPE '!' and escapeLike() escapes with !. The SQLite and PostgreSQL overrides are gone — with no backslash there is nothing per-dialect to undo.
  • A LIKE-style filter on a non-string column is now refused. It used to work on MySQL and SQLite (silent coercion) and fail on PostgreSQL. If a grid relied on contains against a numeric or date column, declare that column as string, or switch to eq/between. Same for a boolean with an ordering operator.
  • view.take: 0 now returns no rows. It was clamped up to 1 by max(1, …), so an aggregates-only request — a footer, a pivot's grand total — always dragged a full-width single-row SELECT along with it. Pass take: 1 if you actually wanted a row.

Removed

  • GroupSpec.rollup and GroupRow.isGrandTotal, plus supportsRollup() and the $rollup parameter of groupBy() on the driver contract. The flag was documented as the way to get subtotals, plumbed from the clients through the protocol into the drivers, and never read by the engine — groupBy() was always called with false, and isGrandTotal was always false.

    Subtotals are unaffected and were never produced by ROLLUP: the engine runs one GROUP BY per level, so each level's aggregates are computed over that level's own rows. Docs across why.md, introduction.md, faq.md, installation.md, defining-grids.md and both client guides now describe that instead of a mechanism that was not running, and the driver capability table no longer lists Rollup as a per-driver feature — grouping is driver-independent.

Fixed

  • 36 PostgreSQL-only 500s from valid-looking filter requests, found by the new request matrix. Two classes, both invisible on MySQL and SQLite:

    Operator vs type. The clients only ever offered type-appropriate operators, but the server never checked, so a hand-built or tampered request could ask for contains on a numeric column. PostgreSQL answers operator does not exist: numeric ~~ text. FilterCompiler now holds the authoritative operator-by-type table: LIKE-style operators require a string column, and a boolean takes only equality and null tests. The schema-consistency check asserts the clients never offer more than the server allows — which immediately caught the filter menu offering contains on boolean columns.

    Multiple placeholders after a backslash. Found by CI's PHP matrix, not locally: the LIKE escape clause was ESCAPE '\', and PDO's own placeholder scanner on PHP 8.2/8.3 reads that \ as escaping the closing quote, loses track of the string, and raises Invalid parameter number for every placeholder after it. Two contains filters in one query — or a substring search across two columns — was enough to break. PHP 8.4+ has the fixed parser, which is why it passed locally. The escape character is now ! for every dialect with an ESCAPE clause; ClickHouse keeps backslash because it has no clause and escapes intrinsically.

    Value vs type. Nothing validated a bound value against its column's type, so "abc" reached a numeric column and "maybe" a boolean. This is also how the natural-language assistant could produce a 500: it coerces numeric strings but passes anything else through. Values that can be coerced now are ("20"20); values that cannot are refused as a GridException instead of being handed to the database.

  • The Livewire grid view did not compile under Livewire 4. data-grid.blade.php raised a Blade parse error for two releases (v0.4.0 and v0.5.0 both reproduce it), and nothing caught it because no test had ever rendered the component. Livewire 4's compiled-wire-keys feature injects a <?php ?> block into the opening tag of any looped element carrying wire:key; Blade's component-tag compiler cannot parse an opening tag containing PHP, so it left the tag as literal text while still compiling the closing tag, and the output was unbalanced. Eight wire:key attributes sat on <flux:*> tags. They are moved off the component tags — onto the enclosing <tr>, which already carried the key, or onto a plain wrapper where a key was genuinely needed. New DataGridRenderTest renders the grid with every feature on; re-adding a single wire:key to a component tag fails six of its assertions.

0.5.0 - 2026-08-23

A security and hardening release. Every finding below was reproduced before being fixed and is covered by a test; the PostgreSQL grouping fix and the export bounds were additionally verified against a live server.

Security

  • Livewire action injection via group values. Group paths are row data and were interpolated raw into wire:click="toggleGroup('…')". Blade's HTML escaping does not help: the entity is decoded before Livewire parses the expression, so a value containing ' (e.g. O'Brien) broke out of the string and could invoke other public component methods. Group paths now go through Js::from(), as row ids already did — and so does every other wire:click argument in the view, removing the whole class.
  • javascript: URLs in link cells. A link cell's href is resolved against the row, so a bare-token template (href: '{url}') let the column's own value choose the scheme and render a clickable XSS. Hrefs are now scheme-checked against an allow-list (http(s), mailto, tel, ftp, plus relative/fragment/protocol-relative); anything else falls back to #. The check tolerates whitespace and control-character obfuscation (java\tscript:) because browsers do. One implementation in @elyra/datagrid-client-core now serves all three clients.
  • Cost controls on the natural-language assistant. ELYRA_DATAGRID_ASSISTANT was never read — the config key existed, nothing consulted it, and the docs presented it as the way to enable the feature. So ask() was reachable on any grid page even with the assistant "off" (the UI was merely hidden), with no length bound and no rate limit on a billed LLM call. The flag is now a real server-side kill switch, questions are capped at assistant.max_query_length (500), and calls are metered per caller and grid via assistant.rate_limit (10/60s; the limiter fails closed). The Livewire ask() action re-checks the component's own $assistant opt-in.
  • Spreadsheet formula injection in CSV export. The export targets Excel (UTF-8 BOM + ;), which evaluates cells beginning =, +, -, @, tab or CR as formulas. Such values are now prefixed with an apostrophe so Excel reads them as text. Numbers are untouched; XLSX needs no equivalent (openspout writes inline strings, never formulas).
  • Mutation responses leaked undeclared columns. create/update returned the row via SELECT *, handing the client every column in the table — password hashes, tokens, internal flags. The response now selects only the grid's declared columns, aliased by protocol key, exactly like a read.
  • Facets and grouping bypassed filterable: false. Both emit a column's distinct values, which is what the flag withholds, but neither checked it — and a facet with no top had no LIMIT at all. Both now require filterable, and facet lists are bounded by the new maxFacetValues limit (1000), which is also the default when a request omits top.
  • Unbounded export paths. max_export_rows defaulted to null while the keyless export path buffered the entire result set in memory, contradicting its own docblock. A keyless grid cannot be paginated (no stable order), so export() now refuses without an explicit cap instead of risking an OOM, and applies the cap in SQL when set. Separately, the ElyraSQL key-window strategy walked the key's value range: with Snowflake/ULID-style ids (~1e18) it needed ~1e12 queries — an effective infinite loop. Window count is now bounded, and sparse keys fall back to keyset pagination (verified: 2 queries instead of ~1.2e12).
  • maxPasteRows was enforced in the wrong layer. The cap lived only in the Livewire client, so any other caller of mutateBatch() — a host app's HTTP endpoint, which is what the JS clients' paste hits — could hold a transaction open over an unbounded number of writes. It now applies in mutateBatch() itself, the choke point every caller passes through.
  • Publishing preflight. scripts/publish-npm.sh shipped whatever was on disk (the packages publish raw src/), with no clean-tree check, no tag match and no test run. It now requires a clean tree, no untracked files inside the published packages, HEAD on the matching release tag, versions in lockstep, and a green suite. .npmrc is gitignored so release.sh's git add -A can never pick up a registry token.

Changed

Four fixes tighten defaults and can reject requests that previously succeeded.

  • BREAKING — the natural-language assistant is off until you enable it. ELYRA_DATAGRID_ASSISTANT was dead code, so a component with public bool $assistant = true; worked with no env var set. The flag is now enforced server-side: set ELYRA_DATAGRID_ASSISTANT=true (or datagrid.assistant.enabled) to keep an existing assistant working. Questions are also capped at 500 characters and rate-limited to 10 per minute per caller; tune via datagrid.assistant.max_query_length and datagrid.assistant.rate_limit.
  • BREAKING — exporting a keyless grid now throws unless a row cap is set. Such a grid cannot be paginated, so the export buffered the whole table in memory. Add ->key(...) to the definition, or set datagrid.limits.max_export_rows (ELYRA_DATAGRID_MAX_EXPORT_ROWS).
  • Facets and grouping now refuse filterable: false columns, which they previously allowed. If a grid faceted or grouped such a column deliberately, declare it filterable: true.
  • Mutation responses now carry only the grid's declared columns. A client that read anything else out of a create/update response will no longer find it — declare the column if it is genuinely needed.
  • Requests exceeding the new breadth limits throw rather than being executed. maxGroupLevels (5) is the one most likely to be hit by an existing grid.

Added

  • Request breadth limits. maxFilterDepth bounded nesting but nothing bounded width, so a shallow request with thousands of conditions, dozens of group levels (one query each) or an unbounded search term was a DoS lever on any grid with anonymous read access. New limits: maxFilterConditions (200), maxGroupLevels (5), maxAggregates (25), maxFacets (25) and maxSearchLength (200), all configurable under datagrid.limits.
  • Engine-level PostgreSQL integration suite (tests/postgres-engine.php, npm run test:postgres-engine). The existing live suite only loads the drivers and compilers, so the grouping bug below was structurally out of its reach; this one drives the real GridEngine/GridDefinition through Laravel's database layer and asserts grouping, aggregates, facets, column flags, breadth limits, mutation responses and export row production against a real server.
  • Accessibility. Menu items in the Vue and Svelte clients are real <button> elements instead of click-handling <div>s. The row editor and filter builder are now role="dialog" aria-modal="true", move focus inside on open, contain Tab/Shift+Tab, close on Escape and return focus to the opener. Span-rendered toggles (select-all, select-row, sort header, group collapse) gained tabindex, accessible names and Enter/Space activation in all three clients — in Vue they previously had role="button" but no tabindex, so they were not even focusable. New e2e/tests/a11y.spec.ts asserts the focus trap and key activation in Chromium; svelte-check a11y warnings went 33 → 0.
  • New selectAll / selectRow messages (and select_all / select_row translations) for the checkbox accessible names.
  • Upgrade guide, starting with the four 0.5.0 changes that can reject requests which previously succeeded.

Fixed

  • Grouping was broken on PostgreSQL. GridEngine hardcoded a backtick-quoted COUNT(*) AS \__count`` while quoting every other identifier through the driver, so grouping — a headline feature — raised a syntax error on a supported database. Now dialect-correct. (The live PostgreSQL suite never loaded GridEngine, which is why this survived; verified end-to-end against a real server, single- and multi-level.)
  • Paste and copy hit the wrong rows when rows were pinned. Each client sorted its own copy of data.rows for display while the core resolved activeCell.r against the unsorted array, so with anything pinned, row r on screen and row r in the operation were different records — a silent wrong-row UPDATE, not just a wrong copy. Row order now has a single definition (orderRows() in the core, displayRows() in the Livewire component) used by rendering and by every index-based operation in all three clients.

Internal

  • CI: npm ci instead of npm install; the bundle-drift check stages the rebuild before diffing so a new dist file is no longer invisible to it (git diff ignores untracked paths); the PostgreSQL job installs composer deps and sets DG_PG_REQUIRED=1, so an unreachable server fails the job instead of exiting 0 with "SKIP" and reporting green.
  • Removed the redundant role="row" from <tr> in all three clients (<tr> carries that role implicitly, including inside role="grid").
  • The server and Livewire packages pin config.platform.php to 8.2.0, so the committed lock files install across the whole supported PHP range. They had been resolved against a newer local PHP, which made composer install fail on the 8.2 and 8.3 CI jobs.

0.4.0 - 2026-07-24

Added

  • PostgreSQL driver (->driver('pgsql')). Double-quoted identifiers, GROUP BY ROLLUP(…) subtotals, native percentiles (percentile_cont) and median, and real full-text search (to_tsvector @@ plainto_tsquery) via search: { mode: 'fulltext' }. Hybrid/vector search stays ElyraSQL-only. Covered by SQL-shape tests plus a live integration test that runs the generated SQL against a real PostgreSQL server (CI service + npm run test:postgres).
  • Configurable tree fan-out. TreeOptions.childLimit (core) and $tree['childLimit'] (Livewire), default 1000, replace the hardcoded per-node fetch limit.

Changed

  • BREAKING — writes/exports are secure-by-default. mutate(), mutateBatch() and export() now throw unless the grid configures either ->authorize(…) or ->withoutAuthorization(). Previously an unconfigured grid allowed them. Add one of the two to existing grids that perform writes/exports.
  • pasteCells() is transactional. Clipboard paste now persists via a single mutateBatch() (rolls back on failure) and is capped at GridLimits::maxPasteRows (default 500, datagrid.limits.max_paste_rows).

Fixed

  • XLSX export against openspout v4. The styled export used the v3 with*() Style API (removed in v4) and would fatal; switched to the v4 set*() API.

Internal

  • PHPStan (server-laravel level 8, client-livewire level 5) wired into CI/npm test.
  • ESLint + Prettier + root tsconfig; TypeScript 5.9, vue-tsc 3, svelte 5.56.7.
  • DataGrid Livewire component split into 8 Concerns traits (1446 → 616 lines).
  • End-to-end tests (Playwright) driving the real client-core engine in Chromium (npm run test:e2e), plus a CI job. Fixture bundled on the fly with esbuild.

0.3.1 - 2026-07-22

Fixed

  • destroy() now tears down cleanly. It previously only invalidated in-flight fetches; it now also cancels the pending debounce and persist timers and drops the store subscription, and refresh() bails if the grid was destroyed — so a debounced fetch can no longer fire after a component unmounts mid-typing.

Changed

  • Grouped headers now work with column virtualization. Previously the header band was disabled when columnVirtual was on. A new headerBandRowVirtual bands each render region (left-frozen / window / right-frozen) and stitches them with spacer cells, so multi-column headers stay aligned with the windowed body in the Vue and Svelte clients.

0.3.0 - 2026-07-22

Added

  • Tree grid in the Livewire client. Self-referencing parent/child data with lazy children, matching the Vue/Svelte clients: set public bool|array $tree = ['parentField' => …, 'rootValue' => null, 'hasChildrenField' => null]. The server component flattens the visible tree (one query per expanded node) and the first column renders depth indentation + an expand chevron (toggleTreeNode). Verified end-to-end.
  • Bulk mutations. Batch saves and clipboard pastes can now persist in ONE request instead of N sequential HTTP calls. New optional editing.mutateBatch(mutations) on the client and GridDefinition::mutateBatch() (+ GridBatchMutation DTO) on the server, run in a transaction (unauthorized/thrown rolls back; per-row validation failures return ok:false). Falls back to sequential mutate when not provided. Verified end-to-end (mixed update/create/delete in one call; rollback on deny).

Fixed

  • Export scalability. Exports now use standard keyset pagination (WHERE key > ? ORDER BY key LIMIT n) by default — correct for numeric and string/UUID keys, skips gaps (no empty queries), and bounded memory per batch. This fixes the previous single-unpaginated read on non-numeric keys (OOM risk) and the wasted empty queries on sparse numeric keys. ElyraSQL keeps its double-bounded key windows (via exportsByKeyWindow()), since it can't stream an unbuffered cursor. Verified end-to-end exporting a string-keyed table.
  • Unescaped wildcards in global search. AbstractDriver::substringPredicate now escapes %/_ (via escapeLike() + the dialect ESCAPE clause), so a search for "50%" matches literally instead of forcing an all-rows scan — matching the FilterCompiler behaviour. Escaping is now shared on the driver.
  • Clipboard data corruption. copySelection() now RFC-4180-quotes cells containing a tab, newline or quote, and pasteAt() parses quoted TSV, so values with tabs/newlines no longer shift columns in Excel/Sheets or on paste.
  • Virtual-scroll request storm. The window still updates immediately while scrolling, but the data fetch is now debounced, so fast scrolling settles into a single request instead of firing one per scroll event.
  • Stale persisted state. Restored localStorage sort/filter/group/column- filters are now validated against the current columns — entries referencing a renamed or removed field are dropped, instead of sending an unknown field to the server after a code change. (Column widths/order/visibility were already keyed by current field.)

Added

  • ClickHouse backend. A new ClickHouse-dialect driver (->driver('clickhouse')) for analytical/OLAP tables. Maps percentiles to quantile(p)(col), median to median(col), distinct counts to uniqExact, and subtotals to GROUP BY … WITH ROLLUP; LIKE uses ClickHouse's intrinsic backslash escaping (no ESCAPE clause). Facets run one GROUP BY per field; HYBRID/vector search remains ElyraSQL-only.
  • SQLite / Elyra SQL Anywhere backend. A new SQLite-dialect driver (->driver('sqlite'), also aliased 'sqlanywhere' since SQL Anywhere is a SQLite-compatible engine). Point ->connection() at a Laravel sqlite connection (embedded file/replica) or any SQLite PDO. Sorting, filtering, paging, grouping subtotals, additive aggregates, editing and streamed export all work; facets fall back to one GROUP BY per field, while WITH ROLLUP, PERCENTILE, HYBRID/full-text remain ElyraSQL-only.
  • Dialect-aware LIKE escaping: the ESCAPE clause is now provided by the driver (likeEscapeClause()), because SQLite string literals don't process backslashes (ESCAPE '\') while MySQL/ElyraSQL do (ESCAPE '\\'). Verified against real SQLite that contains "50%" matches literally.

Changed

  • The generic one-GROUP BY-per-field facet implementation moved to AbstractDriver (shared by the MySQL and SQLite drivers); ElyraSQL keeps its single-pass FACET() override.

0.2.1 - 2026-07-21

Added

  • Natural-language filtering in the Vue and Svelte clients. Pass an ask function in the grid options (typically POSTing to an endpoint that calls GridDefinition::ask()) and both clients render an "ask" box in the toolbar. Submitting calls the new grid.askFilter() on the client core, which applies the returned filter and refetches, tracking asking / askError state. Omit ask to hide the box. New Messages keys askPlaceholder / ask / askFailed. Verified end-to-end in the Vue starter.

Fixed

  • Inter-package version constraints. Sibling packages required each other at ^0.1.0, which excludes 0.2.x and broke composer require/update on the 0.2 line. They now use >=0.1.0 <1.0.0, so the lock-stepped 0.x packages always resolve together across minor bumps.

0.2.0 - 2026-07-21

Added

  • Natural-language filtering ("ask the grid"). Users can type a question ("revenue over 50k in region Nord last quarter") and the grid translates it, server-side, into a FilterGroup via an LLM whose JSON-schema output is restricted to the grid's own declared columns and operators.
    • Safe by design: the result is re-validated against the allow-list (unknown fields/operators dropped) and compiled through the ordinary FilterCompiler (columns allow-listed, values bound) — it can only produce filters the grid already permits. No row data is sent to the model, only column metadata.
    • GridDefinition::ask(string $query, ?callable $engine = null): FilterGroup. The LLM call sits behind an injectable engine, so the validation/assembly is fully unit-tested without an API key (19 assertions).
    • Optional dependency on laravel/ai (a suggest); with no provider configured, ask() degrades gracefully.
    • Livewire client: opt-in public bool $assistant = true; renders an "ask" box in the toolbar that applies the translated filter to the builder.
    • Config: datagrid.assistant. Docs: guides/natural-language-filtering.md.
    • Verified end-to-end against Anthropic: correct AND/nested-OR trees.

Fixed

  • Protocol autoloading. The DTOs that share GridRequest.php / GridMutation.php (FilterCondition, FilterGroup, Viewport, Search, …) were only reachable once GridRequest/GridMutation had been referenced (PSR-4 maps class name to file name). Added a classmap autoload so every protocol class resolves on its own.

Tooling

  • New test:assistant suite and a schema-consistency check that the assistant's operator allow-list matches the protocol FilterOp enum. Both run in CI.

0.1.5 - 2026-07-21

Second hardening pass from the code review: Svelte parity, server guard rails, race protection on auxiliary fetches, and dependency/process fixes.

Fixed

  • Svelte client leaked on unmountDataGrid.svelte now calls GridController.destroy() via onDestroy (Vue already cleaned up via onScopeDispose).
  • Livewire row-id comparisons were loose (in_array($id, …, false)), so int-from-DB vs string-from-JS ids could mismatch. Selection/expanded/pinned membership now compares on the string form (isSelected/idIn/idIndex).
  • perPage <= 0 raised DivisionByZeroError — now clamped to ≥ 1 in updatedPerPage, pageCount, and the request builder.

Security

  • Request guard rails via GridLimits (config('datagrid.limits') or GridDefinition::limits()): max_take (1000), max_skip (100k, deep OFFSET was unbounded), max_filter_depth (15, filter recursion was unbounded), max_in_values (1000, IN lists were unbounded), max_export_rows (null = unlimited).
  • aggregatable is now enforced — aggregates on non-aggregatable columns are rejected (the flag was declared but never checked).
  • LIKE wildcards escaped%/_ in contains/startsWith/endsWith input are escaped with an ESCAPE clause, so "50%" matches literally.

Added

  • Column virtualization in the Svelte client (was missing entirely): renders only frozen columns plus the horizontal window, with spacer cells and onScrollX, matching the Vue client.
  • Race protection extended to auxiliary fetches (facets, tree roots, tree children, group detail): each discards its result if a superseding refresh changed the filter/search/sort mid-flight.

Dependencies / tooling

  • elyra/datagrid-server now requires illuminate/validation (the Validator facade was used but not declared).
  • elyra/datagrid-livewire now requires livewire/flux (the Blade uses flux:* components).
  • package-lock.json is now committed (was git-ignored) for reproducible CI.
  • New scripts/release.sh (npm run release <version>) automates the version bump, lockfile, bundle rebuild, test run, commit and tag.

0.1.4 - 2026-07-21

Hardening release following a full code review. Notably repairs the Svelte client, which was broken in 0.1.3.

Fixed

  • Svelte client was broken in 0.1.3. DataGrid.svelte referenced grid.features in the temporal dead zone (before its declaration), throwing ReferenceError on mount; the keyboard handler shadowed the GridController with a local boolean, throwing TypeError on Space/Enter/F2/Escape; and FilterBuilder.svelte imported FilterCondition/FilterOp, which @elyra/datagrid-client-core never re-exported. All fixed, and the shipped datagrid-js bundle rebuilt.

Security

  • Create validation bypass. On create, every editable column's rules are now applied, so required fires for omitted fields (previously only submitted fields were validated). Updates remain partial.
  • Hybrid-search vectorField allow-list. The client-supplied embedding column was quoted into SQL without an allow-list check; it is now resolved through the grid definition (rejected if undeclared) and passed as a driver-quoted reference.
  • XSS hardening. Row ids embedded into JS-evaluated attributes (Alpine dblclick, Livewire wire:click) are encoded with Illuminate\Support\Js, so string primary keys containing quotes or markup cannot break out.
  • Authorization gate. New GridDefinition::authorize() guards create/update/delete/export; denials throw GridException::unauthorized().

Added

  • debounceMs grid option (default 250): free-text search and column-filter values now debounce instead of firing a request per keystroke. Explicit actions (sort/paging/operator changes) are never debounced and cancel a pending typed refetch. (Response race protection already existed.)
  • isActiveRow(r) and FilterCondition/FilterOp re-exports on client-core.

Changed / Tooling

  • CI now typechecks the Vue and Svelte clients (vue-tsc + svelte-check), runs a PHP compiler/allow-list/authorization test suite (packages/server-laravel/tests/compilers.php), a protocol schema-consistency check (schema ↔ TS ↔ PHP), and a JS-bundle drift gate that fails if datagrid-js/dist is out of sync with the client sources.
  • Protocol docs now describe the contract honestly (hand-mirrored + checked in CI, not code-generated).

0.1.3 - 2026-07-21

Added

  • Double-click a row to edit. Double-clicking anywhere on a row now enters edit mode across all three clients (Livewire, Vue, Svelte). In in-cell mode it opens the cell you double-clicked when that cell is editable, otherwise the first editable cell of the row; in modal/slideover/inline modes it opens the row editor.
  • Tooltips on row command icons. The edit, pin/unpin, and delete icons now show hover tooltips. New localization keys pinRow, unpinRow, and deleteRow (English and Norwegian bundled).
  • isActiveRow(r) on the client-core grid controller.

Changed

  • Active-cell marker removed in favour of an active-row highlight. The boxed active-cell outline (and its heavy 2px focus ring) felt too dominant, especially with a dark accent colour in light mode. The active position is now conveyed by tinting the whole active row a quiet grey (.dg-row-active). The multi-cell range highlight used for copy is retained.
  • The aggregate footer can now stick to the bottom of a fixed-height scroll viewport (.dg-vscroll tfoot td). For virtual scroll, paged mode remains the reliable way to keep the footer always visible.

Fixed

  • In-cell editor never opened on double-click. editCell receives the row id as a string from the client ($wire.editCell('0', …)) while the row id is an integer, so the strict === comparison in the Livewire view never matched and the editor silently failed to render (no console error). Both sides are now compared as strings. Verified end-to-end with real dblclick events.
  • Row double-click now dispatches through a dedicated Alpine method (editRowCell$wire.editCell) instead of a fragile inline expression, and reads the double-clicked cell via data-editable.

0.1.2 - 2026-07-21

Fixed

  • Replaced inline @php(...) in shipped Blade with block @php … @endphp, which caused a fatal parse error on Laravel 13 + Livewire (including the license notice partial). Never use inline @php() in shipped Blade.

0.1.1 - 2026-07-21

Fixed

  • Additional Blade parsing fixes in the filter-builder partials on Laravel 13 + Livewire.

0.1.0 - 2026-07-21

Added

  • Initial release. One JSON protocol powering three clients (Livewire + Flux, Vue + Inertia, Svelte + Inertia) on a PHP server package with first-class ElyraSQL support and a MySQL fallback driver.
  • Columns, sorting (index-friendly stable tiebreaker), column filter row with operator picker, advanced AND/OR filter builder, global search.
  • Grouping with per-level aggregates (including non-additive avg/median/ percentile/countDistinct), detail rows, and roll-ups.
  • Paging with a numbered pager and page-size selector; opt-in totals, facets and aggregates cached per filter.
  • Editing in inline, modal, slideover and in-cell modes, plus batch/in-cell editing with dirty tracking.
  • Row selection, a full keyboard model, master-detail and self-referencing tree grids, left/right frozen columns with auto-fit, and virtual scroll.
  • Streaming XLSX/CSV export via bounded key-range windows.
  • Themeable via --dg-* variables that inherit the host app palette and Flux accent, with light/dark support.
  • Offline RSA-2048 license verification with graceful degradation and a discreet unlicensed notice. Commercial distribution via Private Packagist.