diff --git a/README.md b/README.md index 7e59edb..df6649c 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,12 @@ A modern, dark-mode-first MongoDB GUI. Built as a daily-driver alternative to Mo - **Multiple connections, side-by-side.** Connect to as many clusters as you want; each runs an independent pool. Drag-reorder them in the sidebar, manage them with right-click context menus. - **Built-in SSH tunnelling.** Point a connection at a cluster that is only reachable from a jump host — key, password or agent auth, no external port forwards. Because the tunnel is a dynamic SOCKS5 proxy rather than a single forwarded port, the driver reaches **every replica-set member** it discovers, resolved on the SSH server's side. -- **Three query modes per tab.** Switch a single tab between **Simple** (filter / projection / sort), **Aggregation** pipeline, and **Shell** (`db.coll.find().limit()` syntax) without losing state. +- **Cloudflare Access tunnels.** For a MongoDB published through Cloudflare Access, MongoBench runs `cloudflared access tcp` itself on a free loopback port and connects through it — sign in with your browser or a service token, no local port to pick or forward. +- **Several tabs per collection, with drafts.** Open the same collection as often as you like. Each tab switches between **Simple** (filter / projection / sort), **Aggregation** pipeline and **Shell** (mongosh syntax for reads like `db.coll.find().limit()` and writes like `updateMany`); edits stay drafts until you run them, and every mode keeps its own last result. +- **Schema-aware completion.** Suggestions come from a sample of the collection: field paths with their types and common values, operators that fit the field, pipeline stages that know which fields the previous stages produce, and shell methods. - **Mongo shell syntax everywhere.** Type `ObjectId("…")`, `ISODate("…")`, `UUID("…")`, `NumberLong("…")` etc. directly in filters and editors — MongoBench parses it into canonical EJSON before sending. -- **Optimistic concurrency on writes.** Every edit and delete includes a sha-256 hash precondition of the document; concurrent edits surface as conflicts instead of silently overwriting. +- **Numeric types stay exact.** Int32, Int64, Double and Decimal128 values keep their BSON type through view, edit and save — `5.0` stays a double, `NumberLong("5")` stays a long, `NaN` and `-0.0` survive. +- **Optimistic concurrency on document edits.** Every edit and delete of a single document includes a sha-256 hash precondition of the document; concurrent edits surface as conflicts instead of silently overwriting. - **Built-in observability.** Per-connection dashboard with op rate, read/write/command latency, connection pool state, cache fill, network throughput, and a per-database storage breakdown. - **Updates you decide on.** MongoBench tells you when a newer release exists and shows the download progress, but nothing is fetched or installed until you click. Builds are unsigned — the app never replaces its own binary behind your back. @@ -32,7 +35,7 @@ Saved connections at a glance with quick-connect buttons and inline tips. ### Connection form -Full driver-option surface — auth, SSH tunnel, topology, pool, timeouts, UUID encoding, display timezone. +Full driver-option surface — auth, SSH or Cloudflare Access tunnel, topology, pool, timeouts, UUID encoding, display timezone. ![New connection dialog](https://i.masel.io/ZIXO8/huKuWAvE28.png/raw) @@ -62,20 +65,20 @@ Monaco editor with mongo shell helpers (`ObjectId(…)`, `ISODate(…)`, `Number ### Query autocomplete -Operator autocomplete (`$eq`, `$in`, `$elemMatch`, `$geoWithin`, …) and field-name autocomplete from the most recent results. +Operator autocomplete (`$eq`, `$in`, `$elemMatch`, `$geoWithin`, …) ranked by the field's type, and field-name autocomplete from a sample of the collection. ![Operator autocomplete](https://i.masel.io/ZIXO8/mIhavoWU58.png/raw) ![Field-name autocomplete](https://i.masel.io/ZIXO8/KiTUxUkA81.png/raw) ### Indexes -Per-collection index manager — list with key spec, properties, size; create with full option surface (TTL, partial filter, collation, weights, 2dsphere, wildcard). +Per-collection index manager — list with key spec, properties, size and usage; create with full option surface (TTL, partial filter, collation, weights, 2dsphere, wildcard). ![Indexes dialog](https://i.masel.io/ZIXO8/ZuzImILI62.png/raw) ### Users -Per-database user management. Common-role shortcuts plus arbitrary custom roles. +Per-database user management. A role editor for any role on any database, with one-click toggles for the common roles. ![Manage users](https://i.masel.io/ZIXO8/royEDozi90.png/raw) ![New user dialog](https://i.masel.io/ZIXO8/codOjUZU83.png/raw) @@ -98,9 +101,16 @@ Per-database user management. Common-role shortcuts plus arbitrary custom roles. - Auth via **private key** (+ passphrase), **password**, or the running **SSH agent** - Key files are referenced by path — the key is read at connect time and never stored or copied - Passwords and passphrases go into the same OS keystore as the MongoDB password - - Host keys are checked against your `~/.ssh/known_hosts`; an unknown host is pinned on first use and its `SHA256:` fingerprint surfaced for you to verify. A key that later changes is a hard failure + - Host keys are checked against your `~/.ssh/known_hosts`; an unknown host is pinned on first use and its `SHA256:` fingerprint surfaced for you to verify. A key that later changes is a hard failure, and so is a key your `known_hosts` marks `@revoked` - Implemented as a loopback SOCKS5 proxy over the SSH session (with per-tunnel random credentials), handed to the driver as `proxyHost` / `proxyPort` — so **topology discovery works**: list every replica-set member in the URI under the names the SSH server resolves. `mongodb+srv://` is the exception, as its DNS lookup still happens locally - A tunnel that dies takes its connection down and says so, instead of leaving a connection that only looks alive +- **Cloudflare Access tunnel** per connection, for a MongoDB behind a Cloudflare Access TCP application (**Tunnel → Cloudflare Access** in the form): + - MongoBench starts `cloudflared access tcp` on a free loopback port when you connect and stops it when you disconnect — there is no host or port to enter, only the Access hostname + - Auth via **browser sign-in** or a **service token**. For browser sign-in, cloudflared opens the browser only when it has no valid Access session and reuses the session until it expires; a toast explains the browser window and can reopen the sign-in page. A service token's secret goes into the OS keystore and reaches cloudflared through its environment, never its command line. A saved secret is only used for the hostname and client ID it was saved with; change either and the form asks for the secret again + - Uses `cloudflared` from `PATH` or an executable you pick; the form shows the detected version. A picked executable must be a file on this computer named like a cloudflared release (`cloudflared`, `cloudflared.exe`, `cloudflared-linux-amd64`, …); network and UNC paths are refused + - The tunnel reaches exactly one server, so `directConnection` is always on and `mongodb+srv://` is not supported. The connection string still carries credentials, database and options, but its host is never dialled — with TLS on, it names the certificate to expect. Choosing the tunnel fills an empty connection string with `mongodb://localhost` + - cloudflared's own error (e.g. a rejected service token) is reported instead of a driver timeout, and a cloudflared that exits takes its connection down + - While connected, the loopback port is reachable by other local processes; MongoDB authentication still applies - **Drag-to-reorder** saved connections in the sidebar - **Right-click context menu** per connection: connect / disconnect, edit, delete, new database, refresh, copy URI - Full driver option surface, persisted per connection: @@ -117,33 +127,71 @@ Per-database user management. Common-role shortcuts plus arbitrary custom roles. - Three-level tree: **connection → database → collection** - Live status dot per connection (connected / disconnected / busy) - Right-click on a **database**: new collection, manage users, drop, copy name -- Right-click on a **collection**: open, info, copy namespace, insert document, manage indexes, rename, drop +- Right-click on a **collection**: open, open in new tab, info, copy namespace, insert document, manage indexes, rename, drop - Distinct icons for **collections vs. views** - Hover-quick-create buttons on database rows - Active tab is highlighted in the sidebar; opening a tab from the palette auto-expands and scrolls to the row -### Query modes +### Tabs + +- Clicking a collection focuses its most recently used tab, or opens one; **`Ctrl`/`Cmd`-click**, **middle-click** or **Open in new tab** opens another tab of the same collection +- Tabs of one collection are numbered `#1`, `#2`, …; right-click a tab to **duplicate** or **close** it, middle-click closes it +- Every tab keeps its own editors — text, cursor, scroll position and undo history survive tab and mode switches +- Edits are drafts: nothing runs until you press **Run**, and a dot on the tab marks a draft that differs from its last run +- **Duplicate** copies the drafts and the Simple result; Aggregation and Shell start idle in the copy, so an expensive pipeline doesn't run twice +- ObjectId lookups open the referenced document in a new tab, or re-run an earlier lookup tab you haven't edited +- Tabs follow a collection rename (an Aggregation result is cleared and waits for **Run** under the new name) and close when their collection or database is dropped or their connection disconnects; cached results of that namespace are discarded, so nothing stale comes back -| Mode | Surface | Highlights | -| --------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| **Simple** | filter · projection · sort · skip · limit | EJSON or shell syntax, field-name autocomplete from last results | -| **Aggregation** | pipeline as `[{ $match: … }, …]` | Monaco, full operator autocomplete | -| **Shell** | `db.coll.find({…}).sort({…}).limit(n)` | Curated subset of mongosh syntax — `find`, `findOne`, `aggregate`, `count`, `countDocuments`, `estimatedDocumentCount` | +### Query modes -- **Run:** click button or `Ctrl+Enter` / `Cmd+Enter` -- **Format:** `Shift+Alt+F` — custom formatter that preserves shell helpers -- **Operator autocomplete** for every standard query operator (`$eq`, `$in`, `$elemMatch`, `$geoWithin`, `$jsonSchema`, …) +| Mode | Surface | Highlights | +| --------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Simple** | filter · projection · sort · limit | EJSON or shell syntax; paged, with the total document count (estimated for an empty filter, _count unavailable_ with the reason when the server can't count the filter, e.g. `$where`) | +| **Aggregation** | pipeline as `[{ $match: … }, …]` | Read-only pipelines — `$out` / `$merge` are rejected (they run in Shell), and so is `$changeStream`, which never ends; at most 1,000 rows | +| **Shell** | `db.coll.find({…}).sort({…}).limit(n)` | Reads and writes on any collection of the database — `find`, `findOne`, `aggregate`, `countDocuments`, `count`, `estimatedDocumentCount`, `distinct` (at most 1,000 rows), and the write methods below | + +- **Run:** click button or `Ctrl+Enter` / `Cmd+Enter`. When the input can't run (invalid filter, empty pipeline), the Run tooltip says why +- **Cancel:** while a query runs, Run turns into Cancel — it aborts the operation on the server and the result area shows _Run cancelled_; a cancelled run starts again only when you press Run. In Simple mode a total count that is still loading never blocks Run. Closing a tab aborts its running queries as well, unless a duplicate of the tab still shows them. A Shell write can't be cancelled — the driver has no way to abort a write — so while one runs, the button shows _Writing…_ +- **Switching modes never runs anything:** each mode shows its own last result; Aggregation and Shell show an example until their first run in that tab +- **Result cap:** Aggregation and Shell reads show at most 1,000 rows; the status bar flags a truncated result and how to reach the rest (`$skip` / `$limit` stages, `.skip()` / `.limit()`, or a narrower `distinct` filter). Simple mode keeps its own limit and pager — an empty or 0 limit fetches every match +- **Shell:** address any collection as `db.name`, `db.a.b`, `db["name"]` or `db.getCollection("name")`; chain `sort`, `skip`, `limit`, `projection`, `toArray`, `pretty` after `find`, where `.limit(0)` means no limit. Comments and a trailing `;` are fine. Counts show as one large number — `count()` on an empty filter is estimated from collection metadata, like in mongosh; `countDocuments()` is always exact +- **Shell writes:** `insertOne`, `insertMany`, `updateOne`, `updateMany` (update operators or an update pipeline), `replaceOne`, `deleteOne`, `deleteMany`, `findOneAndUpdate`, `findOneAndReplace`, `findOneAndDelete`, `bulkWrite`, and `aggregate` ending in `$out` / `$merge` (into any collection, also of another database), each with mongosh's options (`upsert`, `arrayFilters`, `hint`, `collation`, `returnDocument` / `returnNewDocument`, …). When the command writes, Run turns amber and its tooltip names the collection it writes to + - A write runs exactly once, when you press Run — holding `Ctrl+Enter`, a double-click on Run, switching tabs or modes, or a refresh after another write never repeats it + - `updateMany` / `deleteMany` with an empty filter (also inside `bulkWrite`) and a `$out` ask for confirmation first + - The result shows the counts that apply (inserted, matched, modified, upserted, deleted), the inserted or upserted `_id`s, where `$out` / `$merge` wrote, and the document a `findOneAnd*` returned — Edit, Duplicate and Delete load the stored document first; a deleted one is read-only + - Afterwards the written collection's results, its stats and the collection list refresh — after a failed write too, since an ordered `insertMany` or `bulkWrite` may have written part + - The legacy `insert`, `update`, `remove`, `save` and `findAndModify` name their modern replacement; `drop`, index and rename commands point to the explorer +- **Format:** `Shift+Alt+F` — re-prints filters, pipelines and shell commands, keeping shell helpers (comments are dropped) + +### Completion + +- Field suggestions come from a random sample of up to 200 documents, taken when an editor gets focus and reused for 10 minutes; **Refresh Field Suggestions** in the editor's context menu samples again. Unprojected documents of the current result are folded in +- Keys suggest field paths ranked by how often they occur, with their types; values suggest the matching constructor (`ObjectId("…")`, `ISODate("…")` in the connection's timezone, `NumberLong("…")`, …) and the field's most common values +- Operators are ranked by the field's type — `$regex` for strings, `$size` / `$elemMatch` for arrays, geo operators for coordinates +- Pipelines: stage names and templates, the fields each stage produces (`$group`, `$project`, `$unwind`, `$lookup`, …), expression operators with their named arguments, fields ranked by the type an argument expects, and `$$` variables with the subfields they hold. `$lookup` and `$unionWith` suggest collection names and know the target collection's fields +- Shell: collections of the database (as `getCollection("…")` when `db.name` can't reach them), collection methods and cursor methods, and the arguments of write methods — update operators, update pipeline stages, option names and `bulkWrite` operations +- Document editor: keys and values from the same sample; suggestions open only on trigger characters or `Ctrl+Space`, so `Enter` still starts a new line. The index form suggests key fields from the same sample +- Word-based suggestions are off, so text from other tabs never shows up ### Documents - Auto-sized table columns, drag-to-resize, per-collection persistence - Multi-select with `Shift+Click` range select -- Right-click row: **view, edit, duplicate, delete, copy `_id`, jump to referenced ObjectId** (cross-collection lookup submenu) +- Right-click row: **view, edit, duplicate, delete, copy as canonical EJSON, jump to referenced ObjectId** (cross-collection lookup submenu) +- **Row actions follow the result:** + - Whole documents — Simple or shell `find` / `findOne` without projection, pipelines of only `$match` / `$sort` / `$skip` / `$limit` — are edited and deleted directly + - Projected rows are partial, so Edit, Duplicate and Delete first load the stored document by `_id` + - Rows of other pipelines, `distinct` values and rows without `_id` are read-only: View, Copy and ObjectId lookups - **View / edit modes:** - - Canonical EJSON (`{ "$oid": "…" }`, `{ "$date": "…" }`, …) or relaxed display + - Documents open in mongo shell syntax built from canonical EJSON (`ObjectId("…")`, `ISODate("…")`, `NumberLong("…")`, …) - Shell-syntax accepted on input, parsed before send - sha-256 hash precondition on `replaceOne` / `deleteOne` to catch concurrent edits + - **Exact numeric types:** documents are read without promoting BSON numbers, so a save writes back what was stored — Int32 as `NumberInt(…)`, Int64 as `NumberLong("…")`, Double as `5.0` / `2.5` / `-0.0` / `NumberDouble("NaN")`, Decimal128 as `NumberDecimal("…")` + - Regular expressions keep their exact pattern and flags, including `s`, `x` and `u` + - On input, a plain integer becomes Int32 (Int64 when larger) and a literal with a `.` or an exponent (`5.0`, `1e3`) a Double; `Double(…)` / `NumberDouble(…)` also take `"NaN"`, `"Infinity"` and `"-Infinity"`. `ObjectId()` without arguments creates a fresh id - **Bulk delete** via multi-select + confirm +- After any write — from the table, the editor or the Shell — the visible result refreshes if it may read that collection — Simple, Aggregation, or a Shell command on that database. A hidden Simple result refreshes when you show it again; a hidden Aggregation or Shell result keeps its last data until you press **Run** +- A multi-document insert that fails part-way reports how many documents were already inserted - **Export** the current result set as JSON, CSV or TSV - **Type-aware rendering** in the table: - `ObjectId` as hex string with kind badge @@ -155,15 +203,23 @@ Per-database user management. Common-role shortcuts plus arbitrary custom roles. ### Indexes - List with name, key spec, type (1/-1/text/2dsphere/2d/hashed), TTL, uniqueness, sparseness, hidden flag, partial filter, collation, weights -- Create with full option surface: `unique`, `sparse`, `hidden`, `expireAfterSeconds`, `partialFilterExpression`, `collation`, `weights`, `default_language`, `language_override`, `textIndexVersion`, `2dsphereIndexVersion`, `bits`, `min`, `max`, `wildcardProjection` +- Create with full option surface: `unique`, `sparse`, `hidden`, `expireAfterSeconds`, `partialFilterExpression`, `collation`, `weights`, `default_language`, `language_override`, `textIndexVersion`, `2dsphereIndexVersion`, `bits`, `min`, `max`, `wildcardProjection`; key fields are suggested from a sample of the collection - Drop with confirmation (`_id` index protected) -- Per-index size from `collStats` +- **Size** and **Usage** columns, both sortable: + - Size per index from `$collStats`, summed across shards + - Usage from `$indexStats`: operations since the counters started, with a per-day rate; hover for the start date and the count per host + - An index with no recorded use for at least a day is flagged **unused** (never `_id_`, hidden, TTL or still-building indexes); indexes still being built carry a **building** badge + - Counters reset when a mongod restarts; a note under the table names the hosts and shards they were counted on +- Statistics load when the dialog opens and are never polled; **Refresh** reloads them. When a source needs a privilege you don't have (`indexStats`, `collStats`), a notice says so and the rest still shows ### Users - List per-database users with their roles -- Create with username, password, role assignments per database -- Common-role shortcuts: `read`, `readWrite`, `dbAdmin`, `dbOwner`, `userAdmin` — plus arbitrary custom roles +- Create with username, password (with a generator) and roles +- **Role editor:** any role on any database + - Suggestions from the server's roles (built-in and custom), the roles of listed users and the known databases + - One-click toggles for `read`, `readWrite`, `dbAdmin`, `dbOwner`, `userAdmin` on the current database + - Roles grouped per database and filterable; while editing, added and removed roles stay marked until you save, and a removed role can be restored - Update password and / or roles independently (preserve unchanged fields) - Drop with confirmation @@ -171,7 +227,7 @@ Per-database user management. Common-role shortcuts plus arbitrary custom roles. - Create / drop database (with confirmation, closes affected tabs) - Create / rename / drop collection -- **Collection info dialog**: namespace, document count, average doc size, storage size (compressed + uncompressed), index count, free space, capped status +- **Collection info dialog**: namespace, document count, average doc size, storage size (compressed + uncompressed), index count, free space, capped status — summed across shards on sharded clusters ### Connection dashboard @@ -198,6 +254,7 @@ Per-connection live view, sampled every 5 s, sliding 5-min history: - Custom **Monaco theme** matching the app palette - **Custom scrollbars** (Chromium): 10 px, transparent track, primary-tinted on grab - **Status indicators** everywhere: spinners on pending operations, success / error toasts, hash-protected badges in the status bar +- **Escape** inside a dialog first closes the innermost popup — suggestion list, editor find widget, dropdown — and only then the dialog ## Install @@ -271,7 +328,13 @@ src/ - Builds are **unsigned** — Windows shows SmartScreen on first launch - No **change-streams / live tail** UI yet -- Shell mode parses a curated subset of `db.coll.(...)` syntax, not the full mongosh grammar +- Shell mode runs one command per run — the reads and writes listed above, not the full mongosh grammar: no variables or JavaScript, no legacy write methods, no collection or index administration (the explorer does that) +- Shell writes can't be cancelled once they run, and they carry no hash precondition: they act on whatever matches when they run +- Aggregation and Shell reads stop at 1,000 rows; page with `$skip` / `$limit` or `.skip()` / `.limit()`. A write result lists at most 1,000 inserted or upserted `_id`s +- **Cancel** stops waiting for `distinct` and `estimatedDocumentCount`, but cannot abort them on the server +- `distinct` shows Int32 and Double values as plain numbers, so it doesn't tell them apart (Int64 values stay exact); queried documents always keep their exact types +- The document a `findOneAnd*` returns may show an integral Double as an Int32 (Int64 values and regular expressions stay exact); editing it starts from the stored document, so no type changes on save. View and Copy show it as returned, so a copied `findOneAndDelete` result is not a type-exact backup of the deleted document +- A Cloudflare Access tunnel reaches a single server: no replica-set discovery, no `mongodb+srv://` - macOS builds aren't published (the codebase runs on macOS in dev; binaries just aren't in the release matrix yet) ## License diff --git a/package-lock.json b/package-lock.json index 048a699..b6b1fbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mongobench", - "version": "1.5.0", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mongobench", - "version": "1.5.0", + "version": "1.6.0", "license": "MIT", "dependencies": { "@fontsource-variable/geist": "^5.2.8", @@ -64,7 +64,7 @@ "tailwindcss-animate": "^1.0.7", "typescript": "^5.6.2", "vite": "^7.0.0", - "vitest": "^4.0.0" + "vitest": "^4.1.12" }, "engines": { "node": ">=20" @@ -437,9 +437,9 @@ "license": "MIT" }, "node_modules/@electron/asar/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -579,9 +579,9 @@ } }, "node_modules/@electron/rebuild": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", - "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.1.tgz", + "integrity": "sha512-IS4pG3BysRrCLJ8xTAfvg+IQDbiUoAzdJy2SfjcYKnLDy5DpCdjnPdhS5Zf5UMfrkB24xu0KC+TqcOxg+eG+6w==", "dev": true, "license": "MIT", "dependencies": { @@ -626,9 +626,9 @@ "license": "MIT" }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -636,9 +636,9 @@ } }, "node_modules/@electron/universal/node_modules/fs-extra": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", - "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", "dev": true, "license": "MIT", "dependencies": { @@ -689,9 +689,9 @@ } }, "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", - "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", "dev": true, "license": "MIT", "optional": true, @@ -1225,9 +1225,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -1346,9 +1346,9 @@ "license": "MIT" }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -1562,13 +1562,13 @@ } }, "node_modules/@noble/hashes": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", - "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "dev": true, "license": "MIT", "engines": { - "node": ">= 20.19.0" + "node": "^14.21.3 || >=16" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -1613,15 +1613,18 @@ } }, "node_modules/@peculiar/asn1-schema": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", - "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.11.0.tgz", + "integrity": "sha512-8xh4avo53/vXO6UEv9yaoJ83hY581geaLTygf61NPIXP5AeIZCPjJhpTaYU23IZkG+NpLN/C9FYzWp7hphIT9A==", "dev": true, "license": "MIT", "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/json-schema": { @@ -3375,16 +3378,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -3393,13 +3396,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.12", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3420,9 +3423,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", "dev": true, "license": "MIT", "dependencies": { @@ -3433,13 +3436,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.12", "pathe": "^2.0.3" }, "funding": { @@ -3447,14 +3450,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3463,9 +3466,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", "dev": true, "license": "MIT", "funding": { @@ -3473,13 +3476,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.12", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3488,9 +3491,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.13", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", - "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", "dev": true, "license": "MIT", "engines": { @@ -3621,9 +3624,9 @@ } }, "node_modules/app-builder-lib": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", - "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", + "version": "26.17.0", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.17.0.tgz", + "integrity": "sha512-wfL7EhujuODDW3uMSD8RPcr/MLzmT0dmpVfWV0wFUWXI0rIdaukzAJ7EFUSyHXOpxuBZqaIGlQt6VeQhtHhetw==", "dev": true, "license": "MIT", "dependencies": { @@ -3635,13 +3638,13 @@ "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", - "@noble/hashes": "^2.2.0", + "@noble/hashes": "^1.8.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", - "builder-util": "26.15.3", + "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", @@ -3649,7 +3652,7 @@ "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", - "electron-publish": "26.15.3", + "electron-publish": "26.16.0", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", @@ -3673,8 +3676,8 @@ "node": ">=14.0.0" }, "peerDependencies": { - "dmg-builder": "26.15.3", - "electron-builder-squirrel-windows": "26.15.3" + "dmg-builder": "26.17.0", + "electron-builder-squirrel-windows": "26.17.0" } }, "node_modules/app-builder-lib/node_modules/@electron/get": { @@ -3956,14 +3959,14 @@ } }, "node_modules/asn1js": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", - "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.1.0.tgz", + "integrity": "sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "pvtsutils": "^1.3.6", - "pvutils": "^1.1.5", + "pvutils": "^1.2.0", "tslib": "^2.8.1" }, "engines": { @@ -4277,9 +4280,9 @@ "optional": true }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4362,9 +4365,9 @@ } }, "node_modules/builder-util": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", - "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", + "version": "26.16.0", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.16.0.tgz", + "integrity": "sha512-RLyJhB7Si3YkzKR9ubQslWuXW3Vhs3CGe1i+SeixBZ0qTd1mk3XBmssvY22TlB6CS5blyko8Gu1JzpYk8UkYAg==", "dev": true, "license": "MIT", "dependencies": { @@ -4570,9 +4573,9 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -5187,9 +5190,9 @@ "license": "MIT" }, "node_modules/dir-compare/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -5218,14 +5221,14 @@ "license": "MIT" }, "node_modules/dmg-builder": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", - "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", + "version": "26.17.0", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.17.0.tgz", + "integrity": "sha512-EAw1f2iGkICmUvAVBOjm7v5mxJdB7/+OZSycHFhKBimiTkCXtvkCC6wBKzHo7ZQYi58C2MmwIleRbgRnROlK2Q==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.15.3", - "builder-util": "26.15.3", + "app-builder-lib": "26.17.0", + "builder-util": "26.16.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } @@ -5244,9 +5247,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.13", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", - "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -5323,9 +5326,9 @@ } }, "node_modules/electron": { - "version": "42.9.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-42.9.0.tgz", - "integrity": "sha512-xyKfPbHP43Jx22Z1hItLR4KB5h3/avb5BIX4xaQZ6uPPRY4cQxuHnRVrKCGbSsMa/9zd0bBT4TAVNVuVjYcYtw==", + "version": "42.11.15", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.11.15.tgz", + "integrity": "sha512-pZOe/Qga4o3t6q9WdL6hV0hYjjlcagJsSCFp3Zc4rxyqTYsIQr6macePGoQefXkdKFnsA4VvwqHjYxTUSiE3qA==", "dev": true, "license": "MIT", "dependencies": { @@ -5342,18 +5345,18 @@ } }, "node_modules/electron-builder": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", - "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", + "version": "26.17.0", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.17.0.tgz", + "integrity": "sha512-iYHBRiagS9sDIbZx1ZD113f5rEGQvtpvTvHf70ovHKJ8mRvxwIkWX7JCwfmVQmVS4eX735p7TZmoxHnBPwF3vA==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.15.3", - "builder-util": "26.15.3", + "app-builder-lib": "26.17.0", + "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", - "dmg-builder": "26.15.3", + "dmg-builder": "26.17.0", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", @@ -5368,15 +5371,15 @@ } }, "node_modules/electron-builder-squirrel-windows": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", - "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", + "version": "26.17.0", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.17.0.tgz", + "integrity": "sha512-uyh+D+B0m12qCRp9c/7dwBirHTh7JcvAzj5eTEbb4dXPPGAhYPx1nozzbu1fC6AdULfat6ZNqgWhipTFpb82lQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "app-builder-lib": "26.15.3", - "builder-util": "26.15.3", + "app-builder-lib": "26.17.0", + "builder-util": "26.16.0", "electron-winstaller": "5.4.0" } }, @@ -5390,15 +5393,15 @@ } }, "node_modules/electron-publish": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", - "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", + "version": "26.16.0", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.16.0.tgz", + "integrity": "sha512-Vt3KzQIiw9BImvNOYtndg9Mjki+tl4+1sQiC/+G5j8khWaENOJFWodiB+sUl6yyHwtd37avehskdtPw7f8y/+Q==", "dev": true, "license": "MIT", "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", - "builder-util": "26.15.3", + "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", @@ -5989,9 +5992,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -6090,9 +6093,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -6321,9 +6324,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "dev": true, "funding": [ { @@ -6378,9 +6381,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -6783,9 +6786,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -7038,9 +7041,9 @@ "license": "ISC" }, "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", "dev": true, "license": "BSD-2-Clause" }, @@ -7184,9 +7187,9 @@ } }, "node_modules/ip-address": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", - "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", "license": "MIT", "engines": { "node": ">= 12" @@ -7732,9 +7735,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", @@ -8551,9 +8554,9 @@ } }, "node_modules/node-abi": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz", - "integrity": "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==", + "version": "4.38.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.38.0.tgz", + "integrity": "sha512-suSdznUPfabdaeLiNERA0LHod14RhcEn8wMPUZEQ6bKvajUCZ+aizY4pIvlLU+gImeLKp2bFDHhOc9yonISsXw==", "dev": true, "license": "MIT", "dependencies": { @@ -8648,9 +8651,9 @@ } }, "node_modules/node-gyp/node_modules/undici": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", - "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "version": "6.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz", + "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==", "dev": true, "license": "MIT", "engines": { @@ -9207,13 +9210,16 @@ } }, "node_modules/pkijs": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", - "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.1.tgz", + "integrity": "sha512-Oo/NZcSWccq8KyoG7gLE9fnltgHns+pNCjCAp/WmjsUySi+sX7y4z4Xqu4fVb42CDHzRPl33fjzT15V1wvcyhA==", "dev": true, "license": "BSD-3-Clause", + "workspaces": [ + "website" + ], "dependencies": { - "@noble/hashes": "1.4.0", + "@noble/hashes": "1.8.0", "asn1js": "^3.0.6", "bytestreamjs": "^2.0.1", "pvtsutils": "^1.3.6", @@ -9224,19 +9230,6 @@ "node": ">=16.0.0" } }, - "node_modules/pkijs/node_modules/@noble/hashes": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", - "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -10532,9 +10525,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -11262,9 +11255,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "dev": true, "license": "MIT", "engines": { @@ -11496,9 +11489,9 @@ } }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "dev": true, "license": "MIT", "optional": true, @@ -12253,19 +12246,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -12293,12 +12286,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index 462ccdb..bd9b98e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mongobench", - "version": "1.5.0", + "version": "1.6.0", "private": true, "description": "A modern, dark-mode-first MongoDB GUI.", "author": "ByteExceptionM", @@ -85,7 +85,7 @@ "tailwindcss-animate": "^1.0.7", "typescript": "^5.6.2", "vite": "^7.0.0", - "vitest": "^4.0.0" + "vitest": "^4.1.12" }, "overrides": { "dompurify": "^3.4.13" diff --git a/src/main/index.ts b/src/main/index.ts index eaef1cc..562db5c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -8,6 +8,8 @@ const APP_ICON = app.isPackaged import log from 'electron-log/main' import { EventChannels } from './ipc/channels' import { registerIpcHandlers } from './ipc/router' +import { opensInBrowser } from './lib/externalUrl' +import { CloudflaredTunnelService } from './services/CloudflaredTunnelService' import { ConnectionService } from './services/ConnectionService' import { DatabaseService } from './services/DatabaseService' import { IndexService } from './services/IndexService' @@ -71,14 +73,14 @@ function createWindow(): void { }) window.webContents.setWindowOpenHandler(({ url }) => { - void shell.openExternal(url) + if (opensInBrowser(url)) void shell.openExternal(url) return { action: 'deny' } }) window.webContents.on('will-navigate', (event, url) => { if (url !== window.webContents.getURL()) { event.preventDefault() - void shell.openExternal(url) + if (opensInBrowser(url)) void shell.openExternal(url) } }) @@ -113,8 +115,11 @@ function createWindow(): void { app.whenReady().then(() => { const secrets = new SecretsStore() const repo = new ConnectionsRepository(secrets) - const tunnels = new SshTunnelService(new HostKeysStore()) - const connections = new ConnectionService(repo, tunnels, (connectionId, reason) => + const sshTunnels = new SshTunnelService(new HostKeysStore()) + const cloudflared = new CloudflaredTunnelService((prompt) => + pushToRenderer(EventChannels.CloudflaredLogin, prompt) + ) + const connections = new ConnectionService(repo, sshTunnels, cloudflared, (connectionId, reason) => pushToRenderer(EventChannels.ConnectionDropped, { connectionId, reason }) ) const databases = new DatabaseService(connections) @@ -127,7 +132,16 @@ app.whenReady().then(() => { services.repo = repo services.connections = connections - registerIpcHandlers({ repo, connections, databases, queries, users, indexes, updater }) + registerIpcHandlers({ + repo, + connections, + databases, + queries, + users, + indexes, + updater, + cloudflared + }) createWindow() diff --git a/src/main/ipc/channels.ts b/src/main/ipc/channels.ts index 5e0f0db..d6003ec 100644 --- a/src/main/ipc/channels.ts +++ b/src/main/ipc/channels.ts @@ -22,15 +22,18 @@ export const Channels = { CollectionsCreate: 'collections:create', CollectionsDrop: 'collections:drop', CollectionsRename: 'collections:rename', + CollectionsSchema: 'collections:schema', IndexesList: 'indexes:list', IndexesCreate: 'indexes:create', IndexesDrop: 'indexes:drop', + IndexesStats: 'indexes:stats', UsersList: 'users:list', UsersCreate: 'users:create', UsersUpdate: 'users:update', UsersDrop: 'users:drop', + RolesList: 'roles:list', QueryFind: 'query:find', QueryAggregate: 'query:aggregate', @@ -40,12 +43,18 @@ export const Channels = { QueryInsertMany: 'query:insertMany', QueryDeleteOne: 'query:deleteOne', QueryDeleteMany: 'query:deleteMany', + QueryDistinct: 'query:distinct', + QueryCancel: 'query:cancel', + QueryWrite: 'query:write', UpdaterCheck: 'updater:check', UpdaterDownload: 'updater:download', UpdaterInstall: 'updater:install', - DialogPickPrivateKey: 'dialog:pickPrivateKey' + CloudflaredVersion: 'cloudflared:version', + + DialogPickPrivateKey: 'dialog:pickPrivateKey', + DialogPickCloudflared: 'dialog:pickCloudflared' } as const export type ChannelName = (typeof Channels)[keyof typeof Channels] @@ -53,5 +62,6 @@ export type ChannelName = (typeof Channels)[keyof typeof Channels] /** main → renderer pushes. Subscribed to in the preload, never `handle`d. */ export const EventChannels = { UpdaterProgress: 'updater:progress', - ConnectionDropped: 'connections:dropped' + ConnectionDropped: 'connections:dropped', + CloudflaredLogin: 'cloudflared:login' } as const diff --git a/src/main/ipc/router.test.ts b/src/main/ipc/router.test.ts new file mode 100644 index 0000000..3eb2d61 --- /dev/null +++ b/src/main/ipc/router.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Result } from '@shared/result' + +type Handler = (event: unknown, payload?: unknown) => Promise> + +const ipc = vi.hoisted(() => ({ handlers: new Map(), warn: vi.fn() })) + +vi.mock('electron', () => ({ + BrowserWindow: { getFocusedWindow: () => null }, + dialog: { showOpenDialog: vi.fn() }, + ipcMain: { handle: (channel: string, handler: Handler) => ipc.handlers.set(channel, handler) } +})) +vi.mock('electron-log/main', () => ({ + default: { info: vi.fn(), warn: ipc.warn, debug: vi.fn(), error: vi.fn() } +})) + +const { registerIpcHandlers } = await import('./router') +const { Channels } = await import('./channels') +type Services = Parameters[0] + +const FIND = { + connectionId: '7f1c2d3e-4b5a-4c6d-8e9f-0a1b2c3d4e5f', + db: 'app', + coll: 'players', + skip: 0 +} + +const queries = { find: vi.fn() } +const repo = { list: vi.fn() } +registerIpcHandlers({ queries, repo } as unknown as Services) + +function invoke(channel: string, payload?: unknown): Promise> { + const handler = ipc.handlers.get(channel) + if (!handler) throw new Error(`no handler for ${channel}`) + return handler({}, payload) +} + +function named(name: string, message: string): Error { + return Object.assign(new Error(message), { name }) +} + +beforeEach(() => { + ipc.warn.mockClear() +}) + +describe('IPC handler failures', () => { + it('returns a cancelled run as an error without logging a warning', async () => { + queries.find.mockRejectedValueOnce(named('CancelledError', 'Query cancelled')) + + await expect(invoke(Channels.QueryFind, FIND)).resolves.toEqual({ + ok: false, + error: { code: 'cancelled', message: 'Query cancelled' } + }) + expect(ipc.warn).not.toHaveBeenCalled() + }) + + it('logs every other failure as a warning', async () => { + queries.find.mockRejectedValueOnce(named('DocumentNotFoundError', 'gone')) + + await expect(invoke(Channels.QueryFind, FIND)).resolves.toMatchObject({ + ok: false, + error: { code: 'not_found' } + }) + expect(ipc.warn).toHaveBeenCalledWith('IPC handler failed', { + code: 'not_found', + message: 'gone' + }) + }) + + it('treats a cancel the same way in handlers without input', async () => { + repo.list.mockRejectedValueOnce(named('CancelledError', 'Query cancelled')) + + await expect(invoke(Channels.ConnectionsList)).resolves.toMatchObject({ + error: { code: 'cancelled' } + }) + expect(ipc.warn).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/router.ts b/src/main/ipc/router.ts index 0c2ef04..b28bad6 100644 --- a/src/main/ipc/router.ts +++ b/src/main/ipc/router.ts @@ -5,6 +5,8 @@ import log from 'electron-log/main' import type { ZodType } from 'zod' import { AggregateRequestSchema, + CancelQuerySchema, + CloudflaredVersionSchema, CollectionRefSchema, ConnectionIdSchema, ConnectionInputSchema, @@ -19,6 +21,7 @@ import { DatabaseRefSchema, DeleteManyRequestSchema, DeleteOneRequestSchema, + DistinctRequestSchema, DropIndexSchema, DropUserSchema, FindRequestSchema, @@ -28,12 +31,14 @@ import { RenameCollectionSchema, ReorderConnectionsSchema, ReplaceOneRequestSchema, - UpdateUserSchema + UpdateUserSchema, + WriteRequestSchema } from '@shared/schemas' import type { Result } from '@shared/result' import { err, ok } from '@shared/result' import { mapError } from '../lib/errorMap' import type { ConnectionsRepository } from '../stores/ConnectionsRepository' +import type { CloudflaredTunnelService } from '../services/CloudflaredTunnelService' import type { ConnectionService } from '../services/ConnectionService' import type { DatabaseService } from '../services/DatabaseService' import type { IndexService } from '../services/IndexService' @@ -50,6 +55,7 @@ export type Services = { users: UserService indexes: IndexService updater: UpdaterService + cloudflared: CloudflaredTunnelService } function withResult( @@ -66,9 +72,7 @@ function withResult( try { return ok(await fn(parsed.data)) } catch (error) { - const mapped = mapError(error) - log.warn(`IPC handler failed`, mapped) - return err(mapped.code, mapped.message, mapped.details) + return failure(error) } } } @@ -78,15 +82,31 @@ function withoutInput(fn: () => Promise): (event: IpcMainInvokeEvent) => P try { return ok(await fn()) } catch (error) { - const mapped = mapError(error) - log.warn(`IPC handler failed`, mapped) - return err(mapped.code, mapped.message, mapped.details) + return failure(error) } } } +function failure(error: unknown): Result { + const mapped = mapError(error) + // A cancel is the user's own action, not a failure. + if (mapped.code !== 'cancelled') log.warn(`IPC handler failed`, mapped) + return err(mapped.code, mapped.message, mapped.details) +} + +/** Native file picker, parented to the focused window when there is one. */ +async function pickFile(options: Electron.OpenDialogOptions): Promise { + const parent = BrowserWindow.getFocusedWindow() + const result = + parent === null + ? await dialog.showOpenDialog(options) + : await dialog.showOpenDialog(parent, options) + if (result.canceled) return null + return result.filePaths[0] ?? null +} + export function registerIpcHandlers(services: Services): void { - const { repo, connections, databases, queries, users, indexes, updater } = services + const { repo, connections, databases, queries, users, indexes, updater, cloudflared } = services ipcMain.handle( Channels.ConnectionsList, @@ -166,6 +186,13 @@ export function registerIpcHandlers(services: Services): void { ) ) + ipcMain.handle( + Channels.CollectionsSchema, + withResult(CollectionRefSchema, ({ connectionId, db, coll }) => + databases.collectionSchema(connectionId, db, coll) + ) + ) + ipcMain.handle( Channels.CollectionsCreate, withResult(CreateCollectionSchema, ({ connectionId, db, name }) => @@ -232,6 +259,22 @@ export function registerIpcHandlers(services: Services): void { withResult(DeleteManyRequestSchema, (request) => queries.deleteMany(request)) ) + ipcMain.handle( + Channels.QueryDistinct, + withResult(DistinctRequestSchema, (request) => queries.distinct(request)) + ) + + // Ids of runs that already settled are ignored. + ipcMain.handle( + Channels.QueryCancel, + withResult(CancelQuerySchema, async ({ requestId }) => queries.cancel(requestId)) + ) + + ipcMain.handle( + Channels.QueryWrite, + withResult(WriteRequestSchema, (request) => queries.write(request)) + ) + ipcMain.handle( Channels.IndexesList, withResult(IndexesListSchema, ({ connectionId, db, coll }) => @@ -239,6 +282,13 @@ export function registerIpcHandlers(services: Services): void { ) ) + ipcMain.handle( + Channels.IndexesStats, + withResult(CollectionRefSchema, ({ connectionId, db, coll }) => + indexes.indexStats(connectionId, db, coll) + ) + ) + ipcMain.handle( Channels.IndexesCreate, withResult(CreateIndexSchema, ({ connectionId, db, coll, keys, options }) => @@ -279,6 +329,16 @@ export function registerIpcHandlers(services: Services): void { ) ) + ipcMain.handle( + Channels.RolesList, + withResult(DatabaseRefSchema, ({ connectionId, db }) => users.listRoles(connectionId, db)) + ) + + ipcMain.handle( + Channels.CloudflaredVersion, + withResult(CloudflaredVersionSchema, ({ binaryPath }) => cloudflared.version(binaryPath)) + ) + ipcMain.handle( Channels.UpdaterCheck, withoutInput(() => updater.check()) @@ -302,20 +362,26 @@ export function registerIpcHandlers(services: Services): void { // main at connect time and never leaves it. ipcMain.handle( Channels.DialogPickPrivateKey, - withoutInput(async () => { - const options: Electron.OpenDialogOptions = { + withoutInput(() => + pickFile({ title: 'Select an SSH private key', defaultPath: join(homedir(), '.ssh'), // Key files carry no extension, and .ssh is a hidden directory. properties: ['openFile', 'showHiddenFiles', 'dontAddToRecent'] - } - const parent = BrowserWindow.getFocusedWindow() - const result = - parent === null - ? await dialog.showOpenDialog(options) - : await dialog.showOpenDialog(parent, options) - if (result.canceled) return null - return result.filePaths[0] ?? null - }) + }) + ) + ) + + ipcMain.handle( + Channels.DialogPickCloudflared, + withoutInput(() => + pickFile({ + title: 'Select the cloudflared executable', + properties: ['openFile', 'dontAddToRecent'], + ...(process.platform === 'win32' + ? { filters: [{ name: 'Executables', extensions: ['exe'] }] } + : {}) + }) + ) ) } diff --git a/src/main/lib/cloudflaredLog.test.ts b/src/main/lib/cloudflaredLog.test.ts new file mode 100644 index 0000000..8cfe333 --- /dev/null +++ b/src/main/lib/cloudflaredLog.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import { findLoginUrl, OutputTail, parseLogLine, splitLines } from './cloudflaredLog' + +// Lines cloudflared 2025.8.1 printed in local runs against a hostname that cannot resolve. +const INF_LINE = '2026-10-16T12:57:58Z INF Start Websocket listener host=127.0.0.1:62183' +const ERR_LINE = + '2026-10-16T12:58:01Z ERR failed to connect to origin error="dial tcp: lookup mongo.example.invalid: no such host" originURL=https://mongo.example.invalid' +const EMPTY_MESSAGE_ERR_LINE = + '2026-10-16T12:58:03Z ERR error="failed to parse as URL: parse \\"https://-x\\": invalid character"' +const LOGIN_URL = + 'https://mongo.example.com/cdn-cgi/access/cli?aud=abc&redirect_url=https%3A%2F%2Fmongo.example.com&token=xyz' + +describe('parseLogLine', () => { + it('parses an INF line', () => { + expect(parseLogLine(INF_LINE)).toEqual({ + level: 'INF', + message: 'Start Websocket listener host=127.0.0.1:62183' + }) + }) + + it('parses an ERR line', () => { + expect(parseLogLine(ERR_LINE)?.level).toBe('ERR') + expect(parseLogLine(ERR_LINE)?.message).toMatch(/^failed to connect to origin error=/) + }) + + it('parses an ERR line whose message is empty down to its fields', () => { + expect(parseLogLine(EMPTY_MESSAGE_ERR_LINE)).toEqual({ + level: 'ERR', + message: 'error="failed to parse as URL: parse \\"https://-x\\": invalid character"' + }) + }) + + it('returns null for a line that is not a log line', () => { + expect(parseLogLine('failed to get app info: Head "https://x": no such host')).toBeNull() + }) +}) + +describe('findLoginUrl', () => { + it('returns the sign-in URL for the configured hostname', () => { + expect(findLoginUrl(LOGIN_URL, 'mongo.example.com')).toBe(LOGIN_URL) + }) + + it('matches the hostname case-insensitively', () => { + expect(findLoginUrl(LOGIN_URL, 'Mongo.Example.com')).toBe(LOGIN_URL) + }) + + it('trims surrounding whitespace', () => { + expect(findLoginUrl(` ${LOGIN_URL}\r`, 'mongo.example.com')).toBe(LOGIN_URL) + }) + + it('ignores a URL for another host', () => { + expect( + findLoginUrl('https://evil.example.net/cdn-cgi/access/cli?aud=abc', 'mongo.example.com') + ).toBeNull() + }) + + it('ignores plain http', () => { + expect(findLoginUrl(LOGIN_URL.replace('https:', 'http:'), 'mongo.example.com')).toBeNull() + }) + + it('ignores another path on the right host', () => { + expect(findLoginUrl('https://mongo.example.com/somewhere/else', 'mongo.example.com')).toBeNull() + }) + + it('ignores prose', () => { + expect( + findLoginUrl('A browser window should have opened at the following URL:', 'mongo.example.com') + ).toBeNull() + }) +}) + +describe('splitLines', () => { + function collect(): { lines: string[]; splitter: ReturnType } { + const lines: string[] = [] + return { lines, splitter: splitLines((line) => lines.push(line)) } + } + + it('joins a line split across chunks', () => { + const { lines, splitter } = collect() + splitter.write('first li') + splitter.write('ne\nsecond\n') + expect(lines).toEqual(['first line', 'second']) + }) + + it('splits CRLF line endings, also when \\r and \\n arrive in separate chunks', () => { + const { lines, splitter } = collect() + splitter.write('one\r\ntwo\r') + splitter.write('\nthree\r\n') + expect(lines).toEqual(['one', 'two', 'three']) + }) + + it('flushes an unterminated last line on end', () => { + const { lines, splitter } = collect() + splitter.write('done\npartial') + expect(lines).toEqual(['done']) + splitter.end() + expect(lines).toEqual(['done', 'partial']) + }) +}) + +describe('OutputTail', () => { + it('keeps the newest ERR message even after many later lines', () => { + const tail = new OutputTail() + tail.add(ERR_LINE) + for (let i = 0; i < 30; i++) + tail.add(`2026-10-16T12:59:${String(i).padStart(2, '0')}Z INF tick ${i}`) + expect(tail.lastError()).toMatch(/^failed to connect to origin error=/) + expect(tail.describe()).toBe(tail.lastError()) + }) + + it('reports no error before an ERR or FTL line', () => { + const tail = new OutputTail() + tail.add(INF_LINE) + expect(tail.lastError()).toBeNull() + }) + + it('describes a failure by the last plain line when nothing was logged as an error', () => { + const tail = new OutputTail() + tail.add(INF_LINE) + tail.add('failed to get app info: Head "https://mongo.example.invalid": no such host') + expect(tail.describe()).toBe( + 'failed to get app info: Head "https://mongo.example.invalid": no such host' + ) + }) + + it('describes a log line by its message', () => { + const tail = new OutputTail() + tail.add(INF_LINE) + expect(tail.describe()).toBe('Start Websocket listener host=127.0.0.1:62183') + }) + + it('says so when cloudflared printed nothing', () => { + const tail = new OutputTail() + tail.add(' ') + expect(tail.describe()).toBe('cloudflared printed nothing') + expect(tail.text()).toBe('') + }) + + it('keeps only the last 20 lines', () => { + const tail = new OutputTail() + for (let i = 0; i < 25; i++) tail.add(`line ${i}`) + const kept = tail.text().split('\n') + expect(kept).toHaveLength(20) + expect(kept[0]).toBe('line 5') + expect(kept[19]).toBe('line 24') + }) + + it('truncates a long line to 500 characters', () => { + const tail = new OutputTail() + tail.add('x'.repeat(2000)) + expect(tail.text()).toHaveLength(500) + }) +}) diff --git a/src/main/lib/cloudflaredLog.ts b/src/main/lib/cloudflaredLog.ts new file mode 100644 index 0000000..9df9440 --- /dev/null +++ b/src/main/lib/cloudflaredLog.ts @@ -0,0 +1,92 @@ +/** + * Reading side of cloudflared's console output: zerolog lines on stderr, with + * no colour when piped, plus the plain text `access login` prints. Pure — the + * service feeds it what the process wrote. + */ + +const LOG_LINE = /^\d{4}-\d{2}-\d{2}T\S+\s+(DBG|INF|WRN|ERR|FTL)\s+(.*)$/ +const LOGIN_PATH = '/cdn-cgi/access/cli' +const MAX_LINES = 20 +const MAX_LINE_LENGTH = 500 + +/** A cloudflared console log line such as `2025-08-21T15:34:00Z ERR failed to connect to origin error="…"`. */ +export function parseLogLine( + line: string +): { level: 'DBG' | 'INF' | 'WRN' | 'ERR' | 'FTL'; message: string } | null { + const match = LOG_LINE.exec(line.trim()) + if (match === null) return null + return { + level: match[1] as 'DBG' | 'INF' | 'WRN' | 'ERR' | 'FTL', + message: (match[2] ?? '').trim() + } +} + +/** The Access sign-in URL cloudflared printed for `hostname`, or null. */ +export function findLoginUrl(line: string, hostname: string): string | null { + let url: URL + try { + url = new URL(line.trim()) + } catch { + return null + } + // The renderer opens this in the browser, so only Access's own sign-in page passes. + if (url.protocol !== 'https:') return null + if (url.hostname !== hostname.toLowerCase()) return null + if (url.pathname !== LOGIN_PATH) return null + return url.href +} + +/** Splits a text stream into lines; `end` flushes a final unterminated line. */ +export function splitLines(onLine: (line: string) => void): { + write: (chunk: string) => void + end: () => void +} { + let pending = '' + return { + write: (chunk) => { + const lines = (pending + chunk).split(/\r?\n/) + pending = lines.pop() ?? '' + for (const line of lines) onLine(line) + }, + end: () => { + if (pending.length > 0) onLine(pending) + pending = '' + } + } +} + +/** Keeps the end of a cloudflared process's output so errors can quote it. */ +export class OutputTail { + private readonly lines: string[] = [] + // Held apart from `lines` so a burst of later INF lines cannot evict the reason. + private newestError: string | null = null + + add(line: string): void { + const kept = line.trim().slice(0, MAX_LINE_LENGTH) + if (kept.length === 0) return + this.lines.push(kept) + if (this.lines.length > MAX_LINES) this.lines.shift() + const parsed = parseLogLine(kept) + if (parsed !== null && (parsed.level === 'ERR' || parsed.level === 'FTL')) { + this.newestError = parsed.message + } + } + + /** The newest ERR or FTL message, or null when cloudflared logged none. */ + lastError(): string | null { + return this.newestError + } + + /** The best single line to explain a failure. */ + describe(): string { + if (this.newestError !== null) return this.newestError + const last = this.lines.at(-1) + if (last === undefined) return 'cloudflared printed nothing' + return parseLogLine(last)?.message ?? last + } + + /** Every kept line, newline-joined. */ + text(): string { + return this.lines.join('\n') + } +} diff --git a/src/main/lib/connectionUri.test.ts b/src/main/lib/connectionUri.test.ts index 575d032..ec9f5a1 100644 --- a/src/main/lib/connectionUri.test.ts +++ b/src/main/lib/connectionUri.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest' import { canonicalize, + firstHostName, hasPasswordPlaceholder, injectStoredPassword, InvalidUriError, - parseUri + parseUri, + replaceHosts } from './connectionUri' describe('parseUri', () => { @@ -126,3 +128,67 @@ describe('injectStoredPassword', () => { expect(restored).toBe(original) }) }) + +describe('replaceHosts', () => { + it('swaps every host and keeps credentials, database and options byte for byte', () => { + expect( + replaceHosts('mongodb://alice:p%40ss@h1:27017,h2:27018/db?authSource=admin', '127.0.0.1:5000') + ).toBe('mongodb://alice:p%40ss@127.0.0.1:5000/db?authSource=admin') + }) + + it('handles a URI with no path', () => { + expect(replaceHosts('mongodb://host', '127.0.0.1:5000')).toBe('mongodb://127.0.0.1:5000') + }) + + it('handles options without a slash before them', () => { + expect(replaceHosts('mongodb://host?tls=true', '127.0.0.1:5000')).toBe( + 'mongodb://127.0.0.1:5000?tls=true' + ) + }) + + it('keeps the stored password placeholder', () => { + expect(replaceHosts('mongodb://alice:%3CMONGOBENCH_PWD%3E@host/db', '127.0.0.1:5000')).toBe( + 'mongodb://alice:%3CMONGOBENCH_PWD%3E@127.0.0.1:5000/db' + ) + }) + + it('replaces an IPv6 literal', () => { + expect(replaceHosts('mongodb://[::1]:27017/db', '127.0.0.1:5000')).toBe( + 'mongodb://127.0.0.1:5000/db' + ) + }) + + it('rejects mongodb+srv://, whose hosts come from DNS', () => { + expect(() => replaceHosts('mongodb+srv://cluster.example.com/db', '127.0.0.1:5000')).toThrow( + InvalidUriError + ) + }) +}) + +describe('firstHostName', () => { + it('returns a DNS name in lower case, without the port', () => { + expect(firstHostName('mongodb://Mongo.Internal:27017/db')).toBe('mongo.internal') + }) + + it('returns null for an IPv4 literal', () => { + expect(firstHostName('mongodb://10.0.0.1:27017')).toBeNull() + }) + + it('returns null for an IPv6 literal', () => { + expect(firstHostName('mongodb://[::1]:27017')).toBeNull() + }) + + it('returns null for a percent-encoded socket path', () => { + expect(firstHostName('mongodb://%2Ftmp%2Fm.sock')).toBeNull() + }) + + it('returns the first host of a multi-host URI', () => { + expect( + firstHostName('mongodb://a.example.com:27017,b.example.com:27018/db?replicaSet=rs') + ).toBe('a.example.com') + }) + + it('ignores credentials', () => { + expect(firstHostName('mongodb://alice:p%40ss@db.example.com/admin')).toBe('db.example.com') + }) +}) diff --git a/src/main/lib/connectionUri.ts b/src/main/lib/connectionUri.ts index e2f2ec6..d102fab 100644 --- a/src/main/lib/connectionUri.ts +++ b/src/main/lib/connectionUri.ts @@ -9,6 +9,8 @@ * and is invariant under accidental decode/encode round-trips by other code. */ +import { isIP } from 'node:net' + const SCHEME_RE = /^(mongodb(?:\+srv)?):\/\// const PASSWORD_PLACEHOLDER = '%3CMONGOBENCH_PWD%3E' @@ -142,3 +144,35 @@ export function injectExternalCredentials(uri: string, username: string, passwor const parts = parseUri(uri) return `${parts.schemeWithSep}${encodeURIComponent(username)}:${encodeURIComponent(password)}@${parts.hostAndRest}` } + +/** + * Swaps the host list of a `mongodb://` URI for one address, keeping credentials, database and options byte for byte. + * @param address `host:port` the driver should dial instead. + * @throws InvalidUriError for `mongodb+srv://`, whose hosts come from DNS. + */ +export function replaceHosts(uri: string, address: string): string { + const parts = parseUri(uri) + if (parts.scheme === 'mongodb+srv') { + throw new InvalidUriError('mongodb+srv:// cannot run through a single forwarded port') + } + // Raw slice, so an injected password or the placeholder survives unchanged. + const prefix = uri.slice(0, uri.length - parts.hostAndRest.length) + const stop = parts.hostAndRest.search(/[/?]/) + return prefix + address + (stop === -1 ? '' : parts.hostAndRest.slice(stop)) +} + +/** + * The first host of a URI when it is a DNS name, lower-cased, for TLS certificate checks. + * @returns null for IP literals (incl. `[…]`) and percent-encoded socket paths. + */ +export function firstHostName(uri: string): string | null { + const { hostAndRest } = parseUri(uri) + const stop = hostAndRest.search(/[/?]/) + const hosts = stop === -1 ? hostAndRest : hostAndRest.slice(0, stop) + const first = hosts.split(',')[0] ?? '' + if (first.startsWith('[')) return null + const colon = first.lastIndexOf(':') + const host = decodeURIComponent(colon === -1 ? first : first.slice(0, colon)) + if (host.length === 0 || host.includes('/') || isIP(host) !== 0) return null + return host.toLowerCase() +} diff --git a/src/main/lib/ejson.test.ts b/src/main/lib/ejson.test.ts index 9cc1f85..7ab178f 100644 --- a/src/main/lib/ejson.test.ts +++ b/src/main/lib/ejson.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from 'vitest' -import { Binary, Decimal128, Long, ObjectId } from 'bson' -import { canonicalHash, parseFilter, toCanonicalString, toRelaxed } from './ejson' +import { BSON, BSONRegExp, Binary, Decimal128, Double, EJSON, Int32, Long, ObjectId } from 'bson' +import { + canonicalHash, + decodeDocument, + hashCanonicalString, + parseFilter, + parseRelaxed, + toCanonical, + toCanonicalString, + toRelaxed +} from './ejson' describe('toRelaxed', () => { it('wraps ObjectId as { $oid }', () => { @@ -21,9 +30,63 @@ describe('toRelaxed', () => { expect(out.price).toEqual({ $numberDecimal: '12345.6789' }) }) - it('keeps small Longs as plain numbers', () => { - const out = toRelaxed({ n: Long.fromNumber(42) }) as { n: number } - expect(out.n).toBe(42) + it('wraps small Longs as { $numberLong }', () => { + const out = toRelaxed({ n: Long.fromNumber(42) }) as { n: unknown } + expect(out.n).toEqual({ $numberLong: '42' }) + }) + + it('keeps Longs beyond 2^53 exact', () => { + const out = toRelaxed({ + big: Long.fromString('9007199254740993'), + neg: Long.fromString('-1234567890123456789') + }) + expect(out).toEqual({ + big: { $numberLong: '9007199254740993' }, + neg: { $numberLong: '-1234567890123456789' } + }) + }) + + it('wraps Longs nested in arrays and subdocuments', () => { + const big = Long.fromString('9007199254740993') + const out = toRelaxed({ list: [big, { inner: [big] }], sub: { deeper: { n: big } } }) + expect(out).toEqual({ + list: [{ $numberLong: '9007199254740993' }, { inner: [{ $numberLong: '9007199254740993' }] }], + sub: { deeper: { n: { $numberLong: '9007199254740993' } } } + }) + }) + + it('leaves other BSON values next to a Long unchanged', () => { + const out = toRelaxed({ + _id: new ObjectId('507f1f77bcf86cd799439011'), + at: new Date('2026-01-15T10:30:00.123Z'), + price: Decimal128.fromString('1.5'), + n: Long.fromNumber(7) + }) + expect(out).toEqual({ + _id: { $oid: '507f1f77bcf86cd799439011' }, + at: { $date: '2026-01-15T10:30:00.123Z' }, + price: { $numberDecimal: '1.5' }, + n: { $numberLong: '7' } + }) + }) + + it('keeps a __proto__ field as an own field', () => { + const doc = Object.defineProperty({}, '__proto__', { + value: { n: Long.fromNumber(1) }, + enumerable: true, + writable: true, + configurable: true + }) + const out = toRelaxed(doc) + expect(Object.getPrototypeOf(out)).toBe(Object.prototype) + expect(Object.getOwnPropertyDescriptor(out, '__proto__')?.value).toEqual({ + n: { $numberLong: '1' } + }) + }) + + it('shows Int32 and finite Double read without promotion as plain numbers', () => { + const out = toRelaxed({ i: new Int32(7), d: new Double(2.5) }) + expect(out).toEqual({ i: 7, d: 2.5 }) }) it('wraps Binary as { $binary }', () => { @@ -51,6 +114,100 @@ describe('toCanonicalString', () => { }) }) +describe('decodeDocument', () => { + const stored = { + l: Long.fromNumber(5), + d: new Double(5), + z: new Double(-0), + i: new Int32(7), + n: new Double(NaN) + } + + function readBack(): Record { + return decodeDocument(BSON.serialize(stored)) + } + + it('keeps the stored numeric type of every field in canonical EJSON', () => { + expect(JSON.parse(toCanonicalString(readBack()))).toEqual({ + l: { $numberLong: '5' }, + d: { $numberDouble: '5.0' }, + z: { $numberDouble: '-0.0' }, + i: { $numberInt: '7' }, + n: { $numberDouble: 'NaN' } + }) + }) + + it('writes back the same BSON types after a canonical round trip', () => { + const reparsed = EJSON.parse(toCanonicalString(readBack()), { relaxed: false }) as object + const written = decodeDocument(BSON.serialize(reparsed)) + const types = Object.fromEntries( + Object.entries(written).map(([k, v]) => [k, (v as { _bsontype: string })._bsontype]) + ) + expect(types).toEqual({ l: 'Long', d: 'Double', z: 'Double', i: 'Int32', n: 'Double' }) + }) + + it('differs from the driver default, which collapses them to the smallest integer type', () => { + const promoted = BSON.deserialize(BSON.serialize(stored)) + expect(JSON.parse(toCanonicalString(promoted))).toMatchObject({ + l: { $numberInt: '5' }, + d: { $numberInt: '5' } + }) + }) + + // A JS RegExp would map 's' to 'g', drop x/u, and rewrite '/', line terminators and ''. + const REGEXES: Array<[string, string]> = [ + ['dot.all', 's'], + ['(?i)abc', ''], + ['a/b', ''], + ['^https://x', ''], + ['a\nb', ''], + ['', ''], + ['a b', 'x'], + ['x', 'u'], + ['^abc$', 'imsux'] + ] + + it.each(REGEXES)('keeps the stored regex /%s/%s exactly', (pattern, options) => { + const doc = decodeDocument(BSON.serialize({ re: new BSONRegExp(pattern, options) })) + expect(JSON.parse(toCanonicalString(doc))).toEqual({ + re: { $regularExpression: { pattern, options } } + }) + }) + + it.each(REGEXES)('writes the regex /%s/%s back unchanged', (pattern, options) => { + const read = decodeDocument(BSON.serialize({ re: new BSONRegExp(pattern, options) })) + const reparsed = EJSON.parse(toCanonicalString(read), { relaxed: false }) as object + const written = decodeDocument(BSON.serialize(reparsed))['re'] as BSONRegExp + expect([written.pattern, written.options]).toEqual([pattern, options]) + }) +}) + +describe('toCanonical', () => { + it('wraps every BSON type, plain numbers included', () => { + const out = toCanonical({ + _id: new ObjectId('507f1f77bcf86cd799439011'), + n: 1, + l: Long.fromNumber(2), + d: new Double(3), + at: new Date(0), + tags: ['a'] + }) + expect(out).toEqual({ + _id: { $oid: '507f1f77bcf86cd799439011' }, + n: { $numberInt: '1' }, + l: { $numberLong: '2' }, + d: { $numberDouble: '3.0' }, + at: { $date: { $numberLong: '0' } }, + tags: ['a'] + }) + }) + + it('returns a plain object that JSON-stringifies to the canonical string', () => { + const doc = { a: Long.fromString('9007199254740993'), b: [new Int32(1)] } + expect(JSON.stringify(toCanonical(doc))).toBe(toCanonicalString(doc)) + }) +}) + describe('parseFilter', () => { it('returns {} for empty / whitespace input', () => { expect(parseFilter(undefined)).toEqual({}) @@ -62,6 +219,18 @@ describe('parseFilter', () => { expect(parseFilter('{ "a": 1 }')).toEqual({ a: 1 }) }) + it('keeps Int64 values beyond 2^53 exact in the BSON sent to the server', () => { + const parsed = parseFilter( + '{ "uid": { "$numberLong": "9007199254740993" }, "max": { "$numberLong": "9223372036854775807" } }' + ) + const sent = BSON.deserialize(BSON.serialize(parsed), { promoteLongs: false }) + expect([String(sent['uid']), String(sent['max'])]).toEqual([ + '9007199254740993', + '9223372036854775807' + ]) + expect([sent['uid']._bsontype, sent['max']._bsontype]).toEqual(['Long', 'Long']) + }) + it('parses EJSON shorthand for ObjectId', () => { const parsed = parseFilter('{ "_id": { "$oid": "507f1f77bcf86cd799439011" } }') expect(parsed['_id']).toBeInstanceOf(ObjectId) @@ -73,6 +242,15 @@ describe('parseFilter', () => { }) }) +describe('parseRelaxed', () => { + it('reads $numberLong as an exact bigint and plain numbers as numbers', () => { + expect(parseRelaxed('{ "big": { "$numberLong": "9007199254740993" }, "n": 1 }')).toEqual({ + big: 9007199254740993n, + n: 1 + }) + }) +}) + describe('canonicalHash', () => { it('produces a 64-char hex digest', () => { const hash = canonicalHash({ a: 1 }) @@ -86,4 +264,9 @@ describe('canonicalHash', () => { it('changes with different content', () => { expect(canonicalHash({ a: 1 })).not.toBe(canonicalHash({ a: 2 })) }) + + it('equals the hash of the canonical string', () => { + const doc = { _id: new ObjectId('507f1f77bcf86cd799439011'), n: Long.fromNumber(5) } + expect(hashCanonicalString(toCanonicalString(doc))).toBe(canonicalHash(doc)) + }) }) diff --git a/src/main/lib/ejson.ts b/src/main/lib/ejson.ts index 28d2408..b71da04 100644 --- a/src/main/lib/ejson.ts +++ b/src/main/lib/ejson.ts @@ -1,5 +1,6 @@ -import { EJSON } from 'bson' +import { BSON, EJSON, type Document } from 'bson' import { createHash } from 'node:crypto' +import { isDocument } from '@shared/writeOps' /** * EJSON helpers — the only place in the main process that touches @@ -12,9 +13,23 @@ import { createHash } from 'node:crypto' export type RelaxedDocument = Record -/** Convert a BSON document to its relaxed-EJSON object form (display). */ +// Not driver options: per operation they would also turn server error codes into Int32 and stop retries. +const DOCUMENT_DECODE_OPTIONS = { promoteValues: false, bsonRegExp: true } as const + +/** Driver options for replies read decoded: Int64 and regexes stay exact; error codes stay numbers. */ +export const REPLY_DECODE_OPTIONS = { promoteLongs: false, bsonRegExp: true } as const + +/** + * Decodes a document read with `raw: true`, keeping every stored BSON type for canonical EJSON. + * @param bytes one raw document as the driver returns it + */ +export function decodeDocument(bytes: Uint8Array): Document { + return BSON.deserialize(bytes, DOCUMENT_DECODE_OPTIONS) +} + +/** Convert a BSON document to its relaxed-EJSON object form (display); Int64 stays `{ $numberLong }`. */ export function toRelaxed(doc: unknown): RelaxedDocument { - return EJSON.serialize(doc as object, { relaxed: true }) as RelaxedDocument + return EJSON.serialize(wrapInt64(doc) as object, { relaxed: true }) as RelaxedDocument } /** Convert a BSON value to its canonical-EJSON string (stable identity). */ @@ -22,6 +37,37 @@ export function toCanonicalString(value: unknown): string { return EJSON.stringify(value, { relaxed: false }) } +/** Convert a BSON value to its canonical-EJSON object form (every BSON type wrapped). */ +export function toCanonical(value: unknown): unknown { + return EJSON.serialize(value as object, { relaxed: false }) +} + +// Relaxed EJSON turns every Long into a JS number, which rounds beyond 2^53. +function wrapInt64(value: unknown): unknown { + if (Array.isArray(value)) return value.map(wrapInt64) + if (typeof value !== 'object' || value === null) return value + if ('_bsontype' in value) { + // Longs inside a DBRef's fields or a Code scope stay relaxed; EJSON serializes those itself. + return value._bsontype === 'Long' ? { $numberLong: String(value) } : value + } + const proto: unknown = Object.getPrototypeOf(value) + if (proto !== Object.prototype && proto !== null) return value + const out: Record = {} + for (const [key, entry] of Object.entries(value)) { + if (key === '__proto__') { + Object.defineProperty(out, key, { + value: wrapInt64(entry), + writable: true, + enumerable: true, + configurable: true + }) + } else { + out[key] = wrapInt64(entry) + } + } + return out +} + /** * Parse a user-supplied EJSON/JSON string into a BSON document. Empty or * whitespace-only input becomes `{}`. Relaxed mode is allowed so users @@ -31,14 +77,24 @@ export function parseFilter(input: string | undefined): Record if (input === undefined) return {} const trimmed = input.trim() if (trimmed.length === 0) return {} - const parsed = EJSON.parse(trimmed, { relaxed: true }) - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + const parsed = parseRelaxed(trimmed) + if (!isDocument(parsed)) { throw new Error('Filter must be a JSON object') } - return parsed as Record + return parsed +} + +/** Parses relaxed EJSON from the user; `$numberLong` becomes a bigint, which stays exact beyond 2^53. */ +export function parseRelaxed(input: string): unknown { + return EJSON.parse(input, { relaxed: true, useBigInt64: true }) } /** sha-256 of a value's canonical-EJSON form. Used for optimistic concurrency in M3. */ export function canonicalHash(value: unknown): string { - return createHash('sha256').update(toCanonicalString(value)).digest('hex') + return hashCanonicalString(toCanonicalString(value)) +} + +/** `canonicalHash` for a value whose canonical-EJSON string is already at hand. */ +export function hashCanonicalString(canonical: string): string { + return createHash('sha256').update(canonical).digest('hex') } diff --git a/src/main/lib/errorMap.test.ts b/src/main/lib/errorMap.test.ts new file mode 100644 index 0000000..0993cb7 --- /dev/null +++ b/src/main/lib/errorMap.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'vitest' +import { MongoBulkWriteError, type BulkWriteResult } from 'mongodb' +import { cancelledError, mapError, validationError } from './errorMap' + +function named(name: string, message = 'boom', fields: Record = {}): Error { + return Object.assign(new Error(message), { name }, fields) +} + +/** The driver's real class: `insertedCount` is a prototype getter over `result`, not an own field. */ +function bulkWriteError(insertedCount: number): MongoBulkWriteError { + return new MongoBulkWriteError( + { message: 'E11000 duplicate key error collection: app.users index: _id_', code: 11000 }, + { insertedCount } as unknown as BulkWriteResult + ) +} + +describe('mapError — tunnels', () => { + it.each([ + ['SshHostKeyMismatchError', 'ssh_host_key_mismatch'], + ['SshAuthError', 'ssh_auth_failed'], + ['SshConnectError', 'ssh_connect_failed'], + ['CloudflaredNotFoundError', 'cloudflared_not_found'], + ['CloudflaredAuthError', 'cloudflared_auth_failed'], + ['CloudflaredTunnelError', 'cloudflared_failed'] + ])('maps %s to %s and keeps the message', (name, code) => { + expect(mapError(named(name, 'tunnel says no'))).toEqual({ code, message: 'tunnel says no' }) + }) + + it('reports a tunnel failure even when its message mentions authentication', () => { + expect(mapError(named('CloudflaredAuthError', 'Access authentication failed')).code).toBe( + 'cloudflared_auth_failed' + ) + expect(mapError(named('SshAuthError', 'SSH authentication failed for tunneluser')).code).toBe( + 'ssh_auth_failed' + ) + }) +}) + +describe('mapError — app errors', () => { + it('maps an error made by cancelledError to cancelled', () => { + expect(mapError(cancelledError('Query cancelled'))).toEqual({ + code: 'cancelled', + message: 'Query cancelled' + }) + }) + + it('maps NotConnectedError to not_connected', () => { + expect(mapError(named('NotConnectedError', 'Connection c1 is not connected'))).toEqual({ + code: 'not_connected', + message: 'Connection c1 is not connected' + }) + }) + + it.each([ + ['InvalidUriError', 'validation_error'], + ['ValidationError', 'validation_error'], + ['ConnectionNotFoundError', 'not_found'], + ['DocumentNotFoundError', 'not_found'], + ['DocumentConflictError', 'conflict'], + ['DocumentImmutableIdError', 'conflict'] + ])('maps %s to %s', (name, code) => { + expect(mapError(named(name, 'nope'))).toEqual({ code, message: 'nope' }) + }) + + it('maps an error made by validationError to validation_error', () => { + expect(mapError(validationError('bad input'))).toEqual({ + code: 'validation_error', + message: 'bad input' + }) + }) + + it('treats an untagged "URI must start" message as a validation error', () => { + expect(mapError(new Error('URI must start with mongodb:// or mongodb+srv://')).code).toBe( + 'validation_error' + ) + }) +}) + +describe('mapError — driver errors', () => { + it('maps MongoServerSelectionError to server_selection_timeout', () => { + expect(mapError(named('MongoServerSelectionError', 'timed out'))).toEqual({ + code: 'server_selection_timeout', + message: 'timed out' + }) + }) + + it.each(['MongoNetworkError', 'MongoNetworkTimeoutError'])('maps %s to network_error', (name) => { + expect(mapError(named(name, 'socket hang up'))).toEqual({ + code: 'network_error', + message: 'socket hang up' + }) + }) + + it('recognises authentication failures by name, code 18, code 13 and message', () => { + expect(mapError(named('MongoAuthenticationError')).code).toBe('auth_failed') + expect(mapError(named('MongoServerError', 'x', { code: 18 })).code).toBe('auth_failed') + expect(mapError(named('MongoServerError', 'x', { code: 13 })).code).toBe('auth_failed') + expect(mapError(new Error('Authentication failed.')).code).toBe('auth_failed') + }) + + it('maps MongoServerError to driver_error with its numeric code', () => { + expect(mapError(named('MongoServerError', 'ns not found', { code: 26 }))).toEqual({ + code: 'driver_error', + message: 'ns not found', + details: { mongoCode: 26 } + }) + }) + + it('maps MongoWriteConcernError to driver_error with its numeric code', () => { + expect( + mapError(named('MongoWriteConcernError', 'waiting for replication timed out', { code: 64 })) + ).toEqual({ + code: 'driver_error', + message: 'waiting for replication timed out', + details: { mongoCode: 64 } + }) + }) + + it('omits details for a MongoServerError without a numeric code', () => { + expect(mapError(named('MongoServerError', 'odd', { code: 'Location1' }))).toEqual({ + code: 'driver_error', + message: 'odd', + details: undefined + }) + }) +}) + +describe('mapError — MongoBulkWriteError', () => { + it('says how many documents an ordered insertMany kept before failing', () => { + const mapped = mapError(bulkWriteError(49)) + expect(mapped.code).toBe('driver_error') + expect(mapped.message).toContain('E11000 duplicate key error') + expect(mapped.message).toContain('49 documents were inserted') + expect(mapped.details).toEqual({ mongoCode: 11000, insertedCount: 49 }) + }) + + it('uses the singular for one inserted document', () => { + expect(mapError(bulkWriteError(1)).message).toContain('1 document was inserted') + }) + + it('keeps the driver message unchanged when nothing was inserted', () => { + expect(mapError(bulkWriteError(0))).toEqual({ + code: 'driver_error', + message: 'E11000 duplicate key error collection: app.users index: _id_', + details: { mongoCode: 11000, insertedCount: 0 } + }) + }) + + it('reports every non-zero counter of a partial bulkWrite', () => { + const error = new MongoBulkWriteError({ message: 'E11000 duplicate key error', code: 11000 }, { + insertedCount: 1, + matchedCount: 4, + modifiedCount: 3, + deletedCount: 2, + upsertedCount: 1 + } as unknown as BulkWriteResult) + expect(mapError(error).message).toBe( + 'E11000 duplicate key error — 1 document was inserted, 4 matched, 3 modified, 2 deleted and 1 upserted before this error' + ) + }) + + it('leaves zero counters out and names the documents with the first one left', () => { + const error = new MongoBulkWriteError({ message: 'E11000 duplicate key error', code: 11000 }, { + insertedCount: 0, + matchedCount: 0, + modifiedCount: 1, + deletedCount: 2, + upsertedCount: 0 + } as unknown as BulkWriteResult) + expect(mapError(error).message).toBe( + 'E11000 duplicate key error — 1 document was modified and 2 deleted before this error' + ) + }) + + it('reports documents that only matched', () => { + const error = new MongoBulkWriteError({ message: 'E11000 duplicate key error', code: 11000 }, { + insertedCount: 0, + matchedCount: 5, + modifiedCount: 0 + } as unknown as BulkWriteResult) + expect(mapError(error).message).toBe( + 'E11000 duplicate key error — 5 documents were matched before this error' + ) + }) + + it('keeps the driver message unchanged when every counter is zero', () => { + const error = new MongoBulkWriteError({ message: 'E11000 duplicate key error', code: 11000 }, { + insertedCount: 0, + matchedCount: 0, + modifiedCount: 0, + deletedCount: 0, + upsertedCount: 0 + } as unknown as BulkWriteResult) + expect(mapError(error)).toEqual({ + code: 'driver_error', + message: 'E11000 duplicate key error', + details: { mongoCode: 11000, insertedCount: 0 } + }) + }) +}) + +describe('mapError — fallbacks', () => { + it('maps an unknown Error to internal with its message', () => { + expect(mapError(named('Socks5ProtocolError', 'bad reply'))).toEqual({ + code: 'internal', + message: 'bad reply' + }) + }) + + it('maps a non-Error value to internal and carries it as details', () => { + expect(mapError('kaputt')).toEqual({ + code: 'internal', + message: 'Unknown error', + details: 'kaputt' + }) + }) +}) diff --git a/src/main/lib/errorMap.ts b/src/main/lib/errorMap.ts index 9266c58..1929270 100644 --- a/src/main/lib/errorMap.ts +++ b/src/main/lib/errorMap.ts @@ -1,5 +1,19 @@ import type { ErrorCode } from '@shared/result' +/** An error `mapError` reports as `validation_error`: the request itself is invalid. */ +export function validationError(message: string): Error { + const e = new Error(message) + e.name = 'ValidationError' + return e +} + +/** An error `mapError` reports as `cancelled`: the user stopped the operation, nothing failed. */ +export function cancelledError(message: string): Error { + const e = new Error(message) + e.name = 'CancelledError' + return e +} + /** * Map a thrown error (driver, filesystem, validation) to one of our * internal `ErrorCode`s. This is the only place that should pattern-match @@ -9,29 +23,15 @@ export function mapError(error: unknown): { code: ErrorCode; message: string; de if (error instanceof Error) { const name = error.name const message = error.message - const code = readCodeField(error) + const code = readNumberField(error, 'code') - // SSH failures come before the driver checks: when a tunnel cannot be + // Tunnel failures come before the driver checks: when a tunnel cannot be // opened the driver never runs, and reporting "server selection timed out" - // for a rejected SSH key would point at the wrong end of the problem. - if (name === 'SshHostKeyMismatchError') { - return { code: 'ssh_host_key_mismatch', message } - } - - if (name === 'SshAuthError') { - return { code: 'ssh_auth_failed', message } - } - - if (name === 'SshConnectError') { - return { code: 'ssh_connect_failed', message } - } - - if (name === 'MongoServerSelectionError') { - return { code: 'server_selection_timeout', message } - } - - if (name === 'MongoNetworkError' || name === 'MongoNetworkTimeoutError') { - return { code: 'network_error', message } + // for a rejected SSH key or a cloudflared that never started would point + // at the wrong end of the problem. + const named = CODE_BY_NAME.get(name) + if (named !== undefined) { + return { code: named, message } } if ( @@ -43,7 +43,21 @@ export function mapError(error: unknown): { code: ErrorCode; message: string; de return { code: 'auth_failed', message } } - if (name === 'MongoServerError' || name === 'MongoBulkWriteError') { + // A failed insertMany / bulkWrite keeps earlier writes; saying so prevents a duplicating retry. + if (name === 'MongoBulkWriteError') { + const insertedCount = readNumberField(error, 'insertedCount') + const progress = bulkProgress(error) + return { + code: 'driver_error', + message: progress === null ? message : `${message} — ${progress} before this error`, + details: { + ...(code !== null ? { mongoCode: code } : {}), + ...(insertedCount !== null ? { insertedCount } : {}) + } + } + } + + if (name === 'MongoServerError' || name === 'MongoWriteConcernError') { return { code: 'driver_error', message, @@ -73,7 +87,45 @@ export function mapError(error: unknown): { code: ErrorCode; message: string; de return { code: 'internal', message: 'Unknown error', details: error } } -function readCodeField(error: Error): number | null { - const code = (error as { code?: unknown }).code - return typeof code === 'number' ? code : null +const CODE_BY_NAME: ReadonlyMap = new Map([ + ['SshHostKeyMismatchError', 'ssh_host_key_mismatch'], + ['SshAuthError', 'ssh_auth_failed'], + ['SshConnectError', 'ssh_connect_failed'], + ['CloudflaredNotFoundError', 'cloudflared_not_found'], + ['CloudflaredAuthError', 'cloudflared_auth_failed'], + ['CloudflaredTunnelError', 'cloudflared_failed'], + ['CancelledError', 'cancelled'], + ['NotConnectedError', 'not_connected'], + ['MongoServerSelectionError', 'server_selection_timeout'], + ['MongoNetworkError', 'network_error'], + ['MongoNetworkTimeoutError', 'network_error'] +]) + +const BULK_COUNTS = [ + ['insertedCount', 'inserted'], + ['matchedCount', 'matched'], + ['modifiedCount', 'modified'], + ['deletedCount', 'deleted'], + ['upsertedCount', 'upserted'] +] as const + +type CountField = (typeof BULK_COUNTS)[number][0] + +// "1 document was inserted, 3 matched and 2 deleted", or null when every counter is zero. +function bulkProgress(error: Error): string | null { + const parts = BULK_COUNTS.flatMap(([field, verb]) => { + const count = readNumberField(error, field) + return count !== null && count > 0 ? [{ count, verb }] : [] + }) + const [first, ...rest] = parts + if (first === undefined) return null + const head = `${first.count} document${first.count === 1 ? ' was' : 's were'} ${first.verb}` + const tail = rest.map(({ count, verb }) => `${count} ${verb}`) + const last = tail.pop() + return last === undefined ? head : `${[head, ...tail].join(', ')} and ${last}` +} + +function readNumberField(error: Error, field: 'code' | CountField): number | null { + const value = (error as unknown as Record)[field] + return typeof value === 'number' ? value : null } diff --git a/src/main/lib/externalUrl.test.ts b/src/main/lib/externalUrl.test.ts new file mode 100644 index 0000000..e9c1d8c --- /dev/null +++ b/src/main/lib/externalUrl.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { opensInBrowser } from './externalUrl' + +describe('opensInBrowser', () => { + it('lets an https URL go to the browser', () => { + expect( + opensInBrowser('https://mongo.example.com/cdn-cgi/access/cli?aud=abc&redirect_url=x') + ).toBe(true) + }) + + it.each([ + ['plain http', 'http://example.com/'], + ['a file URL', 'file:///C:/Windows/System32/calc.exe'], + ['a UNC file URL', 'file://attacker.invalid/share/payload.exe'], + ['a protocol handler', 'ms-msdt:/id PCWDiagnostic'], + ['a search handler', 'search-ms:query=x&crumb=location:\\\\attacker.invalid\\share'], + ['script', 'javascript:alert(1)'], + ['a bare UNC path', '\\\\attacker.invalid\\share\\payload.exe'], + ['an unparseable string', 'not a url'] + ])('keeps %s away from the operating system', (_case, url) => { + expect(opensInBrowser(url)).toBe(false) + }) +}) diff --git a/src/main/lib/externalUrl.ts b/src/main/lib/externalUrl.ts new file mode 100644 index 0000000..466803f --- /dev/null +++ b/src/main/lib/externalUrl.ts @@ -0,0 +1,4 @@ +/** True when main may hand `url` to `shell.openExternal`, which starts whatever handles its scheme: https only. */ +export function opensInBrowser(url: string): boolean { + return URL.parse(url)?.protocol === 'https:' +} diff --git a/src/main/lib/indexStats.test.ts b/src/main/lib/indexStats.test.ts new file mode 100644 index 0000000..ae8b966 --- /dev/null +++ b/src/main/lib/indexStats.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from 'vitest' +import { Long } from 'bson' +import { buildIndexStatsReport, classifyStatsError } from './indexStats' + +type RawDocs = Array> + +const SINCE = new Date('2026-10-01T08:00:00.000Z') + +function fulfilled(value: RawDocs): PromiseSettledResult { + return { status: 'fulfilled', value } +} + +function rejected(reason: unknown): PromiseSettledResult { + return { status: 'rejected', reason } +} + +function serverError(message: string, code: number): Error { + return Object.assign(new Error(message), { code }) +} + +function usageDoc(name: string, ops: unknown, extra: Record = {}) { + return { name, key: { a: 1 }, host: 'db1:27017', accesses: { ops, since: SINCE }, ...extra } +} + +function storageDoc(indexSizes: Record, extra: Record = {}) { + return { ns: 'app.players', storageStats: { indexSizes, ...extra } } +} + +describe('buildIndexStatsReport', () => { + it('turns a single host into ISO samples and merges sizes', () => { + const report = buildIndexStatsReport( + fulfilled([usageDoc('_id_', 12), usageDoc('name_1', 0)]), + fulfilled([storageDoc({ _id_: 4096, name_1: 2048 })]) + ) + + expect(report).toEqual({ + indexes: [ + { + name: '_id_', + size: 4096, + building: false, + usage: [{ host: 'db1:27017', ops: 12, since: '2026-10-01T08:00:00.000Z' }] + }, + { + name: 'name_1', + size: 2048, + building: false, + usage: [{ host: 'db1:27017', ops: 0, since: '2026-10-01T08:00:00.000Z' }] + } + ], + usageUnavailable: null, + storageUnavailable: null + }) + expect(report.indexes[0]?.usage[0]).not.toHaveProperty('shard') + }) + + it('converts Long and bigint counters to numbers', () => { + const report = buildIndexStatsReport( + fulfilled([ + usageDoc('a_1', Long.fromNumber(3)), + usageDoc('b_1', Long.fromString('9007199254740993')), + usageDoc('c_1', 5n) + ]), + fulfilled([]) + ) + + expect(report.indexes.map((i) => i.usage[0]?.ops)).toEqual([3, 9007199254740992, 5]) + }) + + it('keeps one sample per shard in input order and sums sizes across shards', () => { + const report = buildIndexStatsReport( + fulfilled([ + usageDoc('name_1', 7, { host: 'a1:27018', shard: 'shard-a' }), + usageDoc('name_1', 9, { host: 'b1:27018', shard: 'shard-b' }) + ]), + fulfilled([ + storageDoc({ name_1: 1000 }, { shard: 'shard-a' }), + storageDoc({ name_1: 1500 }, { shard: 'shard-b' }) + ]) + ) + + expect(report.indexes).toEqual([ + { + name: 'name_1', + size: 2500, + building: false, + usage: [ + { host: 'a1:27018', shard: 'shard-a', ops: 7, since: '2026-10-01T08:00:00.000Z' }, + { host: 'b1:27018', shard: 'shard-b', ops: 9, since: '2026-10-01T08:00:00.000Z' } + ] + } + ]) + }) + + it('marks an index building from $indexStats', () => { + const report = buildIndexStatsReport( + fulfilled([usageDoc('name_1', 0, { building: true })]), + fulfilled([]) + ) + + expect(report.indexes[0]?.building).toBe(true) + }) + + it('marks an index building from indexBuilds even when usage was rejected', () => { + const report = buildIndexStatsReport( + rejected(serverError('not authorized', 13)), + fulfilled([storageDoc({ _id_: 4096, x_1: 0 }, { indexBuilds: ['x_1'] })]) + ) + + expect(report.indexes).toEqual([ + { name: '_id_', size: 4096, building: false, usage: [] }, + { name: 'x_1', size: 0, building: true, usage: [] } + ]) + }) + + it('skips a usage document without a name', () => { + const report = buildIndexStatsReport( + fulfilled([{ key: { a: 1 }, host: 'db1:27017', accesses: { ops: 1, since: SINCE } }]), + fulfilled([]) + ) + + expect(report.indexes).toEqual([]) + }) + + it('keeps the entry but drops the sample of a malformed usage document', () => { + const report = buildIndexStatsReport( + fulfilled([ + { + name: 'string_since', + host: 'db1:27017', + accesses: { ops: 1, since: SINCE.toISOString() } + }, + { name: 'no_accesses', host: 'db1:27017' }, + usageDoc('bad_ops', 'x'), + usageDoc('no_host', 1, { host: 42 }), + usageDoc('invalid_date', 1, { accesses: { ops: 1, since: new Date(Number.NaN) } }) + ]), + fulfilled([]) + ) + + expect(report.indexes).toEqual([ + { name: 'string_since', building: false, usage: [] }, + { name: 'no_accesses', building: false, usage: [] }, + { name: 'bad_ops', building: false, usage: [] }, + { name: 'no_host', building: false, usage: [] }, + { name: 'invalid_date', building: false, usage: [] } + ]) + }) + + it('skips a non-numeric index size', () => { + const report = buildIndexStatsReport( + fulfilled([]), + fulfilled([storageDoc({ _id_: 'large', name_1: 2048 })]) + ) + + expect(report.indexes).toEqual([{ name: 'name_1', size: 2048, building: false, usage: [] }]) + }) + + it('reports usage as unauthorized on code 13 and still returns sizes', () => { + const report = buildIndexStatsReport( + rejected(serverError('not authorized on app to execute command', 13)), + fulfilled([storageDoc({ _id_: 4096 })]) + ) + + expect(report.usageUnavailable).toEqual({ + reason: 'unauthorized', + message: 'not authorized on app to execute command' + }) + expect(report.storageUnavailable).toBeNull() + expect(report.indexes).toEqual([{ name: '_id_', size: 4096, building: false, usage: [] }]) + }) + + it('reports storage as unsupported on code 166 and still returns usage', () => { + const report = buildIndexStatsReport( + fulfilled([usageDoc('_id_', 3)]), + rejected(serverError('Namespace app.v is a view, not a collection', 166)) + ) + + expect(report.storageUnavailable).toEqual({ + reason: 'unsupported', + message: 'Namespace app.v is a view, not a collection' + }) + expect(report.usageUnavailable).toBeNull() + expect(report.indexes).toEqual([ + { + name: '_id_', + building: false, + usage: [{ host: 'db1:27017', ops: 3, since: '2026-10-01T08:00:00.000Z' }] + } + ]) + }) + + it('does not throw when both sources are rejected', () => { + const report = buildIndexStatsReport(rejected(new Error('boom')), rejected('down')) + + expect(report).toEqual({ + indexes: [], + usageUnavailable: { reason: 'failed', message: 'boom' }, + storageUnavailable: { reason: 'failed', message: 'down' } + }) + }) + + it('creates an entry for a name only $collStats reports', () => { + const report = buildIndexStatsReport( + fulfilled([usageDoc('_id_', 1)]), + fulfilled([storageDoc({ _id_: 4096, fresh_1: 1024 })]) + ) + + expect(report.indexes[1]).toEqual({ name: 'fresh_1', size: 1024, building: false, usage: [] }) + }) + + it('merges an index named __proto__ like any other', () => { + const indexSizes: Record = {} + Object.defineProperty(indexSizes, '__proto__', { + value: 256, + enumerable: true, + configurable: true, + writable: true + }) + + const report = buildIndexStatsReport( + fulfilled([usageDoc('__proto__', 2)]), + fulfilled([storageDoc(indexSizes)]) + ) + + expect(report.indexes).toEqual([ + { + name: '__proto__', + size: 256, + building: false, + usage: [{ host: 'db1:27017', ops: 2, since: '2026-10-01T08:00:00.000Z' }] + } + ]) + }) +}) + +describe('classifyStatsError', () => { + it.each([115, 166, 40324])('reports code %i as unsupported', (code) => { + expect(classifyStatsError(serverError('nope', code))).toEqual({ + reason: 'unsupported', + message: 'nope' + }) + }) + + it('reports code 13 as unauthorized', () => { + expect(classifyStatsError(serverError('not authorized', 13)).reason).toBe('unauthorized') + }) + + it.each([ + ['another server code', serverError('boom', 2)], + ['an Error without a code', new Error('boom')], + ['a thrown string', 'boom'] + ])('reports %s as failed and keeps the message', (_label, error) => { + expect(classifyStatsError(error)).toEqual({ reason: 'failed', message: 'boom' }) + }) +}) diff --git a/src/main/lib/indexStats.ts b/src/main/lib/indexStats.ts new file mode 100644 index 0000000..24e0ae5 --- /dev/null +++ b/src/main/lib/indexStats.ts @@ -0,0 +1,98 @@ +import { Long } from 'bson' +import type { + IndexRuntimeStats, + IndexStatsReport, + IndexStatsUnavailable, + IndexUsageSample +} from '@shared/types' +import { isDocument } from '@shared/writeOps' +import { storageParts, sumIndexSizes } from './storageStats' + +type RawDocs = ReadonlyArray> + +const UNSUPPORTED_CODES = new Set([ + 115, // CommandNotSupported + 166, // CommandNotSupportedOnView + 40324 // Unrecognized pipeline stage name: servers/emulators without $indexStats +]) + +/** Merges `$indexStats` and `$collStats` output into one entry per index name; never throws. */ +export function buildIndexStatsReport( + usage: PromiseSettledResult, + storage: PromiseSettledResult +): IndexStatsReport { + // A Map, not an object: an index may be named "__proto__". + const entries = new Map() + const entry = (name: string): IndexRuntimeStats => { + let stats = entries.get(name) + if (!stats) { + stats = { name, building: false, usage: [] } + entries.set(name, stats) + } + return stats + } + + if (usage.status === 'fulfilled') { + for (const doc of usage.value) { + const name = doc['name'] + if (typeof name !== 'string') continue + const stats = entry(name) + if (doc['building'] === true) stats.building = true + const sample = usageSample(doc) + if (sample) stats.usage.push(sample) + } + } + + if (storage.status === 'fulfilled') { + const parts = storageParts(storage.value) + for (const [name, size] of Object.entries(sumIndexSizes(parts))) entry(name).size = size + for (const part of parts) { + const builds = part['indexBuilds'] + if (!Array.isArray(builds)) continue + for (const name of builds) { + if (typeof name === 'string') entry(name).building = true + } + } + } + + return { + indexes: [...entries.values()], + usageUnavailable: usage.status === 'rejected' ? classifyStatsError(usage.reason) : null, + storageUnavailable: storage.status === 'rejected' ? classifyStatsError(storage.reason) : null + } +} + +/** Maps a failed stats read to a reason the UI can explain; `message` keeps the server text. */ +export function classifyStatsError(error: unknown): IndexStatsUnavailable { + const message = error instanceof Error ? error.message : String(error) + const code = isDocument(error) && typeof error['code'] === 'number' ? error['code'] : null + if (code === 13) return { reason: 'unauthorized', message } + if (code !== null && UNSUPPORTED_CODES.has(code)) return { reason: 'unsupported', message } + return { reason: 'failed', message } +} + +function usageSample(doc: Record): IndexUsageSample | undefined { + const host = doc['host'] + const accesses = doc['accesses'] + if (typeof host !== 'string' || !isDocument(accesses)) return undefined + const ops = toSafeNumber(accesses['ops']) + const since = accesses['since'] + if (ops === undefined || !(since instanceof Date) || Number.isNaN(since.getTime())) { + return undefined + } + const shard = doc['shard'] + return { + host, + ...(typeof shard === 'string' ? { shard } : {}), + ops, + since: since.toISOString() + } +} + +function toSafeNumber(value: unknown): number | undefined { + if (typeof value === 'number') return Number.isFinite(value) ? value : undefined + if (typeof value === 'bigint') return Number(value) + // The driver promotes Longs to numbers only while they fit in 53 bits. + if (Long.isLong(value)) return value.toNumber() + return undefined +} diff --git a/src/main/lib/knownHosts.test.ts b/src/main/lib/knownHosts.test.ts index 46bc999..9858a94 100644 --- a/src/main/lib/knownHosts.test.ts +++ b/src/main/lib/knownHosts.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest' -import { fingerprint, findHostKeys, keyMatches, parseKnownHosts } from './knownHosts' +import { + fingerprint, + findHostKeys, + isRevoked, + keyMatches, + keyTypeOf, + orderHostKeyAlgorithms, + parseKnownHosts +} from './knownHosts' // Real ed25519 key plus the two hashed lines OpenSSH itself produced for it // via `ssh-keygen -H`, so the HMAC matching is checked against the reference @@ -15,20 +23,51 @@ const OTHER_KEY = Buffer.from( 'base64' ) +const RSA_KEY = Buffer.from( + 'AAAAB3NzaC1yc2EAAAADAQABAAAAgQC/gX/7WgOQ6kBJXm40rYn/ObaTIBNCWsuJI/6S/wSImH72Nfe2K50W7fBtgBtvhfhY9b56Mfhzh/jXNBdjJtKXjfjRPS2004BGz2jsaLdKeviBryT/jbo4tEX4SA/+NDKlIUwXb5TgSzN+7yTNcCNTdC0zsU77JVcOEgxyu5jYiQ==', + 'base64' +) +const ECDSA_KEY = Buffer.from( + 'AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBCXAVIxb/s+tZzJwt47GTXuciOdpToANn3jirNMDAYBn0pFQlw0DJ0dTMFgTO8GN36IeBxjU159s/yYS5CmVk7g=', + 'base64' +) + +/** ssh2's own default order on a runtime with ed25519 support. */ +const SSH2_DEFAULTS = [ + 'ssh-ed25519', + 'ecdsa-sha2-nistp256', + 'ecdsa-sha2-nistp384', + 'ecdsa-sha2-nistp521', + 'rsa-sha2-512', + 'rsa-sha2-256', + 'ssh-rsa' +] as const + describe('parseKnownHosts', () => { - it('skips comments, blank lines and marker lines', () => { + it('skips comments, blank lines and @cert-authority lines', () => { const entries = parseKnownHosts( [ '# a comment', '', ' ', `@cert-authority *.example.com ssh-ed25519 ${KEY_BASE64}`, - `@revoked gate.example.com ssh-ed25519 ${KEY_BASE64}`, `gate.example.com ssh-ed25519 ${KEY_BASE64}` ].join('\n') ) expect(entries).toHaveLength(1) expect(entries[0]?.keyType).toBe('ssh-ed25519') + expect(entries[0]?.revoked).toBe(false) + }) + + it('keeps an @revoked line as a revoked entry', () => { + const entries = parseKnownHosts(`@revoked * ssh-ed25519 ${KEY_BASE64}`) + expect(entries).toHaveLength(1) + expect(entries[0]?.revoked).toBe(true) + expect(entries[0]?.key).toEqual(KEY) + }) + + it('skips lines with an unknown marker', () => { + expect(parseKnownHosts(`@unknown gate.example.com ssh-ed25519 ${KEY_BASE64}`)).toEqual([]) }) it('reads several patterns off one line', () => { @@ -106,6 +145,96 @@ describe('findHostKeys', () => { ) expect(findHostKeys(entries, 'gate.example.com', 22)).toHaveLength(2) }) + + it('never returns a revoked key', () => { + const entries = parseKnownHosts( + [ + `@revoked gate.example.com ssh-ed25519 ${KEY_BASE64}`, + `gate.example.com ssh-ed25519 ${OTHER_KEY.toString('base64')}` + ].join('\n') + ) + expect(findHostKeys(entries, 'gate.example.com', 22)).toEqual([OTHER_KEY]) + }) +}) + +describe('isRevoked', () => { + it('applies a wildcard revocation to any host presenting that key', () => { + const entries = parseKnownHosts(`@revoked * ssh-ed25519 ${KEY_BASE64}`) + expect(isRevoked(entries, 'gate.example.com', 22, KEY)).toBe(true) + expect(isRevoked(entries, 'other.example.org', 2222, KEY)).toBe(true) + }) + + it('does not apply to a different key', () => { + const entries = parseKnownHosts(`@revoked * ssh-ed25519 ${KEY_BASE64}`) + expect(isRevoked(entries, 'gate.example.com', 22, OTHER_KEY)).toBe(false) + }) + + it('only applies to hosts its patterns match', () => { + const entries = parseKnownHosts(`@revoked gate.example.com ssh-ed25519 ${KEY_BASE64}`) + expect(isRevoked(entries, 'gate.example.com', 22, KEY)).toBe(true) + expect(isRevoked(entries, 'other.example.com', 22, KEY)).toBe(false) + }) + + it('ignores plain entries carrying the same key', () => { + const entries = parseKnownHosts(`gate.example.com ssh-ed25519 ${KEY_BASE64}`) + expect(isRevoked(entries, 'gate.example.com', 22, KEY)).toBe(false) + }) +}) + +describe('keyTypeOf', () => { + it('reads the type name of an ed25519 blob', () => { + expect(keyTypeOf(KEY)).toBe('ssh-ed25519') + }) + + it('reads the type name of an RSA blob', () => { + expect(keyTypeOf(RSA_KEY)).toBe('ssh-rsa') + }) + + it('reads the type name of an ECDSA blob', () => { + expect(keyTypeOf(ECDSA_KEY)).toBe('ecdsa-sha2-nistp256') + }) + + it('returns null for a blob cut off inside the type name', () => { + expect(keyTypeOf(KEY.subarray(0, 8))).toBeNull() + }) + + it('returns null for a blob too short to hold a length', () => { + expect(keyTypeOf(Buffer.from([0, 0]))).toBeNull() + }) +}) + +describe('orderHostKeyAlgorithms', () => { + it('puts every RSA signature algorithm first for a known ssh-rsa key', () => { + expect(orderHostKeyAlgorithms(['ssh-rsa'], SSH2_DEFAULTS)).toEqual([ + 'rsa-sha2-512', + 'rsa-sha2-256', + 'ssh-rsa', + 'ssh-ed25519', + 'ecdsa-sha2-nistp256', + 'ecdsa-sha2-nistp384', + 'ecdsa-sha2-nistp521' + ]) + }) + + it('keeps the default order among several known types', () => { + expect(orderHostKeyAlgorithms(['ssh-rsa', 'ecdsa-sha2-nistp384'], SSH2_DEFAULTS)).toEqual([ + 'ecdsa-sha2-nistp384', + 'rsa-sha2-512', + 'rsa-sha2-256', + 'ssh-rsa', + 'ssh-ed25519', + 'ecdsa-sha2-nistp256', + 'ecdsa-sha2-nistp521' + ]) + }) + + it('keeps the defaults when no type is known', () => { + expect(orderHostKeyAlgorithms([], SSH2_DEFAULTS)).toEqual([...SSH2_DEFAULTS]) + }) + + it('ignores known types the defaults do not offer', () => { + expect(orderHostKeyAlgorithms(['ssh-dss'], SSH2_DEFAULTS)).toEqual([...SSH2_DEFAULTS]) + }) }) describe('keyMatches', () => { diff --git a/src/main/lib/knownHosts.ts b/src/main/lib/knownHosts.ts index b84dcfd..f6ceda4 100644 --- a/src/main/lib/knownHosts.ts +++ b/src/main/lib/knownHosts.ts @@ -4,14 +4,17 @@ * client is trusted here too. Pure — the caller supplies the contents. * * Supported: plain patterns (with `*` / `?` wildcards and `!` negation), the - * `[host]:port` form, and `|1|salt|hash` hashed entries. `@cert-authority` and - * `@revoked` lines are skipped; we do not implement CA validation, and - * skipping is the safe direction since such a line then authorises nothing. + * `[host]:port` form, `|1|salt|hash` hashed entries, and `@revoked` lines, + * whose key is refused for every host they match. `@cert-authority` lines are + * skipped; we do not implement CA validation, and skipping is the safe + * direction since such a line then authorises nothing. */ import { createHash, createHmac, timingSafeEqual } from 'node:crypto' import { DEFAULT_SSH_PORT } from '@shared/types' +const REVOKED_MARKER = '@revoked' + export type HostMatcher = | { kind: 'plain'; pattern: string; negated: boolean } | { kind: 'hashed'; salt: Buffer; digest: Buffer } @@ -21,16 +24,21 @@ export type KnownHostEntry = { /** e.g. `ssh-ed25519`. Diagnostics only; matching goes by key bytes. */ keyType: string key: Buffer + /** From an `@revoked` line: the key is refused, never authorised. */ + revoked: boolean } export function parseKnownHosts(content: string): KnownHostEntry[] { const entries: KnownHostEntry[] = [] for (const rawLine of content.split(/\r?\n/)) { const line = rawLine.trim() - if (line.length === 0 || line.startsWith('#') || line.startsWith('@')) continue + if (line.length === 0 || line.startsWith('#')) continue - const [hostField, keyType, keyBase64] = line.split(/\s+/) + const fields = line.split(/\s+/) + const revoked = fields[0] === REVOKED_MARKER + const [hostField, keyType, keyBase64] = revoked ? fields.slice(1) : fields if (hostField === undefined || keyType === undefined || keyBase64 === undefined) continue + if (hostField.startsWith('@')) continue const key = Buffer.from(keyBase64, 'base64') if (key.length === 0) continue @@ -38,7 +46,7 @@ export function parseKnownHosts(content: string): KnownHostEntry[] { const hosts = hostField.split(',').flatMap(parseMatcher) if (hosts.length === 0) continue - entries.push({ hosts, keyType, key }) + entries.push({ hosts, keyType, key, revoked }) } return entries } @@ -75,11 +83,24 @@ export function findHostKeys(entries: KnownHostEntry[], host: string, port: numb const names = candidateNames(host, port) const keys: Buffer[] = [] for (const entry of entries) { - if (entryMatches(entry, names)) keys.push(entry.key) + if (!entry.revoked && entryMatches(entry, names)) keys.push(entry.key) } return keys } +/** Whether an `@revoked` line that matches this host lists `key`. */ +export function isRevoked( + entries: KnownHostEntry[], + host: string, + port: number, + key: Buffer +): boolean { + const names = candidateNames(host, port) + return entries.some( + (entry) => entry.revoked && keyMatches(key, entry.key) && entryMatches(entry, names) + ) +} + function entryMatches(entry: KnownHostEntry, names: string[]): boolean { let matched = false for (const matcher of entry.hosts) { @@ -119,3 +140,29 @@ export function keyMatches(candidate: Buffer, known: Buffer): boolean { export function fingerprint(key: Buffer): string { return `SHA256:${createHash('sha256').update(key).digest('base64').replace(/=+$/, '')}` } + +/** The type name a public key blob starts with, e.g. `ssh-rsa`; null for a malformed blob. */ +export function keyTypeOf(blob: Buffer): string | null { + if (blob.length < 4) return null + const end = 4 + blob.readUInt32BE(0) + return end <= blob.length ? blob.toString('ascii', 4, end) : null +} + +/** + * `defaults` with the algorithms of already trusted key types moved to the front, as OpenSSH does. + * @param knownTypes key types (as `keyTypeOf` reads them) the host is trusted with. + * @param defaults host key algorithms in preference order. + * @returns the same algorithms; both groups keep their order from `defaults`. + */ +export function orderHostKeyAlgorithms( + knownTypes: readonly string[], + defaults: readonly T[] +): T[] { + const known = (algorithm: T): boolean => knownTypes.includes(algorithmKeyType(algorithm)) + return [...defaults.filter(known), ...defaults.filter((algorithm) => !known(algorithm))] +} + +// RSA keys also sign under the rsa-sha2-* names (RFC 8332), but their blob always says ssh-rsa. +function algorithmKeyType(algorithm: string): string { + return algorithm.startsWith('rsa-sha2-') ? 'ssh-rsa' : algorithm +} diff --git a/src/main/lib/socks5.test.ts b/src/main/lib/socks5.test.ts index 4988f60..82b2c30 100644 --- a/src/main/lib/socks5.test.ts +++ b/src/main/lib/socks5.test.ts @@ -1,6 +1,7 @@ -import type { Socket } from 'node:net' +import { once } from 'node:events' +import { createConnection, type Socket } from 'node:net' import { PassThrough } from 'node:stream' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { SocksClient } from 'socks' import { createSocks5Server, type Socks5Server } from './socks5' @@ -22,7 +23,8 @@ type Requested = { host: string; port: number } */ async function startProxy( requested: Requested[], - connect?: (host: string, port: number) => Promise + connect?: (host: string, port: number) => Promise, + onError?: (error: Error) => void ): Promise { server = await createSocks5Server({ username: USERNAME, @@ -30,11 +32,37 @@ async function startProxy( connect: (host, port) => { requested.push({ host, port }) return connect ? connect(host, port) : Promise.resolve(new PassThrough()) - } + }, + onError }) return server } +/** + * Sends greeting, credentials and CONNECT in one write and never reads the + * replies, so the test holds the raw socket while the outbound leg is pending. + */ +async function sendConnectRequest(proxyPort: number, destination: Requested): Promise { + const socket = createConnection({ host: '127.0.0.1', port: proxyPort }) + await once(socket, 'connect') + const host = Buffer.from(destination.host, 'utf8') + const port = Buffer.alloc(2) + port.writeUInt16BE(destination.port) + socket.write( + Buffer.concat([ + Buffer.from([0x05, 0x01, 0x02]), + Buffer.from([0x01, USERNAME.length]), + Buffer.from(USERNAME, 'utf8'), + Buffer.from([PASSWORD.length]), + Buffer.from(PASSWORD, 'utf8'), + Buffer.from([0x05, 0x01, 0x00, 0x03, host.length]), + host, + port + ]) + ) + return socket +} + function connectThrough( proxyPort: number, destination: Requested, @@ -146,6 +174,43 @@ describe('createSocks5Server', () => { ).rejects.toThrow() }) + it('reports why the outbound leg could not be opened', async () => { + const errors: Error[] = [] + const proxy = await startProxy( + [], + () => Promise.reject(new Error('(SSH) Channel open failure: administratively prohibited')), + (error) => errors.push(error) + ) + await expect( + connectThrough(proxy.port, { host: 'mongo1.internal', port: 27017 }) + ).rejects.toThrow() + expect(errors[0]?.message).toBe( + 'failed to forward to mongo1.internal:27017: (SSH) Channel open failure: administratively prohibited' + ) + }) + + it('destroys an outbound leg that opens after the client is gone', async () => { + let openRemote: (remote: PassThrough) => void = () => undefined + const pending = new Promise((resolve) => (openRemote = resolve)) + const requested: Requested[] = [] + const errors: Error[] = [] + const proxy = await startProxy( + requested, + () => pending, + (error) => errors.push(error) + ) + + const client = await sendConnectRequest(proxy.port, { host: 'mongo1.internal', port: 27017 }) + await vi.waitFor(() => expect(requested).toHaveLength(1)) + // A reset, unlike a FIN, leaves no 'end' that piping could pass on to the outbound leg. + client.resetAndDestroy() + await vi.waitFor(() => expect(errors).not.toHaveLength(0)) + + const remote = new PassThrough() + openRemote(remote) + await vi.waitFor(() => expect(remote.destroyed).toBe(true)) + }) + it('destroys live connections on close', async () => { const proxy = await startProxy([]) const { socket } = await connectThrough(proxy.port, { host: 'mongo1.internal', port: 27017 }) diff --git a/src/main/lib/socks5.ts b/src/main/lib/socks5.ts index d4a5c50..49de8d5 100644 --- a/src/main/lib/socks5.ts +++ b/src/main/lib/socks5.ts @@ -202,7 +202,13 @@ function handleClient(socket: Socket, options: Socks5Options): void { remote = await options.connect(host, port) } catch (cause) { socket.end(replyFrame(REPLY_CONNECTION_REFUSED)) - throw new Error(`failed to forward to ${host}:${port}`, { cause }) + const reason = cause instanceof Error ? cause.message : String(cause) + throw new Error(`failed to forward to ${host}:${port}: ${reason}`, { cause }) + } + // The client left while the leg was opening, so the 'close' hook below would come too late. + if (socket.destroyed) { + remote.destroy() + return } // The driver drives its own idle timeouts from here on. socket.setTimeout(0) diff --git a/src/main/lib/storageStats.test.ts b/src/main/lib/storageStats.test.ts new file mode 100644 index 0000000..b16710b --- /dev/null +++ b/src/main/lib/storageStats.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest' +import { mergeStorageStats, storageParts, sumIndexSizes } from './storageStats' + +describe('mergeStorageStats', () => { + it('passes a single document through', () => { + const merged = mergeStorageStats([ + { + ns: 'app.players', + storageStats: { + count: 4, + size: 400, + avgObjSize: 100, + storageSize: 8192, + freeStorageSize: 1024, + totalIndexSize: 4096, + totalSize: 12288, + indexSizes: { _id_: 2048, name_1: 2048 }, + nindexes: 2, + capped: false, + wiredTiger: { uri: 'statistics:table:collection-0' } + } + } + ]) + + expect(merged).toEqual({ + ns: 'app.players', + storage: { + count: 4, + size: 400, + storageSize: 8192, + freeStorageSize: 1024, + totalIndexSize: 4096, + totalSize: 12288, + indexSizes: { _id_: 2048, name_1: 2048 }, + nindexes: 2, + capped: false + } + }) + }) + + it('sums every shard and merges indexSizes by name', () => { + const merged = mergeStorageStats([ + { + ns: 'app.players', + shard: 'shard-a', + storageStats: { + count: 3, + size: 300, + avgObjSize: 100, + storageSize: 4096, + freeStorageSize: 0, + totalIndexSize: 3000, + totalSize: 7096, + indexSizes: { _id_: 1000, name_1: 2000 }, + nindexes: 2, + capped: false, + numOrphanDocs: 1 + } + }, + { + ns: 'app.players', + shard: 'shard-b', + storageStats: { + count: 1, + size: 500, + avgObjSize: 500, + storageSize: 8192, + freeStorageSize: 512, + totalIndexSize: 700, + totalSize: 8892, + indexSizes: { _id_: 300, region_1: 400 }, + nindexes: 3, + capped: true, + numOrphanDocs: 2 + } + } + ]) + + expect(merged).toEqual({ + ns: 'app.players', + storage: { + count: 4, + size: 800, + storageSize: 12288, + freeStorageSize: 512, + totalIndexSize: 3700, + totalSize: 15988, + numOrphanDocs: 3, + indexSizes: { _id_: 1300, name_1: 2000, region_1: 400 }, + nindexes: 2, + capped: false + } + }) + }) + + it('leaves numOrphanDocs out when no shard reports it', () => { + const { storage } = mergeStorageStats([ + { ns: 'app.players', storageStats: { count: 1, size: 10 } }, + { ns: 'app.players', storageStats: { count: 1, size: 10 } } + ]) + + expect(storage).not.toHaveProperty('numOrphanDocs') + }) + + it('counts non-number values as 0', () => { + const { storage } = mergeStorageStats([ + { ns: 'app.players', storageStats: { count: 2, size: 'big', storageSize: null } }, + { ns: 'app.players', storageStats: { count: Number.NaN, size: 60, indexSizes: { a_1: 'x' } } } + ]) + + expect(storage).toMatchObject({ + count: 2, + size: 60, + storageSize: 0, + indexSizes: {} + }) + }) + + it('leaves count out when no shard reports one, as for a time-series collection', () => { + const { storage } = mergeStorageStats([ + { + ns: 'app.system.buckets.readings', + storageStats: { size: 959, timeseries: { bucketCount: 3 } } + } + ]) + + expect(storage).not.toHaveProperty('count') + expect(storage['size']).toBe(959) + }) + + it('keeps an index named __proto__ as an own entry', () => { + const indexSizes: Record = {} + Object.defineProperty(indexSizes, '__proto__', { + value: 512, + enumerable: true, + configurable: true, + writable: true + }) + + const { storage } = mergeStorageStats([ + { ns: 'app.players', storageStats: { indexSizes } }, + { ns: 'app.players', storageStats: { indexSizes } } + ]) + + expect(Object.entries(storage['indexSizes'] as object)).toEqual([['__proto__', 1024]]) + }) + + it('gives an empty storage object and no ns for no documents', () => { + expect(mergeStorageStats([])).toEqual({ ns: null, storage: {} }) + }) +}) + +describe('storageParts', () => { + it('takes the storageStats of each document and gives {} where there is none', () => { + expect( + storageParts([{ storageStats: { size: 1 } }, { storageStats: null }, { ns: 'app.players' }]) + ).toEqual([{ size: 1 }, {}, {}]) + }) +}) + +describe('sumIndexSizes', () => { + it('sums each index over the parts and skips parts without indexSizes', () => { + expect( + sumIndexSizes([{ indexSizes: { _id_: 10, a_1: 5 } }, {}, { indexSizes: { _id_: 30 } }]) + ).toEqual({ _id_: 40, a_1: 5 }) + }) +}) diff --git a/src/main/lib/storageStats.ts b/src/main/lib/storageStats.ts new file mode 100644 index 0000000..ad7beca --- /dev/null +++ b/src/main/lib/storageStats.ts @@ -0,0 +1,66 @@ +import { isDocument } from '@shared/writeOps' + +type StorageDoc = Record + +/** + * Sums per-shard `$collStats` storage stats; `nindexes` and `capped` come from the first shard. + * @param docs one `$collStats` document per shard + * @returns the namespace and summed stats; `count` only when a shard reports one + */ +export function mergeStorageStats(docs: ReadonlyArray): { + ns: string | null + storage: Record +} { + const first = docs[0] + if (!first) return { ns: null, storage: {} } + const parts = storageParts(docs) + const firstPart = parts[0] ?? {} + return { + ns: typeof first['ns'] === 'string' ? first['ns'] : null, + storage: { + ...(parts.some((p) => p['count'] !== undefined) ? { count: sum(parts, 'count') } : {}), + size: sum(parts, 'size'), + storageSize: sum(parts, 'storageSize'), + freeStorageSize: sum(parts, 'freeStorageSize'), + totalIndexSize: sum(parts, 'totalIndexSize'), + totalSize: sum(parts, 'totalSize'), + ...(parts.some((p) => p['numOrphanDocs'] !== undefined) + ? { numOrphanDocs: sum(parts, 'numOrphanDocs') } + : {}), + indexSizes: sumIndexSizes(parts), + nindexes: firstPart['nindexes'], + capped: firstPart['capped'] + } + } +} + +function sum(parts: ReadonlyArray, key: string): number { + return parts.reduce((total, part) => total + (finiteNumber(part[key]) ?? 0), 0) +} + +/** The `storageStats` of each `$collStats` document; `{}` where a document has none. */ +export function storageParts(docs: ReadonlyArray): StorageDoc[] { + return docs.map((doc) => { + const stats = doc['storageStats'] + return isDocument(stats) ? stats : {} + }) +} + +/** Each index's size summed over `storageParts`; sizes that are not finite numbers are skipped. */ +export function sumIndexSizes(parts: ReadonlyArray): Record { + // A Map, not an object: an index may be named "__proto__". + const sizes = new Map() + for (const part of parts) { + const indexSizes = part['indexSizes'] + if (!isDocument(indexSizes)) continue + for (const [name, value] of Object.entries(indexSizes)) { + const size = finiteNumber(value) + if (size !== undefined) sizes.set(name, (sizes.get(name) ?? 0) + size) + } + } + return Object.fromEntries(sizes) +} + +function finiteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} diff --git a/src/main/lib/tunnel.test.ts b/src/main/lib/tunnel.test.ts new file mode 100644 index 0000000..ad128f2 --- /dev/null +++ b/src/main/lib/tunnel.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { InvalidUriError } from './connectionUri' +import { applyTunnelRoute, preferTunnelFailure, type Tunnel, type TunnelRoute } from './tunnel' + +const SOCKS: TunnelRoute = { + kind: 'socks5', + proxy: { proxyHost: '127.0.0.1', proxyPort: 1080, proxyUsername: 'u', proxyPassword: 'p' } +} +const LOCAL_PORT: TunnelRoute = { kind: 'localPort', host: '127.0.0.1', port: 5001 } + +function driverError(name: string): Error { + return Object.assign(new Error('driver says no'), { name }) +} + +function tunnelWith(failure: Error | null): Tunnel { + return { + route: LOCAL_PORT, + pinnedHostKey: null, + failure: () => failure, + close: () => Promise.resolve() + } +} + +describe('applyTunnelRoute', () => { + it('keeps the URI and adds the proxy options for a SOCKS5 route', () => { + const uri = 'mongodb://alice:pw@mongo1.internal:27017,mongo2.internal:27017/db' + expect(applyTunnelRoute(uri, { appName: 'MongoBench' }, SOCKS)).toEqual({ + uri, + options: { + appName: 'MongoBench', + proxyHost: '127.0.0.1', + proxyPort: 1080, + proxyUsername: 'u', + proxyPassword: 'p' + } + }) + }) + + it('points the URI at the local port and names the server for TLS', () => { + const target = applyTunnelRoute( + 'mongodb://alice:pw@Mongo.Example.com:27017/db?authSource=admin', + { appName: 'MongoBench', tls: true }, + LOCAL_PORT + ) + expect(target.uri).toBe('mongodb://alice:pw@127.0.0.1:5001/db?authSource=admin') + expect(target.options).toEqual({ + appName: 'MongoBench', + tls: true, + directConnection: true, + servername: 'mongo.example.com' + }) + }) + + it('forces a direct connection even when the options asked for discovery', () => { + const target = applyTunnelRoute( + 'mongodb://a.example.com,b.example.com/db', + { directConnection: false }, + LOCAL_PORT + ) + expect(target.uri).toBe('mongodb://127.0.0.1:5001/db') + expect(target.options.directConnection).toBe(true) + }) + + it('sets no servername for an IP host', () => { + const target = applyTunnelRoute('mongodb://10.0.0.5:27017/db', {}, LOCAL_PORT) + expect(target.options).toEqual({ directConnection: true }) + }) + + it('rejects mongodb+srv:// on a local-port route', () => { + expect(() => applyTunnelRoute('mongodb+srv://cluster.example.com/db', {}, LOCAL_PORT)).toThrow( + InvalidUriError + ) + }) +}) + +describe('preferTunnelFailure', () => { + const reason = new Error( + 'Cloudflare tunnel to mongo.example.com failed: websocket: bad handshake!' + ) + + it.each(['MongoServerSelectionError', 'MongoNetworkError', 'MongoNetworkTimeoutError'])( + 'reports the tunnel reason instead of a %s', + (name) => { + expect(preferTunnelFailure(driverError(name), tunnelWith(reason))).toBe(reason) + } + ) + + it('never masks a MongoDB authentication error', () => { + const error = driverError('MongoAuthenticationError') + expect(preferTunnelFailure(error, tunnelWith(reason))).toBe(error) + }) + + it('keeps the driver error without a tunnel', () => { + const error = driverError('MongoServerSelectionError') + expect(preferTunnelFailure(error, null)).toBe(error) + }) + + it('keeps the driver error when the tunnel knows of no failure', () => { + const error = driverError('MongoServerSelectionError') + expect(preferTunnelFailure(error, tunnelWith(null))).toBe(error) + }) +}) diff --git a/src/main/lib/tunnel.ts b/src/main/lib/tunnel.ts new file mode 100644 index 0000000..0b0f720 --- /dev/null +++ b/src/main/lib/tunnel.ts @@ -0,0 +1,60 @@ +import type { MongoClientOptions } from 'mongodb' +import type { PinnedHostKeyNotice } from '@shared/types' +import { firstHostName, replaceHosts } from './connectionUri' +import { mapError } from './errorMap' + +/** What the driver needs to route through a SOCKS5 tunnel. */ +export type TunnelProxyOptions = { + proxyHost: string + proxyPort: number + proxyUsername: string + proxyPassword: string +} + +/** + * How the driver reaches the server through an open tunnel: a SOCKS5 proxy that + * forwards any host by name, or one loopback port that leads to exactly one server. + */ +export type TunnelRoute = + { kind: 'socks5'; proxy: TunnelProxyOptions } | { kind: 'localPort'; host: string; port: number } + +/** An open tunnel. The caller owns it and is responsible for closing it. */ +export type Tunnel = { + route: TunnelRoute + /** Set only when this open pinned a host key it had never seen. */ + pinnedHostKey: PinnedHostKeyNotice | null + /** The tunnel's own error for traffic it could not carry, when it has one. */ + failure: () => Error | null + close: () => Promise +} + +/** + * The URI and options that send every driver socket through `route`. + * @throws InvalidUriError when a local-port route meets a `mongodb+srv://` URI. + */ +export function applyTunnelRoute( + uri: string, + options: MongoClientOptions, + route: TunnelRoute +): { uri: string; options: MongoClientOptions } { + if (route.kind === 'socks5') return { uri, options: { ...options, ...route.proxy } } + const servername = firstHostName(uri) + return { + uri: replaceHosts(uri, `${route.host}:${route.port}`), + options: { + ...options, + // The port leads to one server; discovery would send the driver to member hosts the tunnel does not reach. + directConnection: true, + // The certificate names the server, not 127.0.0.1. + ...(servername !== null ? { servername } : {}) + } + } +} + +/** A driver timeout behind a tunnel that knows why it failed is reported as the tunnel's reason. */ +export function preferTunnelFailure(error: unknown, tunnel: Tunnel | null): unknown { + const failure = tunnel?.failure() ?? null + if (failure === null) return error + const { code } = mapError(error) + return code === 'server_selection_timeout' || code === 'network_error' ? failure : error +} diff --git a/src/main/services/CloudflaredTunnelService.test.ts b/src/main/services/CloudflaredTunnelService.test.ts new file mode 100644 index 0000000..3b609a1 --- /dev/null +++ b/src/main/services/CloudflaredTunnelService.test.ts @@ -0,0 +1,555 @@ +import type { ChildProcess } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CloudflaredLoginPrompt } from '@shared/events' +import type { CloudflaredRuntime } from './CloudflaredTunnelService' + +// The service logs through electron-log, which pulls in electron itself. +vi.mock('electron-log/main', () => ({ + default: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() } +})) + +const { + CloudflaredAuthError, + CloudflaredNotFoundError, + CloudflaredTunnelError, + CloudflaredTunnelService +} = await import('./CloudflaredTunnelService') +const log = (await import('electron-log/main')).default + +const HOSTNAME = 'mongo.example.com' +const TOKEN_ID = '0123456789abcdef0123456789abcdef.access' +const TOKEN_SECRET = 'cfast_service-token-secret' +const TOKEN_CONFIG = { + hostname: HOSTNAME, + authMethod: 'serviceToken' as const, + serviceTokenId: TOKEN_ID, + serviceTokenSecret: TOKEN_SECRET +} +const BROWSER_CONFIG = { hostname: HOSTNAME, authMethod: 'browser' as const } + +const LOGIN_URL = `https://${HOSTNAME}/cdn-cgi/access/cli?aud=abc&redirect_url=https%3A%2F%2F${HOSTNAME}` +const LOGIN_PROMPT = `A browser window should have opened at the following URL:\n\n${LOGIN_URL}\n\n` +const ORIGIN_ERROR = + '2026-10-16T12:58:01Z ERR failed to connect to origin error="websocket: bad handshake" originURL=https://mongo.example.com' +const BIND_ERROR = (port: number): string => + `2026-10-16T12:58:00Z ERR Error on Websocket listener error="failed to start forwarding server: listen tcp 127.0.0.1:${port}: bind: Only one usage of each socket address is normally permitted."` + +/** Stands in for a cloudflared child: stdio streams plus the exit events Node emits. */ +class FakeProcess extends EventEmitter { + readonly stdout = new PassThrough() + readonly stderr = new PassThrough() + exitCode: number | null = null + signalCode: NodeJS.Signals | null = null + readonly kill = vi.fn((): boolean => { + queueMicrotask(() => this.finish(null, 'SIGTERM')) + return true + }) + + /** cloudflared logs to stderr. */ + log(text: string): void { + this.stderr.write(text) + } + + /** 'exit' first, then 'close' once both streams have been read to the end. */ + finish(code: number | null, signal: NodeJS.Signals | null = null): void { + if (this.exitCode !== null || this.signalCode !== null) return + this.exitCode = code + this.signalCode = signal + this.emit('exit', code, signal) + let open = 2 + const drained = (): void => { + open -= 1 + if (open === 0) this.emit('close', code, signal) + } + this.stdout.once('end', drained) + this.stderr.once('end', drained) + this.stdout.end() + this.stderr.end() + } + + /** A spawn failure: 'error', then 'close', and never 'exit'. */ + fail(code: string): void { + this.emit('error', Object.assign(new Error(`spawn cloudflared ${code}`), { code })) + this.emit('close', -4058, null) + } +} + +type Spawned = { command: string; args: string[]; env: NodeJS.ProcessEnv; child: FakeProcess } + +/** `access login` succeeds at once; every other process keeps running. */ +function signInAtOnce({ args, child }: Spawned): void { + if (args[1] === 'login') child.finish(0) +} + +function fakeRuntime( + options: { onSpawn?: (spawned: Spawned) => void; listening?: (port: number) => boolean } = {} +): { runtime: CloudflaredRuntime; spawned: Spawned[] } { + const spawned: Spawned[] = [] + const ports = [5001, 5002] + const runtime: CloudflaredRuntime = { + spawn: vi.fn((command: string, args: string[], env: NodeJS.ProcessEnv) => { + const child = new FakeProcess() + const call = { command, args, env, child } + spawned.push(call) + // After launch() attached its listeners, as with a real child. + queueMicrotask(() => (options.onSpawn ?? signInAtOnce)(call)) + return child as unknown as ChildProcess + }), + freePort: vi.fn(() => Promise.resolve(ports.shift() ?? 6000)), + isListening: vi.fn((port: number) => Promise.resolve(options.listening?.(port) ?? true)) + } + return { runtime, spawned } +} + +function noPrompts(): void {} + +function spawnedAt(spawned: Spawned[], index: number): Spawned { + const call = spawned[index] + if (call === undefined) throw new Error(`nothing was spawned at ${index}`) + return call +} + +afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() + delete process.env['TUNNEL_SERVICE_HOSTNAME'] +}) + +describe('open with a service token', () => { + it('runs access tcp with plain argv and the token only in the environment', async () => { + process.env['TUNNEL_SERVICE_HOSTNAME'] = 'inherited.example.net' + const { runtime, spawned } = fakeRuntime() + const service = new CloudflaredTunnelService(noPrompts, runtime) + + const tunnel = await service.open(TOKEN_CONFIG) + + expect(spawned).toHaveLength(1) + const { command, args, env } = spawnedAt(spawned, 0) + expect(command).toBe('cloudflared') + expect(args).toEqual(['access', 'tcp', '--hostname=mongo.example.com', '--url=127.0.0.1:5001']) + expect(env['TUNNEL_SERVICE_TOKEN_ID']).toBe(TOKEN_ID) + expect(env['TUNNEL_SERVICE_TOKEN_SECRET']).toBe(TOKEN_SECRET) + expect(env['TUNNEL_SERVICE_HOSTNAME']).toBeUndefined() + // cloudflared itself still has to be found and must reach the network. + expect(Object.keys(env).some((key) => key.toUpperCase() === 'PATH')).toBe(true) + expect(args.join(' ')).not.toContain(TOKEN_SECRET) + expect(tunnel.route).toEqual({ kind: 'localPort', host: '127.0.0.1', port: 5001 }) + expect(service.openCount).toBe(1) + }) + + it('never writes the secret to the log', async () => { + const { runtime } = fakeRuntime() + const service = new CloudflaredTunnelService(noPrompts, runtime) + const tunnel = await service.open(TOKEN_CONFIG) + await tunnel.close() + + const logged = JSON.stringify([ + vi.mocked(log.info).mock.calls, + vi.mocked(log.warn).mock.calls, + vi.mocked(log.debug).mock.calls, + vi.mocked(log.error).mock.calls + ]) + expect(logged).toContain(HOSTNAME) + expect(logged).not.toContain(TOKEN_SECRET) + }) + + it('rejects a missing client ID or secret before spawning anything', async () => { + const { runtime } = fakeRuntime() + const service = new CloudflaredTunnelService(noPrompts, runtime) + + await expect(service.open({ ...TOKEN_CONFIG, serviceTokenId: '' })).rejects.toThrow( + CloudflaredAuthError + ) + const { serviceTokenSecret: _secret, ...withoutSecret } = TOKEN_CONFIG + await expect(service.open(withoutSecret)).rejects.toThrow(CloudflaredAuthError) + expect(runtime.spawn).not.toHaveBeenCalled() + }) +}) + +describe('open with browser sign-in', () => { + it('signs in with access login first and starts the tunnel only after it exits', async () => { + const { runtime, spawned } = fakeRuntime({ onSpawn: () => undefined }) + const service = new CloudflaredTunnelService(noPrompts, runtime) + + const opening = service.open(BROWSER_CONFIG) + await vi.waitFor(() => expect(spawned).toHaveLength(1)) + expect(spawnedAt(spawned, 0).args).toEqual([ + 'access', + 'login', + '--quiet', + '--auto-close', + 'https://mongo.example.com' + ]) + expect(spawnedAt(spawned, 0).env['TUNNEL_SERVICE_TOKEN_SECRET']).toBeUndefined() + // Still signing in: no tunnel yet. + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(spawned).toHaveLength(1) + + spawnedAt(spawned, 0).child.finish(0) + const tunnel = await opening + expect(spawnedAt(spawned, 1).args.slice(0, 2)).toEqual(['access', 'tcp']) + expect(tunnel.route).toEqual({ kind: 'localPort', host: '127.0.0.1', port: 5001 }) + }) + + it('reports a failed sign-in and never starts the tunnel', async () => { + const { runtime, spawned } = fakeRuntime({ + onSpawn: ({ child }) => { + child.log('failed to get app info: Head "https://mongo.example.com": no such host\n') + child.finish(1) + } + }) + const service = new CloudflaredTunnelService(noPrompts, runtime) + + const error = await service.open(BROWSER_CONFIG).catch((e: unknown) => e) + expect(error).toBeInstanceOf(CloudflaredAuthError) + expect((error as Error).message).toContain('failed to get app info') + expect(spawned).toHaveLength(1) + }) + + it('stops a waiting sign-in when the open is aborted and never starts the tunnel', async () => { + const { runtime, spawned } = fakeRuntime({ onSpawn: () => undefined }) + const service = new CloudflaredTunnelService(noPrompts, runtime) + const aborting = new AbortController() + + const opening = service.open(BROWSER_CONFIG, undefined, aborting.signal) + await vi.waitFor(() => expect(spawned).toHaveLength(1)) + aborting.abort() + + const error = await opening.catch((e: unknown) => e) + expect(error).toMatchObject({ name: 'CancelledError' }) + expect((error as Error).message).toContain('cancelled') + expect(spawnedAt(spawned, 0).child.kill).toHaveBeenCalled() + expect(spawned).toHaveLength(1) + }) + + it('starts nothing for an open that was aborted before it began', async () => { + const { runtime } = fakeRuntime() + const service = new CloudflaredTunnelService(noPrompts, runtime) + + await expect( + service.open(BROWSER_CONFIG, undefined, AbortSignal.abort()) + ).rejects.toMatchObject({ name: 'CancelledError' }) + expect(runtime.spawn).not.toHaveBeenCalled() + }) + + it('does not start the tunnel when the open is aborted after the sign-in', async () => { + const { runtime, spawned } = fakeRuntime() + const aborting = new AbortController() + vi.mocked(runtime.freePort).mockImplementationOnce(() => { + aborting.abort() + return Promise.resolve(5001) + }) + const service = new CloudflaredTunnelService(noPrompts, runtime) + + await expect(service.open(BROWSER_CONFIG, undefined, aborting.signal)).rejects.toMatchObject({ + name: 'CancelledError' + }) + expect(spawned.map(({ args }) => args[1])).toEqual(['login']) + }) + + it('announces the sign-in URL while waiting and once more when done', async () => { + const prompts: CloudflaredLoginPrompt[] = [] + const { runtime } = fakeRuntime({ + onSpawn: ({ args, child }) => { + if (args[1] !== 'login') return + child.log('https://evil.example.net/cdn-cgi/access/cli?aud=abc\n') + child.log(LOGIN_PROMPT) + child.finish(0) + } + }) + const service = new CloudflaredTunnelService((prompt) => prompts.push(prompt), runtime) + + await service.open(BROWSER_CONFIG) + + expect(prompts).toEqual([ + { hostname: HOSTNAME, state: 'waiting', url: LOGIN_URL }, + { hostname: HOSTNAME, state: 'finished' } + ]) + }) +}) + +describe('choosing the executable', () => { + it('refuses a binary path that does not name cloudflared, without spawning it', async () => { + const { runtime } = fakeRuntime() + const service = new CloudflaredTunnelService(noPrompts, runtime) + + await expect( + service.open({ ...TOKEN_CONFIG, binaryPath: 'C:\\tools\\calc.exe' }) + ).rejects.toThrow(CloudflaredNotFoundError) + expect(runtime.spawn).not.toHaveBeenCalled() + }) + + it('refuses a cloudflared on another machine, without spawning it', async () => { + const { runtime } = fakeRuntime() + const service = new CloudflaredTunnelService(noPrompts, runtime) + const remote = '\\\\attacker.invalid@SSL\\DavWWWRoot\\cloudflared.exe' + + await expect(service.open({ ...TOKEN_CONFIG, binaryPath: remote })).rejects.toThrow( + CloudflaredNotFoundError + ) + await expect(service.version(remote)).rejects.toThrow(CloudflaredNotFoundError) + expect(runtime.spawn).not.toHaveBeenCalled() + }) + + it('runs a chosen cloudflared executable', async () => { + const { runtime, spawned } = fakeRuntime() + const service = new CloudflaredTunnelService(noPrompts, runtime) + + await service.open({ ...TOKEN_CONFIG, binaryPath: 'C:\\tools\\cloudflared.exe' }) + expect(spawnedAt(spawned, 0).command).toBe('C:\\tools\\cloudflared.exe') + }) + + it('explains a cloudflared missing from PATH', async () => { + const { runtime } = fakeRuntime({ + onSpawn: ({ child }) => child.fail('ENOENT'), + listening: () => false + }) + const service = new CloudflaredTunnelService(noPrompts, runtime) + + const error = await service.open(TOKEN_CONFIG).catch((e: unknown) => e) + expect(error).toBeInstanceOf(CloudflaredNotFoundError) + expect((error as Error).message).toContain('not found on PATH') + }) + + it('reports a file Node refuses to run as not found', async () => { + const { runtime } = fakeRuntime() + vi.mocked(runtime.spawn).mockImplementation(() => { + throw Object.assign(new Error('spawn UNKNOWN'), { code: 'UNKNOWN' }) + }) + const service = new CloudflaredTunnelService(noPrompts, runtime) + + await expect( + service.open({ ...TOKEN_CONFIG, binaryPath: 'C:\\tools\\cloudflared.exe' }) + ).rejects.toThrow(CloudflaredNotFoundError) + }) + + it('reports a spawn refused over its environment as a tunnel failure, without the token', async () => { + const { runtime } = fakeRuntime() + vi.mocked(runtime.spawn).mockImplementation(() => { + throw Object.assign( + new TypeError( + `The property 'options.env' must be a string without null bytes. Received '${TOKEN_SECRET}'` + ), + { code: 'ERR_INVALID_ARG_VALUE' } + ) + }) + const service = new CloudflaredTunnelService(noPrompts, runtime) + + const error = await service.open(TOKEN_CONFIG).catch((e: unknown) => e) + expect(error).toBeInstanceOf(CloudflaredTunnelError) + expect((error as Error).name).toBe('CloudflaredTunnelError') + expect((error as Error).message).toContain('ERR_INVALID_ARG_VALUE') + expect((error as Error).message).not.toContain(TOKEN_SECRET) + }) +}) + +describe('startup', () => { + it('reports why cloudflared quit before the port opened', async () => { + const { runtime } = fakeRuntime({ + onSpawn: ({ child }) => { + child.log( + '2026-10-16T12:58:03Z ERR error="failed to parse as URL: parse \\"https://x\\": invalid"\n' + ) + child.stdout.write('NAME:\n cloudflared access tcp - ...\n') + child.finish(0) + }, + listening: () => false + }) + const service = new CloudflaredTunnelService(noPrompts, runtime) + + const error = await service.open(TOKEN_CONFIG).catch((e: unknown) => e) + expect(error).toBeInstanceOf(CloudflaredTunnelError) + expect((error as Error).message).toContain('failed to parse as URL') + expect(service.openCount).toBe(0) + }) + + it('retries once on another port when the picked one was taken', async () => { + const { runtime, spawned } = fakeRuntime({ + onSpawn: ({ args, child }) => { + if (args[3] !== '--url=127.0.0.1:5001') return + child.log(`${BIND_ERROR(5001)}\n`) + child.finish(1) + }, + listening: (port) => port !== 5001 + }) + const service = new CloudflaredTunnelService(noPrompts, runtime) + + const tunnel = await service.open(TOKEN_CONFIG) + expect(tunnel.route).toEqual({ kind: 'localPort', host: '127.0.0.1', port: 5002 }) + expect(spawned).toHaveLength(2) + }) + + it('gives up when the second port is taken too', async () => { + const { runtime, spawned } = fakeRuntime({ + onSpawn: ({ args, child }) => { + child.log(`${BIND_ERROR(Number(args[3]?.split(':').pop()))}\n`) + child.finish(1) + }, + listening: () => false + }) + const service = new CloudflaredTunnelService(noPrompts, runtime) + + const error = await service.open(TOKEN_CONFIG).catch((e: unknown) => e) + expect(error).toBeInstanceOf(CloudflaredTunnelError) + expect((error as Error).name).toBe('CloudflaredTunnelError') + expect((error as Error).message).toContain('failed to start forwarding server') + expect(spawned).toHaveLength(2) + }) + + it('stops cloudflared when the port never opens', async () => { + vi.useFakeTimers() + const { runtime, spawned } = fakeRuntime({ listening: () => false }) + const service = new CloudflaredTunnelService(noPrompts, runtime) + + const failed = expect(service.open(TOKEN_CONFIG)).rejects.toThrow( + /did not open its local port for mongo\.example\.com within 15 s/ + ) + await vi.advanceTimersByTimeAsync(15_200) + await failed + expect(spawnedAt(spawned, 0).child.kill).toHaveBeenCalled() + }) +}) + +describe('an open tunnel', () => { + it('stops cloudflared on close without reporting a drop, once', async () => { + const { runtime, spawned } = fakeRuntime() + const service = new CloudflaredTunnelService(noPrompts, runtime) + const dropped: string[] = [] + const tunnel = await service.open(TOKEN_CONFIG, (reason) => dropped.push(reason)) + + await tunnel.close() + await tunnel.close() + + const { child } = spawnedAt(spawned, 0) + expect(child.kill).toHaveBeenCalledTimes(1) + expect(service.openCount).toBe(0) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(dropped).toEqual([]) + }) + + it('reports a drop with the exit code and the last error when cloudflared dies', async () => { + const { runtime, spawned } = fakeRuntime() + const service = new CloudflaredTunnelService(noPrompts, runtime) + const dropped: string[] = [] + const tunnel = await service.open(TOKEN_CONFIG, (reason) => dropped.push(reason)) + const { child } = spawnedAt(spawned, 0) + + child.log(`${ORIGIN_ERROR}\n`) + child.finish(1) + await vi.waitFor(() => expect(dropped).toHaveLength(1)) + + expect(dropped[0]).toContain('code 1') + expect(dropped[0]).toContain('websocket: bad handshake') + expect(service.openCount).toBe(0) + expect(tunnel.failure()).toBeInstanceOf(CloudflaredTunnelError) + await tunnel.close() + expect(child.kill).not.toHaveBeenCalled() + }) + + it('knows no failure until cloudflared logs an error', async () => { + const { runtime, spawned } = fakeRuntime() + const service = new CloudflaredTunnelService(noPrompts, runtime) + const tunnel = await service.open(TOKEN_CONFIG) + expect(tunnel.failure()).toBeNull() + + spawnedAt(spawned, 0).child.log(`${ORIGIN_ERROR}\n`) + await vi.waitFor(() => expect(tunnel.failure()).not.toBeNull()) + const failure = tunnel.failure() + expect(failure).toBeInstanceOf(CloudflaredTunnelError) + expect(failure?.message).toContain('websocket: bad handshake') + }) + + it('blames the service token when Access falls back to a browser sign-in', async () => { + const { runtime, spawned } = fakeRuntime() + const service = new CloudflaredTunnelService(noPrompts, runtime) + const tunnel = await service.open(TOKEN_CONFIG) + + spawnedAt(spawned, 0).child.log(LOGIN_PROMPT) + await vi.waitFor(() => expect(tunnel.failure()).toBeInstanceOf(CloudflaredAuthError)) + expect(tunnel.failure()?.message).toContain('Service Auth policy') + }) +}) + +describe('closeAll', () => { + it('stops open tunnels and sign-ins still waiting, reporting no drops', async () => { + const prompts: CloudflaredLoginPrompt[] = [] + const { runtime, spawned } = fakeRuntime({ + onSpawn: ({ args, child }) => { + if (args[1] === 'login') child.log(LOGIN_PROMPT) + } + }) + const service = new CloudflaredTunnelService((prompt) => prompts.push(prompt), runtime) + const dropped: string[] = [] + + await service.open(TOKEN_CONFIG, (reason) => dropped.push(reason)) + const signingIn = service.open(BROWSER_CONFIG, (reason) => dropped.push(reason)) + const signInStopped = expect(signingIn).rejects.toThrow(CloudflaredAuthError) + await vi.waitFor(() => expect(prompts).toHaveLength(1)) + + await service.closeAll() + await signInStopped + + expect(spawnedAt(spawned, 0).child.kill).toHaveBeenCalled() + expect(spawnedAt(spawned, 1).child.kill).toHaveBeenCalled() + expect(spawned).toHaveLength(2) + expect(prompts.map((prompt) => prompt.state)).toEqual(['waiting', 'finished']) + expect(service.openCount).toBe(0) + expect(dropped).toEqual([]) + }) + + it('starts no tunnel for an open that was between sign-in and start', async () => { + const { runtime, spawned } = fakeRuntime() + let portPicked!: (port: number) => void + vi.mocked(runtime.freePort).mockImplementationOnce( + () => new Promise((resolve) => (portPicked = resolve)) + ) + const service = new CloudflaredTunnelService(noPrompts, runtime) + + const opening = service.open(BROWSER_CONFIG) + await vi.waitFor(() => expect(runtime.freePort).toHaveBeenCalled()) + await service.closeAll() + portPicked(5001) + + await expect(opening).rejects.toThrow(CloudflaredTunnelError) + expect(spawned.map(({ args }) => args[1])).toEqual(['login']) + expect(service.openCount).toBe(0) + }) +}) + +describe('version', () => { + it('reads the version cloudflared reports', async () => { + const { runtime, spawned } = fakeRuntime({ + onSpawn: ({ child }) => { + child.stdout.write('cloudflared version 2025.8.1 (built 2025-08-21-1534 UTC)\n') + child.finish(0) + } + }) + const service = new CloudflaredTunnelService(noPrompts, runtime) + + await expect(service.version()).resolves.toBe('2025.8.1') + expect(spawnedAt(spawned, 0).args).toEqual(['--version']) + }) + + it('reports a cloudflared missing from PATH as not found', async () => { + const { runtime } = fakeRuntime({ onSpawn: ({ child }) => child.fail('ENOENT') }) + const service = new CloudflaredTunnelService(noPrompts, runtime) + + await expect(service.version()).rejects.toThrow(/not found on PATH/) + }) + + it('reports a program that names no cloudflared version as not found', async () => { + const { runtime } = fakeRuntime({ + onSpawn: ({ child }) => { + child.stdout.write('something else entirely\n') + child.finish(0) + } + }) + const service = new CloudflaredTunnelService(noPrompts, runtime) + + await expect(service.version('C:\\tools\\cloudflared.exe')).rejects.toThrow( + CloudflaredNotFoundError + ) + }) +}) diff --git a/src/main/services/CloudflaredTunnelService.ts b/src/main/services/CloudflaredTunnelService.ts new file mode 100644 index 0000000..e02eff7 --- /dev/null +++ b/src/main/services/CloudflaredTunnelService.ts @@ -0,0 +1,437 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { connect, createServer, type AddressInfo } from 'node:net' +import log from 'electron-log/main' +import { isCloudflaredExecutable } from '@shared/cloudflared' +import type { CloudflaredLoginPrompt } from '@shared/events' +import type { CloudflaredAuthMethod } from '@shared/types' +import { findLoginUrl, OutputTail, splitLines } from '../lib/cloudflaredLog' +import { cancelledError } from '../lib/errorMap' +import type { Tunnel } from '../lib/tunnel' + +const LOOPBACK = '127.0.0.1' +const PATH_COMMAND = 'cloudflared' +const STARTUP_TIMEOUT_MS = 15_000 +const PROBE_INTERVAL_MS = 100 +// Last resort only: cloudflared gives up by itself after 10 long polls of up to 60 s. +const LOGIN_TIMEOUT_MS = 15 * 60_000 +const VERSION_TIMEOUT_MS = 5_000 +const STOP_TIMEOUT_MS = 3_000 +const BIND_ATTEMPTS = 2 +// Spawn errors that mean the file cannot run; Windows reports a file that is no executable as UNKNOWN. +const FILE_ERRORS = new Set([ + 'ENOENT', + 'ENOTDIR', + 'EACCES', + 'EPERM', + 'EINVAL', + 'ENOEXEC', + 'UNKNOWN' +]) + +/** Tunnel settings with every secret already decrypted. */ +export type ResolvedCloudflaredTunnel = { + hostname: string + authMethod: CloudflaredAuthMethod + serviceTokenId?: string + serviceTokenSecret?: string + /** Absent = `cloudflared` resolved through PATH. */ + binaryPath?: string +} + +export class CloudflaredNotFoundError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'CloudflaredNotFoundError' + } +} + +export class CloudflaredAuthError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'CloudflaredAuthError' + } +} + +export class CloudflaredTunnelError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'CloudflaredTunnelError' + } +} + +// Keeps the parent's name, so a bind failure the retry cannot fix still maps to cloudflared_failed. +class PortTakenError extends CloudflaredTunnelError {} + +/** Process and socket primitives, injectable so tests never start cloudflared. */ +export type CloudflaredRuntime = { + spawn: (command: string, args: string[], env: NodeJS.ProcessEnv) => ChildProcess + freePort: () => Promise + /** True once something accepts TCP connections on the loopback port. */ + isListening: (port: number) => Promise +} + +const nodeRuntime: CloudflaredRuntime = { + spawn: (command, args, env) => + // No shell: the hostname and path reach cloudflared as plain argv entries. + spawn(command, args, { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }), + freePort: () => + new Promise((resolve, reject) => { + const server = createServer() + server.once('error', reject) + server.listen(0, LOOPBACK, () => { + const { port } = server.address() as AddressInfo + server.close(() => resolve(port)) + }) + }), + isListening: (port) => + new Promise((resolve) => { + const socket = connect({ host: LOOPBACK, port }) + socket.once('connect', () => { + socket.destroy() + resolve(true) + }) + socket.once('error', () => resolve(false)) + }) +} + +type Exit = { code: number | null; error: Error | null } + +type Launched = { + child: ChildProcess + output: OutputTail + /** Settles once the process is gone and its output has been read to the end. */ + exited: Promise + /** What `exited` settled with, or null while the process still runs. */ + ended: () => Exit | null +} + +/** Runs one `cloudflared access tcp` process per tunnel, on a free loopback port the driver dials. */ +export class CloudflaredTunnelService { + private readonly active = new Set() + private readonly processes = new Set() + private closing = false + + constructor( + private readonly onLogin: (prompt: CloudflaredLoginPrompt) => void, + /** Overridable so tests never spawn cloudflared. */ + private readonly runtime: CloudflaredRuntime = nodeRuntime + ) {} + + get openCount(): number { + return this.active.size + } + + /** + * Version of the cloudflared that would run. + * @param binaryPath absolute path; omit to use PATH. + * @throws CloudflaredNotFoundError when it cannot be run or reports no version. + */ + async version(binaryPath?: string): Promise { + const command = commandFor(binaryPath) + const { child, output, exited } = this.launch(command, ['--version'], cleanEnv()) + const timer = setTimeout(() => void this.stop(child), VERSION_TIMEOUT_MS) + const { error } = await exited + clearTimeout(timer) + if (error !== null) throw spawnFailure(command, error) + const version = /cloudflared version (\S+)/.exec(output.text())?.[1] + if (version === undefined) { + throw new CloudflaredNotFoundError(`${command} did not report a cloudflared version!`) + } + return version + } + + /** + * Starts a tunnel, signing in first for browser auth. + * @param onDropped fires when cloudflared dies on its own, never on `close()`. + * @param signal aborting it stops a pending sign-in; the open then rejects as cancelled. + */ + async open( + config: ResolvedCloudflaredTunnel, + onDropped?: (reason: string) => void, + signal?: AbortSignal + ): Promise { + const command = commandFor(config.binaryPath) + // Before any spawn, so an incomplete service token fails without starting anything. + const env = tunnelEnv(config) + // access tcp fetches its token only when the driver connects, racing the driver's timeout; access login returns at once with a cached session. + if (config.authMethod === 'browser') await this.login(command, config.hostname, signal) + + for (let attempt = 1; ; attempt++) { + try { + const port = await this.runtime.freePort() + throwIfCancelled(signal, config.hostname) + return await this.start(command, config, env, port, onDropped) + } catch (error) { + // The port was only free when it was picked; another process can bind it before cloudflared does. + if (error instanceof PortTakenError && attempt < BIND_ATTEMPTS) continue + throw error + } + } + } + + async closeAll(): Promise { + // One-way, since only quitting calls this: an open still on its way must not spawn after the sweep below. + this.closing = true + await Promise.allSettled([...this.active].map((tunnel) => tunnel.close())) + // Sign-ins and tunnels still starting have no handle yet. + await Promise.allSettled([...this.processes].map((child) => this.stop(child))) + } + + private async login(command: string, hostname: string, signal?: AbortSignal): Promise { + throwIfCancelled(signal, hostname) + let prompted = false + let timedOut = false + const { child, output, exited } = this.launch( + command, + // --quiet keeps the JWT out of the captured output. + ['access', 'login', '--quiet', '--auto-close', `https://${hostname}`], + cleanEnv(), + this.announceLogin(hostname, () => { + prompted = true + }) + ) + const timer = setTimeout(() => { + timedOut = true + void this.stop(child) + }, LOGIN_TIMEOUT_MS) + const stopOnAbort = (): void => void this.stop(child) + signal?.addEventListener('abort', stopOnAbort, { once: true }) + const { code, error } = await exited + clearTimeout(timer) + signal?.removeEventListener('abort', stopOnAbort) + + if (prompted) this.onLogin({ hostname, state: 'finished' }) + if (error !== null) throw spawnFailure(command, error) + throwIfCancelled(signal, hostname) + if (timedOut) { + throw new CloudflaredAuthError( + `The Cloudflare Access sign-in to ${hostname} did not finish within ${LOGIN_TIMEOUT_MS / 60_000} minutes!` + ) + } + if (code !== 0) { + throw new CloudflaredAuthError( + `Cloudflare Access sign-in to ${hostname} failed: ${output.describe()}!` + ) + } + } + + private async start( + command: string, + config: ResolvedCloudflaredTunnel, + env: NodeJS.ProcessEnv, + port: number, + onDropped?: (reason: string) => void + ): Promise { + const { hostname } = config + let loginPrompted = false + const { child, output, exited, ended } = this.launch( + command, + ['access', 'tcp', `--hostname=${hostname}`, `--url=${LOOPBACK}:${port}`], + env, + // An Access session that expires while connected starts a browser sign-in of its own. + this.announceLogin(hostname, () => { + loginPrompted = true + }) + ) + + const deadline = Date.now() + STARTUP_TIMEOUT_MS + // cloudflared logs "Start Websocket listener" before it binds, so only a TCP probe shows the port is ready. + for (;;) { + const listening = await this.runtime.isListening(port) + const exit = ended() + if (exit !== null) throw startupFailure(command, hostname, port, exit, output) + if (listening) break + if (Date.now() >= deadline) { + await this.stop(child) + throw new CloudflaredTunnelError( + `cloudflared did not open its local port for ${hostname} within ${STARTUP_TIMEOUT_MS / 1000} s: ${output.describe()}!` + ) + } + await delay(PROBE_INTERVAL_MS) + } + + let closed = false + let diedWith: string | null = null + const tunnel: Tunnel = { + route: { kind: 'localPort', host: LOOPBACK, port }, + pinnedHostKey: null, + failure: () => { + // A service token Access did not accept falls back to the browser flow (non-strict organisations). + if (loginPrompted && config.authMethod === 'serviceToken') { + return new CloudflaredAuthError( + `Cloudflare Access asked for a browser sign-in although a service token is configured — check the client ID and secret, and that ${hostname} has a Service Auth policy!` + ) + } + if (diedWith !== null) { + return new CloudflaredTunnelError(`Cloudflare tunnel to ${hostname} failed: ${diedWith}!`) + } + const reason = output.lastError() + return reason === null + ? null + : new CloudflaredTunnelError(`Cloudflare tunnel to ${hostname} failed: ${reason}!`) + }, + close: async () => { + if (closed) return + closed = true + this.active.delete(tunnel) + await this.stop(child) + log.info(`Cloudflare tunnel to ${hostname} closed`) + } + } + this.active.add(tunnel) + + void exited.then(({ code }) => { + if (closed) return + closed = true + this.active.delete(tunnel) + diedWith = `cloudflared exited${code !== null ? ` with code ${code}` : ''}: ${output.describe()}` + log.warn(`Cloudflare tunnel to ${hostname} dropped: ${diedWith}`) + onDropped?.(diedWith) + }) + + log.info(`Cloudflare tunnel to ${hostname} up on ${LOOPBACK}:${port}`) + return tunnel + } + + private announceLogin(hostname: string, onPrompt: () => void): (line: string) => void { + return (line) => { + const url = findLoginUrl(line, hostname) + if (url === null) return + onPrompt() + this.onLogin({ hostname, state: 'waiting', url }) + } + } + + private launch( + command: string, + args: string[], + env: NodeJS.ProcessEnv, + onLine?: (line: string) => void + ): Launched { + if (this.closing) throw new CloudflaredTunnelError('MongoBench is shutting down!') + let child: ChildProcess + try { + child = this.runtime.spawn(command, args, env) + } catch (error) { + // Node refuses some spawns synchronously, e.g. UNKNOWN for a file that is no executable. + throw spawnFailure(command, error) + } + this.processes.add(child) + + const output = new OutputTail() + for (const stream of [child.stdout, child.stderr]) { + if (stream === null) continue + stream.setEncoding('utf8') + const lines = splitLines((line) => { + output.add(line) + onLine?.(line) + }) + stream.on('data', (chunk: string) => lines.write(chunk)) + stream.on('end', () => lines.end()) + } + + let result: Exit | null = null + const exited = new Promise((resolve) => { + // 'exit' may not follow 'error', and output can still be arriving at 'exit'. + child.once('close', (code: number | null) => resolve({ code, error: null })) + // Stays attached: an 'error' with no listener would take the main process down. + child.on('error', (error: Error) => resolve({ code: null, error })) + }).then((exit) => { + this.processes.delete(child) + result = exit + return exit + }) + return { child, output, exited, ended: () => result } + } + + private stop(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() + return new Promise((resolve) => { + const timer = setTimeout(() => { + child.kill('SIGKILL') + resolve() + }, STOP_TIMEOUT_MS) + child.once('exit', () => { + clearTimeout(timer) + resolve() + }) + child.kill() + }) + } +} + +function commandFor(binaryPath?: string): string { + if (binaryPath === undefined) return PATH_COMMAND + // Checked again here: the schema guards new input, not what connections.json already holds. + if (!isCloudflaredExecutable(binaryPath)) { + throw new CloudflaredNotFoundError(`${binaryPath} is not a cloudflared executable!`) + } + return binaryPath +} + +function cleanEnv(): NodeJS.ProcessEnv { + // An inherited TUNNEL_SERVICE_* variable would silently change what this connection configures. + return Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith('TUNNEL_SERVICE_')) + ) +} + +function tunnelEnv(config: ResolvedCloudflaredTunnel): NodeJS.ProcessEnv { + if (config.authMethod === 'browser') return cleanEnv() + const id = config.serviceTokenId ?? '' + const secret = config.serviceTokenSecret ?? '' + if (id.length === 0) throw new CloudflaredAuthError('No service token client ID is configured!') + if (secret.length === 0) { + throw new CloudflaredAuthError('No service token secret is stored for this connection!') + } + // Environment, not argv: argv shows up in process listings. + return { ...cleanEnv(), TUNNEL_SERVICE_TOKEN_ID: id, TUNNEL_SERVICE_TOKEN_SECRET: secret } +} + +function spawnFailure( + command: string, + error: unknown +): CloudflaredNotFoundError | CloudflaredTunnelError { + const code = + error instanceof Error && 'code' in error && typeof error.code === 'string' ? error.code : null + if (code === null || !FILE_ERRORS.has(code)) { + // Only the code: Node's message for a refused environment quotes the value, which can be the token. + return new CloudflaredTunnelError(`Cannot start ${command}: ${code ?? 'unknown error'}!`, { + cause: error + }) + } + const message = + command === PATH_COMMAND && code === 'ENOENT' + ? 'cloudflared was not found on PATH — install it or choose its executable in the connection settings!' + : `Cannot run ${command}: ${code}!` + return new CloudflaredNotFoundError(message, { cause: error }) +} + +function throwIfCancelled(signal: AbortSignal | undefined, hostname: string): void { + if (signal?.aborted === true) { + throw cancelledError(`Connecting to ${hostname} through Cloudflare Access was cancelled!`) + } +} + +function startupFailure( + command: string, + hostname: string, + port: number, + exit: Exit, + output: OutputTail +): Error { + if (exit.error !== null) return spawnFailure(command, exit.error) + // Matched on cloudflared's own wording; the OS part of the message after it is localized. + if (output.text().includes('failed to start forwarding server')) { + return new PortTakenError( + `cloudflared could not listen on ${LOOPBACK}:${port}: ${output.describe()}!` + ) + } + return new CloudflaredTunnelError( + `cloudflared stopped before the tunnel to ${hostname} was up: ${output.describe()}!` + ) +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/main/services/ConnectionService.test.ts b/src/main/services/ConnectionService.test.ts new file mode 100644 index 0000000..ead4b50 --- /dev/null +++ b/src/main/services/ConnectionService.test.ts @@ -0,0 +1,430 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + ConnectionInput, + StoredCloudflaredTunnel, + StoredConnection, + StoredSshTunnel +} from '@shared/types' +import type { Tunnel, TunnelRoute } from '../lib/tunnel' +import type { ConnectionsRepository } from '../stores/ConnectionsRepository' +import type { CloudflaredTunnelService } from './CloudflaredTunnelService' +import type { SshTunnelService } from './SshTunnelService' + +vi.mock('electron-log/main', () => ({ + default: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() } +})) +// The repository module reads electron's userData path; these tests pass a fake repository. +vi.mock('electron', () => ({ app: { getPath: () => '' } })) + +/** MongoClient stand-in: records how it was built; `connect` is steered per test. */ +const driver = vi.hoisted(() => { + const created: FakeMongoClient[] = [] + const connect = vi.fn((): Promise => Promise.resolve()) + class FakeMongoClient { + readonly close = vi.fn((): Promise => Promise.resolve()) + readonly connect = connect + constructor( + readonly uri: string, + readonly options: Record + ) { + created.push(this) + } + db(): { command: (command: Record) => Promise } { + return { + command: (command) => Promise.resolve('ping' in command ? { ok: 1 } : { version: '8.0.4' }) + } + } + } + return { FakeMongoClient, created, connect } +}) +vi.mock('mongodb', () => ({ MongoClient: driver.FakeMongoClient })) + +const { ConnectionService } = await import('./ConnectionService') + +const ID = '6f1d3c2a-8b4e-4f5a-9c7d-1e2f3a4b5c6d' +const SOCKS_ROUTE: TunnelRoute = { + kind: 'socks5', + proxy: { proxyHost: '127.0.0.1', proxyPort: 1080, proxyUsername: 'u', proxyPassword: 'p' } +} +const LOCAL_ROUTE: TunnelRoute = { kind: 'localPort', host: '127.0.0.1', port: 5001 } +const SSH: StoredSshTunnel = { + enabled: true, + host: 'gateway.example.com', + username: 'tunneluser', + authMethod: 'agent' +} +const CLOUDFLARED: StoredCloudflaredTunnel = { + enabled: true, + hostname: 'mongo.example.com', + authMethod: 'serviceToken', + serviceTokenId: 'abc.access', + encryptedServiceTokenSecret: 'enc:secret' +} +const STORED_SECRET = 'decrypted-token-secret' + +function stored(overrides: Partial = {}): StoredConnection { + return { + id: ID, + name: 'Production', + uri: 'mongodb://db.example.com:27017/admin', + createdAt: '2026-10-16T00:00:00.000Z', + updatedAt: '2026-10-16T00:00:00.000Z', + ...overrides + } +} + +type Opened = { config: unknown; onDropped?: (reason: string) => void; tunnel: Tunnel } + +function fakeTunnels(route: TunnelRoute): { + service: { open: ReturnType; closeAll: ReturnType } + opened: Opened[] +} { + const opened: Opened[] = [] + const service = { + open: vi.fn((config: unknown, onDropped?: (reason: string) => void) => { + const tunnel: Tunnel = { + route, + pinnedHostKey: null, + failure: vi.fn((): Error | null => null), + close: vi.fn((): Promise => Promise.resolve()) + } + opened.push({ config, ...(onDropped !== undefined ? { onDropped } : {}), tunnel }) + return Promise.resolve(tunnel) + }), + closeAll: vi.fn((): Promise => Promise.resolve()) + } + return { service, opened } +} + +function setup(connections: StoredConnection[], options: { password?: string } = {}) { + const repo = { + getStored: vi.fn((id: string) => Promise.resolve(connections.find((c) => c.id === id) ?? null)), + decryptPassword: vi.fn(() => options.password ?? null), + decryptSsh: vi.fn(() => ({})), + decryptCloudflared: vi.fn((connection: StoredConnection) => + connection.cloudflared?.encryptedServiceTokenSecret !== undefined + ? { serviceTokenSecret: STORED_SECRET } + : {} + ) + } + const ssh = fakeTunnels(SOCKS_ROUTE) + const cloudflared = fakeTunnels(LOCAL_ROUTE) + const onDropped = vi.fn() + const service = new ConnectionService( + repo as unknown as ConnectionsRepository, + ssh.service as unknown as SshTunnelService, + cloudflared.service as unknown as CloudflaredTunnelService, + onDropped + ) + return { service, repo, ssh, cloudflared, onDropped } +} + +function deferred(): { promise: Promise; resolve: () => void; reject: (e: unknown) => void } { + let resolve!: () => void + let reject!: (e: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +function createdAt(index: number): InstanceType { + const client = driver.created[index] + if (client === undefined) throw new Error(`no client at ${index}`) + return client +} + +function openedAt(opened: Opened[], index: number): Opened { + const call = opened[index] + if (call === undefined) throw new Error(`no tunnel at ${index}`) + return call +} + +function driverError(name: string): Error { + return Object.assign(new Error(`${name} from the driver`), { name }) +} + +afterEach(() => { + driver.created.length = 0 + driver.connect.mockReset() + driver.connect.mockImplementation(() => Promise.resolve()) +}) + +describe('connect', () => { + it('joins a connect already in flight instead of opening a second client and tunnel', async () => { + const { service, ssh } = setup([stored({ ssh: SSH })]) + const gate = deferred() + driver.connect.mockImplementation(() => gate.promise) + + const first = service.connect(ID) + const second = service.connect(ID) + await vi.waitFor(() => expect(driver.created).toHaveLength(1)) + gate.resolve() + + expect(await first).toEqual({ connectionId: ID }) + expect(await second).toEqual({ connectionId: ID }) + expect(driver.created).toHaveLength(1) + expect(ssh.service.open).toHaveBeenCalledTimes(1) + expect(service.isConnected(ID)).toBe(true) + }) + + it('lets a failed connect be retried', async () => { + const { service } = setup([stored()]) + driver.connect.mockImplementationOnce(() => + Promise.reject(driverError('MongoServerSelectionError')) + ) + + await expect(service.connect(ID)).rejects.toThrow('MongoServerSelectionError') + await expect(service.connect(ID)).resolves.toEqual({ connectionId: ID }) + expect(driver.created).toHaveLength(2) + expect(createdAt(0).close).toHaveBeenCalled() + }) +}) + +describe('disconnect', () => { + it('waits for a connect in flight and then closes what it opened', async () => { + const { service, ssh } = setup([stored({ ssh: SSH })]) + const gate = deferred() + driver.connect.mockImplementation(() => gate.promise) + + const connecting = service.connect(ID) + await vi.waitFor(() => expect(driver.created).toHaveLength(1)) + let disconnected = false + const disconnecting = service.disconnect(ID).then(() => { + disconnected = true + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(disconnected).toBe(false) + + gate.resolve() + await connecting + await disconnecting + + expect(service.isConnected(ID)).toBe(false) + expect(createdAt(0).close).toHaveBeenCalled() + expect(openedAt(ssh.opened, 0).tunnel.close).toHaveBeenCalled() + }) + + it('aborts a Cloudflare sign-in in flight instead of waiting for it', async () => { + const { service, cloudflared } = setup([ + stored({ cloudflared: { ...CLOUDFLARED, authMethod: 'browser' } }) + ]) + cloudflared.service.open.mockImplementationOnce( + (_config: unknown, _onDropped: unknown, signal?: AbortSignal) => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => + reject(Object.assign(new Error('sign-in cancelled'), { name: 'CancelledError' })) + ) + }) + ) + + const connecting = service.connect(ID).catch((e: unknown) => e) + await vi.waitFor(() => expect(cloudflared.service.open).toHaveBeenCalled()) + await service.disconnect(ID) + + expect(((await connecting) as Error).name).toBe('CancelledError') + expect(service.isConnected(ID)).toBe(false) + expect(driver.created).toHaveLength(0) + }) + + it('still returns when the connect in flight fails', async () => { + const { service } = setup([stored()]) + const gate = deferred() + driver.connect.mockImplementation(() => gate.promise) + + const connecting = service.connect(ID).catch((e: unknown) => e) + await vi.waitFor(() => expect(driver.created).toHaveLength(1)) + const disconnecting = service.disconnect(ID) + gate.reject(driverError('MongoServerSelectionError')) + + await disconnecting + expect(await connecting).toBeInstanceOf(Error) + expect(service.isConnected(ID)).toBe(false) + }) +}) + +describe('tunnel drops', () => { + it('closes the connection when its own tunnel dies', async () => { + const { service, ssh, onDropped } = setup([stored({ ssh: SSH })]) + await service.connect(ID) + + openedAt(ssh.opened, 0).onDropped?.('the SSH connection closed') + + expect(service.isConnected(ID)).toBe(false) + expect(createdAt(0).close).toHaveBeenCalled() + expect(onDropped).toHaveBeenCalledWith(ID, 'the SSH connection closed') + }) + + it('ignores the death of a tunnel that belonged to an earlier connection', async () => { + const { service, ssh, onDropped } = setup([stored({ ssh: SSH })]) + await service.connect(ID) + await service.disconnect(ID) + await service.connect(ID) + + openedAt(ssh.opened, 0).onDropped?.('stale tunnel died') + + expect(service.isConnected(ID)).toBe(true) + expect(createdAt(1).close).not.toHaveBeenCalled() + expect(onDropped).not.toHaveBeenCalled() + }) +}) + +describe('Cloudflare tunnel', () => { + it('points the driver at the tunnel port with the stored secret decrypted', async () => { + const { service, ssh, cloudflared } = setup( + [ + stored({ + uri: 'mongodb://alice:%3CMONGOBENCH_PWD%3E@Mongo.Example.com:27017/admin?tls=true', + username: 'alice', + encryptedPassword: 'enc:pw', + cloudflared: CLOUDFLARED + }) + ], + { password: 'mongo-pw' } + ) + + await service.connect(ID) + + expect(cloudflared.service.open).toHaveBeenCalledWith( + { + hostname: 'mongo.example.com', + authMethod: 'serviceToken', + serviceTokenId: 'abc.access', + serviceTokenSecret: STORED_SECRET + }, + expect.any(Function), + expect.any(AbortSignal) + ) + expect(ssh.service.open).not.toHaveBeenCalled() + const client = createdAt(0) + expect(client.uri).toBe('mongodb://alice:mongo-pw@127.0.0.1:5001/admin?tls=true') + expect(client.options).toMatchObject({ + directConnection: true, + servername: 'mongo.example.com' + }) + }) + + it('leaves a switched-off Cloudflare section alone', async () => { + const { service, cloudflared } = setup([ + stored({ cloudflared: { ...CLOUDFLARED, enabled: false } }) + ]) + await service.connect(ID) + expect(cloudflared.service.open).not.toHaveBeenCalled() + expect(createdAt(0).uri).toBe('mongodb://db.example.com:27017/admin') + }) + + it('refuses a stored connection with both tunnels switched on', async () => { + const { service, ssh, cloudflared } = setup([stored({ ssh: SSH, cloudflared: CLOUDFLARED })]) + + const error = await service.connect(ID).catch((e: unknown) => e) + expect((error as Error).name).toBe('ValidationError') + expect(ssh.service.open).not.toHaveBeenCalled() + expect(cloudflared.service.open).not.toHaveBeenCalled() + }) + + it("reports the tunnel's own error instead of a driver timeout", async () => { + const { service, cloudflared } = setup([stored({ cloudflared: CLOUDFLARED })]) + const reason = Object.assign(new Error('Cloudflare tunnel failed: bad handshake!'), { + name: 'CloudflaredTunnelError' + }) + cloudflared.service.open.mockImplementationOnce(() => + Promise.resolve({ + route: LOCAL_ROUTE, + pinnedHostKey: null, + failure: () => reason, + close: vi.fn(() => Promise.resolve()) + }) + ) + driver.connect.mockImplementation(() => + Promise.reject(driverError('MongoServerSelectionError')) + ) + + await expect(service.connect(ID)).rejects.toBe(reason) + expect(createdAt(0).close).toHaveBeenCalled() + }) + + it('never hides a MongoDB authentication error behind the tunnel', async () => { + const { service, cloudflared } = setup([stored({ cloudflared: CLOUDFLARED })]) + cloudflared.service.open.mockImplementationOnce(() => + Promise.resolve({ + route: LOCAL_ROUTE, + pinnedHostKey: null, + failure: () => new Error('tunnel trouble'), + close: vi.fn(() => Promise.resolve()) + }) + ) + const authError = driverError('MongoAuthenticationError') + driver.connect.mockImplementation(() => Promise.reject(authError)) + + await expect(service.connect(ID)).rejects.toBe(authError) + }) +}) + +describe('test', () => { + const input: ConnectionInput = { + name: 'Production', + uri: 'mongodb://localhost/admin', + cloudflared: { + enabled: true, + hostname: 'mongo.example.com', + authMethod: 'serviceToken', + serviceTokenId: 'abc.access', + serviceTokenSecret: '' + } + } + + it('runs through a Cloudflare tunnel, using the saved secret when the form left it blank', async () => { + const { service, cloudflared } = setup([stored({ cloudflared: CLOUDFLARED })]) + + const result = await service.test(input, ID) + + expect(result).toMatchObject({ ok: true, serverVersion: '8.0.4' }) + expect(cloudflared.service.open).toHaveBeenCalledWith({ + hostname: 'mongo.example.com', + authMethod: 'serviceToken', + serviceTokenId: 'abc.access', + serviceTokenSecret: STORED_SECRET + }) + expect(createdAt(0).uri).toBe('mongodb://127.0.0.1:5001/admin') + expect(openedAt(cloudflared.opened, 0).tunnel.close).toHaveBeenCalled() + expect(createdAt(0).close).toHaveBeenCalled() + }) + + it.each([ + ['another hostname', { hostname: 'other-app.attacker.invalid' }], + ['another client ID', { serviceTokenId: 'other.access' }] + ])('never sends the saved secret to %s', async (_case, edit) => { + const { service, cloudflared } = setup([stored({ cloudflared: CLOUDFLARED })]) + const edited = { ...input, cloudflared: { ...input.cloudflared!, ...edit } } + + await service.test(edited, ID) + + const opened = openedAt(cloudflared.opened, 0).config as Record + expect(opened).toMatchObject(edit) + expect(opened).not.toHaveProperty('serviceTokenSecret') + }) + + it('closes the tunnel when the connection string cannot go through it', async () => { + const { service, cloudflared } = setup([]) + + await expect( + service.test({ ...input, uri: 'mongodb+srv://cluster.example.com/admin' }) + ).rejects.toThrow('mongodb+srv://') + expect(openedAt(cloudflared.opened, 0).tunnel.close).toHaveBeenCalled() + }) +}) + +describe('closeAll', () => { + it('closes the connections, then sweeps both tunnel services', async () => { + const { service, ssh, cloudflared } = setup([stored()]) + await service.connect(ID) + + await service.closeAll() + + expect(service.isConnected(ID)).toBe(false) + expect(createdAt(0).close).toHaveBeenCalled() + expect(ssh.service.closeAll).toHaveBeenCalled() + expect(cloudflared.service.closeAll).toHaveBeenCalled() + }) +}) diff --git a/src/main/services/ConnectionService.ts b/src/main/services/ConnectionService.ts index 3f674b5..572d7ce 100644 --- a/src/main/services/ConnectionService.ts +++ b/src/main/services/ConnectionService.ts @@ -1,10 +1,13 @@ import { MongoClient, type MongoClientOptions } from 'mongodb' import log from 'electron-log/main' +import { canReuseServiceTokenSecret } from '@shared/cloudflared' import { DEFAULT_SSH_PORT, + type CloudflaredTunnelInput, type ConnectionInput, type ConnectionTestResult, type ConnectResult, + type StoredCloudflaredTunnel, type StoredSshTunnel, type SshTunnelInput, type StoredConnection @@ -19,7 +22,13 @@ import { injectExternalCredentials, injectStoredPassword } from '../lib/connectionUri' -import type { ResolvedSshTunnel, SshTunnelService, Tunnel } from './SshTunnelService' +import { validationError } from '../lib/errorMap' +import { applyTunnelRoute, preferTunnelFailure, type Tunnel } from '../lib/tunnel' +import type { + CloudflaredTunnelService, + ResolvedCloudflaredTunnel +} from './CloudflaredTunnelService' +import type { ResolvedSshTunnel, SshTunnelService } from './SshTunnelService' const DEFAULT_TIMEOUT = 3000 @@ -43,10 +52,16 @@ type Active = { client: MongoClient; tunnel: Tunnel | null } */ export class ConnectionService { private clients = new Map() + /** Connects still in progress, so a second request joins the first and a disconnect can abort it. */ + private readonly pending = new Map< + string, + { opening: Promise; controller: AbortController } + >() constructor( private readonly repo: ConnectionsRepository, - private readonly tunnels: SshTunnelService, + private readonly sshTunnels: SshTunnelService, + private readonly cloudflaredTunnels: CloudflaredTunnelService, /** Called when main takes a connection down by itself. */ private readonly onDropped: (connectionId: string, reason: string) => void ) {} @@ -63,20 +78,15 @@ export class ConnectionService { const uri = await this.materializeFromInput(input, existingId) // A probe's tunnel is nobody else's: it is closed in the finally below, and // its death needs no drop callback — the in-flight ping reports it. - const tunnel = - input.ssh?.enabled === true - ? await this.tunnels.open(await this.resolveSshFromInput(input.ssh, existingId)) - : null + const tunnel = await this.openTunnelFromInput(input, existingId) const startedAt = Date.now() let client: MongoClient | null = null try { // Inside the try: the constructor parses the URI and throws on a bad // option, which would otherwise leak the tunnel. - client = new MongoClient(uri, { - ...this.optionsFromInput(input), - ...(tunnel?.proxyOptions ?? {}) - }) + const target = routeThrough(tunnel, uri, this.optionsFromInput(input)) + client = new MongoClient(target.uri, target.options) await client.connect() const ping = (await client.db('admin').command({ ping: 1 })) as { ok?: number } const buildInfo = (await client.db('admin').command({ buildInfo: 1 })) as { @@ -88,6 +98,8 @@ export class ConnectionService { ...(buildInfo.version !== undefined ? { serverVersion: buildInfo.version } : {}), ...(tunnel?.pinnedHostKey ? { pinnedHostKey: tunnel.pinnedHostKey } : {}) } + } catch (error) { + throw preferTunnelFailure(error, tunnel) } finally { await client?.close().catch(() => undefined) await tunnel?.close().catch(() => undefined) @@ -96,39 +108,20 @@ export class ConnectionService { async connect(id: string): Promise { if (this.clients.has(id)) return { connectionId: id } - const stored = await this.repo.getStored(id) - if (!stored) throw new ConnectionNotFoundError(id) - const uri = this.materializeFromStored(stored) - - const ssh = stored.ssh - const tunnel = - ssh?.enabled === true - ? await this.tunnels.open(this.resolveSshFromStored(stored, ssh), (reason) => - this.dropConnection(id, reason) - ) - : null - - let client: MongoClient | null = null - try { - client = new MongoClient(uri, { - ...this.optionsFromStored(stored), - ...(tunnel?.proxyOptions ?? {}) - }) - await client.connect() - } catch (error) { - await client?.close().catch(() => undefined) - await tunnel?.close().catch(() => undefined) - throw error - } - this.clients.set(id, { client, tunnel }) - log.info(`Connected ${stored.name} (${id})`) - return { - connectionId: id, - ...(tunnel?.pinnedHostKey ? { pinnedHostKey: tunnel.pinnedHostKey } : {}) - } + const inflight = this.pending.get(id) + if (inflight !== undefined) return inflight.opening + const controller = new AbortController() + const opening = this.open(id, controller.signal).finally(() => this.pending.delete(id)) + this.pending.set(id, { opening, controller }) + return opening } async disconnect(id: string): Promise { + const inflight = this.pending.get(id) + // Stops a browser sign-in nobody will finish instead of waiting out its timeout. + inflight?.controller.abort() + // A connect still in flight would otherwise register its client after this returned. + await inflight?.opening.catch(() => undefined) const active = this.clients.get(id) if (!active) return this.clients.delete(id) @@ -144,7 +137,7 @@ export class ConnectionService { const ids = [...this.clients.keys()] await Promise.allSettled(ids.map((id) => this.disconnect(id))) // Sweeps anything a failed open left behind. - await this.tunnels.closeAll() + await Promise.allSettled([this.sshTunnels.closeAll(), this.cloudflaredTunnels.closeAll()]) } isConnected(id: string): boolean { @@ -157,16 +150,89 @@ export class ConnectionService { return active.client } + private async open(id: string, signal: AbortSignal): Promise { + const stored = await this.repo.getStored(id) + if (!stored) throw new ConnectionNotFoundError(id) + const uri = this.materializeFromStored(stored) + + // Bound before the open so the drop callback can tell this tunnel from a later one. + let tunnel: Tunnel | null = null + tunnel = await this.openTunnelFromStored( + stored, + (reason) => this.dropConnection(id, tunnel, reason), + signal + ) + + let client: MongoClient | null = null + try { + const target = routeThrough(tunnel, uri, this.optionsFromStored(stored)) + client = new MongoClient(target.uri, target.options) + await client.connect() + } catch (error) { + // Asked before the tunnel closes, while it still knows why traffic failed. + const reported = preferTunnelFailure(error, tunnel) + await client?.close().catch(() => undefined) + await tunnel?.close().catch(() => undefined) + throw reported + } + this.clients.set(id, { client, tunnel }) + log.info(`Connected ${stored.name} (${id})`) + return { + connectionId: id, + ...(tunnel?.pinnedHostKey ? { pinnedHostKey: tunnel.pinnedHostKey } : {}) + } + } + /** The tunnel died on its own; the client on top of it is finished too. */ - private dropConnection(id: string, reason: string): void { + private dropConnection(id: string, tunnel: Tunnel | null, reason: string): void { const active = this.clients.get(id) - if (active === undefined) return + // A tunnel outliving its connection must not take down a newer one under the same id. + if (active === undefined || active.tunnel !== tunnel) return this.clients.delete(id) void active.client.close().catch(() => undefined) - log.warn(`Closed ${id} because its SSH tunnel dropped: ${reason}`) + log.warn(`Closed ${id} because its tunnel dropped: ${reason}`) this.onDropped(id, reason) } + private async openTunnelFromInput( + input: ConnectionInput, + existingId?: string + ): Promise { + if (input.ssh?.enabled === true) { + return this.sshTunnels.open(await this.resolveSshFromInput(input.ssh, existingId)) + } + if (input.cloudflared?.enabled === true) { + return this.cloudflaredTunnels.open( + await this.resolveCloudflaredFromInput(input.cloudflared, existingId) + ) + } + return null + } + + private async openTunnelFromStored( + stored: StoredConnection, + onDropped: (reason: string) => void, + signal: AbortSignal + ): Promise { + const { ssh, cloudflared } = stored + if (ssh?.enabled === true && cloudflared?.enabled === true) { + throw validationError( + `Connection ${stored.id} has both an SSH and a Cloudflare tunnel switched on` + ) + } + if (ssh?.enabled === true) { + return this.sshTunnels.open(this.resolveSshFromStored(stored, ssh), onDropped) + } + if (cloudflared?.enabled === true) { + return this.cloudflaredTunnels.open( + this.resolveCloudflaredFromStored(stored, cloudflared), + onDropped, + signal + ) + } + return null + } + /** * Per-connection toggle: when true, the explorer should only list * databases / collections the authenticated user has privileges on. @@ -258,6 +324,45 @@ export class ConnectionService { } } + private resolveCloudflaredFromStored( + stored: StoredConnection, + cloudflared: StoredCloudflaredTunnel + ): ResolvedCloudflaredTunnel { + const { serviceTokenSecret } = this.repo.decryptCloudflared(stored) + return { + hostname: cloudflared.hostname, + authMethod: cloudflared.authMethod, + ...(cloudflared.serviceTokenId !== undefined + ? { serviceTokenId: cloudflared.serviceTokenId } + : {}), + ...(serviceTokenSecret !== undefined ? { serviceTokenSecret } : {}), + ...(cloudflared.binaryPath !== undefined ? { binaryPath: cloudflared.binaryPath } : {}) + } + } + + private async resolveCloudflaredFromInput( + input: CloudflaredTunnelInput, + existingId?: string + ): Promise { + let serviceTokenSecret = nonEmpty(input.serviceTokenSecret) + if (serviceTokenSecret === undefined && existingId !== undefined) { + const stored = await this.repo.getStored(existingId) + // A blank secret means the saved one, but only for the hostname and client ID it was saved for. + if (stored && canReuseServiceTokenSecret(stored.cloudflared, input)) { + serviceTokenSecret = this.repo.decryptCloudflared(stored).serviceTokenSecret + } + } + const serviceTokenId = nonEmpty(input.serviceTokenId) + const binaryPath = nonEmpty(input.binaryPath) + return { + hostname: input.hostname, + authMethod: input.authMethod, + ...(serviceTokenId !== undefined ? { serviceTokenId } : {}), + ...(serviceTokenSecret !== undefined ? { serviceTokenSecret } : {}), + ...(binaryPath !== undefined ? { binaryPath } : {}) + } + } + private optionsFromInput(input: ConnectionInput): MongoClientOptions { return buildOptions({ serverSelectionTimeoutMS: input.serverSelectionTimeoutMS, @@ -301,6 +406,14 @@ function nonEmpty(value: string | undefined): string | undefined { return value !== undefined && value.length > 0 ? value : undefined } +function routeThrough( + tunnel: Tunnel | null, + uri: string, + options: MongoClientOptions +): { uri: string; options: MongoClientOptions } { + return tunnel === null ? { uri, options } : applyTunnelRoute(uri, options, tunnel.route) +} + type DriverInputs = { serverSelectionTimeoutMS?: number appName?: string diff --git a/src/main/services/DatabaseService.test.ts b/src/main/services/DatabaseService.test.ts new file mode 100644 index 0000000..db4887f --- /dev/null +++ b/src/main/services/DatabaseService.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it } from 'vitest' +import { BSON, BSONRegExp, Double, Int32, Long, ObjectId, type Document } from 'bson' +import { MongoServerError } from 'mongodb' +import { mapError } from '../lib/errorMap' +import { DatabaseService, databasesFromPrivileges } from './DatabaseService' +import type { ConnectionService } from './ConnectionService' + +type Doc = Record +type Stage = Record + +// Privileges as `connectionStatus` with `showPrivileges` reports them for readAnyDatabase. +const READ_ANY_DATABASE = [ + { resource: { db: '', collection: '' }, actions: ['find', 'listCollections'] }, + { resource: { cluster: true }, actions: ['listDatabases'] }, + { resource: { db: '', collection: 'system.js' }, actions: ['find'] } +] + +const READ_WRITE_APP = [ + { resource: { db: 'app', collection: '' }, actions: ['find', 'insert', 'update', 'remove'] }, + { resource: { db: 'app', collection: 'system.js' }, actions: ['find', 'insert'] } +] + +const ALL_DATABASES = [ + { name: 'admin', sizeOnDisk: 40960, empty: false }, + { name: 'app', sizeOnDisk: 81920, empty: false }, + { name: 'reports', sizeOnDisk: 8192, empty: false } +] + +/** Client double for the commands `listDatabases` and `collectionStats` send. */ +function serviceFor(opts: { + authorizedOnly?: boolean + privileges?: Doc[] + collStats?: Doc[] + estimatedCount?: number +}): DatabaseService { + const client = { + db: () => ({ + admin: () => ({ listDatabases: async () => ({ databases: ALL_DATABASES }) }), + command: async () => ({ + authInfo: { + authenticatedUsers: [{ user: 'reporter', db: 'admin' }], + authenticatedUserPrivileges: opts.privileges ?? [] + } + }), + collection: () => ({ + aggregate: (pipeline: Stage[]) => ({ + toArray: async () => { + const docs = opts.collStats ?? [] + const limit = pipeline.find((s) => '$limit' in s)?.['$limit'] + return typeof limit === 'number' ? docs.slice(0, limit) : docs + } + }), + estimatedDocumentCount: async () => opts.estimatedCount ?? 0 + }) + }) + } + const connections = { + getClient: () => client, + isAuthorizedOnly: async () => opts.authorizedOnly ?? false + } + return new DatabaseService(connections as unknown as ConnectionService) +} + +describe('databasesFromPrivileges', () => { + it('treats the any-database resource of readAnyDatabase as every database', () => { + expect(databasesFromPrivileges(READ_ANY_DATABASE)).toBe('all') + }) + + it('collects the databases of db-scoped privileges', () => { + expect(databasesFromPrivileges(READ_WRITE_APP)).toEqual(new Set(['app'])) + }) + + it('treats anyResource as every database', () => { + expect(databasesFromPrivileges([{ resource: { anyResource: true } }])).toBe('all') + }) + + it('returns an empty set for no privileges', () => { + expect(databasesFromPrivileges([])).toEqual(new Set()) + }) +}) + +describe('DatabaseService.listDatabases', () => { + it('lists every database for a readAnyDatabase user with authorized-only on', async () => { + const service = serviceFor({ authorizedOnly: true, privileges: READ_ANY_DATABASE }) + + const databases = await service.listDatabases('c1') + + expect(databases.map((d) => d.name)).toEqual(['admin', 'app', 'reports']) + }) + + it('keeps only the granted databases for a db-scoped user', async () => { + const service = serviceFor({ authorizedOnly: true, privileges: READ_WRITE_APP }) + + const databases = await service.listDatabases('c1') + + expect(databases.map((d) => d.name)).toEqual(['app']) + }) +}) + +describe('DatabaseService.collectionStats', () => { + it('sums the storage stats of every shard', async () => { + const service = serviceFor({ + collStats: [ + { + ns: 'app.players', + shard: 'shard-a', + storageStats: { + count: 1_000_000, + size: 100_000_000, + avgObjSize: 100, + storageSize: 40_000_000, + freeStorageSize: 1000, + totalIndexSize: 20_000_000, + totalSize: 60_000_000, + indexSizes: { _id_: 20_000_000 }, + nindexes: 1, + capped: false + } + }, + { + ns: 'app.players', + shard: 'shard-b', + storageStats: { + count: 1_000_000, + size: 300_000_000, + avgObjSize: 300, + storageSize: 80_000_000, + freeStorageSize: 0, + totalIndexSize: 25_000_000, + totalSize: 105_000_000, + indexSizes: { _id_: 25_000_000 }, + nindexes: 1, + capped: false + } + } + ] + }) + + const stats = await service.collectionStats('c1', 'app', 'players') + + expect(stats).toEqual({ + ns: 'app.players', + count: 2_000_000, + size: 400_000_000, + storageSize: 120_000_000, + freeStorageSize: 1000, + avgObjSize: 200, + totalIndexSize: 45_000_000, + indexSizes: { _id_: 45_000_000 }, + nindexes: 1, + capped: false, + totalSize: 165_000_000 + }) + }) + + it('counts a time-series collection, whose storage stats carry no count, from metadata', async () => { + const service = serviceFor({ + estimatedCount: 50, + collStats: [ + { + ns: 'app.system.buckets.readings', + storageStats: { + size: 959, + storageSize: 20480, + freeStorageSize: 0, + totalIndexSize: 4096, + totalSize: 24576, + indexSizes: { meta_1_ts_1: 4096 }, + nindexes: 1, + capped: false, + timeseries: { bucketsNs: 'app.system.buckets.readings', bucketCount: 3 } + } + } + ] + }) + + const stats = await service.collectionStats('c1', 'app', 'readings') + + expect(stats).toMatchObject({ count: 50, size: 959, avgObjSize: 959 / 50 }) + }) + + it('reports avgObjSize 0 for an empty collection', async () => { + const service = serviceFor({ + collStats: [{ ns: 'app.empty', storageStats: { count: 0, size: 0 } }] + }) + + const stats = await service.collectionStats('c1', 'app', 'empty') + + expect(stats).toMatchObject({ count: 0, avgObjSize: 0 }) + }) + + it('fails with DocumentNotFoundError when collStats returns nothing', async () => { + const service = serviceFor({ collStats: [] }) + + await expect(service.collectionStats('c1', 'app', 'players')).rejects.toMatchObject({ + name: 'DocumentNotFoundError' + }) + }) +}) + +type ReadOptions = { + raw?: boolean + promoteValues?: boolean + bsonRegExp?: boolean + maxTimeMS?: number + limit?: number +} + +/** + * Client double that stores BSON bytes and answers reads like the driver: raw bytes with `raw`, + * else decoded with the read's options; a `failure` reply is decoded with the same options. + */ +function schemaServiceFor(opts: { + type: 'collection' | 'view' + docs: Document[] + failure?: { code: number; errmsg: string } +}) { + const bytes = opts.docs.map((d) => BSON.serialize(d)) + const reads: Array<{ op: 'find' | 'aggregate'; arg: unknown; options: ReadOptions }> = [] + const cursor = (options: ReadOptions) => + (async function* () { + const bsonOptions = { + promoteValues: options.promoteValues, + bsonRegExp: options.bsonRegExp, + raw: options.raw + } + if (opts.failure) { + const reply = BSON.serialize({ ok: 0, ...opts.failure }) + throw new MongoServerError(BSON.deserialize(reply, bsonOptions)) + } + for (const b of bytes) yield options.raw ? b : BSON.deserialize(b, bsonOptions) + })() + const client = { + db: () => ({ + listCollections: (filter: { name: string }) => ({ + toArray: async () => [{ name: filter.name, type: opts.type }] + }), + collection: () => ({ + find: (filter: unknown, options: ReadOptions) => { + reads.push({ op: 'find', arg: filter, options }) + return cursor(options) + }, + aggregate: (pipeline: unknown, options: ReadOptions) => { + reads.push({ op: 'aggregate', arg: pipeline, options }) + return cursor(options) + } + }) + }) + } + const connections = { getClient: () => client } + return { service: new DatabaseService(connections as unknown as ConnectionService), reads } +} + +describe('DatabaseService.collectionSchema', () => { + const docs = [ + { + _id: new ObjectId(), + qty: new Int32(5), + price: new Double(5), + big: Long.fromNumber(7), + re: new BSONRegExp('dot.all', 's') + }, + { + _id: new ObjectId(), + qty: new Int32(6), + price: new Double(2.5), + big: Long.fromNumber(8), + re: new BSONRegExp('(?i)abc', '') + } + ] + + it('samples a collection with $sample and keeps the stored types', async () => { + const { service, reads } = schemaServiceFor({ type: 'collection', docs }) + + const schema = await service.collectionSchema('c1', 'app', 'orders') + + expect(reads).toEqual([ + { + op: 'aggregate', + arg: [{ $sample: { size: 200 } }], + options: { raw: true, maxTimeMS: 5000 } + } + ]) + expect(schema.sampleSize).toBe(2) + expect(schema.fields.map((f) => [f.path, f.types])).toEqual([ + ['_id', { objectId: 2 }], + ['big', { long: 2 }], + ['price', { double: 2 }], + ['qty', { int: 2 }], + ['re', { regex: 2 }] + ]) + }) + + it('reads a view with a limited find instead of $sample', async () => { + const { service, reads } = schemaServiceFor({ type: 'view', docs }) + + const schema = await service.collectionSchema('c1', 'app', 'orders_view') + + expect(reads).toEqual([ + { op: 'find', arg: {}, options: { raw: true, maxTimeMS: 5000, limit: 200 } } + ]) + expect(schema.sampleSize).toBe(2) + }) + + it('keeps the server error code a number when sampling fails', async () => { + const { service } = schemaServiceFor({ + type: 'collection', + docs, + failure: { code: 189, errmsg: 'Primary stepped down' } + }) + + const error = await service.collectionSchema('c1', 'app', 'orders').then( + () => null, + (e: unknown) => e + ) + + expect(mapError(error)).toMatchObject({ code: 'driver_error', details: { mongoCode: 189 } }) + }) + + it('returns an empty schema for an empty collection', async () => { + const { service } = schemaServiceFor({ type: 'collection', docs: [] }) + + await expect(service.collectionSchema('c1', 'app', 'empty')).resolves.toEqual({ + sampleSize: 0, + fields: [], + truncated: false + }) + }) +}) diff --git a/src/main/services/DatabaseService.ts b/src/main/services/DatabaseService.ts index 3363dbc..49b28d1 100644 --- a/src/main/services/DatabaseService.ts +++ b/src/main/services/DatabaseService.ts @@ -1,8 +1,29 @@ import type { MongoClient } from 'mongodb' -import type { CollectionInfo, CollectionStats, DatabaseInfo, ServerStats } from '@shared/types' +import { createSchemaBuilder } from '@shared/collectionSchema' +import type { + CollectionInfo, + CollectionSchema, + CollectionStats, + DatabaseInfo, + ServerStats +} from '@shared/types' +import { decodeDocument, toCanonical } from '../lib/ejson' +import { validationError } from '../lib/errorMap' +import { mergeStorageStats } from '../lib/storageStats' import type { ConnectionService } from './ConnectionService' const COLLECTION_NAME_RE = /^[^$\0]+$/ +const SCHEMA_SAMPLE_SIZE = 200 +const SCHEMA_SAMPLE_MAX_TIME_MS = 5_000 + +type Privilege = { + resource?: { + db?: string + collection?: string + cluster?: boolean + anyResource?: boolean + } +} /** Metadata + light mutating operations on databases and collections. */ export class DatabaseService { @@ -44,27 +65,28 @@ export class DatabaseService { } async collectionStats(connectionId: string, db: string, coll: string): Promise { - const client = this.connections.getClient(connectionId) - const cursor = client - .db(db) - .collection(coll) - .aggregate([{ $collStats: { storageStats: {} } }, { $limit: 1 }]) + const collection = this.connections.getClient(connectionId).db(db).collection(coll) + const cursor = collection.aggregate([{ $collStats: { storageStats: {} } }]) const docs = await cursor.toArray() - const first = docs[0] - if (!first) { + if (docs.length === 0) { const e = new Error(`collStats returned no result for ${db}.${coll}`) e.name = 'DocumentNotFoundError' throw e } - const ns = (first['ns'] as string) ?? `${db}.${coll}` - const s = (first['storageStats'] as Record) ?? {} + const merged = mergeStorageStats(docs) + const ns = merged.ns ?? `${db}.${coll}` + const s = merged.storage + // Time-series collections report the storage of their buckets but no document count. + const count = + typeof s['count'] === 'number' ? s['count'] : await collection.estimatedDocumentCount() + const size = numberOr(s['size'], 0) return { ns, - count: numberOr(s['count'], 0), - size: numberOr(s['size'], 0), + count, + size, storageSize: numberOr(s['storageSize'], 0), freeStorageSize: numberOr(s['freeStorageSize'], 0), - avgObjSize: numberOr(s['avgObjSize'], 0), + avgObjSize: count > 0 ? size / count : 0, totalIndexSize: numberOr(s['totalIndexSize'], 0), indexSizes: (s['indexSizes'] as Record) ?? {}, nindexes: numberOr(s['nindexes'], 0), @@ -76,6 +98,28 @@ export class DatabaseService { } } + /** Field schema inferred from a random sample of up to 200 documents; empty for an empty collection. */ + async collectionSchema( + connectionId: string, + db: string, + coll: string + ): Promise { + const database = this.connections.getClient(connectionId).db(db) + const [info] = await database + .listCollections({ name: coll }, { nameOnly: true, authorizedCollections: true }) + .toArray() + const collection = database.collection(coll) + const options = { raw: true, maxTimeMS: SCHEMA_SAMPLE_MAX_TIME_MS } + // $sample on a view runs the whole view pipeline first; a limited find streams. + const cursor = + info?.type === 'view' + ? collection.find({}, { ...options, limit: SCHEMA_SAMPLE_SIZE }) + : collection.aggregate([{ $sample: { size: SCHEMA_SAMPLE_SIZE } }], options) + const builder = createSchemaBuilder() + for await (const bytes of cursor) builder.add(toCanonical(decodeDocument(bytes))) + return builder.build() + } + async dropCollection(connectionId: string, db: string, coll: string): Promise { const client = this.connections.getClient(connectionId) await client.db(db).collection(coll).drop() @@ -113,14 +157,10 @@ export class DatabaseService { ): Promise { if (newName === coll) return if (!COLLECTION_NAME_RE.test(newName)) { - const e = new Error('Collection name must not contain $ or null bytes') - e.name = 'ValidationError' - throw e + throw validationError('Collection name must not contain $ or null bytes') } if (newName.startsWith('system.')) { - const e = new Error('Collection name must not start with "system."') - e.name = 'ValidationError' - throw e + throw validationError('Collection name must not start with "system."') } const client = this.connections.getClient(connectionId) await client.db(db).collection(coll).rename(newName) @@ -257,7 +297,7 @@ export class DatabaseService { /** * Returns the set of database names the authenticated user has any privilege on, - * or 'all' when the user holds a cluster-wide / anyResource privilege (or auth + * or 'all' when the user holds an any-database / anyResource privilege (or auth * is disabled and no privilege list is reported). * * Why: `listDatabases({ authorizedDatabases: true })` is ignored server-side for @@ -272,32 +312,29 @@ async function authorizedDatabases(client: MongoClient): Promise | ' .command({ connectionStatus: 1, showPrivileges: true })) as { authInfo?: { authenticatedUsers?: Array<{ user: string; db: string }> - authenticatedUserPrivileges?: Array<{ - resource?: { - db?: string - collection?: string - cluster?: boolean - anyResource?: boolean - } - }> + authenticatedUserPrivileges?: Privilege[] } } const auth = status.authInfo if (!auth || (auth.authenticatedUsers ?? []).length === 0) return 'all' - const privs = auth.authenticatedUserPrivileges ?? [] - const dbs = new Set() - for (const p of privs) { - const r = p.resource - if (!r) continue - if (r.anyResource) return 'all' - if (typeof r.db === 'string' && r.db.length > 0) dbs.add(r.db) - } - return dbs + return databasesFromPrivileges(auth.authenticatedUserPrivileges ?? []) } catch { return 'all' } } +/** Databases the privileges reach, or 'all'; db '' means every database (*AnyDatabase roles). */ +export function databasesFromPrivileges(privs: readonly Privilege[]): Set | 'all' { + const dbs = new Set() + for (const p of privs) { + const r = p.resource + if (!r) continue + if (r.anyResource || r.db === '') return 'all' + if (typeof r.db === 'string') dbs.add(r.db) + } + return dbs +} + /** * Pull the raw cumulative latency / ops counters out of the * `opLatencies.{reads,writes,commands}` block. The renderer derives a @@ -337,26 +374,18 @@ function basename(path: string): string { function assertCollectionName(name: string): void { if (!COLLECTION_NAME_RE.test(name)) { - const e = new Error('Collection name must not contain $ or null bytes') - e.name = 'ValidationError' - throw e + throw validationError('Collection name must not contain $ or null bytes') } if (name.startsWith('system.')) { - const e = new Error('Collection name must not start with "system."') - e.name = 'ValidationError' - throw e + throw validationError('Collection name must not start with "system."') } } function assertDatabaseName(name: string): void { if (name.length === 0 || name.length > 64) { - const e = new Error('Database name must be 1–64 characters') - e.name = 'ValidationError' - throw e + throw validationError('Database name must be 1–64 characters') } if (/[\s/\\."$*<>:|?\0]/.test(name)) { - const e = new Error('Database name contains invalid characters') - e.name = 'ValidationError' - throw e + throw validationError('Database name contains invalid characters') } } diff --git a/src/main/services/IndexService.test.ts b/src/main/services/IndexService.test.ts new file mode 100644 index 0000000..90b50e3 --- /dev/null +++ b/src/main/services/IndexService.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from 'vitest' +import { BSON, Decimal128, ObjectId, type Document } from 'bson' +import { IndexService } from './IndexService' +import type { ConnectionService } from './ConnectionService' + +type Doc = Record +type Stage = Record + +const SINCE = new Date('2026-10-01T08:00:00.000Z') + +/** Collection double whose aggregate answers by first stage and honours `$limit` like a server. */ +function fakeCollection(opts: { + indexes?: Doc[] + indexStats?: () => Promise + collStats?: () => Promise +}) { + const created: Array<{ keys: Doc; options: Doc }> = [] + return { + created, + createIndex: async (keys: Doc, options: Doc) => { + created.push({ keys, options }) + return 'created_1' + }, + listIndexes: () => ({ toArray: async () => opts.indexes ?? [] }), + aggregate: (pipeline: Stage[]) => ({ + toArray: async () => { + const first = pipeline[0] ?? {} + const source = + '$indexStats' in first ? opts.indexStats : '$collStats' in first ? opts.collStats : null + if (!source) throw new Error(`unexpected pipeline ${JSON.stringify(pipeline)}`) + const docs = await source() + const limit = pipeline.find((s) => '$limit' in s)?.['$limit'] + return typeof limit === 'number' ? docs.slice(0, limit) : docs + } + }) + } +} + +function serviceFor(collection: ReturnType): IndexService { + const connections = { + getClient: () => ({ db: () => ({ collection: () => collection }) }) + } + return new IndexService(connections as unknown as ConnectionService) +} + +function shardStats(shard: string, indexSizes: Record): Doc { + return { ns: 'app.players', shard, storageStats: { indexSizes } } +} + +describe('IndexService.listIndexes', () => { + it('returns relaxed EJSON for the fields that carry user values', async () => { + const oid = new ObjectId('507f1f77bcf86cd799439011') + const service = serviceFor( + fakeCollection({ + indexes: [ + { + v: 2, + key: { status: 1 }, + name: 'status_1', + partialFilterExpression: { + status: { $in: [oid] }, + createdAt: { $gt: new Date('2026-01-15T10:30:00.123Z') }, + price: { $lt: Decimal128.fromString('9.99') } + }, + collation: { locale: 'de', strength: 2 } + }, + { + v: 2, + key: { _fts: 'text', _ftsx: 1 }, + name: 'bio_text', + weights: { bio: 3 }, + default_language: 'german' + }, + { v: 2, key: { '$**': 1 }, name: '$**_1', wildcardProjection: { secret: 0 } } + ] + }) + ) + + const indexes = await service.listIndexes('c1', 'app', 'players') + + expect(indexes).toEqual([ + { + v: 2, + key: { status: 1 }, + name: 'status_1', + partialFilterExpression: { + status: { $in: [{ $oid: '507f1f77bcf86cd799439011' }] }, + createdAt: { $gt: { $date: '2026-01-15T10:30:00.123Z' } }, + price: { $lt: { $numberDecimal: '9.99' } } + }, + collation: { locale: 'de', strength: 2 } + }, + { + v: 2, + key: { _fts: 'text', _ftsx: 1 }, + name: 'bio_text', + weights: { bio: 3 }, + default_language: 'german' + }, + { v: 2, key: { '$**': 1 }, name: '$**_1', wildcardProjection: { secret: 0 } } + ]) + }) + + it('lists indexes without reading storage stats', async () => { + const service = serviceFor( + fakeCollection({ + indexes: [{ v: 2, key: { _id: 1 }, name: '_id_' }], + collStats: async () => [shardStats('shard-a', { _id_: 4096 })] + }) + ) + + const indexes = await service.listIndexes('c1', 'app', 'players') + + expect(indexes).toEqual([{ v: 2, key: { _id: 1 }, name: '_id_' }]) + }) +}) + +describe('IndexService.createIndex', () => { + it('sends an Int64 bound beyond 2^53 in a partial filter exactly', async () => { + const collection = fakeCollection({}) + + await serviceFor(collection).createIndex('c1', 'app', 'players', '{"big":1}', { + partialFilterExpression: '{"big":{"$gte":{"$numberLong":"9007199254740993"}}}' + }) + + const sent = collection.created[0]?.options['partialFilterExpression'] as Document + const encoded = BSON.deserialize(BSON.serialize(sent), { promoteLongs: false }) + expect(String(encoded['big']['$gte'])).toBe('9007199254740993') + expect(encoded['big']['$gte']._bsontype).toBe('Long') + }) + + it('keeps numeric key directions plain numbers', async () => { + const collection = fakeCollection({}) + + await serviceFor(collection).createIndex('c1', 'app', 'players', '{"a":1,"b":-1}', undefined) + + expect(collection.created[0]?.keys).toEqual({ a: 1, b: -1 }) + }) +}) + +describe('IndexService.indexStats', () => { + it('reads every shard and merges usage and sizes', async () => { + const service = serviceFor( + fakeCollection({ + indexStats: async () => [ + { + name: '_id_', + host: 'a1:27018', + shard: 'shard-a', + accesses: { ops: 4, since: SINCE } + }, + { + name: '_id_', + host: 'b1:27018', + shard: 'shard-b', + accesses: { ops: 6, since: SINCE } + } + ], + collStats: async () => [ + shardStats('shard-a', { _id_: 1000 }), + shardStats('shard-b', { _id_: 3000 }) + ] + }) + ) + + const report = await service.indexStats('c1', 'app', 'players') + + expect(report).toEqual({ + indexes: [ + { + name: '_id_', + size: 4000, + building: false, + usage: [ + { host: 'a1:27018', shard: 'shard-a', ops: 4, since: '2026-10-01T08:00:00.000Z' }, + { host: 'b1:27018', shard: 'shard-b', ops: 6, since: '2026-10-01T08:00:00.000Z' } + ] + } + ], + usageUnavailable: null, + storageUnavailable: null + }) + }) + + it('degrades the usage source alone when $indexStats is not authorized', async () => { + const service = serviceFor( + fakeCollection({ + indexStats: async () => { + throw Object.assign(new Error('not authorized on app to execute command'), { code: 13 }) + }, + collStats: async () => [shardStats('shard-a', { _id_: 1000 })] + }) + ) + + const report = await service.indexStats('c1', 'app', 'players') + + expect(report).toEqual({ + indexes: [{ name: '_id_', size: 1000, building: false, usage: [] }], + usageUnavailable: { + reason: 'unauthorized', + message: 'not authorized on app to execute command' + }, + storageUnavailable: null + }) + }) + + it('rejects when the connection is not open', async () => { + const connections = { + getClient: () => { + throw Object.assign(new Error('Connection c1 is not open'), { name: 'NotConnectedError' }) + } + } + const service = new IndexService(connections as unknown as ConnectionService) + + await expect(service.indexStats('c1', 'app', 'players')).rejects.toMatchObject({ + name: 'NotConnectedError' + }) + }) +}) diff --git a/src/main/services/IndexService.ts b/src/main/services/IndexService.ts index 91b4bff..4a996b4 100644 --- a/src/main/services/IndexService.ts +++ b/src/main/services/IndexService.ts @@ -1,6 +1,10 @@ import type { CreateIndexesOptions, IndexDirection, IndexSpecification } from 'mongodb' import { EJSON } from 'bson' -import type { IndexCreateOptions, IndexInfo } from '@shared/types' +import type { IndexCreateOptions, IndexInfo, IndexStatsReport } from '@shared/types' +import { isDocument } from '@shared/writeOps' +import { buildIndexStatsReport } from '../lib/indexStats' +import { parseRelaxed, toRelaxed } from '../lib/ejson' +import { validationError } from '../lib/errorMap' import type { ConnectionService } from './ConnectionService' /** Index management on a single collection. */ @@ -10,22 +14,18 @@ export class IndexService { async listIndexes(connectionId: string, db: string, coll: string): Promise { const client = this.connections.getClient(connectionId) const collection = client.db(db).collection(coll) + const raw = await collection.listIndexes().toArray() + return raw.map((info) => mapIndex(info as Record)) + } - const [raw, statsCursor] = await Promise.all([ - collection.listIndexes().toArray(), - collection - .aggregate([{ $collStats: { storageStats: {} } }, { $limit: 1 }]) - .toArray() - .catch(() => [] as Array>) + /** Per-index size, build state and usage; each source degrades on its own instead of failing. */ + async indexStats(connectionId: string, db: string, coll: string): Promise { + const collection = this.connections.getClient(connectionId).db(db).collection(coll) + const [usage, storage] = await Promise.allSettled([ + collection.aggregate([{ $indexStats: {} }]).toArray(), + collection.aggregate([{ $collStats: { storageStats: {} } }]).toArray() ]) - - const sizes = - ((statsCursor[0]?.['storageStats'] as Record | undefined)?.['indexSizes'] as - Record | undefined) ?? {} - - return raw.map((info) => - mapIndex(info as Record, sizes[info['name'] as string]) - ) + return buildIndexStatsReport(usage, storage) } async createIndex( @@ -38,7 +38,7 @@ export class IndexService { const client = this.connections.getClient(connectionId) const keys = parseObject(keysJson, 'keys') if (Object.keys(keys).length === 0) { - throwValidation('At least one key field is required') + throw validationError('At least one key field is required') } assertKeySpec(keys) @@ -52,9 +52,11 @@ export class IndexService { driverOptions.expireAfterSeconds = options.expireAfterSeconds } if (options.partialFilterExpression !== undefined) { + // Filter bounds may be Int64 values beyond 2^53, which only a bigint keeps exact. driverOptions.partialFilterExpression = parseObject( options.partialFilterExpression, - 'partialFilterExpression' + 'partialFilterExpression', + parseRelaxed ) } if (options.collation !== undefined) { @@ -95,43 +97,49 @@ export class IndexService { async dropIndex(connectionId: string, db: string, coll: string, name: string): Promise { if (name === '_id_') { - throwValidation('The default _id_ index cannot be dropped') + throw validationError('The default _id_ index cannot be dropped') } const client = this.connections.getClient(connectionId) await client.db(db).collection(coll).dropIndex(name) } } -function parseObject(input: string, label: string): Record { +function parseObject( + input: string, + label: string, + parse: (text: string) => unknown = (text) => EJSON.parse(text, { relaxed: true }) +): Record { let parsed: unknown try { - parsed = EJSON.parse(input, { relaxed: true }) + parsed = parse(input) } catch (e) { - throwValidation(`${label} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`) + throw validationError( + `${label} is not valid JSON: ${e instanceof Error ? e.message : String(e)}` + ) } - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throwValidation(`${label} must be a JSON object`) + if (!isDocument(parsed)) { + throw validationError(`${label} must be a JSON object`) } - return parsed as Record + return parsed } function assertKeySpec(keys: Record): void { for (const [field, direction] of Object.entries(keys)) { - if (field.length === 0) throwValidation('Key field name cannot be empty') + if (field.length === 0) throw validationError('Key field name cannot be empty') if (typeof direction === 'number' ? direction !== 1 && direction !== -1 : false) { - throwValidation(`Numeric direction for "${field}" must be 1 or -1`) + throw validationError(`Numeric direction for "${field}" must be 1 or -1`) } if (typeof direction === 'string') { if (!['text', '2dsphere', '2d', 'hashed'].includes(direction)) { - throwValidation(`Unknown index type "${direction}" for field "${field}"`) + throw validationError(`Unknown index type "${direction}" for field "${field}"`) } } else if (typeof direction !== 'number') { - throwValidation(`Direction for "${field}" must be 1, -1, or a string type`) + throw validationError(`Direction for "${field}" must be 1, -1, or a string type`) } } } -function mapIndex(raw: Record, size: number | undefined): IndexInfo { +function mapIndex(raw: Record): IndexInfo { const out: IndexInfo = { name: String(raw['name']), key: (raw['key'] as Record) ?? {} @@ -143,14 +151,16 @@ function mapIndex(raw: Record, size: number | undefined): Index out.expireAfterSeconds = Number(raw['expireAfterSeconds']) } if (raw['partialFilterExpression'] !== undefined) { - out.partialFilterExpression = raw['partialFilterExpression'] as Record + out.partialFilterExpression = toRelaxed(raw['partialFilterExpression']) } if (raw['collation'] !== undefined) { - out.collation = raw['collation'] as Record + out.collation = toRelaxed(raw['collation']) } if (raw['hidden'] !== undefined) out.hidden = Boolean(raw['hidden']) if (raw['background'] !== undefined) out.background = Boolean(raw['background']) - if (raw['weights'] !== undefined) out.weights = raw['weights'] as Record + if (raw['weights'] !== undefined) { + out.weights = toRelaxed(raw['weights']) as Record + } if (raw['default_language'] !== undefined) out.default_language = String(raw['default_language']) if (raw['language_override'] !== undefined) { out.language_override = String(raw['language_override']) @@ -163,14 +173,7 @@ function mapIndex(raw: Record, size: number | undefined): Index if (raw['min'] !== undefined) out.min = Number(raw['min']) if (raw['max'] !== undefined) out.max = Number(raw['max']) if (raw['wildcardProjection'] !== undefined) { - out.wildcardProjection = raw['wildcardProjection'] as Record + out.wildcardProjection = toRelaxed(raw['wildcardProjection']) } - if (size !== undefined) out.size = size return out } - -function throwValidation(message: string): never { - const e = new Error(message) - e.name = 'ValidationError' - throw e -} diff --git a/src/main/services/QueryService.test.ts b/src/main/services/QueryService.test.ts new file mode 100644 index 0000000..6544f51 --- /dev/null +++ b/src/main/services/QueryService.test.ts @@ -0,0 +1,1184 @@ +import { describe, expect, it, vi } from 'vitest' +import { BSON, BSONRegExp, Double, EJSON, Int32, Long, ObjectId, type Document } from 'bson' +import { MongoServerError } from 'mongodb' +import { rejectedStageMessage } from '@shared/pipeline' +import { RESULT_CAP, type WriteOp } from '@shared/types' +import { + bulkWriteError, + insertDocumentError, + replacementError, + updateError, + writeOptionsError +} from '@shared/writeOps' +import { mapError } from '../lib/errorMap' +import type { ConnectionService } from './ConnectionService' +import { QueryService } from './QueryService' + +const REF = { connectionId: '7f1c2d3e-4b5a-4c6d-8e9f-0a1b2c3d4e5f', db: 'app', coll: 'players' } +const REQUEST_ID = '0b6e4a52-3c1d-4f7e-9a8b-2d5c6e7f8a9b' +const OID = '507f1f77bcf86cd799439011' +const BEYOND_2_53 = '9007199254740993' + +type ReadOptions = { + raw?: boolean + promoteValues?: boolean + promoteLongs?: boolean + bsonRegExp?: boolean + signal?: AbortSignal +} + +type ServerReply = { code: number; errmsg: string } + +/** + * Holds documents as BSON bytes and answers reads like the driver: raw bytes with `raw`, else + * decoded with the call's BSON options. A `failure` reply is decoded with those same options into + * a MongoServerError, as the driver's connection does. With `hang`, reads settle only when their + * signal aborts, rejecting with `abortError` or else the signal's reason. + */ +function fakeCollection( + docs: Document[], + { + hang = false, + failure, + abortError + }: { hang?: boolean; failure?: ServerReply; abortError?: Error } = {} +) { + const bytes = docs.map((d) => BSON.serialize(d)) + const bsonOptions = (options: ReadOptions | undefined) => ({ + promoteValues: options?.promoteValues, + promoteLongs: options?.promoteLongs, + bsonRegExp: options?.bsonRegExp, + raw: options?.raw + }) + const decoded = (options: ReadOptions | undefined): Document[] => + bytes.map((b) => BSON.deserialize(b, bsonOptions(options))) + const results = (options: ReadOptions | undefined): unknown[] => + options?.raw ? bytes : decoded(options) + const read = (options: ReadOptions | undefined, value: () => T): Promise => { + if (failure) { + const reply = BSON.deserialize(BSON.serialize({ ok: 0, ...failure }), bsonOptions(options)) + return Promise.reject(new MongoServerError(reply)) + } + if (!hang) return Promise.resolve(value()) + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(abortError ?? options.signal?.reason)) + }) + } + + const cursor = (options: ReadOptions | undefined) => { + const c = { + project: vi.fn(() => c), + sort: vi.fn(() => c), + skip: vi.fn(() => c), + limit: vi.fn(() => c), + toArray: () => read(options, () => results(options)) + } + return c + } + + return { + find: vi.fn((_filter: Document, options?: ReadOptions) => cursor(options)), + aggregate: vi.fn((_pipeline: Document[], options?: ReadOptions) => ({ + toArray: () => read(options, () => results(options)) + })), + findOne: vi.fn((_filter: Document, options?: ReadOptions) => + read(options, () => results(options)[0] ?? null) + ), + countDocuments: vi.fn((_filter: Document, options?: ReadOptions) => + read(options, () => docs.length) + ), + estimatedDocumentCount: vi.fn(async () => docs.length), + distinct: vi.fn((field: string, _filter: Document, options?: ReadOptions) => + read(options, () => decoded(options).map((d) => d[field])) + ), + replaceOne: vi.fn(async () => ({ matchedCount: 1, modifiedCount: 1 })), + deleteOne: vi.fn(async () => ({ deletedCount: 1 })) + } +} + +function serviceFor(coll: object): QueryService { + const client = { db: () => ({ collection: () => coll }) } + return new QueryService({ getClient: () => client } as unknown as ConnectionService) +} + +function manyDocs(n: number): Document[] { + return Array.from({ length: n }, (_, i) => ({ i })) +} + +/** A filter value as the driver encodes it, with Longs left unpromoted. */ +function encodedAs(filter: unknown, field: string): string { + const encoded = BSON.deserialize(BSON.serialize(filter as Document), { promoteLongs: false }) + return `${(encoded[field] as { _bsontype?: string })._bsontype}(${String(encoded[field])})` +} + +describe('QueryService.find', () => { + it('reads without promoting values, so stored numeric types survive', async () => { + const coll = fakeCollection([ + { _id: new ObjectId(OID), coins: Long.fromNumber(1500), ratio: new Double(2) } + ]) + const { documents } = await serviceFor(coll).find({ ...REF, skip: 0 }) + + expect(JSON.parse(documents[0]?.canonical ?? '')).toEqual({ + _id: { $oid: OID }, + coins: { $numberLong: '1500' }, + ratio: { $numberDouble: '2.0' } + }) + }) + + it('keeps stored regexes that a JS RegExp cannot represent', async () => { + const coll = fakeCollection([ + { _id: 1, dotAll: new BSONRegExp('dot.all', 's'), pcre: new BSONRegExp('(?i)abc', '') } + ]) + const { documents } = await serviceFor(coll).find({ ...REF, skip: 0 }) + + expect(JSON.parse(documents[0]?.canonical ?? '')).toMatchObject({ + dotAll: { $regularExpression: { pattern: 'dot.all', options: 's' } }, + pcre: { $regularExpression: { pattern: '(?i)abc', options: '' } } + }) + }) + + it('identifies a document by its canonical _id', async () => { + const coll = fakeCollection([{ _id: new ObjectId(OID), name: 'a' }]) + const { documents } = await serviceFor(coll).find({ ...REF, skip: 0 }) + expect(documents[0]?.id).toBe(`{"$oid":"${OID}"}`) + }) + + it('gives rows without _id a null id', async () => { + const coll = fakeCollection([{ name: 'a' }, { name: 'b' }]) + const { documents } = await serviceFor(coll).find({ + ...REF, + projection: '{"_id":0,"name":1}', + skip: 0 + }) + expect(documents.map((d) => d.id)).toEqual([null, null]) + }) + + it('applies projection, sort, skip and limit to the cursor', async () => { + const coll = fakeCollection([{ _id: 1 }]) + await serviceFor(coll).find({ + ...REF, + filter: '{"a":1}', + projection: '{"a":1}', + sort: '{"b":-1}', + skip: 20, + limit: 10 + }) + const cursor = coll.find.mock.results[0]?.value as ReturnType + expect(coll.find.mock.calls[0]?.[0]).toEqual({ a: 1 }) + expect(cursor.project).toHaveBeenCalledWith({ a: 1 }) + expect(cursor.sort).toHaveBeenCalledWith({ b: -1 }) + expect(cursor.skip).toHaveBeenCalledWith(20) + expect(cursor.limit).toHaveBeenCalledWith(10) + }) + + it('reloads a document by an Int64 _id beyond 2^53 without rounding it', async () => { + const coll = fakeCollection([]) + await serviceFor(coll).find({ + ...REF, + filter: `{"_id":{"$numberLong":"${BEYOND_2_53}"}}`, + skip: 0, + limit: 1 + }) + expect(encodedAs(coll.find.mock.calls[0]?.[0], '_id')).toBe(`Long(${BEYOND_2_53})`) + }) +}) + +describe('QueryService.aggregate', () => { + it('caps the pipeline server-side with one extra row to detect truncation', async () => { + const coll = fakeCollection([]) + await serviceFor(coll).aggregate({ ...REF, pipeline: '[{"$match":{"a":"x"}}]' }) + expect(coll.aggregate.mock.calls[0]?.[0]).toEqual([ + { $match: { a: 'x' } }, + { $limit: RESULT_CAP + 1 } + ]) + }) + + it('keeps the stored numeric and regex types', async () => { + const coll = fakeCollection([{ _id: 1, d: new Double(5), re: new BSONRegExp('a b', 'x') }]) + const res = await serviceFor(coll).aggregate({ ...REF, pipeline: '[]' }) + expect(JSON.parse(res.documents[0]?.canonical ?? '')).toEqual({ + _id: { $numberInt: '1' }, + d: { $numberDouble: '5.0' }, + re: { $regularExpression: { pattern: 'a b', options: 'x' } } + }) + }) + + it('returns RESULT_CAP rows and flags truncation when more came back', async () => { + const coll = fakeCollection(manyDocs(RESULT_CAP + 1)) + const res = await serviceFor(coll).aggregate({ ...REF, pipeline: '[]' }) + expect(res.documents).toHaveLength(RESULT_CAP) + expect(res.truncated).toBe(true) + }) + + it('does not flag a result of exactly RESULT_CAP rows', async () => { + const coll = fakeCollection(manyDocs(RESULT_CAP)) + const res = await serviceFor(coll).aggregate({ ...REF, pipeline: '[]' }) + expect(res.documents).toHaveLength(RESULT_CAP) + expect(res.truncated).toBe(false) + }) + + it('gives derived rows without _id a null id', async () => { + const coll = fakeCollection([{ total: 3 }]) + const res = await serviceFor(coll).aggregate({ ...REF, pipeline: '[{"$count":"total"}]' }) + expect(res.documents[0]?.id).toBeNull() + }) + + it.each([ + ['$out', 'target'], + ['$merge', 'target'], + ['$changeStream', {}] + ])('rejects a pipeline with %s before it reaches the server', async (stage, spec) => { + const coll = fakeCollection([]) + const run = serviceFor(coll).aggregate({ + ...REF, + pipeline: JSON.stringify([{ $match: {} }, { [stage]: spec }]) + }) + await expect(run).rejects.toMatchObject({ + name: 'ValidationError', + message: rejectedStageMessage(stage) + }) + expect(coll.aggregate).not.toHaveBeenCalled() + }) + + it('rejects a pipeline that is not an array of stage objects', async () => { + const coll = fakeCollection([]) + const service = serviceFor(coll) + await expect(service.aggregate({ ...REF, pipeline: '{"$match":{}}' })).rejects.toMatchObject({ + name: 'ValidationError' + }) + await expect(service.aggregate({ ...REF, pipeline: '["$match"]' })).rejects.toMatchObject({ + name: 'ValidationError' + }) + }) +}) + +describe('QueryService.count', () => { + it('estimates an empty filter from collection metadata by default', async () => { + const coll = fakeCollection(manyDocs(3)) + const res = await serviceFor(coll).count({ ...REF }) + expect(res).toMatchObject({ count: 3, estimated: true }) + expect(res.tookMs).toBeGreaterThanOrEqual(0) + expect(coll.countDocuments).not.toHaveBeenCalled() + }) + + it('counts exactly when asked, even with an empty filter', async () => { + const coll = fakeCollection(manyDocs(3)) + const res = await serviceFor(coll).count({ ...REF, filter: '{}', exact: true }) + expect(res).toMatchObject({ count: 3, estimated: false }) + expect(res.tookMs).toBeGreaterThanOrEqual(0) + expect(coll.estimatedDocumentCount).not.toHaveBeenCalled() + }) + + it('counts a non-empty filter exactly', async () => { + const coll = fakeCollection(manyDocs(2)) + const res = await serviceFor(coll).count({ ...REF, filter: '{"i":{"$gte":0}}' }) + expect(res).toMatchObject({ count: 2, estimated: false }) + expect(coll.countDocuments.mock.calls[0]?.[0]).toEqual({ i: { $gte: 0 } }) + }) + + it('counts on an Int64 beyond 2^53 without rounding it', async () => { + const coll = fakeCollection([]) + await serviceFor(coll).count({ ...REF, filter: `{"uid":{"$numberLong":"${BEYOND_2_53}"}}` }) + expect(encodedAs(coll.countDocuments.mock.calls[0]?.[0], 'uid')).toBe(`Long(${BEYOND_2_53})`) + }) +}) + +describe('QueryService.distinct', () => { + it('wraps each value as a { value } row without an id', async () => { + const coll = fakeCollection([ + { tag: 'a' }, + { tag: Long.fromString(BEYOND_2_53) }, + { tag: new BSONRegExp('dot.all', 's') } + ]) + const res = await serviceFor(coll).distinct({ ...REF, field: 'tag', filter: '{"x":1}' }) + + expect(coll.distinct.mock.calls[0]?.slice(0, 2)).toEqual(['tag', { x: 1 }]) + expect(res.documents.map((d) => d.id)).toEqual([null, null, null]) + expect(res.documents.map((d) => JSON.parse(d.canonical))).toEqual([ + { value: 'a' }, + { value: { $numberLong: BEYOND_2_53 } }, + { value: { $regularExpression: { pattern: 'dot.all', options: 's' } } } + ]) + expect(res.truncated).toBe(false) + }) + + it('keeps an Int64 within 2^53 an Int64', async () => { + const coll = fakeCollection([{ n: Long.fromNumber(5) }]) + const res = await serviceFor(coll).distinct({ ...REF, field: 'n' }) + + expect(res.documents.map((d) => JSON.parse(d.canonical))).toEqual([ + { value: { $numberLong: '5' } } + ]) + }) + + it('caps the values at RESULT_CAP and flags truncation', async () => { + const coll = fakeCollection(manyDocs(RESULT_CAP + 5)) + const res = await serviceFor(coll).distinct({ ...REF, field: 'i' }) + expect(res.documents).toHaveLength(RESULT_CAP) + expect(res.truncated).toBe(true) + }) + + it('filters on an Int64 beyond 2^53 without rounding it', async () => { + const coll = fakeCollection([]) + await serviceFor(coll).distinct({ + ...REF, + field: 'name', + filter: `{"uid":{"$numberLong":"${BEYOND_2_53}"}}` + }) + expect(encodedAs(coll.distinct.mock.calls[0]?.[1], 'uid')).toBe(`Long(${BEYOND_2_53})`) + }) +}) + +describe('QueryService server errors', () => { + const id = `{"$oid":"${OID}"}` + + it.each([ + ['find', (s: QueryService) => s.find({ ...REF, skip: 0 })], + ['aggregate', (s: QueryService) => s.aggregate({ ...REF, pipeline: '[]' })], + ['distinct', (s: QueryService) => s.distinct({ ...REF, field: 'a' })], + [ + 'the replace precondition', + (s: QueryService) => + s.replaceOne({ ...REF, id, expectedHash: 'x', replacement: `{"_id":${id}}` }) + ], + ['the delete precondition', (s: QueryService) => s.deleteOne({ ...REF, id, expectedHash: 'x' })] + ])('%s keeps the server error code a number, as driver retries require', async (_op, run) => { + const coll = fakeCollection([], { failure: { code: 189, errmsg: 'Primary stepped down' } }) + + const error = await run(serviceFor(coll)).then( + () => null, + (e: unknown) => e + ) + + expect(error).toBeInstanceOf(MongoServerError) + expect(mapError(error)).toEqual({ + code: 'driver_error', + message: 'Primary stepped down', + details: { mongoCode: 189 } + }) + }) +}) + +describe('QueryService writes', () => { + it('checks the replace precondition against a document decoded like the envelope', async () => { + const coll = fakeCollection([ + { _id: new ObjectId(OID), coins: Long.fromNumber(5), re: new BSONRegExp('a/b', 's') } + ]) + const service = serviceFor(coll) + const [env] = (await service.find({ ...REF, skip: 0 })).documents + if (env?.id == null) throw new Error('expected an envelope with an id') + + const res = await service.replaceOne({ + ...REF, + id: env.id, + expectedHash: env.hash, + replacement: env.canonical + }) + + expect(res).toEqual({ matched: 1, modified: 1 }) + }) + + it('checks the delete precondition against a document decoded like the envelope', async () => { + const coll = fakeCollection([ + { _id: new ObjectId(OID), ratio: new Double(1), re: new BSONRegExp('', 'u') } + ]) + const service = serviceFor(coll) + const [env] = (await service.find({ ...REF, skip: 0 })).documents + if (env?.id == null) throw new Error('expected an envelope with an id') + + const res = await service.deleteOne({ ...REF, id: env.id, expectedHash: env.hash }) + + expect(res).toEqual({ deletedCount: 1 }) + }) + + it('refuses a replace when the stored document changed', async () => { + const coll = fakeCollection([{ _id: new ObjectId(OID), coins: Long.fromNumber(5) }]) + const id = `{"$oid":"${OID}"}` + + const run = serviceFor(coll).replaceOne({ + ...REF, + id, + expectedHash: 'stale', + replacement: `{"_id":${id}}` + }) + + await expect(run).rejects.toMatchObject({ name: 'DocumentConflictError' }) + expect(coll.replaceOne).not.toHaveBeenCalled() + }) +}) + +describe('QueryService.cancel', () => { + it('aborts a running find with a CancelledError', async () => { + const service = serviceFor(fakeCollection([{ _id: 1 }], { hang: true })) + const run = service.find({ ...REF, skip: 0, requestId: REQUEST_ID }) + service.cancel(REQUEST_ID) + await expect(run).rejects.toMatchObject({ name: 'CancelledError' }) + }) + + it('aborts a running aggregation', async () => { + const service = serviceFor(fakeCollection([], { hang: true })) + const run = service.aggregate({ ...REF, pipeline: '[]', requestId: REQUEST_ID }) + service.cancel(REQUEST_ID) + await expect(run).rejects.toMatchObject({ name: 'CancelledError' }) + }) + + it('aborts a running exact count', async () => { + const service = serviceFor(fakeCollection([], { hang: true })) + const run = service.count({ ...REF, exact: true, requestId: REQUEST_ID }) + service.cancel(REQUEST_ID) + await expect(run).rejects.toMatchObject({ name: 'CancelledError' }) + }) + + // An abort during getMore kills the cursor, so the in-flight getMore fails with the server's interrupt. + it.each([ + ['find', 237, (s: QueryService) => s.find({ ...REF, skip: 0, requestId: REQUEST_ID })], + [ + 'aggregation', + 11601, + (s: QueryService) => s.aggregate({ ...REF, pipeline: '[]', requestId: REQUEST_ID }) + ] + ])('reports a %s cancelled during getMore as cancelled', async (_op, code, start) => { + const interrupted = new MongoServerError({ + errmsg: 'Executor error during getMore :: caused by :: operation was interrupted', + code + }) + const service = serviceFor(fakeCollection([], { hang: true, abortError: interrupted })) + const run = start(service) + service.cancel(REQUEST_ID) + await expect(run).rejects.toMatchObject({ name: 'CancelledError' }) + }) + + it('passes on a server error of a run that was not cancelled', async () => { + const coll = fakeCollection([], { failure: { code: 2, errmsg: 'bad query' } }) + const run = serviceFor(coll).find({ ...REF, skip: 0, requestId: REQUEST_ID }) + await expect(run).rejects.toBeInstanceOf(MongoServerError) + }) + + it('aborts only the run with that id', async () => { + const otherId = '9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d' + const coll = fakeCollection([], { hang: true }) + const service = serviceFor(coll) + void service.find({ ...REF, skip: 0, requestId: REQUEST_ID }).catch(() => undefined) + void service.find({ ...REF, skip: 0, requestId: otherId }).catch(() => undefined) + + service.cancel(REQUEST_ID) + + const signals = coll.find.mock.calls.map((call) => call[1]?.signal) + expect(signals.map((s) => s?.aborted)).toEqual([true, false]) + service.cancel(otherId) + }) + + it('forgets a run once it settled', async () => { + const coll = fakeCollection([{ _id: 1 }]) + const service = serviceFor(coll) + await service.find({ ...REF, skip: 0, requestId: REQUEST_ID }) + + service.cancel(REQUEST_ID) + + expect(coll.find.mock.calls[0]?.[1]?.signal?.aborted).toBe(false) + }) + + it('ignores unknown ids', () => { + const service = serviceFor(fakeCollection([])) + expect(() => service.cancel(REQUEST_ID)).not.toThrow() + }) +}) + +type WriteCallOptions = ReadOptions & + Document & { includeResultMetadata?: boolean; promoteLongs?: boolean } + +type ModifyReply = { value: Document | null; lastErrorObject: Document } + +type BulkIds = { insertedIds: Record; upsertedIds: Record } + +/** + * Takes writes like the driver and answers with fixed server results. A findOneAnd* reply is built + * as BSON and decoded with the call's BSON options; a `failure` reply is decoded the same way into + * a MongoServerError, as the driver's connection does. + */ +function fakeWriteCollection({ + modify = { value: null, lastErrorObject: { n: 0 } }, + upsertedId = null, + bulkIds = { insertedIds: { 0: new ObjectId(OID), 3: 7 }, upsertedIds: { 1: Long.fromNumber(9) } }, + failure +}: { + modify?: ModifyReply + upsertedId?: unknown + bulkIds?: BulkIds + failure?: ServerReply & Document +} = {}) { + const decode = (reply: Document, options: WriteCallOptions | undefined): Document => + BSON.deserialize(BSON.serialize(reply), { + promoteValues: options?.promoteValues, + promoteLongs: options?.promoteLongs, + bsonRegExp: options?.bsonRegExp, + raw: options?.raw + }) + const findAndModify = (options: WriteCallOptions | undefined): Promise => { + if (failure) return Promise.reject(new MongoServerError(decode({ ok: 0, ...failure }, options))) + const reply = decode({ ...modify, ok: 1 }, options) + return Promise.resolve(options?.includeResultMetadata ? reply : (reply['value'] ?? null)) + } + // The server reports upserted ids; the driver takes inserted ids from the documents it was given. + const updated = async (options: WriteCallOptions | undefined) => { + const reply = decode( + upsertedId === null + ? { n: 2, nModified: 1 } + : { n: 1, nModified: 0, upserted: [{ index: 0, _id: upsertedId }] }, + options + ) + const upserts = (reply['upserted'] as Document[] | undefined) ?? [] + return { + acknowledged: true, + matchedCount: upserts.length > 0 ? 0 : reply['n'], + modifiedCount: reply['nModified'], + upsertedCount: upserts.length, + upsertedId: upserts[0]?.['_id'] ?? null + } + } + const toArray = vi.fn(async () => []) + + return { + insertOne: vi.fn(async (document: Document, _options?: WriteCallOptions) => ({ + acknowledged: true, + insertedId: document['_id'] + })), + insertMany: vi.fn(async (documents: Document[], _options?: WriteCallOptions) => ({ + acknowledged: true, + insertedCount: documents.length, + insertedIds: Object.fromEntries(documents.map((d, i) => [i, d['_id']])) + })), + updateOne: vi.fn((_filter: Document, _update: unknown, options?: WriteCallOptions) => + updated(options) + ), + updateMany: vi.fn((_filter: Document, _update: unknown, options?: WriteCallOptions) => + updated(options) + ), + replaceOne: vi.fn((_filter: Document, _replacement: Document, options?: WriteCallOptions) => + updated(options) + ), + deleteOne: vi.fn(async (_filter: Document, _options?: WriteCallOptions) => ({ + acknowledged: true, + deletedCount: 1 + })), + deleteMany: vi.fn(async (_filter: Document, _options?: WriteCallOptions) => ({ + acknowledged: true, + deletedCount: 3 + })), + findOneAndUpdate: vi.fn((_filter: Document, _update: unknown, options?: WriteCallOptions) => + findAndModify(options) + ), + findOneAndReplace: vi.fn( + (_filter: Document, _replacement: Document, options?: WriteCallOptions) => + findAndModify(options) + ), + findOneAndDelete: vi.fn((_filter: Document, options?: WriteCallOptions) => + findAndModify(options) + ), + bulkWrite: vi.fn(async (_operations: Document[], options?: WriteCallOptions) => ({ + insertedCount: 2, + matchedCount: 3, + modifiedCount: 2, + deletedCount: 4, + upsertedCount: 1, + insertedIds: bulkIds.insertedIds, + upsertedIds: decode({ ids: bulkIds.upsertedIds }, options)['ids'] as Record + })), + aggregate: vi.fn((_pipeline: Document[], _options?: WriteCallOptions) => ({ toArray })), + toArray + } +} + +const canonical = (value: unknown): string => EJSON.stringify(value, { relaxed: false }) + +/** A value as the server receives it, in canonical EJSON object form. */ +function sentAs(value: unknown): unknown { + const { v } = BSON.deserialize(BSON.serialize({ v: value }), { + promoteValues: false, + bsonRegExp: true + }) + return JSON.parse(canonical(v)) +} + +const writeOf = (op: WriteOp) => ({ ...REF, op }) + +const TYPED = { + d: new Double(5), + l: Long.fromString(BEYOND_2_53), + i: new Int32(7), + re: new BSONRegExp('a/b', 's') +} +const TYPED_SENT = { + d: { $numberDouble: '5.0' }, + l: { $numberLong: BEYOND_2_53 }, + i: { $numberInt: '7' }, + re: { $regularExpression: { pattern: 'a/b', options: 's' } } +} + +describe('QueryService.write — inserts', () => { + it('sends the document with its exact BSON types and reports its id', async () => { + const coll = fakeWriteCollection() + const res = await serviceFor(coll).write( + writeOf({ method: 'insertOne', document: canonical({ _id: new ObjectId(OID), ...TYPED }) }) + ) + + expect(sentAs(coll.insertOne.mock.calls[0]?.[0])).toEqual({ + _id: { $oid: OID }, + ...TYPED_SENT + }) + expect(res).toMatchObject({ + counts: { inserted: 1 }, + ids: [`{"$oid":"${OID}"}`], + idsTruncated: false + }) + expect(res.tookMs).toBeGreaterThanOrEqual(0) + expect(res).not.toHaveProperty('document') + }) + + it('passes the insert options to the driver', async () => { + const coll = fakeWriteCollection() + await serviceFor(coll).write( + writeOf({ + method: 'insertOne', + document: '{"_id":1}', + options: '{"bypassDocumentValidation":true}' + }) + ) + expect(coll.insertOne.mock.calls[0]?.[1]).toEqual({ bypassDocumentValidation: true }) + }) + + it('reports insertMany ids in document order with their exact types', async () => { + const coll = fakeWriteCollection() + const res = await serviceFor(coll).write( + writeOf({ + method: 'insertMany', + documents: [canonical({ _id: new Double(1) }), canonical({ _id: Long.fromNumber(2) })], + options: '{"ordered":false}' + }) + ) + + expect(coll.insertMany.mock.calls[0]?.[1]).toEqual({ ordered: false }) + expect(res).toMatchObject({ + counts: { inserted: 2 }, + ids: ['{"$numberDouble":"1.0"}', '{"$numberLong":"2"}'], + idsTruncated: false + }) + }) + + it('caps the reported insertMany ids at RESULT_CAP', async () => { + const coll = fakeWriteCollection() + const documents = Array.from({ length: RESULT_CAP + 1 }, (_, i) => `{"_id":${i}}`) + const res = await serviceFor(coll).write(writeOf({ method: 'insertMany', documents })) + + expect(res.counts).toEqual({ inserted: RESULT_CAP + 1 }) + expect(res.ids).toHaveLength(RESULT_CAP) + expect(res.ids[0]).toBe('{"$numberInt":"0"}') + expect(res.idsTruncated).toBe(true) + }) + + it('does not flag exactly RESULT_CAP ids as truncated', async () => { + const coll = fakeWriteCollection() + const documents = Array.from({ length: RESULT_CAP }, (_, i) => `{"_id":${i}}`) + const res = await serviceFor(coll).write(writeOf({ method: 'insertMany', documents })) + expect(res.ids).toHaveLength(RESULT_CAP) + expect(res.idsTruncated).toBe(false) + }) +}) + +describe('QueryService.write — updates and deletes', () => { + it('sends the filter, the update and the options of updateOne with exact types', async () => { + const coll = fakeWriteCollection() + await serviceFor(coll).write( + writeOf({ + method: 'updateOne', + filter: `{"uid":{"$numberLong":"${BEYOND_2_53}"}}`, + update: canonical({ $set: TYPED }), + options: canonical({ upsert: true, arrayFilters: [{ 'e.k': new Int32(1) }], hint: 'uid_1' }) + }) + ) + + const [filter, update, options] = coll.updateOne.mock.calls[0] ?? [] + expect(encodedAs(filter, 'uid')).toBe(`Long(${BEYOND_2_53})`) + expect(sentAs(update)).toEqual({ $set: TYPED_SENT }) + expect(sentAs(options)).toMatchObject({ + upsert: true, + arrayFilters: [{ 'e.k': { $numberInt: '1' } }], + hint: 'uid_1' + }) + }) + + it.each(['updateOne', 'updateMany', 'replaceOne'] as const)( + '%s reports an upserted Long id exactly, decoding the reply without promoting Longs', + async (method) => { + const coll = fakeWriteCollection({ upsertedId: Long.fromNumber(77) }) + const filter = '{"_id":{"$numberLong":"77"}}' + const op: WriteOp = + method === 'replaceOne' + ? { method, filter, replacement: '{"a":true}', options: '{"upsert":true}' } + : { method, filter, update: '{"$set":{"a":true}}', options: '{"upsert":true}' } + const res = await serviceFor(coll).write(writeOf(op)) + + expect(coll[method].mock.calls[0]?.[2]).toMatchObject({ + upsert: true, + promoteLongs: false, + bsonRegExp: true + }) + expect(coll[method].mock.calls[0]?.[2]).not.toHaveProperty('promoteValues') + expect(res).toMatchObject({ counts: { upserted: 1 }, ids: ['{"$numberLong":"77"}'] }) + } + ) + + // An upsert copies the filter's equality fields into the new document. + it('sends the filter with its exact types', async () => { + const coll = fakeWriteCollection() + await serviceFor(coll).write( + writeOf({ + method: 'updateOne', + filter: canonical({ k: new Double(5), i: new Int32(1) }), + update: '{"$set":{"a":true}}', + options: '{"upsert":true}' + }) + ) + expect(sentAs(coll.updateOne.mock.calls[0]?.[0])).toEqual({ + k: { $numberDouble: '5.0' }, + i: { $numberInt: '1' } + }) + }) + + it('reports matched, modified and upserted, without ids when nothing was upserted', async () => { + const coll = fakeWriteCollection() + const res = await serviceFor(coll).write( + writeOf({ method: 'updateMany', filter: '{}', update: '{"$inc":{"n":{"$numberInt":"1"}}}' }) + ) + expect(res).toMatchObject({ + counts: { matched: 2, modified: 1, upserted: 0 }, + ids: [], + idsTruncated: false + }) + }) + + it('reports the upserted id', async () => { + const coll = fakeWriteCollection({ upsertedId: new ObjectId(OID) }) + const res = await serviceFor(coll).write( + writeOf({ + method: 'updateOne', + filter: '{"a":"x"}', + update: '{"$set":{"b":true}}', + options: '{"upsert":true}' + }) + ) + expect(res).toMatchObject({ + counts: { matched: 0, modified: 0, upserted: 1 }, + ids: [`{"$oid":"${OID}"}`] + }) + }) + + it('passes an update pipeline through as an array', async () => { + const coll = fakeWriteCollection() + await serviceFor(coll).write( + writeOf({ + method: 'updateMany', + filter: '{}', + update: canonical([{ $set: { d: new Double(2) } }, { $unset: 'tmp' }]) + }) + ) + expect(sentAs(coll.updateMany.mock.calls[0]?.[1])).toEqual([ + { $set: { d: { $numberDouble: '2.0' } } }, + { $unset: 'tmp' } + ]) + }) + + it('sends a replacement with exact types and reports the update counters', async () => { + const coll = fakeWriteCollection() + const res = await serviceFor(coll).write( + writeOf({ method: 'replaceOne', filter: '{"_id":1}', replacement: canonical(TYPED) }) + ) + expect(sentAs(coll.replaceOne.mock.calls[0]?.[1])).toEqual(TYPED_SENT) + expect(res.counts).toEqual({ matched: 2, modified: 1, upserted: 0 }) + }) + + it.each([ + ['deleteOne', 1], + ['deleteMany', 3] + ] as const)('%s reports the deleted count', async (method, deleted) => { + const coll = fakeWriteCollection() + const res = await serviceFor(coll).write( + writeOf({ method, filter: '{"a":1}', options: '{"hint":"a_1"}' }) + ) + + expect(sentAs(coll[method].mock.calls[0]?.slice(0, 2))).toEqual([ + { a: { $numberInt: '1' } }, + { hint: 'a_1' } + ]) + expect(res).toMatchObject({ counts: { deleted }, ids: [], idsTruncated: false }) + expect(res).not.toHaveProperty('document') + }) +}) + +describe('QueryService.write — findOneAnd*', () => { + const stored = { + _id: new ObjectId(OID), + l: Long.fromNumber(5), + big: Long.fromString(BEYOND_2_53), + re: new BSONRegExp('dot.all', 's'), + half: new Double(2.5), + d: new Double(5) + } + + it('reads the document with normal decoding, so error replies stay plain objects', async () => { + const coll = fakeWriteCollection() + await serviceFor(coll).write( + writeOf({ method: 'findOneAndUpdate', filter: '{}', update: '{"$set":{"seen":true}}' }) + ) + + const options = coll.findOneAndUpdate.mock.calls[0]?.[2] + expect(options).toMatchObject({ promoteLongs: false, bsonRegExp: true }) + expect(options).not.toHaveProperty('raw') + expect(options).not.toHaveProperty('promoteValues') + }) + + it('keeps Longs and regexes exact; an integral Double reads back as an integer', async () => { + const coll = fakeWriteCollection({ + modify: { value: stored, lastErrorObject: { n: 1, updatedExisting: true } } + }) + const res = await serviceFor(coll).write( + writeOf({ method: 'findOneAndUpdate', filter: '{}', update: '{"$set":{"seen":true}}' }) + ) + + expect(res.document?.id).toBe(`{"$oid":"${OID}"}`) + expect(JSON.parse(res.document?.canonical ?? '')).toEqual({ + _id: { $oid: OID }, + l: { $numberLong: '5' }, + big: { $numberLong: BEYOND_2_53 }, + re: { $regularExpression: { pattern: 'dot.all', options: 's' } }, + half: { $numberDouble: '2.5' }, + d: { $numberInt: '5' } + }) + expect(res).toMatchObject({ counts: { matched: 1, upserted: 0 }, ids: [] }) + }) + + it('returns null when nothing matched', async () => { + const coll = fakeWriteCollection() + const res = await serviceFor(coll).write( + writeOf({ method: 'findOneAndDelete', filter: '{"a":1}' }) + ) + expect(res).toMatchObject({ document: null, counts: { deleted: 0 }, ids: [] }) + }) + + it('reports a findOneAndDelete that removed a document', async () => { + const coll = fakeWriteCollection({ modify: { value: stored, lastErrorObject: { n: 1 } } }) + const res = await serviceFor(coll).write( + writeOf({ method: 'findOneAndDelete', filter: '{}', options: '{"sort":{"d":-1}}' }) + ) + expect(res.counts).toEqual({ deleted: 1 }) + expect(JSON.parse(res.document?.canonical ?? '')).toMatchObject({ + big: { $numberLong: BEYOND_2_53 } + }) + }) + + it('reports an upsert that returned no document, with the exact upserted id', async () => { + const coll = fakeWriteCollection({ + modify: { + value: null, + lastErrorObject: { n: 1, updatedExisting: false, upserted: Long.fromNumber(42) } + } + }) + const res = await serviceFor(coll).write( + writeOf({ + method: 'findOneAndReplace', + filter: '{"_id":{"$numberLong":"42"}}', + replacement: canonical(TYPED), + options: '{"upsert":true}' + }) + ) + + expect(sentAs(coll.findOneAndReplace.mock.calls[0]?.[1])).toEqual(TYPED_SENT) + expect(res).toMatchObject({ + document: null, + counts: { matched: 0, upserted: 1 }, + ids: ['{"$numberLong":"42"}'] + }) + }) + + it.each([ + [true, 'after'], + [false, 'before'] + ])('maps returnNewDocument %s to returnDocument %s', async (returnNewDocument, expected) => { + const coll = fakeWriteCollection() + await serviceFor(coll).write( + writeOf({ + method: 'findOneAndUpdate', + filter: '{}', + update: '{"$set":{"a":true}}', + options: JSON.stringify({ returnNewDocument, upsert: true }) + }) + ) + + const options = coll.findOneAndUpdate.mock.calls[0]?.[2] + expect(options).toMatchObject({ returnDocument: expected, upsert: true }) + expect(options).not.toHaveProperty('returnNewDocument') + }) + + it('passes returnDocument and projection through', async () => { + const coll = fakeWriteCollection() + await serviceFor(coll).write( + writeOf({ + method: 'findOneAndReplace', + filter: '{}', + replacement: '{"a":true}', + options: '{"returnDocument":"after","projection":{"a":{"$numberInt":"1"}}}' + }) + ) + const options = coll.findOneAndReplace.mock.calls[0]?.[2] + expect(options).toMatchObject({ returnDocument: 'after' }) + expect(sentAs(options?.['projection'])).toEqual({ a: { $numberInt: '1' } }) + }) + + it('keeps the server error code a number, as driver retries require', async () => { + const coll = fakeWriteCollection({ failure: { code: 189, errmsg: 'Primary stepped down' } }) + const error = await serviceFor(coll) + .write(writeOf({ method: 'findOneAndDelete', filter: '{}' })) + .then( + () => null, + (e: unknown) => e + ) + + expect(error).toBeInstanceOf(MongoServerError) + expect(mapError(error)).toEqual({ + code: 'driver_error', + message: 'Primary stepped down', + details: { mongoCode: 189 } + }) + }) + + it("keeps a state-change error's topologyVersion an object, as SDAM's stale check requires", async () => { + const coll = fakeWriteCollection({ + failure: { + code: 10107, + errmsg: 'not primary', + topologyVersion: { processId: new ObjectId(OID), counter: Long.fromNumber(6) } + } + }) + const error = (await serviceFor(coll) + .write(writeOf({ method: 'findOneAndUpdate', filter: '{}', update: '{"$set":{"a":true}}' })) + .then( + () => null, + (e: unknown) => e + )) as MongoServerError + + expect(error.code).toBe(10107) + expect(error['topologyVersion']).toEqual({ + processId: new ObjectId(OID), + counter: Long.fromNumber(6) + }) + }) +}) + +describe('QueryService.write — bulkWrite', () => { + it('sends the operations with exact types and the options', async () => { + const coll = fakeWriteCollection() + const operations = [ + { insertOne: { document: TYPED } }, + { + updateOne: { + filter: { l: Long.fromString(BEYOND_2_53) }, + update: { $set: { d: new Double(1) } }, + upsert: true + } + }, + { deleteMany: { filter: { i: new Int32(7) } } } + ] + await serviceFor(coll).write( + writeOf({ + method: 'bulkWrite', + operations: canonical(operations), + options: '{"ordered":false}' + }) + ) + + expect(sentAs(coll.bulkWrite.mock.calls[0]?.[0])).toEqual([ + { insertOne: { document: TYPED_SENT } }, + { + updateOne: { + filter: { l: { $numberLong: BEYOND_2_53 } }, + update: { $set: { d: { $numberDouble: '1.0' } } }, + upsert: true + } + }, + { deleteMany: { filter: { i: { $numberInt: '7' } } } } + ]) + expect(coll.bulkWrite.mock.calls[0]?.[1]).toEqual({ + ordered: false, + promoteLongs: false, + bsonRegExp: true + }) + }) + + it('reports every counter, then the inserted ids followed by the upserted ids', async () => { + const coll = fakeWriteCollection() + const res = await serviceFor(coll).write( + writeOf({ method: 'bulkWrite', operations: '[{"deleteMany":{"filter":{}}}]' }) + ) + expect(res).toMatchObject({ + counts: { inserted: 2, matched: 3, modified: 2, deleted: 4, upserted: 1 }, + ids: [`{"$oid":"${OID}"}`, '{"$numberInt":"7"}', '{"$numberLong":"9"}'], + idsTruncated: false + }) + }) + + // An unordered bulkWrite indexes each map by position among operations of its own kind. + it('keeps inserted and upserted ids whose map keys coincide', async () => { + const coll = fakeWriteCollection({ + bulkIds: { insertedIds: { 0: 'y1', 1: 'y2' }, upsertedIds: { 0: 'x1', 1: 'x2' } } + }) + const res = await serviceFor(coll).write( + writeOf({ + method: 'bulkWrite', + operations: '[{"deleteMany":{"filter":{}}}]', + options: '{"ordered":false}' + }) + ) + expect(res.ids).toEqual(['"y1"', '"y2"', '"x1"', '"x2"']) + }) + + it('caps the reported bulkWrite ids at RESULT_CAP', async () => { + const inserted = Object.fromEntries(Array.from({ length: RESULT_CAP }, (_, i) => [i, i])) + const coll = fakeWriteCollection({ + bulkIds: { insertedIds: inserted, upsertedIds: { 0: 'u' } } + }) + const res = await serviceFor(coll).write( + writeOf({ method: 'bulkWrite', operations: '[{"deleteMany":{"filter":{}}}]' }) + ) + expect(res.ids).toHaveLength(RESULT_CAP) + expect(res.ids).not.toContain('"u"') + expect(res.idsTruncated).toBe(true) + }) +}) + +describe('QueryService.write — aggregation', () => { + it('runs the pipeline as given, without the read cap, and drains its cursor', async () => { + const coll = fakeWriteCollection() + const pipeline = [{ $set: { d: new Double(3) } }, { $merge: { into: 'copy' } }] + const res = await serviceFor(coll).write( + writeOf({ method: 'aggregate', pipeline: canonical(pipeline) }) + ) + + expect(sentAs(coll.aggregate.mock.calls[0]?.[0])).toEqual([ + { $set: { d: { $numberDouble: '3.0' } } }, + { $merge: { into: 'copy' } } + ]) + expect(coll.toArray).toHaveBeenCalledOnce() + expect(res).toMatchObject({ counts: {}, ids: [], idsTruncated: false }) + expect(res).not.toHaveProperty('document') + }) +}) + +describe('QueryService.write — validation', () => { + const methods = [ + 'insertOne', + 'insertMany', + 'updateOne', + 'updateMany', + 'replaceOne', + 'deleteOne', + 'deleteMany', + 'findOneAndUpdate', + 'findOneAndReplace', + 'findOneAndDelete', + 'bulkWrite', + 'aggregate' + ] as const + + async function rejection(op: WriteOp): Promise { + const coll = fakeWriteCollection() + const error = await serviceFor(coll) + .write(writeOf(op)) + .then( + () => null, + (e: unknown) => e + ) + for (const method of methods) expect(coll[method]).not.toHaveBeenCalled() + return error + } + + it.each<[string, WriteOp, string | null]>([ + [ + 'a document that is not an object', + { method: 'insertOne', document: '[{"a":1}]' }, + insertDocumentError([]) + ], + [ + 'one insertMany document that is not an object', + { method: 'insertMany', documents: ['{"a":1}', '"x"'] }, + insertDocumentError('x') + ], + [ + 'an update without operators', + { method: 'updateOne', filter: '{}', update: '{"a":1}' }, + updateError({ a: 1 }) + ], + [ + 'an update pipeline with a read stage', + { method: 'findOneAndUpdate', filter: '{}', update: '[{"$match":{}}]' }, + updateError([{ $match: {} }]) + ], + [ + 'a replacement with operators', + { method: 'findOneAndReplace', filter: '{}', replacement: '{"$set":{"a":1}}' }, + replacementError({ $set: { a: 1 } }) + ], + [ + 'a filter that is not an object', + { method: 'deleteOne', filter: '[{"a":1}]' }, + 'deleteOne() filter must be an object' + ], + [ + 'an unknown option', + { method: 'deleteMany', filter: '{}', options: '{"upsert":true}' }, + writeOptionsError('deleteMany', { upsert: true }) + ], + [ + 'options that are not an object', + { method: 'updateMany', filter: '{}', update: '{"$set":{}}', options: '[]' }, + writeOptionsError('updateMany', []) + ], + [ + 'returnDocument together with returnNewDocument', + { + method: 'findOneAndUpdate', + filter: '{}', + update: '{"$set":{}}', + options: '{"returnDocument":"after","returnNewDocument":true}' + }, + writeOptionsError('findOneAndUpdate', { returnDocument: 'after', returnNewDocument: true }) + ], + [ + 'an invalid bulk operation', + { method: 'bulkWrite', operations: '[{"insertOne":{}}]' }, + bulkWriteError([{ insertOne: {} }]) + ], + [ + 'a pipeline that does not end in a write stage', + { method: 'aggregate', pipeline: '[{"$match":{}}]' }, + 'A write pipeline must end with $out or $merge' + ], + [ + 'a write stage before the last stage', + { method: 'aggregate', pipeline: '[{"$out":"a"},{"$merge":{"into":"b"}}]' }, + 'Stage 1: $out must be the last stage' + ], + [ + 'a change stream before the write stage', + { method: 'aggregate', pipeline: '[{"$changeStream":{}},{"$out":"a"}]' }, + `Stage 1: ${rejectedStageMessage('$changeStream')}` + ], + [ + 'a pipeline that is not an array', + { method: 'aggregate', pipeline: '{"$out":"a"}' }, + 'Pipeline must be an array of stage objects' + ] + ])('rejects %s before it reaches the server', async (_case, op, message) => { + expect(message).not.toBeNull() + expect(await rejection(op)).toMatchObject({ name: 'ValidationError', message }) + }) +}) diff --git a/src/main/services/QueryService.ts b/src/main/services/QueryService.ts index 31b27b6..fa45956 100644 --- a/src/main/services/QueryService.ts +++ b/src/main/services/QueryService.ts @@ -1,26 +1,69 @@ -import type { Document, Filter, Sort } from 'mongodb' -import { EJSON } from 'bson' import type { - AggregateRequest, - AggregateResponse, - DeleteManyRequest, - DeleteManyResponse, - DeleteOneRequest, - DeleteOneResponse, - DocumentEnvelope, - FindRequest, - FindResponse, - InsertManyRequest, - InsertManyResponse, - InsertOneRequest, - InsertOneResponse, - ReplaceOneRequest, - ReplaceOneResponse + AnyBulkWriteOperation, + Collection, + Document, + Filter, + ModifyResult, + Sort, + UpdateResult +} from 'mongodb' +import { EJSON } from 'bson' +import { + finalWriteStage, + findRejectedStage, + rejectedStageMessage, + writePipelineError +} from '@shared/pipeline' +import { + bulkWriteError, + filterError, + insertDocumentError, + isDocument, + replacementError, + updateError, + writeOptionsError +} from '@shared/writeOps' +import { + RESULT_CAP, + type AggregateRequest, + type AggregateResponse, + type CountRequest, + type CountResponse, + type DeleteManyRequest, + type DeleteManyResponse, + type DeleteOneRequest, + type DeleteOneResponse, + type DistinctRequest, + type DistinctResponse, + type DocumentEnvelope, + type FindRequest, + type FindResponse, + type InsertManyRequest, + type InsertManyResponse, + type InsertOneRequest, + type InsertOneResponse, + type ReplaceOneRequest, + type ReplaceOneResponse, + type WriteMethod, + type WriteOp, + type WriteRequest, + type WriteResponse } from '@shared/types' import type { ConnectionService } from './ConnectionService' -import { canonicalHash, parseFilter, toCanonicalString, toRelaxed } from '../lib/ejson' +import { + REPLY_DECODE_OPTIONS, + canonicalHash, + decodeDocument, + hashCanonicalString, + parseFilter, + toCanonicalString, + toRelaxed +} from '../lib/ejson' +import { cancelledError, validationError } from '../lib/errorMap' export class QueryService { + private readonly running = new Map() + constructor(private readonly connections: ConnectionService) {} async find(req: FindRequest): Promise { @@ -32,16 +75,17 @@ export class QueryService { const sort = parseFilter(req.sort) as Sort const startedAt = Date.now() - let cursor = coll.find(filter) - if (Object.keys(projection).length > 0) cursor = cursor.project(projection) - if (Object.keys(sort).length > 0) cursor = cursor.sort(sort) - if (req.skip > 0) cursor = cursor.skip(req.skip) - if (req.limit !== undefined && req.limit > 0) cursor = cursor.limit(req.limit) - - const docs: Document[] = await cursor.toArray() + const docs = await this.track(req.requestId, (signal) => { + let cursor = coll.find(filter, { raw: true, signal }) + if (Object.keys(projection).length > 0) cursor = cursor.project(projection) + if (Object.keys(sort).length > 0) cursor = cursor.sort(sort) + if (req.skip > 0) cursor = cursor.skip(req.skip) + if (req.limit !== undefined && req.limit > 0) cursor = cursor.limit(req.limit) + return cursor.toArray() + }) const tookMs = Date.now() - startedAt - const documents: DocumentEnvelope[] = docs.map(toEnvelope) + const documents: DocumentEnvelope[] = docs.map((bytes) => toEnvelope(decodeDocument(bytes))) return { documents, tookMs } } @@ -53,27 +97,56 @@ export class QueryService { const pipeline = parsePipeline(req.pipeline) const startedAt = Date.now() - const docs = await coll.aggregate(pipeline).toArray() + // The server applies the cap, so a $sort before it becomes a top-k sort. + const docs = await this.track(req.requestId, (signal) => + coll + .aggregate([...pipeline, { $limit: RESULT_CAP + 1 }], { raw: true, signal }) + .toArray() + ) const tookMs = Date.now() - startedAt - return { documents: docs.map(toEnvelope), tookMs } + return { + documents: docs.slice(0, RESULT_CAP).map((bytes) => toEnvelope(decodeDocument(bytes))), + tookMs, + truncated: docs.length > RESULT_CAP + } } - async count(req: { - connectionId: string - db: string - coll: string - filter?: string - }): Promise<{ count: number; estimated: boolean }> { + async count(req: CountRequest): Promise { const client = this.connections.getClient(req.connectionId) const coll = client.db(req.db).collection(req.coll) const filter = parseFilter(req.filter) - if (Object.keys(filter).length === 0) { + const startedAt = Date.now() + if (!req.exact && Object.keys(filter).length === 0) { const count = await coll.estimatedDocumentCount() - return { count, estimated: true } + return { count, estimated: true, tookMs: Date.now() - startedAt } } - const count = await coll.countDocuments(filter) - return { count, estimated: false } + const count = await this.track(req.requestId, (signal) => + coll.countDocuments(filter, { signal }) + ) + return { count, estimated: false, tookMs: Date.now() - startedAt } + } + + async distinct(req: DistinctRequest): Promise { + const client = this.connections.getClient(req.connectionId) + const coll = client.db(req.db).collection(req.coll) + const filter = parseFilter(req.filter) + + const startedAt = Date.now() + // distinct cannot read raw, so Int32 and Double values both arrive as numbers. + const values = await coll.distinct(req.field, filter, REPLY_DECODE_OPTIONS) + const tookMs = Date.now() - startedAt + + return { + documents: values.slice(0, RESULT_CAP).map((value: unknown) => toEnvelope({ value })), + tookMs, + truncated: values.length > RESULT_CAP + } + } + + /** Aborts the run registered under `requestId`; ids of settled or unknown runs are ignored. */ + cancel(requestId: string): void { + this.running.get(requestId)?.abort(cancelledError('Query cancelled')) } async replaceOne(req: ReplaceOneRequest): Promise { @@ -83,13 +156,13 @@ export class QueryService { const replacement = parseDocument(req.replacement) const idFilter = { _id: idValue } as Filter - const fresh = await coll.findOne(idFilter) + const fresh = await coll.findOne(idFilter, { raw: true }) if (!fresh) { const e = new Error('Document not found — it may have been deleted') e.name = 'DocumentNotFoundError' throw e } - if (canonicalHash(fresh) !== req.expectedHash) { + if (canonicalHash(decodeDocument(fresh)) !== req.expectedHash) { const e = new Error('Document changed since you opened it') e.name = 'DocumentConflictError' throw e @@ -142,11 +215,11 @@ export class QueryService { const idValue = parseId(req.id) const idFilter = { _id: idValue } as Filter - const fresh = await coll.findOne(idFilter) + const fresh = await coll.findOne(idFilter, { raw: true }) if (!fresh) { return { deletedCount: 0 } } - if (canonicalHash(fresh) !== req.expectedHash) { + if (canonicalHash(decodeDocument(fresh)) !== req.expectedHash) { const e = new Error('Document changed since you opened it') e.name = 'DocumentConflictError' throw e @@ -155,15 +228,42 @@ export class QueryService { const result = await coll.deleteOne(idFilter) return { deletedCount: result.deletedCount } } + + /** Validates a shell write by the parser's rules, then runs it; writes take no abort signal. */ + async write(req: WriteRequest): Promise { + const execute = prepareWrite(req.op) + const coll = this.connections.getClient(req.connectionId).db(req.db).collection(req.coll) + const startedAt = Date.now() + const outcome = await execute(coll) + return { ...outcome, tookMs: Date.now() - startedAt } + } + + // Registers before the first await, so a cancel that arrives right after the request finds it. + private async track( + requestId: string | undefined, + run: (signal: AbortSignal | undefined) => Promise + ): Promise { + if (requestId === undefined) return run(undefined) + const controller = new AbortController() + this.running.set(requestId, controller) + try { + return await run(controller.signal) + } catch (error) { + // A cancel during getMore surfaces as the server's interrupt error, not as the abort reason. + throw controller.signal.aborted ? controller.signal.reason : error + } finally { + if (this.running.get(requestId) === controller) this.running.delete(requestId) + } + } } function toEnvelope(doc: Document): DocumentEnvelope { - const idValue = doc['_id'] + const canonical = toCanonicalString(doc) return { - id: toCanonicalString(idValue), + id: '_id' in doc ? toCanonicalString(doc['_id']) : null, data: toRelaxed(doc), - canonical: toCanonicalString(doc), - hash: canonicalHash(doc) + canonical, + hash: hashCanonicalString(canonical) } } @@ -173,27 +273,203 @@ function parseId(canonical: string): unknown { function parseDocument(canonical: string): Document { const parsed = EJSON.parse(canonical, { relaxed: false }) - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - const e = new Error('Document must be a JSON object') - e.name = 'ValidationError' - throw e - } + if (!isDocument(parsed)) throw validationError('Document must be a JSON object') return parsed as Document } function parsePipeline(canonical: string): Document[] { + const stages = parseStages(canonical) + const rejected = findRejectedStage(stages) + if (rejected !== null) throw validationError(rejectedStageMessage(rejected)) + return stages +} + +function parseStages(canonical: string): Document[] { const parsed = EJSON.parse(canonical, { relaxed: false }) - if (!Array.isArray(parsed)) { - const e = new Error('Pipeline must be an array of stage objects') - e.name = 'ValidationError' - throw e - } + if (!Array.isArray(parsed)) throw validationError('Pipeline must be an array of stage objects') for (const stage of parsed) { - if (typeof stage !== 'object' || stage === null || Array.isArray(stage)) { - const e = new Error('Each pipeline stage must be an object') - e.name = 'ValidationError' - throw e - } + if (!isDocument(stage)) throw validationError('Each pipeline stage must be an object') } return parsed as Document[] } + +type WriteOutcome = Omit + +// Not raw: an error reply's topologyVersion would arrive as bytes and break failover detection. +const MODIFY_OPTIONS = { ...REPLY_DECODE_OPTIONS, includeResultMetadata: true } as const + +// Parses and validates up front, so an invalid write throws before anything reaches the server. +function prepareWrite(op: WriteOp): (coll: Collection) => Promise { + switch (op.method) { + case 'insertOne': { + const document = parseChecked(op.document, insertDocumentError) + const options = parseOptions(op.method, op.options) + return async (coll) => { + const result = await coll.insertOne(document, options) + return { counts: { inserted: 1 }, ...idList([result.insertedId]) } + } + } + case 'insertMany': { + const documents = op.documents.map((text) => parseChecked(text, insertDocumentError)) + const options = parseOptions(op.method, op.options) + return async (coll) => { + const result = await coll.insertMany(documents, options) + return { + counts: { inserted: result.insertedCount }, + ...idList(Object.values(result.insertedIds)) + } + } + } + case 'updateOne': + case 'updateMany': { + const { method } = op + const filter = parseWriteFilter(method, op.filter) + const update = parseChecked(op.update, updateError) + const options = { ...parseOptions(method, op.options), ...REPLY_DECODE_OPTIONS } + return async (coll) => + updateOutcome( + method === 'updateOne' + ? await coll.updateOne(filter, update, options) + : await coll.updateMany(filter, update, options) + ) + } + case 'replaceOne': { + const filter = parseWriteFilter(op.method, op.filter) + const replacement = parseChecked(op.replacement, replacementError) + const options = { ...parseOptions(op.method, op.options), ...REPLY_DECODE_OPTIONS } + return async (coll) => updateOutcome(await coll.replaceOne(filter, replacement, options)) + } + case 'deleteOne': + case 'deleteMany': { + const { method } = op + const filter = parseWriteFilter(method, op.filter) + const options = parseOptions(method, op.options) + return async (coll) => { + const result = + method === 'deleteOne' + ? await coll.deleteOne(filter, options) + : await coll.deleteMany(filter, options) + return { counts: { deleted: result.deletedCount }, ...idList([]) } + } + } + case 'findOneAndUpdate': { + const filter = parseWriteFilter(op.method, op.filter) + const update = parseChecked(op.update, updateError) + const options = returnDocumentOptions(parseOptions(op.method, op.options)) + return async (coll) => + modifyOutcome( + await coll.findOneAndUpdate(filter, update, { ...options, ...MODIFY_OPTIONS }), + 'update' + ) + } + case 'findOneAndReplace': { + const filter = parseWriteFilter(op.method, op.filter) + const replacement = parseChecked(op.replacement, replacementError) + const options = returnDocumentOptions(parseOptions(op.method, op.options)) + return async (coll) => + modifyOutcome( + await coll.findOneAndReplace(filter, replacement, { ...options, ...MODIFY_OPTIONS }), + 'update' + ) + } + case 'findOneAndDelete': { + const filter = parseWriteFilter(op.method, op.filter) + const options = parseOptions(op.method, op.options) + return async (coll) => + modifyOutcome( + await coll.findOneAndDelete(filter, { ...options, ...MODIFY_OPTIONS }), + 'delete' + ) + } + case 'bulkWrite': { + const operations = parseChecked(op.operations, bulkWriteError) as AnyBulkWriteOperation[] + const options = { ...parseOptions(op.method, op.options), ...REPLY_DECODE_OPTIONS } + return async (coll) => { + const result = await coll.bulkWrite(operations, options) + return { + counts: { + inserted: result.insertedCount, + matched: result.matchedCount, + modified: result.modifiedCount, + deleted: result.deletedCount, + upserted: result.upsertedCount + }, + // Not merged by key: unordered writes key each map by position among operations of its kind. + ...idList([...Object.values(result.insertedIds), ...Object.values(result.upsertedIds)]) + } + } + } + case 'aggregate': { + const stages = parseStages(op.pipeline) + rejectInvalid( + finalWriteStage(stages) === null + ? 'A write pipeline must end with $out or $merge' + : writePipelineError(stages) + ) + return async (coll) => { + // The server writes while the cursor is drained; $out / $merge return no documents. + await coll.aggregate(stages).toArray() + return { counts: {}, ...idList([]) } + } + } + } +} + +function updateOutcome(result: UpdateResult): WriteOutcome { + return { + counts: { + matched: result.matchedCount, + modified: result.modifiedCount, + upserted: result.upsertedCount + }, + ...idList(result.upsertedCount > 0 ? [result.upsertedId] : []) + } +} + +function modifyOutcome(result: ModifyResult, kind: 'update' | 'delete'): WriteOutcome { + const meta = result.lastErrorObject ?? {} + const n = Number(meta['n'] ?? 0) + const upserted = 'upserted' in meta ? [meta['upserted']] : [] + return { + counts: + kind === 'delete' + ? { deleted: n } + : { matched: meta['updatedExisting'] === true ? n : 0, upserted: upserted.length }, + ...idList(upserted), + document: result.value === null ? null : toEnvelope(result.value) + } +} + +function idList(ids: readonly unknown[]): Pick { + return { + ids: ids.slice(0, RESULT_CAP).map((id) => toCanonicalString(id)), + idsTruncated: ids.length > RESULT_CAP + } +} + +function parseChecked(canonical: string, problem: (value: unknown) => string | null): Document { + const value: unknown = EJSON.parse(canonical, { relaxed: false }) + rejectInvalid(problem(value)) + return value as Document +} + +function parseWriteFilter(method: WriteMethod, canonical: string): Document { + return parseChecked(canonical, (filter) => filterError(method, filter)) +} + +function parseOptions(method: WriteMethod, canonical: string | undefined): Document { + return canonical === undefined + ? {} + : parseChecked(canonical, (options) => writeOptionsError(method, options)) +} + +// mongosh's returnNewDocument; the driver only knows returnDocument. +function returnDocumentOptions({ returnNewDocument, ...options }: Document): Document { + return returnNewDocument === undefined + ? options + : { ...options, returnDocument: returnNewDocument === true ? 'after' : 'before' } +} + +function rejectInvalid(message: string | null): void { + if (message !== null) throw validationError(message) +} diff --git a/src/main/services/SshTunnelService.test.ts b/src/main/services/SshTunnelService.test.ts index 08d9175..a9753eb 100644 --- a/src/main/services/SshTunnelService.test.ts +++ b/src/main/services/SshTunnelService.test.ts @@ -1,8 +1,12 @@ import { generateKeyPairSync } from 'node:crypto' +import { promises as fs } from 'node:fs' import type { AddressInfo } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { SocksClient } from 'socks' import { Server, utils, type Connection } from 'ssh2' +import type { Tunnel, TunnelProxyOptions } from '../lib/tunnel' // The service logs through electron-log, which pulls in electron itself. vi.mock('electron-log/main', () => ({ @@ -12,8 +16,8 @@ vi.mock('electron-log/main', () => ({ const { SshAuthError, SshHostKeyMismatchError, SshTunnelService } = await import('./SshTunnelService') const { fingerprint } = await import('../lib/knownHosts') +const { default: log } = await import('electron-log/main') type SshTunnelServiceType = InstanceType -type TunnelProxyOptions = Awaited>['proxyOptions'] const USERNAME = 'tunneluser' const PASSWORD = 'tunnelsecret' @@ -21,6 +25,7 @@ const PASSWORD = 'tunnelsecret' const NO_KNOWN_HOSTS = 'C:\\nonexistent\\mongobench-test\\known_hosts' let hostKeyPem: string +let ed25519HostKey: string beforeAll(() => { // RSA rather than ed25519: PEM is the format ssh2's server side reads @@ -30,11 +35,12 @@ beforeAll(() => { publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs1', format: 'pem' } }).privateKey + ed25519HostKey = utils.generateKeyPairSync('ed25519').private }) /** The public key blob as it goes over the wire, for the pin-store fixtures. */ -function hostKeyBlob(): Buffer { - const parsed = utils.parseKey(hostKeyPem) +function hostKeyBlob(privateKey = hostKeyPem): Buffer { + const parsed = utils.parseKey(privateKey) if (parsed instanceof Error) throw parsed const key = Array.isArray(parsed) ? parsed[0] : parsed if (key === undefined) throw new Error('no key parsed') @@ -63,18 +69,29 @@ afterEach(async () => { fixture = null }) +type FixtureOptions = { + storedHostKey?: Buffer + /** Also offer an ed25519 host key, which ssh2 clients prefer by default. */ + withEd25519HostKey?: boolean + /** known_hosts content for the fixture's port; omitted means no file. */ + knownHosts?: (sshPort: number) => string + /** Refuse every direct-tcpip channel, as sshd does with AllowTcpForwarding no. */ + refuseForwards?: boolean +} + /** * A real in-process SSH server that accepts password auth and echoes back * everything sent through a direct-tcpip channel. */ -async function startFixture(options: { storedHostKey?: Buffer } = {}): Promise { +async function startFixture(options: FixtureOptions = {}): Promise { const forwarded: Forwarded[] = [] const pinned: Array<{ host: string; port: number }> = [] const dropped: string[] = [] const live = new Set() - const server = new Server({ hostKeys: [hostKeyPem] }, (client: Connection) => { + const hostKeys = options.withEd25519HostKey ? [hostKeyPem, ed25519HostKey] : [hostKeyPem] + const server = new Server({ hostKeys }, (client: Connection) => { live.add(client) client.on('close', () => live.delete(client)) client.on('authentication', (ctx) => { @@ -86,8 +103,12 @@ async function startFixture(options: { storedHostKey?: Buffer } = {}): Promise { - client.on('tcpip', (accept, _reject, info) => { + client.on('tcpip', (accept, reject, info) => { forwarded.push({ host: info.destIP, port: info.destPort }) + if (options.refuseForwards) { + reject() + return + } const channel = accept() channel.on('data', (chunk: Buffer) => channel.write(chunk)) }) @@ -110,7 +131,15 @@ async function startFixture(options: { storedHostKey?: Buffer } = {}): Promise { for (const client of live) client.end() @@ -125,15 +154,21 @@ async function startFixture(options: { storedHostKey?: Buffer } = {}): Promise - new Promise((resolve) => { + stop: async () => { + await new Promise((resolve) => { killClients() server.close(() => resolve()) }) + if (knownHostsDir !== null) await fs.rm(knownHostsDir, { recursive: true, force: true }) + } } return fixture } +function rsaKnownHostsLine(sshPort: number, marker = ''): string { + return `${marker}[127.0.0.1]:${sshPort} ssh-rsa ${hostKeyBlob().toString('base64')}\n` +} + function passwordConfig(port: number) { return { host: '127.0.0.1', @@ -144,18 +179,24 @@ function passwordConfig(port: number) { } } +function socksProxy(tunnel: Tunnel): TunnelProxyOptions { + if (tunnel.route.kind !== 'socks5') throw new Error('expected a SOCKS5 route') + return tunnel.route.proxy +} + function throughProxy( - tunnel: { proxyOptions: TunnelProxyOptions }, + tunnel: Tunnel, destination: { host: string; port: number }, timeout?: number ): ReturnType { + const proxy = socksProxy(tunnel) return SocksClient.createConnection({ proxy: { - host: tunnel.proxyOptions.proxyHost, - port: tunnel.proxyOptions.proxyPort, + host: proxy.proxyHost, + port: proxy.proxyPort, type: 5, - userId: tunnel.proxyOptions.proxyUsername, - password: tunnel.proxyOptions.proxyPassword + userId: proxy.proxyUsername, + password: proxy.proxyPassword }, command: 'connect', destination, @@ -182,6 +223,20 @@ describe('SshTunnelService', () => { expect(f.forwarded).toEqual([{ host: 'mongo2.internal', port: 27017 }]) }) + it('logs the SSH reason when the server refuses to forward', async () => { + const f = await startFixture({ refuseForwards: true }) + const tunnel = await f.service.open(passwordConfig(f.sshPort)) + vi.mocked(log.warn).mockClear() + + await expect(throughProxy(tunnel, { host: 'mongo1.internal', port: 27017 })).rejects.toThrow() + + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining( + 'failed to forward to mongo1.internal:27017: (SSH) Channel open failure' + ) + ) + }) + it('pins a host key it has never seen and reports it', async () => { const f = await startFixture() const tunnel = await f.service.open(passwordConfig(f.sshPort)) @@ -205,6 +260,52 @@ describe('SshTunnelService', () => { expect(f.service.openCount).toBe(0) }) + it('negotiates the key type known_hosts lists when the server also has a preferred one', async () => { + const f = await startFixture({ + withEd25519HostKey: true, + knownHosts: (sshPort) => rsaKnownHostsLine(sshPort) + }) + const tunnel = await f.service.open(passwordConfig(f.sshPort)) + expect(tunnel.pinnedHostKey).toBeNull() + expect(f.pinned).toEqual([]) + }) + + it('ignores the type of an older pin once known_hosts lists the host', async () => { + const f = await startFixture({ + withEd25519HostKey: true, + storedHostKey: hostKeyBlob(ed25519HostKey), + knownHosts: (sshPort) => rsaKnownHostsLine(sshPort) + }) + const tunnel = await f.service.open(passwordConfig(f.sshPort)) + expect(tunnel.pinnedHostKey).toBeNull() + }) + + it('negotiates the pinned key type when the server gained a preferred one', async () => { + const f = await startFixture({ withEd25519HostKey: true, storedHostKey: hostKeyBlob() }) + const tunnel = await f.service.open(passwordConfig(f.sshPort)) + expect(tunnel.pinnedHostKey).toBeNull() + expect(f.pinned).toEqual([]) + }) + + it('refuses a host key known_hosts marks as revoked instead of pinning it', async () => { + const f = await startFixture({ + knownHosts: () => `@revoked * ssh-rsa ${hostKeyBlob().toString('base64')}\n` + }) + const opening = f.service.open(passwordConfig(f.sshPort)) + await expect(opening).rejects.toThrow(SshHostKeyMismatchError) + await expect(opening).rejects.toThrow(/marks as revoked!$/) + expect(f.pinned).toEqual([]) + expect(f.service.openCount).toBe(0) + }) + + it('refuses a revoked host key even though it was pinned earlier', async () => { + const f = await startFixture({ + storedHostKey: hostKeyBlob(), + knownHosts: (sshPort) => rsaKnownHostsLine(sshPort, '@revoked ') + }) + await expect(f.service.open(passwordConfig(f.sshPort))).rejects.toThrow(SshHostKeyMismatchError) + }) + it('reports bad credentials as an auth failure', async () => { const f = await startFixture() await expect( diff --git a/src/main/services/SshTunnelService.ts b/src/main/services/SshTunnelService.ts index 65d1b8d..f82227d 100644 --- a/src/main/services/SshTunnelService.ts +++ b/src/main/services/SshTunnelService.ts @@ -4,16 +4,21 @@ import { homedir } from 'node:os' import { join } from 'node:path' import type { Duplex } from 'node:stream' import log from 'electron-log/main' -import { Client, type ConnectConfig } from 'ssh2' +import { Client, type Algorithms, type ConnectConfig } from 'ssh2' +import ssh2Constants from 'ssh2/lib/protocol/constants' import type { PinnedHostKeyNotice, SshAuthMethod } from '@shared/types' import { findHostKeys, fingerprint, + isRevoked, keyMatches, + keyTypeOf, + orderHostKeyAlgorithms, parseKnownHosts, type KnownHostEntry } from '../lib/knownHosts' import { createSocks5Server, type Socks5Server } from '../lib/socks5' +import type { Tunnel } from '../lib/tunnel' const READY_TIMEOUT_MS = 15_000 /** Idle sessions get dropped by firewalls and by sshd's own timeouts. */ @@ -31,22 +36,6 @@ export type ResolvedSshTunnel = { passphrase?: string } -/** What the driver needs to route through the tunnel. */ -export type TunnelProxyOptions = { - proxyHost: string - proxyPort: number - proxyUsername: string - proxyPassword: string -} - -/** An open tunnel. The caller owns it and is responsible for closing it. */ -export type Tunnel = { - proxyOptions: TunnelProxyOptions - /** Set only when this open pinned a host key it had never seen. */ - pinnedHostKey: PinnedHostKeyNotice | null - close: () => Promise -} - export class SshConnectError extends Error { constructor(message: string, options?: ErrorOptions) { super(message, options) @@ -103,6 +92,7 @@ export class SshTunnelService { /** `onDropped` fires when the session dies on its own, never on `close()`. */ async open(config: ResolvedSshTunnel, onDropped?: (reason: string) => void): Promise { const auth = await authConfig(config) + const knownHosts = await readKnownHosts(this.knownHostsPath) const client = new Client() // hostVerifier can only answer yes/no, so the reason is kept here to make @@ -119,14 +109,16 @@ export class SshTunnelService { }) try { + const algorithms = await this.hostKeyAlgorithms(knownHosts, config.host, config.port) client.connect({ host: config.host, port: config.port, username: config.username, readyTimeout: READY_TIMEOUT_MS, keepaliveInterval: KEEPALIVE_INTERVAL_MS, + algorithms, hostVerifier: (key: Buffer, verify: (valid: boolean) => void): void => { - this.verifyHostKey(config.host, config.port, key) + this.verifyHostKey(knownHosts, config.host, config.port, key) .then((pinned) => { pinnedHostKey = pinned verify(true) @@ -154,7 +146,7 @@ export class SshTunnelService { username: proxyUsername, password: proxyPassword, connect: (host, port) => forwardOut(client, host, port), - onError: (error) => log.debug(`SOCKS5 proxy: ${error.message}`) + onError: (error) => log.warn(`SOCKS5 proxy: ${error.message}`) }) } catch (error) { client.destroy() @@ -169,8 +161,12 @@ export class SshTunnelService { } const tunnel: Tunnel = { - proxyOptions: { proxyHost: LOOPBACK, proxyPort: server.port, proxyUsername, proxyPassword }, + route: { + kind: 'socks5', + proxy: { proxyHost: LOOPBACK, proxyPort: server.port, proxyUsername, proxyPassword } + }, pinnedHostKey, + failure: () => null, close: async () => { if (closed) return await teardown() @@ -202,16 +198,39 @@ export class SshTunnelService { await Promise.allSettled(tunnels.map((tunnel) => tunnel.close())) } + private async hostKeyAlgorithms( + knownHosts: KnownHostEntry[], + host: string, + port: number + ): Promise { + // Same trust order as verifyHostKey: the pin store counts only when known_hosts lists nothing. + const listed = findHostKeys(knownHosts, host, port) + const pinned = listed.length > 0 ? null : await this.hostKeys.get(host, port) + const trusted = pinned === null ? listed : [pinned] + const types = trusted.map(keyTypeOf).filter((type): type is string => type !== null) + if (types.length === 0) return undefined + // hostVerifier sees only the negotiated key, so a trusted key type has to win the negotiation. + return { serverHostKey: orderHostKeyAlgorithms(types, ssh2Constants.DEFAULT_SERVER_HOST_KEY) } + } + /** - * Trust order: the user's own known_hosts wins, then our pin store, and only - * a host neither knows about gets pinned on the spot. + * A key known_hosts revokes is refused outright. Otherwise the user's own + * known_hosts wins, then our pin store, and only a host neither knows about + * gets pinned on the spot. */ private async verifyHostKey( + knownHosts: KnownHostEntry[], host: string, port: number, key: Buffer ): Promise { - const allowed = findHostKeys(await readKnownHosts(this.knownHostsPath), host, port) + if (isRevoked(knownHosts, host, port, key)) { + throw new SshHostKeyMismatchError( + `${host} presented host key ${fingerprint(key)}, which your ~/.ssh/known_hosts marks as revoked!` + ) + } + + const allowed = findHostKeys(knownHosts, host, port) if (allowed.length > 0) { if (allowed.some((known) => keyMatches(key, known))) return null throw new SshHostKeyMismatchError( diff --git a/src/main/services/UserService.test.ts b/src/main/services/UserService.test.ts new file mode 100644 index 0000000..0787e98 --- /dev/null +++ b/src/main/services/UserService.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { ObjectId } from 'bson' +import type { ConnectionService } from './ConnectionService' +import { UserService } from './UserService' + +function serviceReturning(reply: Record): { + service: UserService + calls: Array<{ db: string; command: Record }> +} { + const calls: Array<{ db: string; command: Record }> = [] + const connections = { + getClient: () => ({ + db: (db: string) => ({ + command: async (command: Record) => { + calls.push({ db, command }) + return reply + } + }) + }) + } as unknown as ConnectionService + return { service: new UserService(connections), calls } +} + +describe('UserService.listUsers', () => { + it('returns customData as relaxed EJSON so it survives structured clone', async () => { + const id = new ObjectId('64b7f0c2a1b2c3d4e5f60718') + const { service } = serviceReturning({ + users: [ + { + user: 'svc', + db: 'shop', + roles: [{ role: 'read', db: 'shop' }], + customData: { owner: id, since: new Date('2024-01-02T03:04:05.678Z') } + } + ] + }) + const [user] = await service.listUsers('c1', 'shop') + expect(user?.customData).toEqual({ + owner: { $oid: '64b7f0c2a1b2c3d4e5f60718' }, + since: { $date: '2024-01-02T03:04:05.678Z' } + }) + }) + + it('leaves customData out when the user has none', async () => { + const { service } = serviceReturning({ users: [{ user: 'svc', db: 'shop' }] }) + expect(await service.listUsers('c1', 'shop')).toEqual([{ user: 'svc', db: 'shop', roles: [] }]) + }) +}) + +describe('UserService.listRoles', () => { + it('runs rolesInfo with built-in roles on the given db', async () => { + const { service, calls } = serviceReturning({ roles: [] }) + await service.listRoles('c1', 'shop') + expect(calls).toEqual([{ db: 'shop', command: { rolesInfo: 1, showBuiltinRoles: true } }]) + }) + + it('maps the reported roles and flags built-ins', async () => { + const { service } = serviceReturning({ + roles: [ + { role: 'read', db: 'shop', isBuiltin: true, roles: [], inheritedRoles: [] }, + { role: 'orderClerk', db: 'shop', isBuiltin: false }, + { role: 'legacy', db: 'shop' } + ] + }) + expect(await service.listRoles('c1', 'shop')).toEqual([ + { role: 'read', db: 'shop', isBuiltin: true }, + { role: 'orderClerk', db: 'shop', isBuiltin: false }, + { role: 'legacy', db: 'shop', isBuiltin: false } + ]) + }) + + it('returns an empty list when the server reports no roles field', async () => { + const { service } = serviceReturning({ ok: 1 }) + expect(await service.listRoles('c1', 'shop')).toEqual([]) + }) +}) diff --git a/src/main/services/UserService.ts b/src/main/services/UserService.ts index 2df89fa..fae70e5 100644 --- a/src/main/services/UserService.ts +++ b/src/main/services/UserService.ts @@ -1,6 +1,7 @@ import type { Document } from 'mongodb' import type { ConnectionService } from './ConnectionService' -import type { DatabaseUser, DatabaseUserRole } from '@shared/types' +import { toRelaxed } from '../lib/ejson' +import type { DatabaseRoleInfo, DatabaseUser, DatabaseUserRole } from '@shared/types' export class UserService { constructor(private readonly connections: ConnectionService) {} @@ -20,11 +21,23 @@ export class UserService { user: u.user, db: u.db, roles: u.roles ?? [], - ...(u.customData !== undefined ? { customData: u.customData } : {}), + ...(u.customData !== undefined ? { customData: toRelaxed(u.customData) } : {}), ...(u.mechanisms !== undefined ? { mechanisms: u.mechanisms } : {}) })) } + async listRoles(connectionId: string, db: string): Promise { + const client = this.connections.getClient(connectionId) + const result = (await client.db(db).command({ rolesInfo: 1, showBuiltinRoles: true })) as { + roles?: Array<{ role: string; db: string; isBuiltin?: boolean }> + } + return (result.roles ?? []).map((r) => ({ + role: r.role, + db: r.db, + isBuiltin: r.isBuiltin === true + })) + } + async createUser( connectionId: string, db: string, diff --git a/src/main/ssh2Constants.d.ts b/src/main/ssh2Constants.d.ts new file mode 100644 index 0000000..a654525 --- /dev/null +++ b/src/main/ssh2Constants.d.ts @@ -0,0 +1,10 @@ +// ssh2 ships no types for its internals; only the list SshTunnelService reorders is declared. +declare module 'ssh2/lib/protocol/constants' { + import type { ServerHostKeyAlgorithm } from 'ssh2' + + const constants: { + /** ssh2's host key preference order; it lists `ssh-ed25519` only where the runtime supports it. */ + DEFAULT_SERVER_HOST_KEY: readonly ServerHostKeyAlgorithm[] + } + export = constants +} diff --git a/src/main/stores/ConnectionsRepository.test.ts b/src/main/stores/ConnectionsRepository.test.ts index af21daa..f71620d 100644 --- a/src/main/stores/ConnectionsRepository.test.ts +++ b/src/main/stores/ConnectionsRepository.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ConnectionInput, SshTunnelInput } from '@shared/types' +import type { CloudflaredTunnelInput, ConnectionInput, SshTunnelInput } from '@shared/types' // The repository imports electron for the default userData path; every test // passes an explicit directory instead, so getPath is never actually called. @@ -26,6 +26,7 @@ const secrets = { const SSH_PASSWORD = 'ssh-cleartext-password' const SSH_PASSPHRASE = 'key-cleartext-passphrase' const MONGO_PASSWORD = 'mongo-cleartext-password' +const TOKEN_SECRET = 'cfast_cleartext-token-secret' let dir: string let repo: InstanceType @@ -61,6 +62,21 @@ function input(ssh?: Partial): ConnectionInput { } } +function cloudflaredInput(cloudflared: Partial): ConnectionInput { + return { + name: 'Behind Access', + uri: 'mongodb://user@localhost/admin', + password: MONGO_PASSWORD, + cloudflared: { + enabled: true, + hostname: 'mongo.example.com', + authMethod: 'serviceToken', + serviceTokenId: 'abc.access', + ...cloudflared + } + } +} + describe('secrets on disk', () => { it('never writes a cleartext password or passphrase', async () => { await repo.create( @@ -154,6 +170,90 @@ describe('secrets across edits', () => { }) }) +describe('Cloudflare service token secret', () => { + it('never writes the cleartext secret and decrypts it back', async () => { + const created = await repo.create(cloudflaredInput({ serviceTokenSecret: TOKEN_SECRET })) + const stored = await repo.getStored(created.id) + + expect(await onDisk()).not.toContain(TOKEN_SECRET) + expect(stored?.cloudflared?.encryptedServiceTokenSecret).toMatch(/^enc:/) + expect(repo.decryptCloudflared(stored!)).toEqual({ serviceTokenSecret: TOKEN_SECRET }) + }) + + it('keeps the stored secret when the form leaves it blank', async () => { + const created = await repo.create(cloudflaredInput({ serviceTokenSecret: TOKEN_SECRET })) + await repo.update(created.id, cloudflaredInput({ serviceTokenSecret: '' })) + const stored = await repo.getStored(created.id) + expect(repo.decryptCloudflared(stored!)).toEqual({ serviceTokenSecret: TOKEN_SECRET }) + }) + + it.each([ + ['hostname', { hostname: 'other-app.example.com' }], + ['client ID', { serviceTokenId: 'other.access' }] + ])( + 'drops the saved secret when the %s changes and the form leaves it blank', + async (_case, edit) => { + const created = await repo.create(cloudflaredInput({ serviceTokenSecret: TOKEN_SECRET })) + await repo.update(created.id, cloudflaredInput({ ...edit, serviceTokenSecret: '' })) + const stored = await repo.getStored(created.id) + expect(stored?.cloudflared?.encryptedServiceTokenSecret).toBeUndefined() + expect(repo.decryptCloudflared(stored!)).toEqual({}) + } + ) + + it('stores the secret typed together with a new hostname', async () => { + const created = await repo.create(cloudflaredInput({ serviceTokenSecret: TOKEN_SECRET })) + await repo.update( + created.id, + cloudflaredInput({ hostname: 'other-app.example.com', serviceTokenSecret: 'other-secret' }) + ) + const stored = await repo.getStored(created.id) + expect(repo.decryptCloudflared(stored!)).toEqual({ serviceTokenSecret: 'other-secret' }) + }) + + it('drops the secret when switching to browser sign-in', async () => { + const created = await repo.create(cloudflaredInput({ serviceTokenSecret: TOKEN_SECRET })) + await repo.update(created.id, cloudflaredInput({ authMethod: 'browser' })) + const stored = await repo.getStored(created.id) + expect(stored?.cloudflared?.encryptedServiceTokenSecret).toBeUndefined() + expect(repo.decryptCloudflared(stored!)).toEqual({}) + }) + + it('stores no secret typed while browser sign-in is selected', async () => { + const created = await repo.create( + cloudflaredInput({ authMethod: 'browser', serviceTokenSecret: TOKEN_SECRET }) + ) + const stored = await repo.getStored(created.id) + expect(stored?.cloudflared?.encryptedServiceTokenSecret).toBeUndefined() + }) + + it('keeps the tunnel settings when it is switched off', async () => { + const created = await repo.create( + cloudflaredInput({ serviceTokenSecret: TOKEN_SECRET, binaryPath: 'C:\\cf\\cloudflared.exe' }) + ) + await repo.update(created.id, cloudflaredInput({ enabled: false, serviceTokenSecret: '' })) + const stored = await repo.getStored(created.id) + expect(stored?.cloudflared).toMatchObject({ + enabled: false, + hostname: 'mongo.example.com', + serviceTokenId: 'abc.access' + }) + expect(repo.decryptCloudflared(stored!)).toEqual({ serviceTokenSecret: TOKEN_SECRET }) + }) + + it('stores blank optional fields as absent', async () => { + const created = await repo.create( + cloudflaredInput({ authMethod: 'browser', serviceTokenId: '', binaryPath: '' }) + ) + const stored = await repo.getStored(created.id) + expect(Object.keys(stored?.cloudflared ?? {}).sort()).toEqual([ + 'authMethod', + 'enabled', + 'hostname' + ]) + }) +}) + describe('toRendererView', () => { it('replaces the SSH secrets with hasStored flags', async () => { const created = await repo.create(input({ password: SSH_PASSWORD })) @@ -193,6 +293,23 @@ describe('toRendererView', () => { const created = await repo.create(input()) const view = toRendererView((await repo.getStored(created.id))!) expect(view.ssh).toBeUndefined() + expect(view.cloudflared).toBeUndefined() expect(view.hasStoredPassword).toBe(true) }) + + it('replaces the service token secret with a hasStored flag', async () => { + const created = await repo.create(cloudflaredInput({ serviceTokenSecret: TOKEN_SECRET })) + const view = toRendererView((await repo.getStored(created.id))!) + + expect(view.cloudflared).toEqual({ + enabled: true, + hostname: 'mongo.example.com', + authMethod: 'serviceToken', + serviceTokenId: 'abc.access', + hasStoredServiceTokenSecret: true + }) + const serialized = JSON.stringify(view) + expect(serialized).not.toContain(CIPHER_PREFIX) + expect(serialized).not.toContain(TOKEN_SECRET) + }) }) diff --git a/src/main/stores/ConnectionsRepository.ts b/src/main/stores/ConnectionsRepository.ts index 4c741d9..c0d2cbb 100644 --- a/src/main/stores/ConnectionsRepository.ts +++ b/src/main/stores/ConnectionsRepository.ts @@ -2,11 +2,15 @@ import { app } from 'electron' import { promises as fs } from 'node:fs' import { join } from 'node:path' import { randomUUID } from 'node:crypto' +import { canReuseServiceTokenSecret } from '@shared/cloudflared' import type { + CloudflaredTunnelInput, + CloudflaredTunnelView, ConnectionConfig, ConnectionInput, SshTunnelInput, SshTunnelView, + StoredCloudflaredTunnel, StoredConnection, StoredSshTunnel } from '@shared/types' @@ -116,6 +120,12 @@ export class ConnectionsRepository { } } + /** The Cloudflare service token secret in cleartext. Empty when the connection has none. */ + decryptCloudflared(stored: StoredConnection): { serviceTokenSecret?: string } { + const encrypted = stored.cloudflared?.encryptedServiceTokenSecret + return encrypted !== undefined ? { serviceTokenSecret: this.secrets.decrypt(encrypted) } : {} + } + private fromInput(input: ConnectionInput, existing?: StoredConnection): StoredConnection { const now = new Date().toISOString() const canonical = canonicalize({ @@ -127,6 +137,7 @@ export class ConnectionsRepository { }) const ssh = this.sshFromInput(input.ssh, existing?.ssh) + const cloudflared = this.cloudflaredFromInput(input.cloudflared, existing?.cloudflared) let encryptedPassword: string | undefined = existing?.encryptedPassword let storageUri = canonical.storageUri @@ -157,6 +168,7 @@ export class ConnectionsRepository { : {}), ...(input.appName !== undefined ? { appName: input.appName } : {}), ...(ssh !== undefined ? { ssh } : {}), + ...(cloudflared !== undefined ? { cloudflared } : {}), ...(input.directConnection !== undefined ? { directConnection: input.directConnection } : {}), ...(input.replicaSet !== undefined ? { replicaSet: input.replicaSet } : {}), ...(input.readPreference !== undefined ? { readPreference: input.readPreference } : {}), @@ -217,6 +229,37 @@ export class ConnectionsRepository { } } + private cloudflaredFromInput( + input: CloudflaredTunnelInput | undefined, + existing: StoredCloudflaredTunnel | undefined + ): StoredCloudflaredTunnel | undefined { + if (input === undefined) return undefined + + const usesSecret = input.authMethod === 'serviceToken' + // Blank keeps the saved secret only while a service token still targets the hostname and client ID it was saved for. + const carriedSecret = + usesSecret && canReuseServiceTokenSecret(existing, input) + ? existing?.encryptedServiceTokenSecret + : undefined + const encryptedServiceTokenSecret = + usesSecret && input.serviceTokenSecret !== undefined && input.serviceTokenSecret.length > 0 + ? this.secrets.encrypt(input.serviceTokenSecret) + : carriedSecret + + return { + enabled: input.enabled, + hostname: input.hostname, + authMethod: input.authMethod, + ...(input.serviceTokenId !== undefined && input.serviceTokenId.length > 0 + ? { serviceTokenId: input.serviceTokenId } + : {}), + ...(encryptedServiceTokenSecret !== undefined ? { encryptedServiceTokenSecret } : {}), + ...(input.binaryPath !== undefined && input.binaryPath.length > 0 + ? { binaryPath: input.binaryPath } + : {}) + } + } + private async load(): Promise { if (this.cache !== null) return [...this.cache] try { @@ -248,18 +291,19 @@ export class ConnectionsRepository { * renderer never sees either the placeholder token or any cleartext. * The username remains available as its own field; the renderer can * reconstruct a display string from `{uri, username, hasStoredPassword}`. - * The SSH secrets go the same way, down to `hasStored…` flags. + * The tunnel secrets go the same way, down to `hasStored…` flags. * * THIS IS THE ONLY PLACE THIS PROJECTION SHOULD HAPPEN. */ export function toRendererView(stored: StoredConnection): ConnectionConfig { - const { encryptedPassword, ssh, ...rest } = stored + const { encryptedPassword, ssh, cloudflared, ...rest } = stored const parts = parseUri(stored.uri) const bareUri = `${parts.schemeWithSep}${parts.hostAndRest}` return { ...rest, uri: bareUri, ...(ssh !== undefined ? { ssh: toSshView(ssh) } : {}), + ...(cloudflared !== undefined ? { cloudflared: toCloudflaredView(cloudflared) } : {}), hasStoredPassword: encryptedPassword !== undefined } } @@ -272,3 +316,8 @@ function toSshView(ssh: StoredSshTunnel): SshTunnelView { hasStoredPassphrase: encryptedPassphrase !== undefined } } + +function toCloudflaredView(cloudflared: StoredCloudflaredTunnel): CloudflaredTunnelView { + const { encryptedServiceTokenSecret, ...rest } = cloudflared + return { ...rest, hasStoredServiceTokenSecret: encryptedServiceTokenSecret !== undefined } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index b32fd1b..9f46d5a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,11 +1,18 @@ import { contextBridge, ipcRenderer } from 'electron' import type { Api } from '@shared/api' -import type { ConnectionDropped, UpdateCheckResult, UpdateProgress } from '@shared/events' +import type { + CloudflaredLoginPrompt, + ConnectionDropped, + UpdateCheckResult, + UpdateProgress +} from '@shared/events' import type { Result } from '@shared/result' import type { AggregateRequest, AggregateResponse, + CancelQueryRequest, CollectionInfo, + CollectionSchema, CollectionStats, ConnectionConfig, ConnectionInput, @@ -17,16 +24,20 @@ import type { CreateIndexPayload, CreateUserPayload, DatabaseInfo, + DatabaseRoleInfo, DatabaseUser, DeleteManyRequest, DeleteManyResponse, DeleteOneRequest, DeleteOneResponse, + DistinctRequest, + DistinctResponse, DropIndexPayload, DropUserPayload, FindRequest, FindResponse, IndexInfo, + IndexStatsReport, InsertManyRequest, InsertManyResponse, InsertOneRequest, @@ -35,7 +46,9 @@ import type { ReplaceOneRequest, ReplaceOneResponse, ServerStats, - UpdateUserPayload + UpdateUserPayload, + WriteRequest, + WriteResponse } from '@shared/types' const invoke = (channel: string, payload?: unknown): Promise> => @@ -69,8 +82,14 @@ const api: Api = { onDropped: (listener: (payload: ConnectionDropped) => void) => subscribe('connections:dropped', listener) }, + cloudflared: { + version: (binaryPath?: string) => invoke('cloudflared:version', { binaryPath }), + onLogin: (listener: (prompt: CloudflaredLoginPrompt) => void) => + subscribe('cloudflared:login', listener) + }, dialog: { - pickPrivateKey: () => invoke('dialog:pickPrivateKey') + pickPrivateKey: () => invoke('dialog:pickPrivateKey'), + pickCloudflared: () => invoke('dialog:pickCloudflared') }, databases: { list: (connectionId: string) => invoke('databases:list', { connectionId }), @@ -90,7 +109,9 @@ const api: Api = { invoke('collections:create', payload), drop: (payload: { connectionId: string; db: string; coll: string }) => invoke('collections:drop', payload), - rename: (payload: RenameCollectionPayload) => invoke('collections:rename', payload) + rename: (payload: RenameCollectionPayload) => invoke('collections:rename', payload), + schema: (payload: { connectionId: string; db: string; coll: string }) => + invoke('collections:schema', payload) }, query: { find: (request: FindRequest) => invoke('query:find', request), @@ -103,7 +124,10 @@ const api: Api = { invoke('query:insertMany', request), deleteOne: (request: DeleteOneRequest) => invoke('query:deleteOne', request), deleteMany: (request: DeleteManyRequest) => - invoke('query:deleteMany', request) + invoke('query:deleteMany', request), + distinct: (request: DistinctRequest) => invoke('query:distinct', request), + cancel: (request: CancelQueryRequest) => invoke('query:cancel', request), + write: (request: WriteRequest) => invoke('query:write', request) }, users: { list: (payload: { connectionId: string; db: string }) => @@ -112,11 +136,17 @@ const api: Api = { update: (payload: UpdateUserPayload) => invoke('users:update', payload), drop: (payload: DropUserPayload) => invoke('users:drop', payload) }, + roles: { + list: (payload: { connectionId: string; db: string }) => + invoke('roles:list', payload) + }, indexes: { list: (payload: { connectionId: string; db: string; coll: string }) => invoke('indexes:list', payload), create: (payload: CreateIndexPayload) => invoke<{ name: string }>('indexes:create', payload), - drop: (payload: DropIndexPayload) => invoke('indexes:drop', payload) + drop: (payload: DropIndexPayload) => invoke('indexes:drop', payload), + stats: (payload: { connectionId: string; db: string; coll: string }) => + invoke('indexes:stats', payload) }, updater: { check: () => invoke('updater:check'), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4228f75..375c280 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -7,6 +7,7 @@ import { CollectionTab } from '@/features/collection/CollectionTab' import { ConnectionDashboard } from '@/features/dashboard/ConnectionDashboard' import { ServerStatsCollector } from '@/features/dashboard/ServerStatsCollector' import { CommandPalette } from '@/features/palette/CommandPalette' +import { showCloudflaredLogin } from '@/features/connections/cloudflaredLoginToast' import { UpdateNotifier } from '@/features/updater/UpdateNotifier' import { Welcome } from '@/features/welcome/Welcome' import { useTabsStore } from '@/store/tabs' @@ -41,7 +42,7 @@ export default function App() { const onWelcome = !activeTab && !dashboardConnection // Main took a connection down without being asked — today that means a dead - // SSH tunnel. Subscribed here because the sidebar is not always mounted. + // tunnel. Subscribed here because the sidebar is not always mounted. // Open tabs are left alone: a drop is usually followed by a reconnect, and // discarding the user's queries over a network hiccup would be worse. useEffect(() => { @@ -51,6 +52,9 @@ export default function App() { }) }, [markDisconnected]) + // Sign-ins start from every place that connects, and mid-session once an Access session expires. + useEffect(() => api.cloudflared.onLogin(showCloudflaredLogin), []) + return (
diff --git a/src/renderer/src/components/ui/autocomplete-input.tsx b/src/renderer/src/components/ui/autocomplete-input.tsx new file mode 100644 index 0000000..8d84076 --- /dev/null +++ b/src/renderer/src/components/ui/autocomplete-input.tsx @@ -0,0 +1,122 @@ +import * as React from 'react' +import { Input, type InputProps } from '@/components/ui/input' +import { rankSuggestions } from '@/lib/suggestions' +import { cn } from '@/lib/utils' + +type AutocompleteInputProps = Omit & { + value: string + onValueChange: (value: string) => void + suggestions: readonly string[] + /** Layout of the positioning wrapper, e.g. `flex-1` or a fixed width. */ + wrapperClassName?: string +} + +/** Input with a ranked suggestion list that owns Arrow keys, Tab, Enter and Escape while open. */ +const AutocompleteInput = React.forwardRef( + ( + { value, onValueChange, suggestions, wrapperClassName, className, onKeyDown, ...props }, + ref + ) => { + const [open, setOpen] = React.useState(false) + const [active, setActive] = React.useState(0) + const listId = React.useId() + const listRef = React.useRef(null) + + const matches = React.useMemo(() => rankSuggestions(value, suggestions), [value, suggestions]) + const visible = open && matches.length > 0 + const current = active < matches.length ? active : 0 + + React.useEffect(() => { + setActive(0) + }, [value]) + + React.useEffect(() => { + if (visible) listRef.current?.children[current]?.scrollIntoView({ block: 'nearest' }) + }, [visible, current]) + + const accept = (suggestion: string) => { + onValueChange(suggestion) + setOpen(false) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (visible) { + if (e.key === 'ArrowDown') { + e.preventDefault() + setActive((current + 1) % matches.length) + return + } + if (e.key === 'ArrowUp') { + e.preventDefault() + setActive((current - 1 + matches.length) % matches.length) + return + } + if (e.key === 'Tab' || e.key === 'Enter') { + e.preventDefault() + accept(matches[current]!) + return + } + if (e.key === 'Escape') { + e.preventDefault() + setOpen(false) + return + } + } + onKeyDown?.(e) + } + + return ( +
+ { + onValueChange(e.target.value) + setOpen(true) + }} + onFocus={() => setOpen(true)} + onBlur={() => setOpen(false)} + onKeyDown={handleKeyDown} + className={cn('w-full font-mono', className)} + role="combobox" + aria-autocomplete="list" + aria-expanded={visible} + aria-controls={visible ? listId : undefined} + aria-activedescendant={visible ? `${listId}-${current}` : undefined} + data-consumes-escape={visible ? '' : undefined} + /> + {visible && ( +
    e.preventDefault()} + > + {matches.map((s, i) => ( +
  • setActive(i)} + onClick={() => accept(s)} + className={cn( + 'cursor-pointer rounded-sm px-2 py-1 font-mono text-xs', + i === current ? 'bg-accent text-accent-foreground' : 'text-foreground' + )} + > + {s} +
  • + ))} +
+ )} +
+ ) + } +) +AutocompleteInput.displayName = 'AutocompleteInput' + +export { AutocompleteInput } diff --git a/src/renderer/src/components/ui/dialog.tsx b/src/renderer/src/components/ui/dialog.tsx index 8d5fa11..848b7e5 100644 --- a/src/renderer/src/components/ui/dialog.tsx +++ b/src/renderer/src/components/ui/dialog.tsx @@ -23,28 +23,52 @@ const DialogOverlay = React.forwardRef< )) DialogOverlay.displayName = DialogPrimitive.Overlay.displayName +const isInside = (target: EventTarget | null, selector: string): boolean => + target instanceof Element && target.closest(selector) !== null + +/** Dialog panel; Escape first closes an open Monaco widget or `[data-consumes-escape]` popup inside it. */ const DialogContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - {children} - - - Close - - - -)) +>(({ className, children, onEscapeKeyDown, onKeyDown, ...props }, ref) => { + const closeRef = React.useRef(null) + return ( + + + { + onEscapeKeyDown?.(event) + // Radix handles Escape on document in the capture phase, before the focused widget sees it. + if (isInside(event.target, '.monaco-editor, [data-consumes-escape]')) { + event.preventDefault() + } + }} + onKeyDown={(event) => { + onKeyDown?.(event) + // Monaco stops propagation of keys it consumes, so an Escape arriving here was left unhandled. + if (event.key === 'Escape' && isInside(event.target, '.monaco-editor')) { + closeRef.current?.click() + } + }} + {...props} + > + {children} + + + Close + + + + ) +}) DialogContent.displayName = DialogPrimitive.Content.displayName const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( diff --git a/src/renderer/src/components/ui/tooltip.test.ts b/src/renderer/src/components/ui/tooltip.test.ts new file mode 100644 index 0000000..7b1bc9a --- /dev/null +++ b/src/renderer/src/components/ui/tooltip.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { placeTooltip } from './tooltip' + +const viewport = { width: 1266, height: 763 } +// The Run button: 16 px from the right edge, 48 px from the top. +const runButton = { top: 48, bottom: 80, left: 1186, width: 64 } +// A three-line explanation at the max-w-sm cap. +const reason = { width: 384, height: 58 } + +describe('placeTooltip', () => { + it('stays above the trigger while it fits there', () => { + const trigger = { top: 300, bottom: 330, left: 500, width: 100 } + expect(placeTooltip(trigger, reason, viewport, 'top')).toEqual({ top: 234, left: 358 }) + }) + + it('moves below the trigger when the tooltip is taller than the room above', () => { + expect(placeTooltip(runButton, reason, viewport, 'top').top).toBe(88) + }) + + it('keeps a wide tooltip next to the right edge inside the window', () => { + const { left } = placeTooltip(runButton, reason, viewport, 'top') + expect(left).toBe(viewport.width - 8 - reason.width) + }) + + it('keeps it inside the left edge', () => { + const trigger = { top: 300, bottom: 330, left: 0, width: 20 } + expect(placeTooltip(trigger, reason, viewport, 'top').left).toBe(8) + }) + + it('moves above when there is no room below', () => { + const trigger = { top: 720, bottom: 750, left: 500, width: 100 } + expect(placeTooltip(trigger, reason, viewport, 'bottom').top).toBe(654) + }) +}) diff --git a/src/renderer/src/components/ui/tooltip.tsx b/src/renderer/src/components/ui/tooltip.tsx index 7ccb2b5..840af31 100644 --- a/src/renderer/src/components/ui/tooltip.tsx +++ b/src/renderer/src/components/ui/tooltip.tsx @@ -1,9 +1,42 @@ -import { cloneElement, useEffect, useRef, useState, type ReactElement, type Ref } from 'react' +import { + cloneElement, + useEffect, + useLayoutEffect, + useRef, + useState, + type ReactElement, + type Ref +} from 'react' import { createPortal } from 'react-dom' import { cn } from '@/lib/utils' type Side = 'top' | 'bottom' +type Box = { top: number; left: number } + +const GAP = 8 +const EDGE_MARGIN = 8 + +/** + * Places a tooltip of `size` next to `trigger`, on the other side when `side` lacks room. + * @returns Viewport coordinates of the tooltip's top-left corner, kept inside the window. + */ +export function placeTooltip( + trigger: { top: number; bottom: number; left: number; width: number }, + size: { width: number; height: number }, + viewport: { width: number; height: number }, + side: Side +): Box { + const above = trigger.top - GAP - size.height + const below = trigger.bottom + GAP + const fitsAbove = above >= EDGE_MARGIN + const fitsBelow = below + size.height <= viewport.height - EDGE_MARGIN + const top = side === 'top' ? (fitsAbove ? above : below) : fitsBelow ? below : above + const centered = trigger.left + trigger.width / 2 - size.width / 2 + const left = Math.max(EDGE_MARGIN, Math.min(centered, viewport.width - EDGE_MARGIN - size.width)) + return { top, left } +} + type TriggerProps = { ref?: Ref onMouseEnter?: (e: React.MouseEvent) => void @@ -28,44 +61,35 @@ type Props = { * tokens so it doesn't look like the OS-native yellow `title` bubble. * * Positions via fixed coordinates measured against the trigger's bounding - * rect; the tooltip itself is rendered into a portal at `document.body` - * to escape the parent's overflow clipping (relevant for the document - * table's scroll container). + * rect and its own rendered size; the tooltip itself is rendered into a + * portal at `document.body` to escape the parent's overflow clipping + * (relevant for the document table's scroll container). */ export function Tooltip({ content, children, delay = 350, side = 'top', disabled }: Props) { const [open, setOpen] = useState(false) - const [box, setBox] = useState<{ top: number; left: number; placement: Side }>({ - top: 0, - left: 0, - placement: side - }) + const [box, setBox] = useState({ top: 0, left: 0 }) const timerRef = useRef | null>(null) const triggerRef = useRef(null) + const tooltipRef = useRef(null) - const measure = (): void => { - if (!triggerRef.current) return - const rect = triggerRef.current.getBoundingClientRect() - const gap = 8 - let placement: Side = side - let top = placement === 'top' ? rect.top - gap : rect.bottom + gap - if (placement === 'top' && top < 28) { - placement = 'bottom' - top = rect.bottom + gap - } else if (placement === 'bottom' && top > window.innerHeight - 28) { - placement = 'top' - top = rect.top - gap - } - const left = Math.min(Math.max(rect.left + rect.width / 2, 16), window.innerWidth - 16) - setBox({ top, left, placement }) - } + // Placement needs the rendered size; a layout effect corrects it before the first paint. + useLayoutEffect(() => { + const trigger = triggerRef.current + const tooltip = tooltipRef.current + if (!open || !trigger || !tooltip) return + const next = placeTooltip( + trigger.getBoundingClientRect(), + { width: tooltip.offsetWidth, height: tooltip.offsetHeight }, + { width: window.innerWidth, height: window.innerHeight }, + side + ) + setBox((prev) => (prev.top === next.top && prev.left === next.left ? prev : next)) + }, [open, content, side]) const show = (): void => { if (disabled) return if (timerRef.current) clearTimeout(timerRef.current) - timerRef.current = setTimeout(() => { - measure() - setOpen(true) - }, delay) + timerRef.current = setTimeout(() => setOpen(true), delay) } const hide = (): void => { if (timerRef.current) { @@ -135,16 +159,14 @@ export function Tooltip({ content, children, delay = 350, side = 'top', disabled visibleContent && createPortal(
{content}
, diff --git a/src/renderer/src/features/collection/CollectionTab.tsx b/src/renderer/src/features/collection/CollectionTab.tsx index 82ce6a9..291813c 100644 --- a/src/renderer/src/features/collection/CollectionTab.tsx +++ b/src/renderer/src/features/collection/CollectionTab.tsx @@ -1,22 +1,110 @@ -import { useMemo } from 'react' -import { useQuery, useQueryClient } from '@tanstack/react-query' -import { ServerCrash } from 'lucide-react' +import { useMemo, useState } from 'react' +import { + hashKey, + isCancelledError, + useMutation, + useMutationState, + useQuery, + useQueryClient, + type MutationState, + type QueryKey, + type UseQueryResult +} from '@tanstack/react-query' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '@/components/ui/alert-dialog' +import { compilePipelineSource } from '@/lib/aggregationPipeline' import { api, ApiError } from '@/lib/api' -import { queryKeys } from '@/lib/queryClient' -import { parseMongoQuery } from '@/lib/mongoQueryLang' -import { parseShellCommand } from '@/lib/shellParser' +import { useCompletionEnv } from '@/lib/completion/useCompletionEnv' +import { cancelRequests } from '@/lib/queryCancellation' +import { + explicitRunOptions, + mutationKeys, + queryKeys, + runQueryOptions, + shellWriteOptions, + simpleRunOptions, + type RunnableQuery, + type ShellWriteVariables +} from '@/lib/queryClient' +import { + compileFind, + runAggregation, + runFind, + runShellCommand, + type QueryResult, + type ShellWriteCommand, + type ShellWriteResult +} from '@/lib/queryRun' +import { parseShellCommand, writeConfirmation } from '@/lib/shellParser' import { useTabsStore, - type CollectionTab as CollectionTabType, - type QueryPatch + type AppliedSimple, + type AppliedSource, + type CollectionTab as CollectionTabType } from '@/store/tabs' +import type { CollectionRef, UuidEncoding } from '@shared/types' import { QueryToolbar } from './QueryToolbar' import { DocumentTable } from './DocumentTable' -import { StatusBar } from './StatusBar' -import type { FindResponse, UuidEncoding } from '@shared/types' +import { + CancelledState, + CountResult, + ErrorState, + IdleState, + LoadingState, + WriteResult +} from './ResultStates' +import { StatusBar, type StatusSummary } from './StatusBar' + +type DocumentsResult = Extract + +type ShellWriteState = MutationState + +/** What the view reads of a shell write: the run it belongs to and its outcome. */ +type ShownWrite = Pick & + Partial> + +/** A write waiting for the user to confirm it. */ +type PendingWrite = { tabId: string; reason: string } + +/** The visible mode's applied query: never run, not compilable, ready with its result key, or a write. */ +type ResultRun = + | { kind: 'idle'; mode: 'aggregation' | 'shell' } + | { kind: 'invalid'; error: string } + | ({ kind: 'ready' } & RunnableQuery) + // Run executes it once as a mutation; it has no query, so no refetch or invalidation repeats it. + | { kind: 'write'; epoch: number } + +const runnable = (run: ResultRun | null): RunnableQuery | null => + run?.kind === 'ready' ? run : null + +const isCancelled = (error: unknown): boolean => + isCancelledError(error) || (error instanceof ApiError && error.code === 'cancelled') + +const EMPTY_MESSAGES: Record = { + find: 'No documents match the current filter.', + aggregate: 'The pipeline returned no documents.', + distinct: 'No values found.' +} + +const TRUNCATION_HINTS: Record = { + find: 'Use .skip() / .limit() to page through the rest.', + aggregate: 'Add $skip / $limit stages to page through the rest.', + distinct: 'Narrow the filter to see the remaining values.' +} export function CollectionTab({ tab }: { tab: CollectionTabType }) { - const setQuery = useTabsStore((s) => s.setQuery) + // Only the mode: the rest of the draft changes on every keystroke and never affects results. + const mode = useTabsStore((s) => s.drafts[tab.id]?.mode ?? 'simple') + const setSkip = useTabsStore((s) => s.setSkip) + const commitRun = useTabsStore((s) => s.run) const queryClient = useQueryClient() const connectionsQuery = useQuery({ @@ -27,277 +115,377 @@ export function CollectionTab({ tab }: { tab: CollectionTabType }) { const uuidEncoding: UuidEncoding = connectionConfig?.uuidEncoding ?? 'default' const timezone = connectionConfig?.timezone ?? 'UTC' - // Compile all three modes' inputs up-front; the active query just picks - // its slice. parseMongoQuery rejects anything that isn't valid mongo - // shell syntax (with our $-operator extensions). - const compiled = useMemo(() => { - if (tab.mode === 'simple') { - const f = parseMongoQuery(tab.filter) - const p = parseMongoQuery(tab.projection) - const s = parseMongoQuery(tab.sort) - const ok = f.ok && p.ok && s.ok - return { - mode: 'simple' as const, - ok, - filter: f.ok ? f.ejson : null, - projection: p.ok ? p.ejson : null, - sort: s.ok ? s.ejson : null, - error: - (!f.ok && `Filter: ${f.error}`) || - (!p.ok && `Projection: ${p.error}`) || - (!s.ok && `Sort: ${s.error}`) || - null - } - } - if (tab.mode === 'aggregation') { - const r = parseMongoQuery(tab.pipeline) - if (!r.ok) { - return { mode: 'aggregation' as const, ok: false, pipeline: null, error: r.error } - } - if (!Array.isArray(r.value)) { - return { - mode: 'aggregation' as const, - ok: false, - pipeline: null, - error: 'Pipeline must be an array' - } - } - return { mode: 'aggregation' as const, ok: true, pipeline: r.ejson, error: null } - } - // shell - const r = parseShellCommand(tab.shell) - if (!r.ok) { - return { mode: 'shell' as const, ok: false, parsed: null, error: r.error } - } - if (r.coll !== tab.coll) { - return { - mode: 'shell' as const, - ok: false, - parsed: null, - error: `Command targets "${r.coll}" but this tab is "${tab.coll}"` - } - } - return { mode: 'shell' as const, ok: true, parsed: r, error: null } - }, [tab.mode, tab.filter, tab.projection, tab.sort, tab.pipeline, tab.shell, tab.coll]) + const { id, connectionId, db, coll, applied } = tab + const simple = applied.simple + const appliedSource = + mode === 'aggregation' ? applied.aggregation : mode === 'shell' ? applied.shell : null + const compiledFind = useMemo(() => compileFind(simple), [simple]) + const findRun = useMemo( + () => simpleRun({ connectionId, db, coll }, simple, compiledFind), + [connectionId, db, coll, simple, compiledFind] + ) + const sourceRun = useMemo( + () => + mode === 'simple' ? null : explicitRun(id, { connectionId, db, coll }, mode, appliedSource), + [id, connectionId, db, coll, mode, appliedSource] + ) - const findQuery = useQuery({ - queryKey: - compiled.mode === 'simple' - ? queryKeys.find( - tab.connectionId, - tab.db, - tab.coll, - tab.filter, - tab.projection, - tab.sort, - tab.skip, - tab.limit, - tab.runEpoch - ) - : compiled.mode === 'aggregation' - ? queryKeys.aggregate(tab.connectionId, tab.db, tab.coll, tab.pipeline, tab.runEpoch) - : queryKeys.shell(tab.connectionId, tab.db, tab.coll, tab.shell, tab.runEpoch), - queryFn: () => runForMode(tab, compiled), - enabled: compiled.ok, - // Don't auto-refetch when the user just switches between tabs; the - // query only re-runs when its key changes (filter / mode / runEpoch). - refetchOnMount: false, - staleTime: Infinity - }) + // Observed in every mode so a mode switch shows the kept result without re-running it. + const findQuery = useQuery( + runQueryOptions(id, runnable(findRun), simpleRunOptions, mode === 'simple') + ) + const sourceQuery = useQuery(runQueryOptions(id, runnable(sourceRun), explicitRunOptions)) + const run = sourceRun ?? findRun + const resultQuery = sourceRun ? sourceQuery : findQuery - // Count is only meaningful for the simple mode — aggregation/shell show - // the result size in the StatusBar instead. - const countQuery = useQuery({ - queryKey: queryKeys.count(tab.connectionId, tab.db, tab.coll, tab.filter), - queryFn: () => - api.query.count({ - connectionId: tab.connectionId, - db: tab.db, - coll: tab.coll, - ...(compiled.mode === 'simple' && compiled.filter ? { filter: compiled.filter } : {}) - }), - enabled: compiled.mode === 'simple' && compiled.ok + const writeMutation = useMutation(shellWriteOptions(queryClient)) + // Filtered while rendering: useMutationState applies changed filters only on its next cache event. + const writeStates = useMutationState({ + filters: { mutationKey: mutationKeys.shellWrite }, + select: (mutation): ShownWrite => { + const { variables, status, data, error } = mutation.state as ShellWriteState + return { tabId: variables?.tabId, epoch: variables?.epoch, status, data, error } + } }) + const writeState = + run.kind === 'write' + ? writeStates.find((write) => write.tabId === id && write.epoch === run.epoch) + : undefined + const writing = writeState?.status === 'pending' - const apply = (patch: QueryPatch) => setQuery(tab.id, patch) + const [confirming, setConfirming] = useState(null) + // One view serves every tab; a confirmation belongs to the tab that asked for it. + if (confirming !== null && confirming.tabId !== id) setConfirming(null) + + const runWrite = () => { + // Committing first gives the write its run's epoch, which releases the tab's previous write. + const committed = commitRun(id) + if (committed === null || !('source' in committed)) return + // Parsed from the committed source, so the write is exactly the run the tab records and shows. + const parsed = parseShellCommand(committed.source) + if (!parsed.ok) return + const { command } = parsed + if (command.op.kind !== 'write') return + writeMutation.mutate({ + tabId: id, + epoch: committed.epoch, + target: { connectionId, db }, + command: { coll: command.coll, op: command.op } + }) + } + + const requestWrite = (command: ShellWriteCommand) => { + const reason = writeConfirmation(command, db) + if (reason === null) runWrite() + else setConfirming({ tabId: id, reason }) + } + + const countKey = queryKeys.count(connectionId, db, coll, simple.filter, simple.epoch) + const countQuery = useQuery( + runQueryOptions( + id, + compiledFind.ok + ? { + key: countKey, + execute: (requestId) => + api.query.count({ + connectionId, + db, + coll, + ...(compiledFind.find.filter ? { filter: compiledFind.find.filter } : {}), + requestId + }) + } + : null, + simpleRunOptions, + // Only Simple mode pages, so only it needs the total. + mode === 'simple' + ) + ) const cancel = () => { - // Soft cancel via react-query: the IPC promise still settles in main - // but its result is discarded, so the UI returns to its previous state - // immediately. No queryFn signal plumbing needed. - const key = - tab.mode === 'simple' - ? queryKeys.find( - tab.connectionId, - tab.db, - tab.coll, - tab.filter, - tab.projection, - tab.sort, - tab.skip, - tab.limit, - tab.runEpoch - ) - : tab.mode === 'aggregation' - ? queryKeys.aggregate(tab.connectionId, tab.db, tab.coll, tab.pipeline, tab.runEpoch) - : queryKeys.shell(tab.connectionId, tab.db, tab.coll, tab.shell, tab.runEpoch) - void queryClient.cancelQueries({ queryKey: key }) + const keys: QueryKey[] = run.kind === 'ready' ? [run.key] : [] + if (mode === 'simple') keys.push(countKey) + for (const key of keys) { + const hash = hashKey(key) + cancelRequests((k) => hashKey(k) === hash) + // Kept as a failure instead of reverting, so the cancelled run is never re-executed implicitly. + void queryClient.cancelQueries({ queryKey: key, exact: true }, { revert: false }) + } } - const documents = findQuery.data?.documents ?? [] - const tookMs = findQuery.data?.tookMs + const result = resultQuery.data + const completionEnv = useCompletionEnv({ + connectionId, + db, + coll, + uuidEncoding, + timezone, + // Projected, derived or other collections' rows would teach the schema fields `coll` does not store. + documents: + result?.kind === 'documents' && result.rowAccess === 'full' && result.coll === coll + ? result.documents + : undefined + }) + const loading = resultQuery.isFetching + // A slow count shows as running but never blocks Run. + const countLoading = mode === 'simple' && countQuery.isFetching + const summary: StatusSummary = + mode === 'simple' + ? { + kind: 'paged', + count: countQuery.data?.count, + countError: + countQuery.error && !isCancelled(countQuery.error) ? countQuery.error.message : null, + estimated: countQuery.data?.estimated ?? false, + pageSize: simple.limit, + skip: simple.skip, + pageDocs: result?.kind === 'documents' ? result.documents.length : 0, + busy: findQuery.isFetching, + onJump: (skip) => setSkip(id, skip) + } + : result?.kind === 'documents' + ? rowsSummary(result, coll) + : { kind: 'none' } return (
setQuery(tab.id, { skip })} + loading={loading || countLoading || writing} + tookMs={run.kind === 'write' ? writeState?.data?.tookMs : result?.tookMs} + summary={summary} />
- {compiled.error ? ( - - ) : findQuery.error instanceof ApiError ? ( - - ) : ( - - )} +
+ + { + if (!open) setConfirming(null) + }} + > + + + Run this write? + + {confirming?.reason} A write can't be cancelled once it runs. + + + + Cancel + { + if (confirming !== null) runWrite() + }} + > + Run write + + + +
) } -type Compiled = - | { - mode: 'simple' - ok: boolean - filter: string | null - projection: string | null - sort: string | null - error: string | null - } - | { mode: 'aggregation'; ok: boolean; pipeline: string | null; error: string | null } - | { - mode: 'shell' - ok: boolean - parsed: (ReturnType & { ok: true }) | null - error: string | null - } - -async function runForMode(tab: CollectionTabType, compiled: Compiled): Promise { - if (compiled.mode === 'simple') { - return api.query.find({ - connectionId: tab.connectionId, - db: tab.db, - coll: tab.coll, - ...(compiled.filter ? { filter: compiled.filter } : {}), - ...(compiled.projection ? { projection: compiled.projection } : {}), - ...(compiled.sort ? { sort: compiled.sort } : {}), - skip: tab.skip, - ...(tab.limit > 0 ? { limit: tab.limit } : {}) - }) - } - if (compiled.mode === 'aggregation') { - if (!compiled.pipeline) throw new Error('unreachable: aggregation enabled without pipeline') - return api.query.aggregate({ - connectionId: tab.connectionId, - db: tab.db, - coll: tab.coll, - pipeline: compiled.pipeline - }) +function ResultView({ + run, + query, + write, + tab, + uuidEncoding, + timezone +}: { + run: ResultRun + query: Pick, 'data' | 'error' | 'fetchStatus'> + write: ShownWrite | undefined + tab: CollectionTabType + uuidEncoding: UuidEncoding + timezone: string +}) { + const { id, connectionId, db, coll } = tab + if (run.kind === 'idle') return + if (run.kind === 'invalid') return + if (run.kind === 'write') { + return ( + + ) } - // shell - if (!compiled.parsed || !compiled.parsed.ok) - throw new Error('unreachable: shell enabled without parsed command') - const op = compiled.parsed.op - if (op.kind === 'aggregate') { - return api.query.aggregate({ - connectionId: tab.connectionId, - db: tab.db, - coll: tab.coll, - pipeline: op.pipeline - }) + const { data: result, error } = query + if (isCancelled(error)) return + if (error) return + if (!result) return query.fetchStatus === 'idle' ? : + if (result.kind === 'count') { + return ( + + ) } - if (op.kind === 'find') { - return api.query.find({ - connectionId: tab.connectionId, - db: tab.db, - coll: tab.coll, - filter: op.filter, - ...(op.projection ? { projection: op.projection } : {}), - ...(op.sort ? { sort: op.sort } : {}), - skip: op.skip ?? 0, - ...(op.limit !== null ? { limit: op.limit } : {}) - }) + return ( + + ) +} + +function ShellWriteView({ + state, + tabId, + connectionId, + db, + coll, + uuidEncoding, + timezone +}: { + state: ShownWrite | undefined + tabId: string + connectionId: string + db: string + coll: string + uuidEncoding: UuidEncoding + timezone: string +}) { + const returned = state?.data?.returned + const documents = useMemo(() => (returned?.document ? [returned.document] : []), [returned]) + if (!state) return + if (state.error) return + if (!state.data) return + const { writes } = state.data + return ( + + {returned && ( + + )} + + ) +} + +function simpleRun( + ref: CollectionRef, + simple: AppliedSimple, + compiled: ReturnType +): ResultRun { + if (!compiled.ok) return { kind: 'invalid', error: compiled.error } + const { find } = compiled + return { + kind: 'ready', + key: queryKeys.find( + ref.connectionId, + ref.db, + ref.coll, + simple.filter, + simple.projection, + simple.sort, + simple.skip, + simple.limit, + simple.epoch + ), + execute: (requestId) => runFind(ref, find, requestId) } - if (op.kind === 'findOne') { - return api.query.find({ - connectionId: tab.connectionId, - db: tab.db, - coll: tab.coll, - filter: op.filter, - skip: 0, - limit: 1 - }) +} + +function explicitRun( + tabId: string, + ref: CollectionRef, + mode: 'aggregation' | 'shell', + applied: AppliedSource | null +): ResultRun { + if (applied === null || applied.source.trim() === '') return { kind: 'idle', mode } + if (mode === 'aggregation') { + const compiled = compilePipelineSource(applied.source) + if (!compiled.ok) return { kind: 'invalid', error: compiled.error } + return { + kind: 'ready', + key: queryKeys.aggregate( + tabId, + ref.connectionId, + ref.db, + ref.coll, + applied.source, + applied.epoch + ), + execute: (requestId) => runAggregation(ref, compiled, requestId) + } } - // countDocuments — return an empty docs response; the count shows in the - // StatusBar via a one-off api.query.count call below. - const countResp = await api.query.count({ - connectionId: tab.connectionId, - db: tab.db, - coll: tab.coll, - filter: op.filter - }) + const parsed = parseShellCommand(applied.source) + if (!parsed.ok) return { kind: 'invalid', error: parsed.error } + const { coll, op } = parsed.command + if (op.kind === 'write') return { kind: 'write', epoch: applied.epoch } return { - documents: [ - { - id: '"_count"', - data: { _count: countResp.count }, - canonical: `{"_count":${countResp.count}}`, - hash: 'a'.repeat(64) - } - ], - tookMs: 0 + kind: 'ready', + key: queryKeys.shell(tabId, ref.connectionId, ref.db, applied.source, applied.epoch), + execute: (requestId) => runShellCommand(ref, { coll, op }, requestId) } } -function ErrorState({ message }: { message: string }) { - return ( -
- -
-
Query failed
-
{message}
-
-
- ) +function rowsSummary(result: DocumentsResult, tabColl: string): StatusSummary { + return { + kind: 'rows', + rows: result.documents.length, + truncated: result.truncated, + truncationHint: TRUNCATION_HINTS[result.origin], + readOnlyReason: + result.rowAccess !== 'none' + ? null + : result.origin === 'distinct' + ? "Distinct values aren't documents." + : `Pipeline output isn't 1:1 documents of ${result.coll}, so rows can't be edited or deleted.`, + foreignColl: result.coll === tabColl ? null : result.coll + } } diff --git a/src/renderer/src/features/collection/DocumentTable.tsx b/src/renderer/src/features/collection/DocumentTable.tsx index b1e22f3..1e9289c 100644 --- a/src/renderer/src/features/collection/DocumentTable.tsx +++ b/src/renderer/src/features/collection/DocumentTable.tsx @@ -13,7 +13,8 @@ import { ContextMenuSubTrigger, ContextMenuTrigger } from '@/components/ui/context-menu' -import { queryKeys } from '@/lib/queryClient' +import { invalidateWrittenCollection, queryKeys } from '@/lib/queryClient' +import type { RowAccess } from '@/lib/queryRun' import { useTabsStore } from '@/store/tabs' import { AlertDialog, @@ -30,6 +31,7 @@ import { api, ApiError } from '@/lib/api' import { extractColumns, inspectBson, kindBadgeClass, kindLabel } from '@/lib/bsonDisplay' import { cn } from '@/lib/utils' import type { DocumentEnvelope, UuidEncoding } from '@shared/types' +import { Notice } from './ResultStates' const MAX_CELL_CHARS = 80 const COL_MIN_WIDTH = 72 @@ -47,23 +49,31 @@ type RowMenuState = { envelope: DocumentEnvelope | null } +/** A row with an `_id`, so it can address its stored document. */ +type StoredEnvelope = DocumentEnvelope & { id: string } + +const hasId = (doc: DocumentEnvelope): doc is StoredEnvelope => doc.id !== null + type PendingDelete = - { kind: 'one'; envelope: DocumentEnvelope } | { kind: 'many'; ids: string[] } | null + { kind: 'one'; envelope: StoredEnvelope } | { kind: 'many'; ids: string[] } | null export function DocumentTable({ documents, - loading, connectionId, db, coll, + rowAccess, + emptyMessage, uuidEncoding, timezone }: { documents: DocumentEnvelope[] - loading: boolean connectionId: string db: string + /** Collection the rows belong to; every write targets it. */ coll: string + rowAccess: RowAccess + emptyMessage: string uuidEncoding: UuidEncoding timezone: string }) { @@ -71,11 +81,12 @@ export function DocumentTable({ const [editorState, setEditorState] = useState({ mode: null, envelope: null }) const [pendingDelete, setPendingDelete] = useState(null) const [selected, setSelected] = useState>(() => new Set()) - const [activeLookup, setActiveLookup] = useState<{ docId: string; ref: ExtractedRef } | null>( + const [activeLookup, setActiveLookup] = useState<{ rowKey: string; ref: ExtractedRef } | null>( null ) const lastClickedIdRef = useRef(null) - const openTab = useTabsStore((s) => s.open) + const openWithFilter = useTabsStore((s) => s.openWithFilter) + const rowsWritable = rowAccess !== 'none' // Prefetched in the background as soon as the table mounts so the // ObjectId-lookup submenu (right-click on an ObjectId cell) doesn't have to @@ -90,12 +101,6 @@ export function DocumentTable({ /** User-set widths; auto-estimated widths fill in for the rest. */ const [userColumnWidths, setUserColumnWidths] = useState>({}) - // Reset manual widths when the *coll* changes — different schema means - // different "natural" widths. Filter/sort within the same coll keeps them. - useEffect(() => { - setUserColumnWidths({}) - }, [connectionId, db, coll]) - const autoColumnWidths = useMemo(() => { const widths: Record = {} for (const col of columns) { @@ -144,7 +149,7 @@ export function DocumentTable({ // Drop selections that no longer correspond to any visible document // (e.g. after a query change or pagination). useEffect(() => { - const visible = new Set(documents.map((d) => d.id)) + const visible = new Set(rowsWritable ? documents.map((d) => d.id) : []) setSelected((prev) => { let changed = false const next = new Set() @@ -154,10 +159,10 @@ export function DocumentTable({ } return changed ? next : prev }) - }, [documents]) + }, [documents, rowsWritable]) const deleteOneMutation = useMutation({ - mutationFn: (env: DocumentEnvelope) => + mutationFn: (env: StoredEnvelope) => api.query.deleteOne({ connectionId, db, @@ -168,8 +173,7 @@ export function DocumentTable({ onSuccess: (result) => { if (result.deletedCount > 0) { toast.success('Document deleted') - void queryClient.invalidateQueries({ queryKey: ['find'] }) - void queryClient.invalidateQueries({ queryKey: ['count'] }) + void invalidateWrittenCollection(queryClient, { connectionId, db, coll }) } else { toast.warning('Document was already gone') } @@ -188,8 +192,7 @@ export function DocumentTable({ toast.success( `Deleted ${result.deletedCount} document${result.deletedCount === 1 ? '' : 's'}` ) - void queryClient.invalidateQueries({ queryKey: ['find'] }) - void queryClient.invalidateQueries({ queryKey: ['count'] }) + void invalidateWrittenCollection(queryClient, { connectionId, db, coll }) setSelected(new Set()) setPendingDelete(null) }, @@ -213,6 +216,24 @@ export function DocumentTable({ } } + // Partial rows can't be written back and their hash never matches; act on the stored document. + const withStoredDocument = (doc: StoredEnvelope, act: (stored: StoredEnvelope) => void) => { + if (rowAccess === 'full') { + act(doc) + return + } + void api.query + .find({ connectionId, db, coll, filter: `{"_id":${doc.id}}`, skip: 0, limit: 1 }) + .then(({ documents: [stored] }) => { + if (stored && hasId(stored)) act(stored) + else toast.error('Document not found') + }) + .catch((e: unknown) => { + const message = e instanceof ApiError ? e.message : String(e) + toast.error(`Loading the document failed: ${message}`) + }) + } + /** * All checkbox interactions go through this single onClick handler. * @@ -243,7 +264,8 @@ export function DocumentTable({ setSelected((prev) => { const next = new Set(prev) for (let i = lo; i <= hi; i++) { - const docId = indices[i]! + const docId = indices[i] + if (!docId) continue if (targetSelected) next.add(docId) else next.delete(docId) } @@ -293,17 +315,19 @@ export function DocumentTable({ // No modifier: leave selection alone (user is just reading a cell). } - const allVisibleSelected = documents.length > 0 && documents.every((d) => selected.has(d.id)) - const someVisibleSelected = !allVisibleSelected && documents.some((d) => selected.has(d.id)) + const selectableIds = rowsWritable ? documents.filter(hasId).map((d) => d.id) : [] + const allVisibleSelected = + selectableIds.length > 0 && selectableIds.every((id) => selected.has(id)) + const someVisibleSelected = !allVisibleSelected && selectableIds.some((id) => selected.has(id)) const toggleSelectAllVisible = () => { setSelected((prev) => { if (allVisibleSelected) { const next = new Set(prev) - for (const d of documents) next.delete(d.id) + for (const id of selectableIds) next.delete(id) return next } const next = new Set(prev) - for (const d of documents) next.add(d.id) + for (const id of selectableIds) next.add(id) return next }) lastClickedIdRef.current = null @@ -314,27 +338,14 @@ export function DocumentTable({ lastClickedIdRef.current = null } - if (documents.length === 0) { - return ( -
- {loading ? ( - - - Loading… - - ) : ( - 'No documents match the current filter.' - )} -
- ) - } + if (documents.length === 0) return {emptyMessage} const selectionCount = selected.size return ( <>
- {selectionCount > 0 && ( + {rowsWritable && selectionCount > 0 && ( - + {rowsWritable && } {columns.map((col) => ( ))} - - toggleSelectAllVisible()} - aria-label={allVisibleSelected ? 'Deselect all visible' : 'Select all visible'} - /> - + {rowsWritable && ( + + toggleSelectAllVisible()} + aria-label={ + allVisibleSelected ? 'Deselect all visible' : 'Select all visible' + } + /> + + )} {columns.map((col) => ( - {documents.map((doc) => { - const isSelected = selected.has(doc.id) - const lookupRef = activeLookup?.docId === doc.id ? activeLookup.ref : null + {documents.map((doc, index) => { + // Derived rows may repeat an _id or have none, so the index keeps keys unique. + const rowKey = `${index}:${doc.id ?? ''}` + const stored = rowsWritable && hasId(doc) ? doc : null + const isSelected = stored !== null && selected.has(stored.id) + const lookupRef = activeLookup?.rowKey === rowKey ? activeLookup.ref : null return ( { - if (!open && activeLookup?.docId === doc.id) setActiveLookup(null) + if (!open && activeLookup?.rowKey === rowKey) setActiveLookup(null) }} > handleRowClick(doc.id, e)} + onClick={stored ? (e) => handleRowClick(stored.id, e) : undefined} onMouseDown={(e) => { // Prevent native text-selection range expansion when the // user shift-clicks across rows. @@ -416,17 +434,21 @@ export function DocumentTable({ isSelected ? 'bg-primary/10 hover:bg-primary/15' : 'hover:bg-accent/30' )} > - e.stopPropagation()} - onContextMenu={(e) => e.stopPropagation()} - > - handleRowCheckboxClick(doc.id, e)} - aria-label={isSelected ? 'Deselect document' : 'Select document'} - /> - + {rowsWritable && ( + e.stopPropagation()} + onContextMenu={(e) => e.stopPropagation()} + > + {stored && ( + handleRowCheckboxClick(stored.id, e)} + aria-label={isSelected ? 'Deselect document' : 'Select document'} + /> + )} + + )} {columns.map((col) => ( { - const ref = col === '_id' ? null : extractRef(doc.data[col]) - if (ref) setActiveLookup({ docId: doc.id, ref }) + // A stored document's own _id only points back at itself. + const ref = + col === '_id' && rowsWritable ? null : extractRef(doc.data[col]) + if (ref) setActiveLookup({ rowKey, ref }) else setActiveLookup(null) }} /> @@ -448,12 +472,10 @@ export function DocumentTable({ <> - openTab({ - connectionId, - db: lookupRef.db ?? db, - coll: lookupRef.ref, - filter: `{ _id: ObjectId("${lookupRef.oid}") }` - }) + openWithFilter( + { connectionId, db: lookupRef.db ?? db, coll: lookupRef.ref }, + `{ _id: ObjectId("${lookupRef.oid}") }` + ) } > @@ -502,12 +524,10 @@ export function DocumentTable({ - openTab({ - connectionId, - db, - coll: c.name, - filter: `{ _id: ObjectId("${lookupRef.oid}") }` - }) + openWithFilter( + { connectionId, db, coll: c.name }, + `{ _id: ObjectId("${lookupRef.oid}") }` + ) } > {c.name} @@ -525,31 +545,51 @@ export function DocumentTable({ View - setEditorState({ mode: 'edit', envelope: doc })} - > - - Edit - - setEditorState({ mode: 'duplicate', envelope: doc })} - > - - Duplicate - + {stored && ( + <> + + withStoredDocument(stored, (envelope) => + setEditorState({ mode: 'edit', envelope }) + ) + } + > + + Edit + + + withStoredDocument(stored, (envelope) => + setEditorState({ mode: 'duplicate', envelope }) + ) + } + > + + Duplicate + + + )} void onCopy(doc)}> Copy - - setPendingDelete({ kind: 'one', envelope: doc })} - > - - Delete - + {stored && ( + <> + + + withStoredDocument(stored, (envelope) => + setPendingDelete({ kind: 'one', envelope }) + ) + } + > + + Delete + + + )} ) diff --git a/src/renderer/src/features/collection/IndexesDialog.tsx b/src/renderer/src/features/collection/IndexesDialog.tsx index 916d737..eb3d44e 100644 --- a/src/renderer/src/features/collection/IndexesDialog.tsx +++ b/src/renderer/src/features/collection/IndexesDialog.tsx @@ -1,16 +1,22 @@ -import { type FormEvent, type KeyboardEvent, useEffect, useMemo, useState } from 'react' +import { type FormEvent, useEffect, useMemo, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { + ArrowDown, ArrowLeft, + ArrowUp, + ArrowUpDown, ChevronDown, ChevronRight, + CircleOff, EyeOff, Hash, + Info, Key, Loader2, Lock, Plus, + RefreshCw, ServerCrash, Sparkles, Timer, @@ -25,6 +31,7 @@ import { DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { AutocompleteInput } from '@/components/ui/autocomplete-input' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -46,16 +53,36 @@ import { AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog' +import { Tooltip } from '@/components/ui/tooltip' import { api, ApiError } from '@/lib/api' +import { collectionSchemaQuery } from '@/lib/completion/useCompletionEnv' +import { formatDateInZone } from '@/lib/dateZone' +import { + buildIndexRows, + formatAge, + formatCount, + formatRate, + nextSort, + sortIndexRows, + unavailableText, + usageAvailable, + usageNotes, + usageSources, + type IndexRow, + type IndexSort, + type IndexSortKey +} from '@/lib/indexUsage' import { queryKeys } from '@/lib/queryClient' import { cn } from '@/lib/utils' -import type { IndexCreateOptions, IndexInfo } from '@shared/types' +import { schemaFieldPaths } from '@shared/collectionSchema' +import type { IndexCreateOptions, IndexInfo, IndexStatsReport } from '@shared/types' type Props = { open: boolean connectionId: string db: string coll: string + timezone: string onClose: () => void } @@ -70,13 +97,17 @@ const KEY_TYPE_OPTIONS: Array<{ value: string; label: string }> = [ { value: 'hashed', label: 'Hashed' } ] -export function IndexesDialog({ open, connectionId, db, coll, onClose }: Props) { +export function IndexesDialog({ open, connectionId, db, coll, timezone, onClose }: Props) { const [view, setView] = useState({ kind: 'list' }) const [pendingDrop, setPendingDrop] = useState(null) + const [sort, setSort] = useState(null) const queryClient = useQueryClient() useEffect(() => { - if (open) setView({ kind: 'list' }) + if (open) { + setView({ kind: 'list' }) + setSort(null) + } }, [open]) const indexesQuery = useQuery({ @@ -85,6 +116,13 @@ export function IndexesDialog({ open, connectionId, db, coll, onClose }: Props) enabled: open }) + // No polling: on mongos $indexStats fans out to every shard, so refresh stays manual. + const statsQuery = useQuery({ + queryKey: queryKeys.indexStats(connectionId, db, coll), + queryFn: () => api.indexes.stats({ connectionId, db, coll }), + enabled: open + }) + const dropMutation = useMutation({ mutationFn: (name: string) => api.indexes.drop({ connectionId, db, coll, name }), onSuccess: (_, name) => { @@ -104,7 +142,7 @@ export function IndexesDialog({ open, connectionId, db, coll, onClose }: Props) return ( !o && onClose()}> - + {view.kind !== 'list' && ( @@ -131,6 +169,20 @@ export function IndexesDialog({ open, connectionId, db, coll, onClose }: Props) isLoading={indexesQuery.isLoading} error={indexesQuery.error} indexes={indexesQuery.data ?? []} + // A failed refresh keeps the previous data; showing it beside the error would mislead. + report={statsQuery.isError ? undefined : statsQuery.data} + statsLoading={statsQuery.isLoading} + statsError={statsQuery.error} + now={statsQuery.dataUpdatedAt} + timezone={timezone} + sort={sort} + onSort={(key) => setSort((s) => nextSort(s, key))} + refreshing={indexesQuery.isFetching || statsQuery.isFetching} + onRefresh={() => + void queryClient.invalidateQueries({ + queryKey: queryKeys.indexes(connectionId, db, coll) + }) + } onCreate={() => setView({ kind: 'create' })} onDrop={(idx) => setPendingDrop(idx)} /> @@ -185,15 +237,38 @@ function IndexesList({ isLoading, error, indexes, + report, + statsLoading, + statsError, + now, + timezone, + sort, + onSort, + refreshing, + onRefresh, onCreate, onDrop }: { isLoading: boolean error: unknown indexes: IndexInfo[] + report: IndexStatsReport | undefined + statsLoading: boolean + statsError: Error | null + now: number + timezone: string + sort: IndexSort + onSort: (key: IndexSortKey) => void + refreshing: boolean + onRefresh: () => void onCreate: () => void onDrop: (idx: IndexInfo) => void }) { + const rows = useMemo( + () => sortIndexRows(buildIndexRows(indexes, report, now), sort), + [indexes, report, now, sort] + ) + if (isLoading) { return (
@@ -214,34 +289,92 @@ function IndexesList({ ) } + const usageUnavailable = report?.usageUnavailable ?? null + const storageUnavailable = report?.storageUnavailable ?? null + const hasUsage = usageAvailable(report) + const sizes = rows.flatMap((r) => (r.size === undefined ? [] : [r.size])) + const sources = report && hasUsage ? usageSources(report) : null + return (
{indexes.length} index{indexes.length === 1 ? '' : 'es'} + {sizes.length > 0 && ` · ${formatBytes(sizes.reduce((sum, size) => sum + size, 0))}`} +
+
+ +
-
+ {(statsError !== null || usageUnavailable !== null || storageUnavailable !== null) && ( +
+ +
+ {statsError !== null && ( +
+ Could not read index statistics: {statsError.message}{' '} + +
+ )} + {usageUnavailable !== null && ( + +
{unavailableText('usage', usageUnavailable)}
+
+ )} + {storageUnavailable !== null && ( + +
{unavailableText('storage', storageUnavailable)}
+
+ )} +
+
+ )}
- - - + {/* z-10: spinning badges are transformed and would otherwise paint over the sticky header. */} + + + - + + - {indexes.map((idx) => ( - - + {rows.map((row) => ( + + - +
Name
Key PropertiesSize
{idx.name}
{row.index.name}
- {Object.entries(idx.key).map(([field, dir]) => ( + {Object.entries(row.index.key).map(([field, dir]) => (
- {indexBadges(idx).map((b) => ( + {indexBadges(row.index, row.building).map((b) => (
- {idx.size !== undefined ? formatBytes(idx.size) : '—'} + + {statsLoading ? '…' : row.size !== undefined ? formatBytes(row.size) : '—'} + +
+ {sources !== null && sources.hosts.length > 0 && ( +

+ {sources.hosts.length === 1 + ? `Usage counted on ${sources.hosts[0]} only; counters reset when it restarts or the index changes.` + : `Usage summed over ${sources.hosts.length} mongods${ + sources.shards.length > 0 + ? ` across ${sources.shards.length} shard${sources.shards.length === 1 ? '' : 's'}` + : '' + }; counters reset when a mongod restarts.`} +

+ )}
) } -function indexBadges(idx: IndexInfo): Array<{ label: string; icon: JSX.Element; title?: string }> { +function SortableHeader({ + label, + sortKey, + sort, + onSort, + disabled, + alignRight +}: { + label: string + sortKey: IndexSortKey + sort: IndexSort + onSort: (key: IndexSortKey) => void + disabled?: boolean + alignRight?: boolean +}) { + const dir = sort?.key === sortKey ? sort.dir : null + const Icon = dir === 'asc' ? ArrowUp : dir === 'desc' ? ArrowDown : ArrowUpDown + return ( + + + + ) +} + +function UsageCell({ + row, + statsLoading, + hasUsage, + now, + timezone +}: { + row: IndexRow + statsLoading: boolean + hasUsage: boolean + now: number + timezone: string +}) { + if (statsLoading) return … + if (row.building) return building + if (!hasUsage) return — + const { usage } = row + if (usage === null) { + return ( + + — + + ) + } + + const notes = usageNotes(row, now) + return ( + +
+ {usage.ops.toLocaleString('en-US')} ops since {formatDateInZone(usage.since, timezone)} +
+ {usage.hosts.length > 1 && + usage.hosts.map((h, i) => ( +
+ {h.shard !== undefined && `${h.shard} · `} + {h.host} — {h.ops.toLocaleString('en-US')} since{' '} + {formatDateInZone(h.since, timezone)} +
+ ))} + {notes.map((note) => ( +
{note}
+ ))} +
+ } + > +
+
+ {formatCount(usage.ops)} +
+ {row.unused ? ( +
+ + unused for {formatAge(now - usage.coveredSince)} +
+ ) : ( +
+ {usage.ops > 0 && usage.opsPerDay !== null + ? `${formatRate(usage.opsPerDay)} over ${formatAge(now - usage.since)}` + : `over ${formatAge(now - usage.since)}`} +
+ )} +
+ + ) +} + +function indexBadges( + idx: IndexInfo, + building: boolean +): Array<{ label: string; icon: JSX.Element; title?: string }> { const badges: Array<{ label: string; icon: JSX.Element; title?: string }> = [] + if (building) { + badges.push({ + label: 'building', + icon: , + title: 'Index build in progress' + }) + } if (idx.unique) badges.push({ label: 'unique', icon: }) if (idx.sparse) badges.push({ label: 'sparse', icon: }) - if (idx.hidden) badges.push({ label: 'hidden', icon: }) + if (idx.hidden) { + badges.push({ + label: 'hidden', + icon: , + title: 'Hidden from the query planner' + }) + } if (idx.expireAfterSeconds !== undefined) { badges.push({ label: `TTL ${idx.expireAfterSeconds}s`, @@ -395,12 +678,10 @@ function CreateIndexForm({ const [showAdvanced, setShowAdvanced] = useState(false) const queryClient = useQueryClient() - // Sample a handful of documents to offer their field paths as completions. // Best-effort: an empty or failed sample just means no suggestions. const fieldPathsQuery = useQuery({ - queryKey: queryKeys.fieldPaths(connectionId, db, coll), - queryFn: () => api.query.find({ connectionId, db, coll, skip: 0, limit: 50 }), - select: (res) => collectFieldPaths(res.documents.map((d) => d.data)) + ...collectionSchemaQuery(connectionId, db, coll), + select: schemaFieldPaths }) const fieldSuggestions = fieldPathsQuery.data ?? [] @@ -508,11 +789,14 @@ function CreateIndexForm({
{state.rows.map((row, i) => (
- updateRow(i, { field })} + onValueChange={(field) => updateRow(i, { field })} suggestions={fieldSuggestions} autoFocus={i === 0} + placeholder="field.path (use foo.$** for wildcard)" + spellCheck={false} />
{ - onChange(e.target.value) - setOpen(true) - }} - onFocus={() => setOpen(true)} - onBlur={() => setOpen(false)} - onKeyDown={onKeyDown} - placeholder="field.path (use foo.$** for wildcard)" - spellCheck={false} - className="w-full font-mono" - autoFocus={autoFocus} - role="combobox" - aria-expanded={open && matches.length > 0} - aria-autocomplete="list" - /> - {open && matches.length > 0 && ( -
    e.preventDefault()} - > - {matches.map((s, i) => ( -
  • setActive(i)} - onClick={() => accept(s)} - className={cn( - 'cursor-pointer rounded-sm px-2 py-1 font-mono text-xs', - i === active ? 'bg-accent text-accent-foreground' : 'text-foreground' - )} - > - {s} -
  • - ))} -
- )} -
- ) -} - -const MAX_FIELD_PATH_DEPTH = 4 - -/** - * Distinct dot-notation field paths across the sampled documents, sorted. - * `data` is relaxed EJSON, so objects whose keys all start with `$` - * (e.g. { $oid }, { $date }) are type wrappers, not subdocuments. Array - * elements share their parent path, matching how indexes address them. - */ -function collectFieldPaths(docs: Array>): string[] { - const paths = new Set() - const visit = (value: unknown, prefix: string, depth: number): void => { - if (depth > MAX_FIELD_PATH_DEPTH || value === null || typeof value !== 'object') return - if (Array.isArray(value)) { - for (const item of value) visit(item, prefix, depth) - return - } - const obj = value as Record - const keys = Object.keys(obj) - if (prefix && keys.length > 0 && keys.every((k) => k.startsWith('$'))) return - for (const key of keys) { - if (key.startsWith('$')) continue - const path = prefix ? `${prefix}.${key}` : key - paths.add(path) - visit(obj[key], path, depth + 1) - } - } - for (const doc of docs) visit(doc, '', 0) - return Array.from(paths).sort((a, b) => a.localeCompare(b)) -} - function validateForm(state: FormState, ttlEligible: boolean): string | null { const filledRows = state.rows.filter((r) => r.field.trim()) if (filledRows.length === 0) return 'At least one key field is required' diff --git a/src/renderer/src/features/collection/QueryEditor.test.tsx b/src/renderer/src/features/collection/QueryEditor.test.tsx new file mode 100644 index 0000000..1307199 --- /dev/null +++ b/src/renderer/src/features/collection/QueryEditor.test.tsx @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' +import type { OnMount } from '@monaco-editor/react' +import type { CompletionEnv } from '@/lib/completion/useCompletionEnv' +import { QueryEditor } from './QueryEditor' + +const editorProps = vi.hoisted(() => ({ onMount: null as unknown })) + +vi.mock('monaco-editor', () => ({})) +vi.mock('@monaco-editor/react', () => ({ + default: (props: { onMount: unknown }) => { + editorProps.onMount = props.onMount + return null + } +})) +vi.mock('@/lib/completion/monacoAdapter', () => ({ + attachCompletion: () => ({ dispose: () => undefined }) +})) +// The real module boots Monaco's workers and languages as a side effect. +vi.mock('@/lib/monacoSetup', () => ({ BASE_EDITOR_OPTIONS: {} })) + +const KeyMod = { CtrlCmd: 2048, Shift: 1024, Alt: 512 } +const KeyCode = { Enter: 3, KeyF: 36 } +const CTRL_ENTER = KeyMod.CtrlCmd | KeyCode.Enter + +type KeyEvent = { + browserEvent: { repeat: boolean } + equals: (keybinding: number) => boolean + preventDefault: () => void + stopPropagation: () => void +} + +/** Mounts the editor on a fake Monaco; the returned `press` reports whether the editor stopped the key. */ +function mountEditor(onSubmit: () => void): (keybinding: number, repeat: boolean) => boolean { + renderToStaticMarkup( + undefined} + onSubmit={onSubmit} + /> + ) + const disposable = { dispose: () => undefined } + const keyListeners: ((event: KeyEvent) => void)[] = [] + const actions = new Map void>() + const editor = { + addAction: ({ keybindings, run }: { keybindings: number[]; run: () => void }) => { + for (const keybinding of keybindings) actions.set(keybinding, run) + return disposable + }, + onKeyDown: (listener: (event: KeyEvent) => void) => { + keyListeners.push(listener) + return disposable + }, + getModel: () => null, + onDidChangeModel: () => disposable, + onDidContentSizeChange: () => disposable, + getContentHeight: () => 18 + } + ;(editorProps.onMount as OnMount)(editor as never, { KeyMod, KeyCode } as never) + return (keybinding, repeat) => { + let stopped = false + const event: KeyEvent = { + browserEvent: { repeat }, + equals: (other) => other === keybinding, + preventDefault: () => undefined, + stopPropagation: () => (stopped = true) + } + for (const listener of keyListeners) listener(event) + // Monaco dispatches keybindings from the editor's container, which a stopped event never reaches. + if (!stopped) actions.get(keybinding)?.() + return stopped + } +} + +describe('QueryEditor Run keybinding', () => { + it('runs once per press of Ctrl/Cmd+Enter, not on the key repeats of holding it', () => { + const onSubmit = vi.fn() + const press = mountEditor(onSubmit) + press(CTRL_ENTER, false) + press(CTRL_ENTER, true) + press(CTRL_ENTER, true) + expect(onSubmit).toHaveBeenCalledTimes(1) + press(CTRL_ENTER, false) + expect(onSubmit).toHaveBeenCalledTimes(2) + }) + + it('leaves the key repeats of other keys to the editor', () => { + const press = mountEditor(vi.fn()) + expect(press(KeyCode.Enter, true)).toBe(false) + expect(press(KeyMod.Shift | KeyCode.Enter, true)).toBe(false) + }) +}) diff --git a/src/renderer/src/features/collection/QueryEditor.tsx b/src/renderer/src/features/collection/QueryEditor.tsx index c5357ac..a79326c 100644 --- a/src/renderer/src/features/collection/QueryEditor.tsx +++ b/src/renderer/src/features/collection/QueryEditor.tsx @@ -1,9 +1,39 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import Editor, { type OnMount } from '@monaco-editor/react' import * as monaco from 'monaco-editor' import { cn } from '@/lib/utils' +import type { CompletionBinding } from '@/lib/completion/monacoAdapter' +import { useCompletionAttachment } from '@/lib/completion/useCompletionAttachment' +import { BASE_EDITOR_OPTIONS } from '@/lib/monacoSetup' +import { queryModelTabId } from '@/lib/queryModelUri' +import { useTabsStore } from '@/store/tabs' + +// @monaco-editor/react keys its unmount save by the editor's first path, wrong once path changes. +const viewStates = new Map() + +function storeViewState(editor: monaco.editor.IStandaloneCodeEditor | null): void { + const model = editor?.getModel() + const state = editor?.saveViewState() + if (model && state) viewStates.set(model.uri.toString(), state) +} + +// Query models outlive editor instances (keepCurrentModel) and end with their tab. +useTabsStore.subscribe((state, prev) => { + if (state.tabs === prev.tabs) return + const openIds = new Set(state.tabs.map((t) => t.id)) + for (const model of monaco.editor.getModels()) { + const tabId = queryModelTabId(model.uri) + if (tabId === null || openIds.has(tabId)) continue + viewStates.delete(model.uri.toString()) + model.dispose() + } +}) type Props = { + /** Monaco model path (see queryModelUri); one model per tab and slot, disposed with the tab. */ + modelPath: string + /** Editor kind and environment for completion; read on every request. */ + completion: CompletionBinding value: string onChange: (next: string) => void onSubmit: () => void @@ -30,14 +60,16 @@ type Props = { } /** - * MongoDB filter editor. + * MongoDB query editor. * - * Wraps Monaco with: JSON syntax highlighting, bracket auto-pairing, - * format-on-paste, MongoDB operator IntelliSense (registered once via - * `ensureProviderRegistered`), and a destructive-coloured border when - * the value isn't valid JSON. + * Wraps Monaco with: mongo-shell syntax highlighting, bracket auto-pairing, + * format-on-paste, schema-aware MongoDB completion (bound per editor via + * `attachCompletion`), and a destructive-coloured border when + * the value doesn't parse. */ export function QueryEditor({ + modelPath, + completion, value, onChange, onSubmit, @@ -51,6 +83,8 @@ export function QueryEditor({ onContentHeightChange }: Props) { const editorRef = useRef(null) + const actionsRef = useRef([]) + const bindCompletion = useCompletionAttachment(completion) const submitRef = useRef(onSubmit) const formatRef = useRef(onFormat) const minHeightRef = useRef(minHeight) @@ -77,15 +111,31 @@ export function QueryEditor({ const ch = editor.getContentHeight() setContentHeight(Math.min(Math.max(ch, minHeight), maxHeight)) }, [minHeight, maxHeight]) + // Editor disposal does not release addAction registrations. + useEffect(() => () => actionsRef.current.forEach((d) => d.dispose()), []) + // Layout cleanup runs before the library's passive effects swap the model or dispose the editor. + useLayoutEffect(() => () => storeViewState(editorRef.current), [modelPath]) const handleMount: OnMount = (editor, m) => { editorRef.current = editor - ensureProviderRegistered() - editor.addCommand(m.KeyMod.CtrlCmd | m.KeyCode.Enter, () => submitRef.current()) - editor.addAction({ + const runKeybinding = m.KeyMod.CtrlCmd | m.KeyCode.Enter + // addAction scopes the keybinding to this editor; addCommand binds it globally to the last one. + const runAction = editor.addAction({ + id: 'mongobench.run', + label: 'Run query', + keybindings: [runKeybinding], + run: () => submitRef.current() + }) + // Keybindings dispatch from the container after this listener; a held key must not run again. + editor.onKeyDown((e) => { + if (!e.browserEvent.repeat || !e.equals(runKeybinding)) return + e.preventDefault() + e.stopPropagation() + }) + const formatAction = editor.addAction({ id: 'mongobench.format', - label: 'Format JSON', + label: 'Format', keybindings: [m.KeyMod.Shift | m.KeyMod.Alt | m.KeyCode.KeyF], run: () => { if (formatRef.current) { @@ -95,6 +145,16 @@ export function QueryEditor({ } } }) + actionsRef.current = [runAction, formatAction] + bindCompletion(editor) + + const restoreViewState = () => { + const model = editor.getModel() + const state = model ? viewStates.get(model.uri.toString()) : undefined + if (state) editor.restoreViewState(state) + } + editor.onDidChangeModel(restoreViewState) + restoreViewState() const sync = () => { const ch = editor.getContentHeight() @@ -124,11 +184,15 @@ export function QueryEditor({ width="100%" language="mongo-shell" theme="mongobench-dark" + path={modelPath} + keepCurrentModel + saveViewState={false} value={value} onMount={handleMount} onChange={(v) => onChange(v ?? '')} loading={
Loading editor…
} options={{ + ...BASE_EDITOR_OPTIONS, minimap: { enabled: false }, lineNumbers: 'off', fontFamily: 'JetBrains Mono, ui-monospace, monospace', @@ -156,9 +220,8 @@ export function QueryEditor({ alwaysConsumeMouseWheel: false }, quickSuggestions: { other: true, comments: false, strings: true }, - suggestOnTriggerCharacters: true, - acceptSuggestionOnEnter: 'smart', tabCompletion: 'on', + suggest: { ...BASE_EDITOR_OPTIONS.suggest, snippetsPreventQuickSuggestions: false }, autoClosingBrackets: 'always', autoClosingQuotes: 'always', autoSurround: 'languageDefined', @@ -179,389 +242,3 @@ export function QueryEditor({
) } - -let providerRegistered = false - -// Distinct top-level keys from the most recently fetched documents. The -// completion provider reads this directly so it doesn't have to be -// re-registered on every fetch — refreshing the cache is enough. -let documentFieldNames: string[] = [] - -export function setDocumentFieldNames(names: Iterable): void { - documentFieldNames = Array.from(new Set(names)).sort((a, b) => a.localeCompare(b)) -} - -function ensureProviderRegistered(): void { - if (providerRegistered) return - providerRegistered = true - - monaco.languages.registerCompletionItemProvider('mongo-shell', { - triggerCharacters: ['$', '"', 'I', 'O', 'N', 'U', 'D', 'B', 'T', 'M', '{', ',', ' '], - provideCompletionItems(model, position) { - const lineUpToCursor = model.getValueInRange({ - startLineNumber: position.lineNumber, - startColumn: 1, - endLineNumber: position.lineNumber, - endColumn: position.column - }) - - // Three trigger contexts: - // - `$foo` (optionally with leading `"`) → MongoDB query operator - // - bare identifier prefix → mongo shell helper (ObjectId, ISODate, …) - // - after `{` or `,` (object key position) → cached document field names - const opMatch = /"?\$[A-Za-z]*$/.exec(lineUpToCursor) - const helperMatch = /(?:^|[\s:,[(])([A-Za-z][A-Za-z0-9]*)$/.exec(lineUpToCursor) - const helperPrefix = helperMatch ? helperMatch[1]! : null - const keyMatch = /(?:^|[{,])\s*("?)([A-Za-z_$][\w.]*)?$/.exec(lineUpToCursor) - - if (opMatch) { - const range = new monaco.Range( - position.lineNumber, - position.column - opMatch[0].length, - position.lineNumber, - position.column - ) - const suggestions = buildMongoCompletions().map((c): monaco.languages.CompletionItem => ({ - label: c.label, - kind: c.kind ?? monaco.languages.CompletionItemKind.Keyword, - insertText: c.insertText, - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: { value: c.doc }, - detail: c.detail, - range, - sortText: c.sortText ?? c.label - })) - return { suggestions } - } - - const suggestions: monaco.languages.CompletionItem[] = [] - - if (keyMatch && documentFieldNames.length > 0) { - const hasQuote = keyMatch[1] === '"' - const partial = keyMatch[2] ?? '' - const startCol = position.column - partial.length - (hasQuote ? 1 : 0) - const range = new monaco.Range( - position.lineNumber, - startCol, - position.lineNumber, - position.column - ) - for (const name of documentFieldNames) { - suggestions.push({ - label: name, - kind: monaco.languages.CompletionItemKind.Field, - insertText: `"${name}"`, - filterText: name, - detail: 'Document field', - range, - // Prefix '0' so field names sort above operators / helpers when - // both contexts overlap (e.g. user typed a bare letter). - sortText: `0_${name}` - }) - } - } - - if (helperPrefix) { - const range = new monaco.Range( - position.lineNumber, - position.column - helperPrefix.length, - position.lineNumber, - position.column - ) - for (const c of buildShellHelpers()) { - suggestions.push({ - label: c.label, - kind: c.kind ?? monaco.languages.CompletionItemKind.Keyword, - insertText: c.insertText, - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - documentation: { value: c.doc }, - detail: c.detail, - range, - sortText: c.sortText ?? c.label - }) - } - } - - return { suggestions } - } - }) -} - -type CompletionDef = { - label: string - insertText: string - doc: string - detail: string - kind?: monaco.languages.CompletionItemKind - sortText?: string -} - -// Always wraps in `"…"` to produce valid JSON keys regardless of whether -// the user already typed an opening quote (range covers the leading `"`). -const op = ( - label: string, - insertText: string, - doc: string, - detail = 'MongoDB query operator' -): CompletionDef => ({ - label, - insertText: `"${insertText}"`.replace(/^""/, '"'), - doc, - detail, - kind: monaco.languages.CompletionItemKind.Function -}) - -// Comparison -const COMPARISON: CompletionDef[] = [ - op('$eq', '$eq": ${1:value}', 'Matches values equal to a specified value.'), - op('$ne', '$ne": ${1:value}', 'Matches values not equal to a specified value.'), - op('$gt', '$gt": ${1:value}', 'Matches values greater than a specified value.'), - op('$gte', '$gte": ${1:value}', 'Matches values greater than or equal to a specified value.'), - op('$lt', '$lt": ${1:value}', 'Matches values less than a specified value.'), - op('$lte', '$lte": ${1:value}', 'Matches values less than or equal to a specified value.'), - op('$in', '$in": [${1:values}]', 'Matches any of the values in the array.'), - op('$nin', '$nin": [${1:values}]', 'Matches none of the values in the array.') -] - -// Logical -const LOGICAL: CompletionDef[] = [ - op('$and', '$and": [\n\t{ $0 }\n]', 'Joins clauses with logical AND.'), - op('$or', '$or": [\n\t{ $0 }\n]', 'Joins clauses with logical OR.'), - op('$nor', '$nor": [\n\t{ $0 }\n]', 'Joins clauses with logical NOR.'), - op('$not', '$not": { $0 }', 'Inverts the effect of an expression.') -] - -// Element -const ELEMENT: CompletionDef[] = [ - op('$exists', '$exists": ${1|true,false|}', 'Matches documents that have or lack the field.'), - op( - '$type', - '$type": "${1|double,string,object,array,binData,objectId,bool,date,null,regex,int,timestamp,long,decimal,minKey,maxKey|}"', - 'Matches documents whose field is one of the BSON types.' - ) -] - -// Evaluation -const EVALUATION: CompletionDef[] = [ - op('$expr', '$expr": { $0 }', 'Allows the use of aggregation expressions inside the query.'), - op('$jsonSchema', '$jsonSchema": { $0 }', 'Validates documents against a JSON Schema.'), - op('$mod', '$mod": [${1:divisor}, ${2:remainder}]', 'Performs a modulo operation.'), - op('$regex', '$regex": "${1:pattern}", "$options": "${2:i}"', 'Pattern match against a regex.'), - op('$options', '$options": "${1:i}"', 'Regex options: i (case-insensitive), m, x, s.'), - op('$text', '$text": { "$search": "${1:query}" }', 'Performs a text search.'), - op('$search', '$search": "${1:query}"', 'Search string for a $text query.'), - op('$language', '$language": "${1:english}"', 'Language to use for the text search.'), - op('$caseSensitive', '$caseSensitive": ${1|true,false|}', 'Case-sensitive text search.'), - op( - '$diacriticSensitive', - '$diacriticSensitive": ${1|true,false|}', - 'Diacritic-sensitive text search.' - ), - op( - '$where', - '$where": "${1:function() { return true; }}"', - 'Server-side JavaScript predicate (slow!).' - ) -] - -// Array -const ARRAY_OPS: CompletionDef[] = [ - op('$all', '$all": [${1:values}]', 'Matches arrays containing all the specified elements.'), - op( - '$elemMatch', - '$elemMatch": { $0 }', - 'Matches arrays with at least one element matching all criteria.' - ), - op('$size', '$size": ${1:n}', 'Matches arrays of the given length.') -] - -// Bitwise -const BITWISE: CompletionDef[] = [ - op('$bitsAllClear', '$bitsAllClear": ${1:mask}', 'All bits in the mask are clear (0).'), - op('$bitsAllSet', '$bitsAllSet": ${1:mask}', 'All bits in the mask are set (1).'), - op('$bitsAnyClear', '$bitsAnyClear": ${1:mask}', 'Any bit in the mask is clear.'), - op('$bitsAnySet', '$bitsAnySet": ${1:mask}', 'Any bit in the mask is set.') -] - -// Geospatial -const GEO: CompletionDef[] = [ - op( - '$geoIntersects', - '$geoIntersects": { "$geometry": { "type": "${1:Point}", "coordinates": [${2:0}, ${3:0}] } }', - 'Selects geometries that intersect with a GeoJSON geometry.' - ), - op( - '$geoWithin', - '$geoWithin": { "$geometry": { "type": "${1:Polygon}", "coordinates": $2 } }', - 'Selects geometries within a bounding GeoJSON geometry.' - ), - op( - '$near', - '$near": { "$geometry": { "type": "Point", "coordinates": [${1:lng}, ${2:lat}] }, "$maxDistance": ${3:meters} }', - 'Returns docs ordered by proximity to a point.' - ), - op( - '$nearSphere', - '$nearSphere": { "$geometry": { "type": "Point", "coordinates": [${1:lng}, ${2:lat}] }, "$maxDistance": ${3:meters} }', - 'Like $near but uses spherical geometry.' - ), - op( - '$geometry', - '$geometry": { "type": "${1:Point}", "coordinates": ${2} }', - 'GeoJSON geometry helper.' - ), - op('$maxDistance', '$maxDistance": ${1:meters}', 'Max distance (meters) from $near point.'), - op('$minDistance', '$minDistance": ${1:meters}', 'Min distance (meters) from $near point.'), - op( - '$box', - '$box": [[${1:lngLow}, ${2:latLow}], [${3:lngHigh}, ${4:latHigh}]]', - 'Legacy bounding box for $geoWithin.' - ), - op( - '$center', - '$center": [[${1:lng}, ${2:lat}], ${3:radius}]', - 'Legacy circle (flat) for $geoWithin.' - ), - op( - '$centerSphere', - '$centerSphere": [[${1:lng}, ${2:lat}], ${3:radians}]', - 'Spherical circle for $geoWithin.' - ), - op( - '$polygon', - '$polygon": [[${1:lng1}, ${2:lat1}], [${3:lng2}, ${4:lat2}], [${5:lng3}, ${6:lat3}]]', - 'Legacy polygon for $geoWithin.' - ) -] - -// Projection / cursor modifiers (used in projection field, but also valid in find spec) -const PROJECTION: CompletionDef[] = [ - op('$slice', '$slice": ${1:n}', 'Limit array elements in projection.', 'Projection operator'), - op('$meta', '$meta": "${1|textScore,indexKey|}"', 'Project metadata.', 'Projection operator') -] - -// EJSON wrappers — for value position (BSON types) -function buildEjsonWrappers(): CompletionDef[] { - const nowIso = new Date().toISOString() - const nowSec = Math.floor(Date.now() / 1000) - return [ - op( - '$oid', - '$oid": "${1:507f1f77bcf86cd799439011}"', - 'EJSON: ObjectId wrapper.', - 'EJSON / BSON type' - ), - op( - '$date', - `$date": "\${1:${nowIso}}"`, - 'EJSON: Date wrapper (ISO-8601 string or { "$numberLong": "ms" }).', - 'EJSON / BSON type' - ), - op('$numberLong', '$numberLong": "${1:0}"', 'EJSON: 64-bit integer.', 'EJSON / BSON type'), - op('$numberInt', '$numberInt": "${1:0}"', 'EJSON: 32-bit integer.', 'EJSON / BSON type'), - op('$numberDouble', '$numberDouble": "${1:0.0}"', 'EJSON: 64-bit float.', 'EJSON / BSON type'), - op( - '$numberDecimal', - '$numberDecimal": "${1:0}"', - 'EJSON: 128-bit decimal.', - 'EJSON / BSON type' - ), - op( - '$binary', - '$binary": { "base64": "${1:}", "subType": "${2:00}" }', - 'EJSON: BSON binary.', - 'EJSON / BSON type' - ), - op( - '$timestamp', - `$timestamp": { "t": \${1:${nowSec}}, "i": \${2:0} }`, - 'EJSON: BSON timestamp (replication).', - 'EJSON / BSON type' - ), - op( - '$uuid', - '$uuid": "${1:00000000-0000-0000-0000-000000000000}"', - 'EJSON: UUID (BSON binary subType 4).', - 'EJSON / BSON type' - ), - op( - '$regularExpression', - '$regularExpression": { "pattern": "${1:}", "options": "${2:i}" }', - 'EJSON: BSON regex (canonical).', - 'EJSON / BSON type' - ), - op('$symbol', '$symbol": "${1:}"', 'EJSON: deprecated BSON symbol.', 'EJSON / BSON type'), - op('$code', '$code": "${1:function() {}}"', 'EJSON: BSON Code.', 'EJSON / BSON type'), - op('$minKey', '$minKey": 1', 'EJSON: BSON MinKey marker.', 'EJSON / BSON type'), - op('$maxKey', '$maxKey": 1', 'EJSON: BSON MaxKey marker.', 'EJSON / BSON type'), - op('$undefined', '$undefined": true', 'EJSON: deprecated BSON Undefined.', 'EJSON / BSON type') - ] -} - -function buildMongoCompletions(): CompletionDef[] { - return [ - ...COMPARISON, - ...LOGICAL, - ...ELEMENT, - ...EVALUATION, - ...ARRAY_OPS, - ...BITWISE, - ...GEO, - ...PROJECTION, - ...buildEjsonWrappers() - ] -} - -// Shell helpers — bare identifiers the user types in value position. -// The mongoQueryLang parser rewrites these into EJSON wrappers before the -// query is sent to the backend, so suggesting them as snippets is safe. -const helper = ( - label: string, - insertText: string, - doc: string, - detail = 'mongo shell helper' -): CompletionDef => ({ - label, - insertText, - doc, - detail, - kind: monaco.languages.CompletionItemKind.Constructor -}) - -function buildShellHelpers(): CompletionDef[] { - const nowIso = new Date().toISOString() - const nowSec = Math.floor(Date.now() / 1000) - return [ - helper('ObjectId', 'ObjectId("${1:507f1f77bcf86cd799439011}")', 'ObjectId hex literal.'), - helper('ISODate', `ISODate("\${1:${nowIso}}")`, 'ISO-8601 date — defaults to now.'), - helper('Date', `Date("\${1:${nowIso}}")`, 'Same as ISODate — defaults to now.'), - helper('NumberLong', 'NumberLong("${1:0}")', 'BSON 64-bit integer.'), - helper('NumberInt', 'NumberInt(${1:0})', 'BSON 32-bit integer.'), - helper('NumberDecimal', 'NumberDecimal("${1:0}")', 'BSON 128-bit decimal.'), - helper( - 'UUID', - 'UUID("${1:00000000-0000-0000-0000-000000000000}")', - 'UUID literal (BSON subType 04).' - ), - helper( - 'JUUID', - 'JUUID("${1:00000000-0000-0000-0000-000000000000}")', - 'Legacy Java-driver UUID (BSON subType 03, Java byte order).' - ), - helper('BinData', 'BinData(${1:0}, "${2:base64==}")', 'Binary data with subType.'), - helper('Timestamp', `Timestamp(\${1:${nowSec}}, \${2:0})`, 'BSON timestamp (replication).'), - helper('MinKey', 'MinKey', 'Sorts before any other BSON value.'), - helper('MaxKey', 'MaxKey', 'Sorts after any other BSON value.'), - helper('DBRef', 'DBRef("${1:coll}", ${2:id})', 'Document reference.'), - helper('Code', 'Code("${1:function() {}}")', 'BSON Code value.'), - helper( - 'RegExp', - 'RegExp("${1:pattern}", "${2:i}")', - 'Regex; equivalent to a /pattern/flags literal.' - ), - helper('true', 'true', 'Boolean true.', 'literal'), - helper('false', 'false', 'Boolean false.', 'literal'), - helper('null', 'null', 'Null value.', 'literal'), - helper('undefined', 'undefined', 'BSON Undefined (deprecated).', 'literal') - ] -} diff --git a/src/renderer/src/features/collection/QueryToolbar.tsx b/src/renderer/src/features/collection/QueryToolbar.tsx index 9e534b6..161a8c6 100644 --- a/src/renderer/src/features/collection/QueryToolbar.tsx +++ b/src/renderer/src/features/collection/QueryToolbar.tsx @@ -1,19 +1,26 @@ -import { type FormEvent, useEffect, useMemo, useState } from 'react' -import { ArrowDownUp, Eye, Play, Wand2, X, XCircle } from 'lucide-react' +import { type FormEvent, useMemo, useState } from 'react' +import { ArrowDownUp, Eye, Loader2, PenLine, Play, Wand2, X, XCircle } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' +import { compilePipelineSource } from '@/lib/aggregationPipeline' +import type { CompletionBinding } from '@/lib/completion/monacoAdapter' +import type { CompletionEnv } from '@/lib/completion/useCompletionEnv' +import { formatMongoSource } from '@/lib/mongoFormat' import { parseMongoQuery } from '@/lib/mongoQueryLang' -import { parseShellCommand, type ShellParseResult } from '@/lib/shellParser' -import type { CollectionTab, QueryMode, QueryPatch } from '@/store/tabs' -import type { DocumentEnvelope, UuidEncoding } from '@shared/types' +import type { SerializeOptions } from '@/lib/mongoQuerySerialize' +import { queryModelUri } from '@/lib/queryModelUri' +import type { CompiledFind, ShellWriteCommand } from '@/lib/queryRun' +import { + formatShellCommand, + parseShellCommand, + shellCollectionAccessor, + writeTargetName +} from '@/lib/shellParser' +import { useTabsStore, type CollectionTab, type QueryDraft, type QueryMode } from '@/store/tabs' +import type { UuidEncoding } from '@shared/types' import { ExportButton } from './ExportButton' -import { QueryEditor, setDocumentFieldNames } from './QueryEditor' - -const EMPTY_OBJECT = '{}' -const EMPTY_ARRAY = '[]' -const objOrDefault = (s: string): string => (s.trim().length === 0 ? EMPTY_OBJECT : s) -const arrOrDefault = (s: string): string => (s.trim().length === 0 ? EMPTY_ARRAY : s) +import { QueryEditor } from './QueryEditor' const MODES: ReadonlyArray<{ id: QueryMode; label: string }> = [ { id: 'simple', label: 'Simple' }, @@ -21,119 +28,115 @@ const MODES: ReadonlyArray<{ id: QueryMode; label: string }> = [ { id: 'shell', label: 'Shell' } ] -export function QueryToolbar({ - tab, - onApply, - onCancel, - loading, - documents, - uuidEncoding, - timezone, - compiledFilter, - compiledProjection, - compiledSort -}: { +type ToolbarProps = { tab: CollectionTab - onApply: (patch: QueryPatch) => void onCancel: () => void + /** Runs a shell draft that writes; reads run through the store's `run`. */ + onRunWrite: (command: ShellWriteCommand) => void loading: boolean - documents: DocumentEnvelope[] + /** The shown shell write is running; it can't be cancelled. */ + writing: boolean + completionEnv: CompletionEnv uuidEncoding: UuidEncoding timezone: string - compiledFilter: string | null - compiledProjection: string | null - compiledSort: string | null -}) { - const [filter, setFilter] = useState(() => objOrDefault(tab.filter)) - const [projection, setProjection] = useState(() => objOrDefault(tab.projection)) - const [sort, setSort] = useState(() => objOrDefault(tab.sort)) - const [pipeline, setPipeline] = useState(() => arrOrDefault(tab.pipeline)) - const [shell, setShell] = useState(() => tab.shell) - const [limit, setLimit] = useState(tab.limit > 0 ? String(tab.limit) : '') + /** The applied Simple query, exported as is; null when it doesn't compile. */ + exportFind: CompiledFind | null + /** Rows of the visible Simple page; seeds the export's document count. */ + exportDocCount: number +} - useEffect(() => { - setFilter(objOrDefault(tab.filter)) - setProjection(objOrDefault(tab.projection)) - setSort(objOrDefault(tab.sort)) - setPipeline(arrOrDefault(tab.pipeline)) - setShell(tab.shell) - setLimit(tab.limit > 0 ? String(tab.limit) : '') - }, [tab.id, tab.filter, tab.projection, tab.sort, tab.pipeline, tab.shell, tab.limit]) +export function QueryToolbar(props: ToolbarProps) { + const draft = useTabsStore((s) => s.drafts[props.tab.id]) + // Tabs and drafts are created and removed in one store update. + if (!draft) return null + return +} - // Cache distinct top-level field names from the most recent fetch so - // the editor can offer them as completions in any input. - useEffect(() => { - const names = new Set() - for (const env of documents) { - for (const key of Object.keys(env.data)) names.add(key) - } - setDocumentFieldNames(names) - }, [documents]) +function QueryForm({ + tab, + draft, + onCancel, + onRunWrite, + loading, + writing, + completionEnv, + uuidEncoding, + timezone, + exportFind, + exportDocCount +}: ToolbarProps & { draft: QueryDraft }) { + const setDraft = useTabsStore((s) => s.setDraft) + const run = useTabsStore((s) => s.run) + const setFilter = (filter: string) => setDraft(tab.id, { filter }) + const setProjection = (projection: string) => setDraft(tab.id, { projection }) + const setSort = (sort: string) => setDraft(tab.id, { sort }) + const setPipeline = (pipeline: string) => setDraft(tab.id, { pipeline }) + const setShell = (shell: string) => setDraft(tab.id, { shell }) + const setLimit = (limit: string) => setDraft(tab.id, { limit }) - const filterStatus = useMemo(() => parseObjectStatus(filter), [filter]) - const projectionStatus = useMemo(() => parseObjectStatus(projection), [projection]) - const sortStatus = useMemo(() => parseObjectStatus(sort), [sort]) - const pipelineStatus = useMemo(() => parsePipelineStatus(pipeline), [pipeline]) - const shellStatus = useMemo(() => parseShellStatus(shell, tab.coll), [shell, tab.coll]) + const filterStatus = useMemo(() => parseObjectStatus(draft.filter), [draft.filter]) + const projectionStatus = useMemo(() => parseObjectStatus(draft.projection), [draft.projection]) + const sortStatus = useMemo(() => parseObjectStatus(draft.sort), [draft.sort]) + const pipelineStatus = useMemo(() => parsePipelineStatus(draft.pipeline), [draft.pipeline]) + const { status: shellStatus, write: shellWrite } = useMemo( + () => parseShellDraft(draft.shell), + [draft.shell] + ) + const write = draft.mode === 'shell' ? shellWrite : null - const simpleInvalid = - filterStatus.kind === 'invalid' || - projectionStatus.kind === 'invalid' || - sortStatus.kind === 'invalid' - const modeInvalid = - tab.mode === 'simple' - ? simpleInvalid - : tab.mode === 'aggregation' - ? pipelineStatus.kind !== 'ok' - : shellStatus.kind !== 'ok' + const blockedReason = + draft.mode === 'simple' + ? (invalidReason('Filter', filterStatus) ?? + invalidReason('Projection', projectionStatus) ?? + invalidReason('Sort', sortStatus)) + : draft.mode === 'aggregation' + ? pipelineStatus.kind === 'empty' + ? 'Add a pipeline, e.g. [{ $match: { … } }]' + : invalidReason(null, pipelineStatus) + : shellStatus.kind === 'empty' + ? `Type a command, e.g. ${shellCollectionAccessor(tab.coll)}.find({})` + : invalidReason(null, shellStatus) const apply = (e?: FormEvent) => { e?.preventDefault() - if (modeInvalid || loading) return - // Bumping runEpoch guarantees a fresh fetch even when none of the - // mode-specific params changed (e.g. the user pressed Run twice on - // the same filter). Tab switches don't go through here. - const runEpoch = tab.runEpoch + 1 - if (tab.mode === 'simple') { - const limitNum = Number.parseInt(limit, 10) - const nextLimit = Number.isFinite(limitNum) && limitNum > 0 ? limitNum : 0 - onApply({ filter, projection, sort, skip: 0, limit: nextLimit, runEpoch }) - } else if (tab.mode === 'aggregation') { - onApply({ pipeline, skip: 0, runEpoch }) - } else { - onApply({ shell, skip: 0, runEpoch }) - } + if (blockedReason !== null || loading || writing) return + if (write) onRunWrite(write) + else run(tab.id) } const setMode = (mode: QueryMode) => { - if (mode === tab.mode) return - onApply({ mode }) + if (mode !== draft.mode) setDraft(tab.id, { mode }) } - const formatObject = (status: ObjectStatus, setter: (next: string) => void): void => { - if (status.kind !== 'ok') return - try { - setter(JSON.stringify(JSON.parse(status.ejson), null, 2)) - } catch { - // ok-status guarantees parseable EJSON. - } + const formatOptions: SerializeOptions = { timezone, uuidEncoding, indent: 2, printWidth: 80 } + const formatSource = (value: string, setter: (next: string) => void): void => { + const formatted = formatMongoSource(value, formatOptions) + if (formatted !== null) setter(formatted) } - const formatPipeline = () => { - if (pipelineStatus.kind !== 'ok') return - try { - setPipeline(JSON.stringify(JSON.parse(pipelineStatus.ejson), null, 2)) - } catch { - // unreachable - } + const formatShell = (): void => { + const formatted = formatShellCommand(draft.shell, formatOptions) + if (formatted !== null) setShell(formatted) } return (
- +
- {tab.mode === 'simple' && } - {loading ? ( + {draft.mode === 'simple' && } + {writing ? ( + + + + ) : loading ? ( )} - {tab.mode === 'simple' && ( + {draft.mode === 'simple' && ( )}
- {tab.mode === 'simple' && ( + {draft.mode === 'simple' && ( apply()} - formatObject={formatObject} + formatSource={formatSource} /> )} - {tab.mode === 'aggregation' && ( - apply()} - onFormat={formatPipeline} + onFormat={() => formatSource(draft.pipeline, setPipeline)} + minHeight={120} + maxHeight={400} + placeholder={ + '[\n { $match: { … } },\n { $group: { _id: "$type", n: { $sum: 1 } } }\n]' + } /> )} - {tab.mode === 'shell' && ( - apply()} + onFormat={formatShell} + minHeight={60} + maxHeight={240} + placeholder={`${shellCollectionAccessor(tab.coll)}.find({ … }).sort({ … }).limit(50)`} /> )}
@@ -241,6 +279,8 @@ function ModeTabs({ current, onChange }: { current: QueryMode; onChange: (m: Que } function SimpleBody({ + tabId, + completionEnv, filter, setFilter, filterStatus, @@ -251,19 +291,21 @@ function SimpleBody({ setProjection, projectionStatus, onSubmit, - formatObject + formatSource }: { + tabId: string + completionEnv: CompletionEnv filter: string setFilter: (next: string) => void - filterStatus: ObjectStatus + filterStatus: SourceStatus sort: string setSort: (next: string) => void - sortStatus: ObjectStatus + sortStatus: SourceStatus projection: string setProjection: (next: string) => void - projectionStatus: ObjectStatus + projectionStatus: SourceStatus onSubmit: () => void - formatObject: (status: ObjectStatus, setter: (next: string) => void) => void + formatSource: (value: string, setter: (next: string) => void) => void }) { // Sort and Projection share their height: whichever editor's content is // taller pushes the shorter one to match. Each reports its natural @@ -274,122 +316,53 @@ function SimpleBody({ return ( <> -
- setFilter(objOrDefault(next))} - onSubmit={onSubmit} - onFormat={() => formatObject(filterStatus, setFilter)} - hasError={filterStatus.kind === 'invalid'} - minHeight={32} - maxHeight={180} - placeholder='filter · { _id: ObjectId("…"), createdAt: { $gt: ISODate("2024-01-01") } } ⌘/Ctrl-Enter to run' - actions={ - <> - {filterStatus.kind === 'invalid' && } - formatObject(filterStatus, setFilter)} - /> - - } - /> -
+ formatSource(filter, setFilter)} + minHeight={32} + maxHeight={180} + placeholder='filter · { _id: ObjectId("…"), createdAt: { $gt: ISODate("2024-01-01") } } ⌘/Ctrl-Enter to run' + />
} label="Sort" placeholder="{ createdAt: -1 }" value={sort} onChange={setSort} onSubmit={onSubmit} - onFormat={() => formatObject(sortStatus, setSort)} + onFormat={() => formatSource(sort, setSort)} status={sortStatus} minHeight={sharedRowH} - onContentHeight={setSortContentH} + onContentHeightChange={setSortContentH} /> } label="Projection" placeholder="{ name: 1, _id: 0 }" value={projection} onChange={setProjection} onSubmit={onSubmit} - onFormat={() => formatObject(projectionStatus, setProjection)} + onFormat={() => formatSource(projection, setProjection)} status={projectionStatus} minHeight={sharedRowH} - onContentHeight={setProjContentH} + onContentHeightChange={setProjContentH} />
) } -function AggregationBody({ - pipeline, - setPipeline, - status, - onSubmit, - onFormat -}: { - pipeline: string - setPipeline: (next: string) => void - status: PipelineStatus - onSubmit: () => void - onFormat: () => void -}) { - return ( -
- setPipeline(arrOrDefault(next))} - onSubmit={onSubmit} - onFormat={onFormat} - hasError={status.kind === 'invalid'} - minHeight={120} - maxHeight={400} - placeholder={'[\n { $match: { … } },\n { $group: { _id: "$type", n: { $sum: 1 } } }\n]'} - actions={ - <> - {status.kind === 'invalid' && } - - - } - /> -
- ) -} - -function ShellBody({ - coll, - shell, - setShell, - status, - onSubmit -}: { - coll: string - shell: string - setShell: (next: string) => void - status: ShellStatus - onSubmit: () => void -}) { - return ( -
- {status.kind === 'invalid' && }} - /> -
- ) -} - function LimitInput({ value, onChange }: { value: string; onChange: (next: string) => void }) { return ( @@ -413,58 +386,54 @@ function LimitInput({ value, onChange }: { value: string; onChange: (next: strin function OptionRow({ icon, label, - placeholder, - value, - onChange, - onSubmit, - onFormat, - status, - minHeight, - onContentHeight -}: { - icon: React.ReactNode - label: string - placeholder: string - value: string - onChange: (next: string) => void - onSubmit: () => void - onFormat: () => void - status: ObjectStatus - minHeight: number - onContentHeight: (px: number) => void -}) { + ...editor +}: Omit & { icon: React.ReactNode; label: string }) { return (
{icon} {label}
-
- onChange(objOrDefault(next))} - onSubmit={onSubmit} - onFormat={onFormat} - hasError={status.kind === 'invalid'} - minHeight={minHeight} - maxHeight={120} - placeholder={placeholder} - onContentHeightChange={onContentHeight} - actions={ - <> - {status.kind === 'invalid' && } - - - } - /> -
+ +
+ ) +} + +type SourceEditorProps = { + modelPath: string + completion: CompletionBinding + value: string + onChange: (next: string) => void + status: SourceStatus + onSubmit: () => void + onFormat: () => void + minHeight: number + maxHeight: number + placeholder: string + onContentHeightChange?: (px: number) => void +} + +function SourceEditor({ status, onFormat, ...editor }: SourceEditorProps) { + return ( +
+ + {status.kind === 'invalid' && } + + + } + />
) } function FormatButton({ disabled, onClick }: { disabled: boolean; onClick: () => void }) { return ( - +
- updateSsh({ enabled: v })} - /> - {form.ssh.enabled && ( + + + + {form.tunnel === 'ssh' && ( )} + {viaCloudflare && ( + + )}
+
+ + +
+ +

+ MongoBench starts cloudflared when you connect and stops it when you disconnect. The driver + talks only to the server behind the tunnel, so replica-set discovery is off;{' '} + mongodb+srv:// is not supported. +

+ + ) +} + +/** Which cloudflared would run for `binaryPath` (blank = PATH), or why none can. */ +function CloudflaredVersionStatus({ binaryPath }: { binaryPath: string }) { + const runnable = binaryPath === '' || isCloudflaredExecutable(binaryPath) + const version = useQuery({ + queryKey: queryKeys.cloudflaredVersion(binaryPath), + queryFn: () => api.cloudflared.version(binaryPath === '' ? undefined : binaryPath), + enabled: runnable, + staleTime: 60_000 + }) + + if (!runnable) return {INVALID_CLOUDFLARED_PATH_MESSAGE} + if (version.isPending) return Looking for cloudflared… + if (version.isError) { + const error = version.error + return ( + + {error instanceof ApiError ? error.message : String(error)} + + ) + } + return ( + + {binaryPath === '' + ? `cloudflared ${version.data} found on PATH` + : `cloudflared ${version.data}`} + + ) +} + +function StatusLine({ + tone, + children +}: { + tone: 'pending' | 'ok' | 'error' + children: React.ReactNode +}) { + return ( +

+ {tone === 'pending' && } + {tone === 'ok' && } + {tone === 'error' && } + {children} +

+ ) +} + function Field({ label, htmlFor, @@ -847,23 +1165,25 @@ function ToggleRow({ label, hint, checked, + disabled, onCheckedChange }: { id: string label: string hint?: string checked: boolean + disabled?: boolean onCheckedChange: (v: boolean) => void }) { return (
-
- +
) } diff --git a/src/renderer/src/features/connections/TimezoneSelect.tsx b/src/renderer/src/features/connections/TimezoneSelect.tsx index 8e93a7d..946796f 100644 --- a/src/renderer/src/features/connections/TimezoneSelect.tsx +++ b/src/renderer/src/features/connections/TimezoneSelect.tsx @@ -51,7 +51,7 @@ export function TimezoneSelect({ value, onChange, id }: Props) { }, [open]) return ( -
+
diff --git a/src/renderer/src/features/explorer/CollectionLeaf.tsx b/src/renderer/src/features/explorer/CollectionLeaf.tsx index f66a00e..945c7dc 100644 --- a/src/renderer/src/features/explorer/CollectionLeaf.tsx +++ b/src/renderer/src/features/explorer/CollectionLeaf.tsx @@ -9,14 +9,15 @@ import { KeyRound, Loader2, Pencil, + SquarePlus, Table, Trash2 } from 'lucide-react' import { cn } from '@/lib/utils' import { useExplorerStore } from '@/store/explorer' -import { useTabsStore } from '@/store/tabs' +import { activeCollectionKey, collectionKey, useTabsStore } from '@/store/tabs' import { api, ApiError } from '@/lib/api' -import { queryKeys } from '@/lib/queryClient' +import { queryKeys, removeNamespaceQueries } from '@/lib/queryClient' import { ContextMenu, ContextMenuContent, @@ -53,11 +54,12 @@ export function CollectionLeaf({ name: string type: 'collection' | 'view' }) { + const target = { connectionId, db, coll: name } const open = useTabsStore((s) => s.open) + const openNew = useTabsStore((s) => s.openNew) const closeForCollection = useTabsStore((s) => s.closeForCollection) - const activeTabId = useTabsStore((s) => s.activeTabId) - const tabId = `${connectionId}::${db}::${name}` - const isActive = activeTabId === tabId + const key = collectionKey(target) + const isActive = useTabsStore((s) => activeCollectionKey(s) === key) const [dialog, setDialog] = useState(null) const queryClient = useQueryClient() const buttonRef = useRef(null) @@ -87,6 +89,7 @@ export function CollectionLeaf({ mutationFn: () => api.collections.drop({ connectionId, db, coll: name }), onSuccess: () => { closeForCollection(connectionId, db, name) + removeNamespaceQueries(queryClient, connectionId, db, name) void queryClient.invalidateQueries({ queryKey: queryKeys.collections(connectionId, db) }) toast.success(`Dropped ${db}.${name}`) setDialog(null) @@ -116,7 +119,14 @@ export function CollectionLeaf({ - open({ connectionId, db, coll: name })}> + open(target)}> Open + openNew(target)}> + + Open in new tab + setDialog('info')}> Info @@ -178,6 +192,7 @@ export function CollectionLeaf({ connectionId={connectionId} db={db} coll={name} + timezone={timezone} onClose={() => setDialog(null)} /> diff --git a/src/renderer/src/features/explorer/ConnectionGroup.tsx b/src/renderer/src/features/explorer/ConnectionGroup.tsx index e042ae4..6cde4fa 100644 --- a/src/renderer/src/features/explorer/ConnectionGroup.tsx +++ b/src/renderer/src/features/explorer/ConnectionGroup.tsx @@ -16,9 +16,8 @@ import { import { cn } from '@/lib/utils' import { useAppStore } from '@/store' import { useExplorerStore } from '@/store/explorer' -import { useTabsStore } from '@/store/tabs' import { api, ApiError } from '@/lib/api' -import { queryKeys } from '@/lib/queryClient' +import { queryKeys, releaseConnection } from '@/lib/queryClient' import { formatHostShort } from '@/lib/displayUri' import { ContextMenu, @@ -57,8 +56,6 @@ export function ConnectionGroup({ }: Props) { const isActive = useAppStore((s) => s.activeConnectionIds.has(connection.id)) const markConnected = useAppStore((s) => s.markConnected) - const markDisconnected = useAppStore((s) => s.markDisconnected) - const closeTabsForConnection = useTabsStore((s) => s.closeForConnection) const expanded = useExplorerStore((s) => s.expandedConnections.has(connection.id)) const expandConnection = useExplorerStore((s) => s.expandConnection) const collapseConnection = useExplorerStore((s) => s.collapseConnection) @@ -91,10 +88,7 @@ export function ConnectionGroup({ const disconnectMutation = useMutation({ mutationFn: () => api.connections.disconnect(connection.id), - onSuccess: () => { - markDisconnected(connection.id) - closeTabsForConnection(connection.id) - } + onSuccess: () => releaseConnection(queryClient, connection.id) }) const busy = connectMutation.isPending || disconnectMutation.isPending diff --git a/src/renderer/src/features/explorer/ConnectionsExplorer.tsx b/src/renderer/src/features/explorer/ConnectionsExplorer.tsx index fc5d513..f8029ca 100644 --- a/src/renderer/src/features/explorer/ConnectionsExplorer.tsx +++ b/src/renderer/src/features/explorer/ConnectionsExplorer.tsx @@ -17,9 +17,8 @@ import { import { ConnectionFormDialog } from '@/features/connections/ConnectionFormDialog' import { ConnectionGroup, type DragHandlers } from './ConnectionGroup' import { api, ApiError } from '@/lib/api' -import { queryKeys } from '@/lib/queryClient' -import { useAppStore } from '@/store' -import { useTabsStore } from '@/store/tabs' +import { queryKeys, releaseConnection } from '@/lib/queryClient' +import { reorderIds } from '@/lib/reorder' import type { ConnectionConfig } from '@shared/types' export function ConnectionsExplorer() { @@ -30,8 +29,6 @@ export function ConnectionsExplorer() { const [dragOverIdx, setDragOverIdx] = useState<{ id: string; pos: 'before' | 'after' } | null>( null ) - const markDisconnected = useAppStore((s) => s.markDisconnected) - const closeTabsForConnection = useTabsStore((s) => s.closeForConnection) const queryClient = useQueryClient() const { @@ -46,8 +43,7 @@ export function ConnectionsExplorer() { const deleteMutation = useMutation({ mutationFn: (id: string) => api.connections.delete(id), onSuccess: (_, id) => { - markDisconnected(id) - closeTabsForConnection(id) + releaseConnection(queryClient, id) void queryClient.invalidateQueries({ queryKey: queryKeys.connections }) toast.success(`${pendingDelete?.name ?? 'Connection'} deleted`) setPendingDelete(null) @@ -104,19 +100,13 @@ export function ConnectionsExplorer() { onDrop: (e) => { e.preventDefault() if (!dragId || !connections) return - const fromIdx = connections.findIndex((c) => c.id === dragId) - const overIdx = connections.findIndex((c) => c.id === id) - if (fromIdx < 0 || overIdx < 0 || fromIdx === overIdx) { - setDragId(null) - setDragOverIdx(null) - return - } - const ids = connections.map((c) => c.id) - ids.splice(fromIdx, 1) - const insertAt = - dragOverIdx?.pos === 'after' ? (fromIdx < overIdx ? overIdx : overIdx + 1) : overIdx - ids.splice(insertAt, 0, dragId) - reorderMutation.mutate(ids) + const ids = reorderIds( + connections.map((c) => c.id), + dragId, + id, + dragOverIdx?.pos ?? 'before' + ) + if (ids) reorderMutation.mutate(ids) setDragId(null) setDragOverIdx(null) } diff --git a/src/renderer/src/features/explorer/DatabaseGroup.tsx b/src/renderer/src/features/explorer/DatabaseGroup.tsx index 91db393..c46f705 100644 --- a/src/renderer/src/features/explorer/DatabaseGroup.tsx +++ b/src/renderer/src/features/explorer/DatabaseGroup.tsx @@ -12,7 +12,7 @@ import { Users } from 'lucide-react' import { api, ApiError } from '@/lib/api' -import { queryKeys } from '@/lib/queryClient' +import { queryKeys, removeNamespaceQueries } from '@/lib/queryClient' import { useExplorerStore } from '@/store/explorer' import { useTabsStore } from '@/store/tabs' import { Button } from '@/components/ui/button' @@ -83,6 +83,7 @@ function DatabaseRow({ connectionId, db }: { connectionId: string; db: DatabaseI mutationFn: () => api.databases.drop({ connectionId, db: db.name }), onSuccess: () => { closeForDatabase(connectionId, db.name) + removeNamespaceQueries(queryClient, connectionId, db.name) void queryClient.invalidateQueries({ queryKey: queryKeys.databases(connectionId) }) toast.success(`Dropped ${db.name}`) setDialog(null) diff --git a/src/renderer/src/features/palette/CommandPalette.tsx b/src/renderer/src/features/palette/CommandPalette.tsx index 7da0ad9..f85874c 100644 --- a/src/renderer/src/features/palette/CommandPalette.tsx +++ b/src/renderer/src/features/palette/CommandPalette.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' import { Database, Eye, Plug, Power, Server, Table } from 'lucide-react' import { Dialog, DialogContent } from '@/components/ui/dialog' import { @@ -11,8 +12,9 @@ import { CommandList } from '@/components/ui/command' import { notifyPinnedHostKey } from '@/features/connections/pinnedHostKeyToast' -import { api } from '@/lib/api' -import { queryKeys } from '@/lib/queryClient' +import { api, ApiError } from '@/lib/api' +import { formatConnectionAddress } from '@/lib/displayUri' +import { queryKeys, releaseConnection } from '@/lib/queryClient' import { useAppStore } from '@/store' import { useExplorerStore } from '@/store/explorer' import { useTabsStore } from '@/store/tabs' @@ -59,8 +61,6 @@ export function CommandPalette({ open, onOpenChange }: Props) { const queryClient = useQueryClient() const activeIds = useAppStore((s) => s.activeConnectionIds) const markConnected = useAppStore((s) => s.markConnected) - const markDisconnected = useAppStore((s) => s.markDisconnected) - const closeForConnection = useTabsStore((s) => s.closeForConnection) const openTab = useTabsStore((s) => s.open) const revealInExplorer = useExplorerStore((s) => s.reveal) @@ -166,7 +166,7 @@ export function CommandPalette({ open, onOpenChange }: Props) { if (!q) return list const scored: Array<{ c: ConnectionConfig; score: number }> = [] for (const c of list) { - const fields = [c.name, c.username ?? '', c.uri] + const fields = [c.name, c.username ?? '', formatConnectionAddress(c)] let best: number | null = null for (const f of fields) { const s = fuzzyScore(q, f.toLowerCase()) @@ -204,14 +204,14 @@ export function CommandPalette({ open, onOpenChange }: Props) { onOpenChange(false) if (activeIds.has(c.id)) { await api.connections.disconnect(c.id).catch(() => undefined) - markDisconnected(c.id) - closeForConnection(c.id) + releaseConnection(queryClient, c.id) } else { try { notifyPinnedHostKey(await api.connections.connect(c.id)) markConnected(c.id) - } catch { - // toast handled at row level on the sidebar + } catch (e: unknown) { + const message = e instanceof ApiError ? e.message : String(e) + toast.error(`Connect failed: ${message}`) } } }} @@ -277,7 +277,9 @@ function ConnectionRow({
{conn.name}
-
{conn.uri}
+
+ {formatConnectionAddress(conn)} +
s.tabs) const activeTabId = useTabsStore((s) => s.activeTabId) const activate = useTabsStore((s) => s.activate) const close = useTabsStore((s) => s.close) + const duplicate = useTabsStore((s) => s.duplicate) if (tabs.length === 0) return null @@ -17,9 +25,11 @@ export function TabBar() { activate(tab.id)} onClose={() => close(tab.id)} + onDuplicate={() => duplicate(tab.id)} /> ))}
@@ -29,51 +39,83 @@ export function TabBar() { function TabPill({ tab, + ordinal, active, onSelect, - onClose + onClose, + onDuplicate }: { tab: CollectionTab + ordinal: number | null active: boolean onSelect: () => void onClose: () => void + onDuplicate: () => void }) { + // Drafts change on every keystroke; this selector re-renders only the pill whose dot flips. + const dirty = useTabsStore((s) => { + const draft = s.drafts[tab.id] + return draft !== undefined && isDraftDirty(draft, tab.applied) + }) return ( -
{ - // Middle-click closes the tab. Prevent the default to suppress - // Windows' autoscroll cursor. - if (e.button === 1) { - e.preventDefault() - onClose() - } - }} - className={cn( - 'group relative flex shrink-0 cursor-pointer items-center gap-2 border-r px-3 text-xs', - active - ? 'border-b-0 bg-background font-medium text-foreground' - : 'text-muted-foreground hover:bg-accent/40 hover:text-foreground' - )} - > - {active && } - - {tab.db}. - {tab.coll} - - -
+ + +
{ + // Middle-click closes the tab. Prevent the default to suppress + // Windows' autoscroll cursor. + if (e.button === 1) { + e.preventDefault() + onClose() + } + }} + className={cn( + 'group relative flex shrink-0 cursor-pointer items-center gap-2 border-r px-3 text-xs', + active + ? 'border-b-0 bg-background font-medium text-foreground' + : 'text-muted-foreground hover:bg-accent/40 hover:text-foreground data-[state=open]:bg-accent/40' + )} + > + {active && } + + {tab.db}. + {tab.coll} + {ordinal !== null && #{ordinal}} + + +
+
+ + + + Duplicate tab + + + + + Close tab + + +
) } diff --git a/src/renderer/src/features/users/ManageUsersDialog.tsx b/src/renderer/src/features/users/ManageUsersDialog.tsx index 89db726..3d3fc1e 100644 --- a/src/renderer/src/features/users/ManageUsersDialog.tsx +++ b/src/renderer/src/features/users/ManageUsersDialog.tsx @@ -45,8 +45,10 @@ import { type PasswordOptions } from '@/lib/passwordGenerator' import { queryKeys } from '@/lib/queryClient' +import { diffRoles, orderRoles, roleKey, roleLabel } from '@/lib/userRoles' import { cn } from '@/lib/utils' import type { DatabaseUser, DatabaseUserRole } from '@shared/types' +import { RoleEditor } from './RoleEditor' type Props = { open: boolean @@ -57,15 +59,7 @@ type Props = { type FormMode = { kind: 'list' } | { kind: 'create' } | { kind: 'edit'; user: DatabaseUser } -const COMMON_DB_ROLES = ['read', 'readWrite', 'dbAdmin', 'dbOwner', 'userAdmin'] as const - -const ROLE_LABEL: Record = { - read: 'Read', - readWrite: 'Read & write', - dbAdmin: 'DB admin', - dbOwner: 'DB owner', - userAdmin: 'User admin' -} +const ROLE_PREVIEW_LIMIT = 6 export function ManageUsersDialog({ open, connectionId, db, onClose }: Props) { const [mode, setMode] = useState({ kind: 'list' }) @@ -98,7 +92,7 @@ export function ManageUsersDialog({ open, connectionId, db, onClose }: Props) { return ( !o && onClose()}> - + {mode.kind !== 'list' && ( @@ -114,7 +108,9 @@ export function ManageUsersDialog({ open, connectionId, db, onClose }: Props) { )} {mode.kind === 'list' && <>Manage users} {mode.kind === 'create' && <>New user} - {mode.kind === 'edit' && <>Edit user — {mode.user.user}} + {mode.kind === 'edit' && ( + Edit user — {mode.user.user} + )} {db} @@ -136,6 +132,7 @@ export function ManageUsersDialog({ open, connectionId, db, onClose }: Props) { mode="create" connectionId={connectionId} db={db} + users={usersQuery.data ?? []} onDone={() => setMode({ kind: 'list' })} /> )} @@ -146,6 +143,7 @@ export function ManageUsersDialog({ open, connectionId, db, onClose }: Props) { connectionId={connectionId} db={db} existing={mode.user} + users={usersQuery.data ?? []} onDone={() => setMode({ kind: 'list' })} /> )} @@ -226,7 +224,7 @@ function UsersListView({ } return ( -
+
{users.length} user{users.length === 1 ? '' : 's'} @@ -240,10 +238,10 @@ function UsersListView({ No users defined in {db}.
) : ( -
+
- - + + @@ -252,24 +250,11 @@ function UsersListView({ {users.map((u) => ( - - + -
User Roles
{u.user} -
- {u.roles.map((r, i) => ( - - {r.role} - @{r.db} - - ))} - {u.roles.length === 0 && ( - no roles - )} -
+
{u.user} + +
+ )} +
+ ) +} + function UserForm({ mode, connectionId, db, existing, + users, onDone }: { mode: 'create' | 'edit' connectionId: string db: string existing?: DatabaseUser + users: DatabaseUser[] onDone: () => void }) { - const initial = useMemo(() => { - if (mode === 'edit' && existing) { - const selected = new Set() - const others: DatabaseUserRole[] = [] - for (const r of existing.roles) { - if (r.db === db && (COMMON_DB_ROLES as readonly string[]).includes(r.role)) { - selected.add(r.role) - } else { - others.push(r) - } - } - return { - username: existing.user, - selectedRoles: selected, - customRoles: others.length ? JSON.stringify(others, null, 2) : '' - } - } - return { username: '', selectedRoles: new Set(), customRoles: '' } - }, [mode, existing, db]) - - const [username, setUsername] = useState(initial.username) + const initialRoles = useMemo(() => existing?.roles ?? [], [existing]) + const [username, setUsername] = useState(existing?.user ?? '') const [password, setPassword] = useState('') const [generatorOpen, setGeneratorOpen] = useState(false) - const [selectedRoles, setSelectedRoles] = useState>(new Set(initial.selectedRoles)) - const [customRoles, setCustomRoles] = useState(initial.customRoles) + const [roles, setRoles] = useState(initialRoles) const [serverError, setServerError] = useState(null) const queryClient = useQueryClient() - const customRolesParse = useMemo(() => { - if (customRoles.trim().length === 0) - return { ok: true as const, roles: [] as DatabaseUserRole[] } - try { - const parsed = JSON.parse(customRoles) - if (!Array.isArray(parsed)) return { ok: false as const, error: 'Must be a JSON array' } - const roles: DatabaseUserRole[] = [] - for (const r of parsed) { - if (typeof r !== 'object' || r === null || Array.isArray(r)) { - return { ok: false as const, error: 'Each entry must be an object' } - } - const role = (r as Record)['role'] - const roleDb = (r as Record)['db'] - if (typeof role !== 'string' || typeof roleDb !== 'string') { - return { ok: false as const, error: 'Each entry needs `role` and `db` strings' } - } - roles.push({ role, db: roleDb }) - } - return { ok: true as const, roles } - } catch (e) { - return { ok: false as const, error: e instanceof Error ? e.message : 'Invalid JSON' } - } - }, [customRoles]) + const diff = useMemo(() => diffRoles(initialRoles, roles), [initialRoles, roles]) + const rolesChanged = diff.added.length + diff.removed.length > 0 + // The server matches existing users by their exact name, whitespace included. + const targetUser = existing?.user ?? username.trim() const validation = useMemo(() => { if (username.trim().length === 0) return 'Username is required' if (mode === 'create' && password.length === 0) return 'Password is required' - if (!customRolesParse.ok) return `Custom roles: ${customRolesParse.error}` + if (mode === 'edit' && !rolesChanged && password.length === 0) return 'No changes to save' return null - }, [username, password, mode, customRolesParse]) - - const allRoles = (): DatabaseUserRole[] => { - if (!customRolesParse.ok) return [] - const own = Array.from(selectedRoles).map((role) => ({ role, db })) - return [...own, ...customRolesParse.roles] - } + }, [username, password, mode, rolesChanged]) const createMutation = useMutation({ mutationFn: () => @@ -386,7 +364,7 @@ function UserForm({ db, username: username.trim(), password, - roles: allRoles() + roles }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.users(connectionId, db) }) @@ -403,13 +381,13 @@ function UserForm({ api.users.update({ connectionId, db, - username: username.trim(), + username: targetUser, password: password.length > 0 ? password : null, - roles: allRoles() + roles: rolesChanged ? roles : null }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.users(connectionId, db) }) - toast.success(`User ${username.trim()} updated`) + toast.success(`User ${targetUser} updated`) onDone() }, onError: (e: unknown) => { @@ -427,111 +405,67 @@ function UserForm({ const busy = createMutation.isPending || updateMutation.isPending - const toggleRole = (role: string) => - setSelectedRoles((prev) => { - const next = new Set(prev) - if (next.has(role)) next.delete(role) - else next.add(role) - return next - }) - return ( - -
-
- - setUsername(e.target.value)} - disabled={mode === 'edit'} - spellCheck={false} - className="font-mono" - autoFocus={mode === 'create'} - /> -
-
- -
+ +
+
+
+ setPassword(e.target.value)} - autoComplete="new-password" - placeholder={mode === 'edit' ? 'Leave blank to keep current' : ''} + id="user-name" + value={username} + onChange={(e) => setUsername(e.target.value)} + disabled={mode === 'edit'} + spellCheck={false} + className="font-mono" + autoFocus={mode === 'create'} /> -
-
-
- - {generatorOpen && } - -
- -
- {COMMON_DB_ROLES.map((role) => ( -
-
-
- -