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.shand.npmrc.distare deleted, the fivepublishConfigblocks are gone, and the JS packages are marked"private": truesonpm publishrefuses 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 theelyra/datagrid-jsComposer package, which is the documented and working path.The
package.jsonfiles stay — they are npm workspace manifests, and the symlinks innode_modules/@elyra/are what make@elyra/*imports resolve for local development, typechecking and tests."private": trueis a stronger guard than the oldpublishConfigwas: that only redirected the registry, so dropping it would have madenpm publishfall back to public npmjs.org.
Internal
- Release policy in
docs/distribution.md: only tag when something underpackages/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-runruns 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.shrequires a reachable PostgreSQL, and refuses before touching anything if there is none. SQLite accepts MySQL-style backticks and coercesLIKEon 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:postgresandtest:postgres-engine, which are absent fromnpm testbecause they need a live server — but a release is exactly when they should run.ELYRA_RELEASE_WITHOUT_POSTGRES=1overrides, loudly. -
release.shrestores 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-rundoes the same on success, so it really leaves nothing behind.(The doc-link check itself was already covered:
lint:docsis the third step ofnpm 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#fragmentmatches a real heading slug, following GitHub's slug rules including-1suffixes 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.comdoes not resolve, with a subscription active, whilepackagist.comand unrelatednpm.*hosts do. So nothing has ever been published there and nothing can be.publish-npm.shnow refuses up front with that explanation rather than letting a DNS error read like an outage;ELYRA_NPM_PROVISIONED=1lifts it once a real registry is in place..npmrc.distcarries the same notice — it is the template customers are given, and it would have failed for them.docs/distribution.mdhas the provisioning checklist.Nothing is lost: the JavaScript clients ship inside the
elyra/datagrid-jsComposer package, which is the documented and working path. -
docs/upgrading.mdtold people tonpm update @elyra/*. They are not npm packages — they are Vite aliases ontovendor/elyra/datagrid-js/dist/, socomposer updateis the whole upgrade. Introduced with the guide in 0.6.0. -
scripts/publish-npm.shpreflight 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 asENOTFOUNDpartway through, with the earlier packages already published and their version numbers burned. Preflight now also requires that every package pinspublishConfig.registrywithaccess: 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 clients —
DataPivotfor Livewire, Vue and Svelte, plus a headlessloadPivot()in@elyra/datagrid-client-coreandGridPivotin 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 requiresfilterableand a measure still requiresaggregatable, 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/percentilesubtotals correct: forNord = {10, 20, 5}split by status the subtotal is35/3, which is neither the sum (20) nor the mean (10) of the two cells.Cost is
rows + 2requests, 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 SETSwould fold them into one but only two of the five drivers support it. -
maxGroupRows(10 000). The grouping queries had noLIMITat 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 newgroupsTruncated: trueso 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')plusmeta: ['version']returns a cheapdataVersion; clients poll that and re-fetch only the visible window when it moves (liveUpdatesin Vue/Svelte/headless,$liveUpdateson 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(*)andMAX(versionColumn), not the maximum alone:MAX(updated_at)is unchanged by aDELETE, 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 aGridDefinition— 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
GridExceptionis a correct answer (the allow-list working), aQueryExceptionis always a bug (invalid SQL). It also asserts metamorphic invariants —eq(v)andin([v])must agree,isNullandisNotNullmust 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
LIKEon 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 (queuedExporton a Livewire grid,queueExportUrlin Vue/Svelte,queueExportData()headless).Because
authorize()holds a closure, a job cannot carry its definition: it stores a NAME and rebuilds it, viaGrid::define()or any class implementingContracts\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_limitand nomax_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 offromArray(). A queued export persists its request so the download can re-authorize with the same context, and a stable array shape survives a deploy whereserialize()would not. -
groupView: { skip, take }pages the outermost grouping level, withgroupTotalin 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 anINrestriction on the paged values rather than their ownLIMIT/OFFSET, and aNULLouter group gets its ownIS NULLarm becauseINwould silently drop all of its children.The pivot uses it for the row axis (
rowSkip/rowTake,rowAxisTotal,rowPageSizein all three clients, defaulting to 50), so a pivot is no longer limited tomaxGroupRowsrow groups. Paging is applied to the body and subtotal queries only — they all group byrows[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/testbenchandphpunitwere 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 forDataPivot, 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 intonpm testand CI.
Changed
- The LIKE escape character is
!, not\. Only visible if you inspect generated SQL or implement a custom driver:likeEscapeClause()returnsESCAPE '!'andescapeLike()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
containsagainst a numeric or date column, declare that column asstring, or switch toeq/between. Same for a boolean with an ordering operator. view.take: 0now returns no rows. It was clamped up to 1 bymax(1, …), so an aggregates-only request — a footer, a pivot's grand total — always dragged a full-width single-rowSELECTalong with it. Passtake: 1if you actually wanted a row.
Removed
-
GroupSpec.rollupandGroupRow.isGrandTotal, plussupportsRollup()and the$rollupparameter ofgroupBy()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 withfalse, andisGrandTotalwas alwaysfalse.Subtotals are unaffected and were never produced by
ROLLUP: the engine runs oneGROUP BYper level, so each level's aggregates are computed over that level's own rows. Docs acrosswhy.md,introduction.md,faq.md,installation.md,defining-grids.mdand 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
containson a numeric column. PostgreSQL answersoperator does not exist: numeric ~~ text.FilterCompilernow 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 offeringcontainson 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 raisesInvalid parameter numberfor every placeholder after it. Twocontainsfilters 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 anESCAPEclause; 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 aGridExceptioninstead of being handed to the database. -
The Livewire grid view did not compile under Livewire 4.
data-grid.blade.phpraised 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 carryingwire: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. Eightwire:keyattributes 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. NewDataGridRenderTestrenders the grid with every feature on; re-adding a singlewire:keyto 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 throughJs::from(), as row ids already did — and so does every otherwire:clickargument in the view, removing the whole class. javascript:URLs in link cells. Alinkcell'shrefis 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-corenow serves all three clients.- Cost controls on the natural-language assistant.
ELYRA_DATAGRID_ASSISTANTwas never read — the config key existed, nothing consulted it, and the docs presented it as the way to enable the feature. Soask()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 atassistant.max_query_length(500), and calls are metered per caller and grid viaassistant.rate_limit(10/60s; the limiter fails closed). The Livewireask()action re-checks the component's own$assistantopt-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/updatereturned the row viaSELECT *, 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 notophad noLIMITat all. Both now requirefilterable, and facet lists are bounded by the newmaxFacetValueslimit (1000), which is also the default when a request omitstop. - Unbounded export paths.
max_export_rowsdefaulted 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), soexport()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). maxPasteRowswas enforced in the wrong layer. The cap lived only in the Livewire client, so any other caller ofmutateBatch()— 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 inmutateBatch()itself, the choke point every caller passes through.- Publishing preflight.
scripts/publish-npm.shshipped whatever was on disk (the packages publish rawsrc/), 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..npmrcis gitignored sorelease.sh'sgit add -Acan 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_ASSISTANTwas dead code, so a component withpublic bool $assistant = true;worked with no env var set. The flag is now enforced server-side: setELYRA_DATAGRID_ASSISTANT=true(ordatagrid.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 viadatagrid.assistant.max_query_lengthanddatagrid.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 setdatagrid.limits.max_export_rows(ELYRA_DATAGRID_MAX_EXPORT_ROWS). - Facets and grouping now refuse
filterable: falsecolumns, which they previously allowed. If a grid faceted or grouped such a column deliberately, declare itfilterable: true. - Mutation responses now carry only the grid's declared columns. A client
that read anything else out of a
create/updateresponse 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.
maxFilterDepthbounded 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) andmaxSearchLength(200), all configurable underdatagrid.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 realGridEngine/GridDefinitionthrough 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 nowrole="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) gainedtabindex, accessible names and Enter/Space activation in all three clients — in Vue they previously hadrole="button"but notabindex, so they were not even focusable. Newe2e/tests/a11y.spec.tsasserts the focus trap and key activation in Chromium;svelte-checka11y warnings went 33 → 0. - New
selectAll/selectRowmessages (andselect_all/select_rowtranslations) 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.
GridEnginehardcoded a backtick-quotedCOUNT(*) 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 loadedGridEngine, 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.rowsfor display while the core resolvedactiveCell.ragainst the unsorted array, so with anything pinned, row r on screen and row r in the operation were different records — a silent wrong-rowUPDATE, 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 ciinstead ofnpm install; the bundle-drift check stages the rebuild before diffing so a new dist file is no longer invisible to it (git diffignores untracked paths); the PostgreSQL job installs composer deps and setsDG_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 insiderole="grid"). - The server and Livewire packages pin
config.platform.phpto8.2.0, so the committed lock files install across the whole supported PHP range. They had been resolved against a newer local PHP, which madecomposer installfail 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) viasearch: { 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()andexport()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 singlemutateBatch()(rolls back on failure) and is capped atGridLimits::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 v4set*()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. DataGridLivewire component split into 8Concernstraits (1446 → 616 lines).- End-to-end tests (Playwright) driving the real
client-coreengine 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, andrefresh()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
columnVirtualwas on. A newheaderBandRowVirtualbands 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 andGridDefinition::mutateBatch()(+GridBatchMutationDTO) on the server, run in a transaction (unauthorized/thrown rolls back; per-row validation failures returnok:false). Falls back to sequentialmutatewhen 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 (viaexportsByKeyWindow()), since it can't stream an unbuffered cursor. Verified end-to-end exporting a string-keyed table. - Unescaped wildcards in global search.
AbstractDriver::substringPredicatenow escapes%/_(viaescapeLike()+ the dialectESCAPEclause), 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, andpasteAt()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
localStoragesort/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 toquantile(p)(col), median tomedian(col), distinct counts touniqExact, and subtotals toGROUP BY … WITH ROLLUP;LIKEuses ClickHouse's intrinsic backslash escaping (noESCAPEclause). 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 Laravelsqliteconnection (embedded file/replica) or any SQLite PDO. Sorting, filtering, paging, grouping subtotals, additive aggregates, editing and streamed export all work; facets fall back to oneGROUP BYper field, whileWITH ROLLUP,PERCENTILE,HYBRID/full-text remain ElyraSQL-only. - Dialect-aware
LIKEescaping: theESCAPEclause 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 thatcontains "50%"matches literally.
Changed
- The generic one-
GROUP BY-per-field facet implementation moved toAbstractDriver(shared by the MySQL and SQLite drivers); ElyraSQL keeps its single-passFACET()override.
0.2.1 - 2026-07-21
Added
- Natural-language filtering in the Vue and Svelte clients. Pass an
askfunction in the grid options (typically POSTing to an endpoint that callsGridDefinition::ask()) and both clients render an "ask" box in the toolbar. Submitting calls the newgrid.askFilter()on the client core, which applies the returned filter and refetches, trackingasking/askErrorstate. Omitaskto hide the box. NewMessageskeysaskPlaceholder/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 excludes0.2.xand brokecomposer require/updateon 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
FilterGroupvia 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(asuggest); 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-ORtrees.
Fixed
- Protocol autoloading. The DTOs that share
GridRequest.php/GridMutation.php(FilterCondition, FilterGroup, Viewport, Search, …) were only reachable onceGridRequest/GridMutationhad been referenced (PSR-4 maps class name to file name). Added aclassmapautoload so every protocol class resolves on its own.
Tooling
- New
test:assistantsuite and a schema-consistency check that the assistant's operator allow-list matches the protocolFilterOpenum. 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 unmount —
DataGrid.sveltenow callsGridController.destroy()viaonDestroy(Vue already cleaned up viaonScopeDispose). - 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 <= 0raisedDivisionByZeroError— now clamped to ≥ 1 inupdatedPerPage,pageCount, and the request builder.
Security
- Request guard rails via
GridLimits(config('datagrid.limits')orGridDefinition::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). aggregatableis now enforced — aggregates on non-aggregatable columns are rejected (the flag was declared but never checked).- LIKE wildcards escaped —
%/_incontains/startsWith/endsWithinput are escaped with anESCAPEclause, 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-servernow requiresilluminate/validation(theValidatorfacade was used but not declared).elyra/datagrid-livewirenow requireslivewire/flux(the Blade usesflux:*components).package-lock.jsonis 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.sveltereferencedgrid.featuresin the temporal dead zone (before its declaration), throwingReferenceErroron mount; the keyboard handler shadowed theGridControllerwith a local boolean, throwingTypeErroron Space/Enter/F2/Escape; andFilterBuilder.svelteimportedFilterCondition/FilterOp, which@elyra/datagrid-client-corenever re-exported. All fixed, and the shippeddatagrid-jsbundle rebuilt.
Security
- Create validation bypass. On create, every editable column's rules are now
applied, so
requiredfires for omitted fields (previously only submitted fields were validated). Updates remain partial. - Hybrid-search
vectorFieldallow-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, Livewirewire:click) are encoded withIlluminate\Support\Js, so string primary keys containing quotes or markup cannot break out. - Authorization gate. New
GridDefinition::authorize()guards create/update/delete/export; denials throwGridException::unauthorized().
Added
debounceMsgrid 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)andFilterCondition/FilterOpre-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 ifdatagrid-js/distis 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, anddeleteRow(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
2pxfocus 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.
editCellreceives 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 realdblclickevents. - 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 viadata-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.