Repository navigation
feat(providers): Elasticsearch and OpenSearch, two type-ids over one SQL transport (#424) - #429
Conversation
…SQL transport (#424) Phase 1 of #424, the largest gap on the coverage map. One implementation in `providers/sql/search/` serves both type-ids; everything the two products disagree about is data in a dialect table, never a branch on the type-id. **The query surface was decided by measurement, not by the docs.** Both products expose an SQL endpoint with no licence gate - `_sql?format=json` on Elasticsearch 9.1.4 basic, `_plugins/_sql` on OpenSearch 3.8.0. ES|QL also works, and exists only on Elasticsearch (OpenSearch answers 405), so it cannot be the shared language. Choosing SQL puts the provider on `SQLBaseProvider`, which is what makes the explorer, ER diagrams, schema diff, the query limiter and agent plan mode work with no per-provider cost. The schema still comes from `_mapping`, never from SQL: `SELECT *` describes the statement, and an index mapping `flattened`/`nested` answers it with zero columns. Three defects the live probes found, all of which mocks would have missed: - **A truncated result presented as complete.** An Elasticsearch aggregation is paged by the engine even with no `fetch_size`: a `GROUP BY` over 1500 distinct values answers 1000 rows plus a cursor. Dropping that cursor returned two thirds of the buckets and called it the answer. `query()` now follows the cursor (page two carries rows and no column declaration), and refuses loudly at a bound rather than truncating silently. - **A wrong column label.** `SELECT customer AS who` declares `name: "who"` on Elasticsearch and `name: "customer", alias: "who"` on OpenSearch, so the grid labelled the same statement's column differently per product. - **Silently wrong rows from a generated query.** `query-generators.ts` derives its dialect from `defaultPort`, which held only while every engine had a distinct one. Both products are 9200 and they disagree about the quote character: on OpenSearch a double-quoted identifier is a STRING LITERAL, so `WHERE "customer" = 'acme'` answers 200 with `total: 0` while backticks return the row. Fixed through a declared capability (`identifierQuoting`), not a type-id check - absent means "keep deriving it from the port", so no existing provider changed. Two places where the honest answer was to offer LESS. Multi-fields are dropped from the column list on both products because OpenSearch cannot select `note.keyword` in any spelling and dynamic mapping creates one for every text field - keeping them only on Elasticsearch would make the dialect decide a behaviour, which this provider's own rule forbids. And `supportsExplain` is false: neither SQL endpoint returns a plan tree, so an Explain tab would render for a plan nothing can parse. Also updated: `docs/BRAND_MESSAGING.md`, whose standing rule said no such provider exists and forbade mentioning either product - a claim this work falsifies, and one nothing would have flagged.
The login showcase that #428 added ranks every engine through an exhaustive `Record<DatabaseType, number>`, so the two type-ids this branch adds broke typecheck on the missing keys - the compile-time guard doing exactly its job. Elasticsearch and OpenSearch take ranks 7 and 8, ahead of the analytical stores: Elasticsearch is one of the better-known names on that page and OpenSearch reads as its sibling, which is what the code says too - one HTTP SQL transport behind both. The test-side exhaustive lists in db-ui-config.test.ts move with them.
Both engines were driven in Chrome against real clusters - Elasticsearch 9.1.4 and
OpenSearch 3.8.0, seeded with a microservice-shaped dataset (orders with an object
mapping and a multi-field, 1500-document app_logs, Turkish names throughout). Every
finding below is a browser measurement, and each one is invisible to the unit suite
because the wrong answer only appears once a real engine and the real UI are both in
the loop.
**The first click on an index failed on Elasticsearch.** "Select Top 50 Documents"
generated `SELECT * FROM orders LIMIT 50;` and this grammar has no terminator:
`parsing_exception`, "extraneous input ';' expecting <EOF>". "Generate Query" failed
the same way. `docs/providers/elasticsearch.md` had already recorded the defect as a
fact about the engine; it is now fixed instead. `ProviderCapabilities.statementTerminator`
is `"none"` on both products - the absence runs on OpenSearch too, so one answer serves
both type-ids rather than a branch on dialect - and `query-generators.ts` asks the
capability.
**The grid showed NULL over values the API had returned.** TanStack reads a dot in
`accessorKey` as a path into the row, so `shipping.city` was fetched as
`row.shipping.city` while the row carries that flat key. Measured: the API answered
`{"order_id":"o-001","shipping.city":"İzmir"}` and the cell read NULL, while the CSV
export - which reads `row[column]` - wrote İzmir the whole time. Every object field in
a mapping flattens to a dotted leaf, so on a search cluster this was most of a user's
columns, and the screen was the only surface that lied. Now `id` + `accessorFn`, with
`Object.hasOwn` for the same reason the export path has it.
**Plan mode wrote the wrong language.** A live plan run on an OpenSearch connection,
asked for one runnable statement, answered with a native aggregation body -
`{"size":0,"aggs":{…}}` - correct for the product and unrunnable through the SQL
endpoint this provider speaks to. The guard then declined to classify it
(NO_STATEMENT), so nothing ran; the user was still handed a plan they could not
execute. `queryLanguage: "sql"` was already true and settled nothing, because the
engine's NAME carries the stronger prior. `ProviderLabels.statementLanguage` now names
the endpoint and rules out each product's own alternatives (ES|QL upstream, PPL on the
fork), and the plan contract states it. Verified after the fix: the run drafted
`SELECT service, COUNT(*), AVG(latency_ms) … GROUP BY service`, the guard read it as a
bounded read, and the statement runs on the cluster.
**Two maintenance entries led nowhere.** An index is addressable, so the
`tablesAreDerivedGroupings` gate #427 added did not catch it: "Index Statistics" and
"Merge Segments" rendered on an engine declaring `supportsMaintenance: false`, and
/admin/operations gates its Global Operations card on that same capability - so the
click landed on a page with no controls, no error, and none of the wording written for
exactly that moment. The gate now asks for an addressable row AND a declared
capability.
Also: both provider docs said "multi-fields kept" in their §11 coverage summaries
while `introspect.ts` drops them by a measured, documented rule - the tri-sync
invariant, wrong in the one phrase that matters.
Three findings are recorded in docs/BACKLOG.md instead of fixed here, each because it
predates this PR and spans every engine: the monitoring Queries tab tells all eleven
engines to enable `pg_stat_statements` (U12), BEGIN/SANDBOX render where no provider
declares transactions and the route refuses with 400 (U13), and monitoring reports
relational zeros as measurements where the engine keeps no such counter (U14).
Live-cluster verification (Chrome, both engines)Driven in a real browser against Elasticsearch 9.1.4 and OpenSearch 3.8.0 (the pinned What already worked, measured rather than assumed
Four defects the browser found, all fixed in
Plus a tri-sync fix: both docs said "multi-fields kept" in their §11 coverage summaries while Re-verified in the browser after the fixes: the generated query is Deliberately not fixed here — recorded in Gates after the fixes: format · lint · typecheck · knip · test (7827 pass, 0 fail) · build, plus |
…he credentials that work (#424) The section was titled "database compose connection tests" and listed five services - the HTTP ones - which read as the fixture's whole surface while eight more were in the file. Engines were columns, so each new provider meant re-drawing the grid; they are rows now, one per type-id, the same set the table at the top of this file carries. Every row was verified against its running container rather than transcribed from the compose file, and twice that mattered: - Oracle's environment sets `ORACLE_PDB: ORCLPDB1`, a variable `gvenzl/oracle-xe` does not read - it takes `ORACLE_DATABASE`. The only PDB on the node is the image default `XEPDB1` (`SELECT name FROM v$pdbs`), and the listener refuses the other name. - SQL Server's `MSSQL_DATABASE: mssql` is ignored by the official image, which creates no database. `master` is what the fixture guarantees (`SELECT name FROM sys.databases`). Also recorded: SQLite and LibreDB have no service and never reach a network, so they are rows that say so and point at their sample connections; Redis genuinely has no password (`CONFIG GET requirepass` answers empty) rather than a blank default; and the `Profile` column names Druid's seven containers as the one block a plain `up -d` leaves out.
#424) The fixture table's hosts are all `localhost`, which is only true for a Studio running on the host. A plain `docker run -p 3000:3000 ghcr.io/libredb/libredb-studio` reaches none of these services, and the failure is quiet in the worst way: from inside a container `curl localhost:9200` returns no HTTP status at all, which reads like a network problem rather than "you asked the wrong machine". Three routes, measured on this machine rather than recited: - Joining `libredb-studio_default` and addressing services by name, which is the one that needs no published ports. It carries its own port table because the numbers differ: **OpenSearch is 9200 inside the network**, not the 9201 the host table shows - 9201 exists only to dodge a collision with the `elasticsearch` service, and there is no collision inside. Verified `http://opensearch:9200/` reports distribution `opensearch` 3.8.0, and `elasticsearch:9200` / `clickhouse:8123` answer too. - `--add-host=host.docker.internal:host-gateway`, with the note that the name resolves by itself only on Docker Desktop. Verified on Linux: 9200 and 9201 both answer 200 through it, so the host table applies as written. - `--network host`, last, with what it costs stated: Linux only, no `-p`, and the whole host network namespace for the sake of one database. Also recorded: the root `docker-compose.yml` and the fixture both name a container `libredb-postgres`, so running both fails on the name.
…ion. because docker logs show the initial admin password
… the localhost trap (#424) The Docker Hub page had drifted in five places, and the first two are the ones a reader acts on: **The engine list stopped at seven.** Couchbase, ClickHouse and Apache Druid had been shipping for months, LibreDB is in the connection dialog, and this PR adds Elasticsearch and OpenSearch - none of them appeared, on the page whose whole job is to say what the image can talk to. All twelve are listed now, with the three read-only engines named as read-only *because their own SQL is*, and the nine wire-compatible engines (MariaDB, Citus, Valkey, DragonflyDB, KeyDB, FerretDB, CockroachDB, Materialize, RisingWave) get a table of their own with the support level each one measured. **SQLite's driver was wrong.** The page said `better-sqlite3`; the DB provider is `bun:sqlite` / `node:sqlite`, selected by the runtime with a `LIBREDB_SQLITE_DRIVER` override. `better-sqlite3` is the storage layer, which is a different thing on a different code path. **Plan mode's grounding claim predated #414.** It said a plan run is grounded on PostgreSQL and SQLite; since #414 it is grounded on every engine, reads the inventory before the model's first turn, and asks for a statement in that engine's own language. Agent mode is still the two dialects, and that half was already right. **Nothing said what `localhost` means inside a container** - on the page written for people who run `docker run -p 3000:3000`. It is the container's own loopback, so a database on the host or in another container is simply not there, and the failure arrives as a timeout that never mentions the host. A new section gives the four cases (container network by service name, `host.docker.internal` with the `host-gateway` flag Linux needs, `--network host`, a managed service) plus the two facts that decide between them: the port inside a network is the engine's own rather than the one you published, and `host.docker.internal` resolves by itself only on Docker Desktop. Also: the pin example was `0.9.16` (now 0.12.0), and the DBA line promised VACUUM/ANALYZE/REINDEX as if every engine had them - it now says maintenance is offered in each engine's own terms, and not at all where an engine has no maintenance statement.
…at ship (#424) Twenty-three files told a prospective user this product speaks seven engines. Couchbase, ClickHouse and Apache Druid have been shipping since 0.11.0, LibreDB is in the connection dialog, and #424 adds Elasticsearch and OpenSearch - none of that had reached the text people actually read before installing. Each was edited in the form its channel allows rather than by pasting one sentence everywhere: - **Store listings with hard limits** got a fitted rewrite. Azure's search summary (100 chars) and short description (256) are asserted by `tests/unit/build-azure-package.test.ts`, so both were measured, not guessed - 98 and 253. Chocolatey's summary, WinGet's ShortDescription and Homebrew's `desc` name a few engines and the count instead of listing twelve inside a one-line field. - **The OLM bundle was regenerated, not hand-edited.** `operator/config` carries the description and `make -C operator bundle` produced the bundle copy; CI runs the same command and fails on drift, and the regenerated file matches this commit apart from the `createdAt` stamp. `operator-sdk bundle validate --select-optional suite=operatorframework` passes. - **The two Flatpak descriptor sets** list engines as `<li>` items, so the new ones are items - with the read-only three marked read-only, and Redis's wire-compatible family (Valkey, DragonflyDB, KeyDB) named on its own line since the probe covered them. Flathub's copy is updated too: the channel is deprecated, not deleted. - **Desktop and Flatpak `Keywords=`** gain SQLServer, ClickHouse, Couchbase, Druid, Elasticsearch and OpenSearch, which is what a store search matches on. - **The FlatPark network comment** said the app reaches its databases "over TCP"; five of the twelve are HTTP, and the sandbox rationale is more honest for saying so. - **CLAUDE.md's own overview** named seven engines, so the repo's instructions disagreed with the repo. - Two docs were wrong about a different count: `docs/editor/query-optimization.md` said "the two non-SQL types" when LibreDB makes three, and `docs/AGENT_GUIDE.md` listed the engines where agent mode is unsupported without the two search engines - which is exactly the list a user checks before opening a run. **Left alone deliberately:** `deploy/rancher/CATALOG_LISTING.md`, which already carries an accuracy gate explaining that its prose is version-scoped to a released tag and that the twelve-engine wording waits for a release. It is pinned on purpose, not stale. `README.md`'s Phase 4 roadmap line is history and stays as written. Gates: format, lint, typecheck, knip, `bun run test` (7827 pass, 0 fail), `node scripts/distribution-check.mjs --strict`, and every edited JSON/YAML/XML parsed.
|
) Chart-only release: `appVersion` stays 0.12.0 and the deployed image is the one 0.1.37 already installs. The two versions move independently, and this one moves alone. `description` named seven engines while twelve ship. That string is not internal - it is what Rancher renders on its Apps catalog card and what ArtifactHub shows beside the chart - so it was the last listing still telling a prospective user this product stops at Redis, after every other channel was refreshed with #424 Phase 1. Couchbase, ClickHouse and Apache Druid have shipped since 0.11.0; Elasticsearch and OpenSearch landed in #429. The same five engines are now chart `keywords`. ArtifactHub search matches on those, so an engine that is only in prose is an engine nobody finds. `artifacthub.io/containsSecurityUpdates` flips to **false**, and that is a statement rather than a default: this chart version carries no application change at all, so claiming a security update would be false. The app-level fixes stay recorded on 0.1.37, the chart version that first shipped them. `artifacthub.io/changes` is rewritten for the same reason - the entries a user reads on 0.1.38 describe 0.1.38, and the `Track app release` line stays last so the next `chart:bump` can still find it. Mechanics, in the order `.claude/skills/cut-release/SKILL.md` requires: `Chart.yaml` and the chart README hand-edited first (`chart:bump` moves `version:` only when `package.json` disagrees, which it does not here), then `chart:bump` last to refresh the vendored `operator/helm-charts/` copy. `chart:check` reports 0.1.38 / 0.12.0 in sync and `helm lint --strict` passes. `make -C operator bundle` produces no change beyond its volatile `createdAt`, which the CI gate ignores, so the bundle is left alone. The runbook gains the reminder that would have caught this earlier: checking `description` and `keywords` against `SHIPPED` in `src/lib/db/compatibility.ts` is now step 3 of the version bump, beside the `artifacthub.io/changes` rule that exists for the same reason - a field `chart:bump` never touches goes stale silently - plus a Traps row.
…nd make every engine count agree (#440) Elasticsearch and OpenSearch shipped in #429, but the epic's Phase 1 wrap-up never closed, so this finishes it. Three things, none of them a behaviour change. **Which distribution, and under which licence.** The box asked for it and its own premise was wrong: it said "Elasticsearch is SSPL / Elastic License". The pinned image `docker.elastic.co/elasticsearch/elasticsearch:9.1.4` ships **Elastic License 2.0** and says so in its own `/usr/share/elasticsearch/LICENSE.txt`, read out of the running container rather than recalled; `opensearchproject/opensearch:3.8.0` ships Apache-2.0 the same way. The blur actually worth removing turned out to be a different one: both docs said "basic licence", where **basic** is the self-generated subscription tier `GET /_license` reports and not the software licence at all. Each doc now separates the two, and seven older sites that used "licence" in the tier sense are reworded, because introducing the distinction is what made them ambiguous. **Gate 7, run rather than argued.** #429 recorded it as "asserted rather than assumed" - both ids ground through #414's provider-inventory path, so plan mode should work at no per-provider cost. The epic's own wording is "Expected is not verified". Both are now run against the live containers: plan mode captured 3 indices on each - its word, not "tables" - and drafted a statement over the real `orders` index and `shipping.country`, a nested field it could only know from the mapping capture. Applied unmodified it answered `TR | 40` on both, columns typed `keyword` and `long`. **One engine count, stated with its denominator.** Three numbers were in circulation and each was locally true: the issue said twelve, the README says thirteen, the login page says fourteen. They count different sets, and none said which. The external count is thirteen and the internal one is fourteen with the embedded `libredb`; every surface now agrees with its own list. That reached further than expected - twenty-six files still enumerated engines without Trino, including the OLM bundle, the Azure and Rancher listings and five packaging manifests. `snap/snapcraft.yaml` gains the missing names but deliberately no number: Store copy can only be corrected by a re-release, so introducing a count there creates drift nothing can fix in between. The operator CSV was regenerated with `make -C operator bundle` rather than hand-mirrored, and the generator reproduced it exactly.



Phase 1 of #424 — the largest gap on the coverage map. One implementation in
src/lib/db/providers/sql/search/serves both type-ids; everything the two products disagree about is data in a dialect table, never a branch on the type-id.The query surface was decided by measurement
_sql?format=json,{columns, rows}_plugins/_sql,{schema, datarows}Both speak SQL with no licence gate, so SQL is the shared language and ES|QL cannot be. That puts the provider on
SQLBaseProvider, which is what makes the explorer, ER diagrams, schema diff, the query limiter and agent plan mode work with no per-provider cost. The schema still comes from_mapping, never from SQL —SELECT *describes the statement, and an index mappingflattened/nestedanswers it with zero columns.Three defects the live probes found
A truncated result presented as complete. An Elasticsearch aggregation is paged by the engine even with no
fetch_size: aGROUP BYover 1500 distinct values answers 1000 rows plus a cursor. Dropping it returned two thirds of the buckets and labelled the result complete — worse than an error, because a user reading aGROUP BYhas no way to notice 500 missing groups.query()now follows the cursor (page two carries rows and no column declaration, so page one's names are carried forward) and refuses loudly at a bound rather than truncating quietly. Verified live: 1500 rows where it previously returned 1000.A wrong column label.
SELECT customer AS whodeclaresname: "who"on Elasticsearch andname: "customer", alias: "who"on OpenSearch. The transport readnameonly, so the same statement's column was labelledwhoon one product andcustomeron the other.Silently wrong rows from a generated query.
query-generators.tsderives its dialect fromdefaultPort— an assumption that held only while every engine had a distinct one. Both products are 9200 and they disagree about the quote character: on OpenSearch a double-quoted identifier is a string literal, soWHERE "customer" = 'acme'answers HTTP 200 withtotal: 0while the backtick form returns the row. No error, just no rows for data that exists. Fixed with a declared capability (ProviderCapabilities.identifierQuoting) rather than a type-id check; absent means "keep deriving it from the port", so no existing provider changed.Two places the honest answer was to offer less
SELECT note.keywordin every spelling (SemanticCheckException) while Elasticsearch selects it fine, and dynamic mapping creates such a child for every text field — so keeping them would break the generated starter query on nearly any dynamically-mapped OpenSearch index. Keeping them only on Elasticsearch would make the dialect decide a behaviour, whichintrospect.ts's own recorded rule forbids. The Elasticsearch-side loss is written down, not papered over.supportsExplain: false. Neither SQL endpoint returns a plan tree, so an Explain tab would render for a plan nothing can parse.Registration surfaces
Recon measured which surfaces the compiler enforces by adding the union member and running
tsc: eight fail the build, and roughly as many fail silently — the factory switch and the literal type list in its error message, the seed zod enum,selectableTypes, the icon exports, the grammar maps. All are registered, and three that had no test at all now do: the icon list, both factory cases (each asserted to produce its own class), and the error-message list, which now iteratesSHIPPED_DATABASE_TYPESinstead of naming three engines.SHIPPED_DATABASE_TYPESitself was the one mandatory surface with no guard — a hand-written array whose count test read the same constant on both sides. It is now derived from an exhaustiveRecord, so an omission is a compile error.Claim surfaces this work falsified
docs/BRAND_MESSAGING.mdcarried a standing rule: "There is no Elasticsearch or OpenSearch provider. Neither may appear as an example, in any list of engines, or in any story about polyglot teams." Shipping the provider makes that false while the rule keeps forbidding us to mention it. Nothing in the repo would have flagged this — it is not a provider file, a test, or a doc anyone greps when adding an engine. Updated, along with its engine count and the candidate row indocs/ADDING_A_PROVIDER.md.Verification
Seven gates of #424's Definition of Done:
docs/providers/{elasticsearch,opensearch}.md+ one integration test per type-idcheck-coverage: OK — 37635/37635 lines (100.00%)QueryErrorfor a missing index, versionsElasticsearch 9.1.4/OpenSearch 3.8.0e2e/search-providers.spec.ts— both drivers selectable, port prefilled, no connection-string toggle, and no unearned compatibility hintformat·lint(0 errors) ·typecheck·knip·test·buildall cleanprovider-inventory(#414's non-catalog path), asserted incontext-snapshot.test.tsContainer fixtures added to
database-compose.ymlwith the exact configuration the probed containers ran, so gate 4 is reproducible.Recorded limitations
No EXPLAIN. Aliases and data streams are not in the schema tree (
_cat/indicesdoes not list them). No maintenance operations, no active sessions, no slow queries.totalHitsis unavailable on Elasticsearch. A trailing semicolon is a syntax error there, andOFFSETis too. Nested-mapping document counts exceed what aSELECTreturns. Noresult-export.tsdialect entry: both engines rejectCREATE TABLE, so no type spelling makes an exported file replayable into its own engine — the #422 criterion cannot be met here, and inventing a row would bury a promise the product cannot keep.