diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d6e31e59b..d1c1386de 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -192,6 +192,7 @@ For Discovery View, both `treeId` and `clusterId` are sanitized (all `/` replace See `src/tree/models/BaseClusterModel.ts` and `docs/analysis/08-cluster-model-simplification-plan.md` for details. +- [skills/tree-cluster-architecture/SKILL.md](skills/tree-cluster-architecture/SKILL.md) - Required patterns for cluster tree items, dual identity, provider lookup, and regression tests - [skills/telemetry-instrumentation/SKILL.md](skills/telemetry-instrumentation/SKILL.md) - Telemetry instrumentation patterns ## Terminology @@ -226,4 +227,5 @@ For detailed patterns, see: - [instructions/typescript.instructions.md](instructions/typescript.instructions.md) - TypeScript patterns and anti-patterns - [instructions/wizard.instructions.md](instructions/wizard.instructions.md) - AzureWizard implementation details +- [skills/tree-cluster-architecture/SKILL.md](skills/tree-cluster-architecture/SKILL.md) - Cluster tree items, identity, lookup, and test contracts - [skills/telemetry-instrumentation/SKILL.md](skills/telemetry-instrumentation/SKILL.md) - Telemetry instrumentation patterns diff --git a/.github/skills/tree-cluster-architecture/SKILL.md b/.github/skills/tree-cluster-architecture/SKILL.md new file mode 100644 index 000000000..3ac86a8c3 --- /dev/null +++ b/.github/skills/tree-cluster-architecture/SKILL.md @@ -0,0 +1,198 @@ +--- +name: tree-cluster-architecture +description: Patterns for cluster nodes and tree data providers in vscode-documentdb. Use when adding or changing a cluster tree item, discovery provider, Connections/Azure/Discovery tree hierarchy, tree/list mode, cluster identity, treeId/clusterId lookup, Collection View import/export resolution, reveal behavior, credentials, shell actions, or copy connection string support. +--- + +# Tree Cluster Architecture + +Use this skill whenever a tree node represents a database cluster or a hierarchy change can move a cluster node. + +## Non-Negotiable Rule + +Every **browsable cluster node** must extend `ClusterItemBase` from `src/tree/documentdb/ClusterItemBase.ts`. + +Do not implement a cluster as a plain `TreeElement` or `createGenericElement*` node. Hand-rolled cluster nodes silently lose shared behavior and force each feature to reimplement it: + +- database and collection expansion +- credential/client cache integration +- retry and open-shell recovery nodes +- the canonical `treeItem_documentdbcluster` context tag +- standard command/menu eligibility +- `getCredentials()` used by Copy Connection String and Save/Add to Connections flows +- consistent connection progress, cancellation, and error handling + +Generic tree elements are appropriate for structural parents, placeholders, actions, and **non-browsable state rows**. When a state becomes browsable, render a `ClusterItemBase` subclass. + +## Implementing a Cluster Item + +1. Define a model extending `BaseClusterModel`. +2. Construct a `TreeCluster` containing both stable identity and tree position. +3. Extend `ClusterItemBase` and call `super(cluster)`. +4. Implement: + - `getCredentials(): Promise` + - `authenticateAndConnect(): Promise` +5. Optionally override: + - `beforeCachedClientConnect()` for tunnels or reachability preparation + - presentation through `descriptionOverride`, `tooltipOverride`, `iconPath`, or a justified `getTreeItem()` override +6. Preserve the base context value. Add feature tags with `createContextValue([this.contextValue, ...extraTags])`; never replace it with a string that omits `treeItem_documentdbcluster`. +7. Use `this.cluster.clusterId` for `CredentialCache` and `ClustersClient`, never `this.id`. + +Representative subclasses: + +- Stored connection: `src/tree/connections-view/DocumentDBClusterItem.ts` +- Kubernetes discovery: `src/plugins/service-kubernetes/discovery-tree/documentdb/KubernetesResourceItem.ts` +- Atlas discovery: `src/plugins/service-atlas-mongodb/discovery-tree/AtlasClusterItem.ts` +- Managed synthetic cluster: `QuickStartClusterItem` in `src/tree/connections-view/LocalQuickStart/LocalQuickStartItem.ts` + +## Dual Identity Contract + +A cluster has two IDs with different ownership: + +| ID | Meaning | Stability | Valid uses | +| ----------- | ---------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------- | +| `clusterId` | Stable resource/cache identity | Must survive tree moves and layout changes | credentials, clients, sessions, serialized webview context, reverse lookup input | +| `treeId` | Current hierarchical tree position | May change with folders, parents, or view mode | `TreeElement.id`, child IDs, reveal/navigation | + +Rules: + +- `clusterId` must not contain `/`. +- `treeId` must represent the node's actual rendered path. +- `viewId` must identify the branch provider that owns the rendered node. +- Child IDs derive from `treeId`, using helpers such as `buildDatabaseTreeId()` and `buildCollectionTreeId()`. +- Never cache by `treeId` or `this.id`. +- Never assume a stable ID is also a tree path unless the provider explicitly guarantees `clusterId === treeId`. +- Never change tree construction without checking reverse lookup from `clusterId`. + +## Design Rendering and Lookup Together + +When adding a cluster hierarchy or a tree/list mode, specify these four functions before coding: + +1. **Stable identity:** How is `clusterId` built and made collision-safe? +2. **Rendering:** How is `treeId` built in every layout? +3. **Ownership:** Which branch provider receives `viewId` and `clusterId` later? +4. **Reverse lookup:** How does that provider recover the current `treeId` and then the collection node? + +The round trip must hold: + +```text +rendered cluster + -> { clusterId, treeId, viewId } + -> open Collection View with { clusterId, viewId } + -> owning provider resolves current treeId + -> provider resolves // +``` + +### Connections View + +Persisted connections use `clusterId = storageId`. Reconstruct the current folder path from storage with `buildFullTreePath()`; do not persist or guess an old `treeId`. + +Synthetic nodes are not in connection storage. Their feature must own: + +- an exact, side-effect-free ownership predicate +- tree path builders used by rendering and reveal code +- stable-ID-to-tree-ID resolution + +The Connections provider may dispatch to that feature before falling back to persisted storage. Classify ownership **before invoking feature code**, so a feature resolver failure cannot break ordinary stored connections. See: + +- `src/tree/connections-view/resolveConnectionsClusterTreeId.ts` +- `src/tree/connections-view/LocalQuickStart/quickStartTreeIdentity.ts` + +Do not put feature-specific ID prefixes or synthetic paths into `buildFullTreePath()`. + +### Discovery View + +`clusterId` is provider-prefixed. The current `DiscoveryBranchDataProvider` removes that prefix and finds a cached tree node by its final suffix. Therefore every layout for that provider must end the cluster `treeId` with the same stable unprefixed suffix. + +- Preserve visible hierarchy in preceding path segments. +- Use one helper to build the stable suffix for both `clusterId` and every tree layout. +- Make the suffix collision-safe within the provider. +- Test tree and list modes separately. +- Do not rely on cluster display names alone when uniqueness is scoped by project, namespace, context, or source. + +This suffix lookup is a current compatibility contract, not an ideal general identity mechanism. Issue #869 tracks exact stable-identity lookup. Until that changes, new Discovery providers must satisfy the suffix contract. + +### Azure Resources View + +Direct lookup is valid only where the provider intentionally guarantees `clusterId === treeId`. Document that invariant and test it. + +## Provider Responsibilities + +Branch providers must implement both when their clusters can open Collection View: + +- `findClusterNodeByClusterId(clusterId)` +- `findCollectionByClusterId(clusterId, databaseName, collectionName)` + +Prefer one shared cluster-ID-to-tree-ID resolver so cluster and collection lookup cannot drift. Once the cluster node is known, use scoped child lookup to avoid expanding unrelated branches. + +Do not silently fall back between providers or storage zones unless identity ownership is explicit. An owned but currently unavailable synthetic cluster should return `undefined`, not masquerade as a persisted connection. + +## Required Tests for New Cluster Nodes or Layouts + +Add focused tests before considering the feature complete. + +### 1. Base cluster behavior + +- The browsable item is a `ClusterItemBase` subclass. +- Its context value retains `treeItem_documentdbcluster` plus feature tags. +- `getCredentials()` is implemented for commands that need connection material. +- Expansion returns database nodes and base failure paths return retry/open-shell nodes as applicable. + +Use `src/tree/documentdb/ClusterItemBase.test.ts` as the base-behavior reference. + +### 2. Identity invariants + +- `clusterId` is stable and slash-free. +- Moving folders or switching layout changes only `treeId`, not `clusterId`. +- IDs remain unique where display names can repeat. +- Cache operations use `clusterId`, not `treeId`. + +Use `src/tree/connections-view/models/ConnectionClusterModel.test.ts` as a reference. + +### 3. Render-to-lookup round trip (mandatory) + +Construct the cluster through the **real parent/root item**, not only as a model fixture, then assert: + +```typescript +expect(rendered.cluster.clusterId).toBe(expectedStableId); +expect(rendered.cluster.treeId).toBe(expectedTreePath); +expect(await provider.findClusterNodeByClusterId(rendered.cluster.clusterId)).toBe(rendered); +expect(await provider.findCollectionByClusterId(rendered.cluster.clusterId, databaseName, collectionName)).toBe( + expectedCollection, +); +``` + +This test must exist for every materially different layout: root/folder, tree/list, or other synthetic hierarchy. It would have caught both the Atlas suffix mismatch and the Quick Start storage-path mismatch. + +### 4. Ownership isolation for synthetic nodes + +- Exact owned ID uses the feature resolver. +- Ordinary IDs never invoke the feature resolver. +- Owned-but-unavailable IDs do not fall through to persisted lookup. +- A feature resolver failure cannot affect ordinary persisted lookup. + +Use `src/tree/connections-view/resolveConnectionsClusterTreeId.test.ts` as a reference. + +### 5. Command compatibility + +At minimum, verify the context contract and `getCredentials()` path needed by: + +- Copy Connection String +- Open Shell +- Save/Add to Connections when enabled + +If a command is intentionally unavailable, gate it explicitly with context values and document why; do not omit base behavior accidentally. + +## Review Checklist + +Before approving a cluster tree change, answer yes to all: + +- [ ] Browsable cluster extends `ClusterItemBase`. +- [ ] Base cluster context tag is preserved. +- [ ] `clusterId`, `treeId`, and `viewId` have documented owners. +- [ ] Credentials and clients use `clusterId` only. +- [ ] Every rendered layout has a reverse lookup strategy. +- [ ] Cluster and collection lookup share one resolver. +- [ ] Synthetic ownership is exact and isolated from stored lookup. +- [ ] IDs are collision-safe beyond display names. +- [ ] A real render-to-lookup round-trip test covers every layout. +- [ ] Standard cluster commands work or are explicitly gated off. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bca3cf3f0..1c7d62535 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,7 +2,7 @@ name: CI # This workflow handles the following scenarios: # -# 1. Push to `main` or `release/**`: +# 1. Push to `main`: # - Runs all jobs: code checks, tests, packaging, and caches build sizes # for PR comparisons # @@ -25,7 +25,6 @@ on: push: branches: - main - - release/** pull_request: branches: @@ -34,11 +33,7 @@ on: - feature/** concurrency: - # Use head_ref for PRs, ref_name for pushes. When a forward-merge PR - # (release/X.Y → main) is open, both the push to release/X.Y and the - # PR trigger resolve to the same group (release/X.Y) so they cancel - # each other instead of running CI twice. - group: ${{ github.head_ref || github.ref_name }} + group: '${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}' cancel-in-progress: true jobs: diff --git a/README.md b/README.md index 2bc8a46a1..0456bc9b0 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Connect to any database that speaks the MongoDB API wire protocol. - **Azure Service Discovery**: Browse and connect to Azure DocumentDB, Azure Cosmos DB for MongoDB (RU), and DocumentDB on Azure VMs directly from the sidebar - **MongoDB Atlas**: Connect using your Atlas connection string - **Entra ID authentication**: Multi-account, multi-tenant support for Azure-hosted databases -- **Local instances and emulators**: Connect to DocumentDB Local, Azure Cosmos DB Emulator, or any local MongoDB API instance +- **Local instances and emulators**: [Create and manage DocumentDB Local with Docker Quick Start](docs/user-manual/local-quick-start.md), or connect to Azure Cosmos DB Emulator and other local MongoDB API instances - **Folder organization**: Group your connections into folders and subfolders ## Browse and Manage Data diff --git a/docs/ai-and-plans/PRs/653-local-quickstart-design/description.md b/docs/ai-and-plans/PRs/653-local-quickstart-design/description.md new file mode 100644 index 000000000..a3f5c24b5 --- /dev/null +++ b/docs/ai-and-plans/PRs/653-local-quickstart-design/description.md @@ -0,0 +1,79 @@ +# PR #653: Local DocumentDB Quick Start — Design Decisions + +**Status:** Open +**Branch:** `guanzhou/local-quickstart-design` +**Base:** `main` +**Date:** 2026-06-15 + +## Why this note + +This PR adds a design doc only (no shipping code). Before implementation we +benchmarked the design against a proven, shipping reference — the +**PostgreSQL extension's "Local Docker Server"** flow (`ms-ossdata.vscode-pgsql`, +read at the source level). This note records the **non-obvious decisions**: +where we deliberately diverge from that reference and why, where we match it, +and the deviations that override shared/base behavior inside our own codebase. +It exists so human reviewers and review agents don't have to re-derive the +rationale — or mistake a deliberate deviation for an oversight. + +Full design: [`../../local-quickstart/local-quickstart-v2.md`](../../local-quickstart/local-quickstart-v2.md) +(iteration 2, supersedes iteration 1). Review-resolution map is in v2 §18. + +## Reference: PostgreSQL "Local Docker Server" + +A 3-page webview (Home → Prereqs → Create form) that checks Docker (CLI + +daemon) via VS Code tasks, runs a **required-field** form, creates a container +through `@microsoft/vscode-container-client`, passes credentials via a temp +`--env-file`, waits for `pg_isready` **inside** the container, then saves + +reveals the connection and auto-closes the webview. It is **create-and-connect +only** — no persistent volume, no lifecycle (stop/start/delete), no +labels/adopt. The "Easy Management" welcome copy is marketing; no such +commands exist in its source. + +## Deliberate deviations from the reference (non-obvious — keep) + +| We do | PG does | Why we deviate | +| ----------------------------------------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------- | +| **Zero required fields** (generate creds/names) | 3 required fields | First-run friction is the thing to remove; PG even shipped a "password required before start" bug. | +| **Persistent named volume** | No volume (data lost on `rm`) | A local dev DB that silently loses data on container removal is a footgun. | +| **Wire-protocol readiness, 60 s** | `pg_isready` in-container, 5 s | DocumentDB has no in-image readiness CLI we can rely on; PG's 5 s is fragile on a cold `initdb`. | +| **Full lifecycle (7 states; stop/start/delete/logs)** | None (create-only) | The managed-instance tree is our core value; PG only _markets_ lifecycle. | +| **tRPC + FluentUI** | mssql-fork custom RPC | Matches this repo's webview stack (CollectionView/DocumentView); we are not a fork. | +| **Stricter telemetry** (resolved semver only) | sends registry/image/tag | Avoid leaking image/registry identifiers. | +| **Docker labels + adopt** | name-only refuse | Lets us recognize and re-attach containers we created. | + +## Changes folded back into v2 from the PG study + +- **v1.0 create-progress = terminal task + button spinner + auto-close** (the + in-webview multi-step progress card is descoped to v1.1). PG proves the + terminal-task model ships without streaming `docker pull` % into a webview. +- **Adopt `@microsoft/vscode-container-client`** (the runtime layer PG uses; + ships both `DockerClient` and `PodmanClient`) instead of a hand-rolled + abstraction → makes "OCI/podman later" a driver swap, not a rewrite. +- **Port rule:** only auto-fallback the _default_ port; never silently relocate + a port the user typed in Advanced (match PG's intent-respecting behavior). +- **Read the real bound host port from `docker inspect`** before composing the + saved connection string (don't trust the requested port; matters with fallback). +- **Distinguish failed-to-create vs failed-to-start** in error copy (cheap, via inspect). +- **Pre-create duplicate check on both connection name and container name.** + +## Deviations that override shared/base behavior (flag for reviewers) + +These are the easy-to-miss ones — we extend or override shared infrastructure +rather than add isolated code: + +1. **TLS exception folded into the shared new-connection wizard** (gated to + localhost / private hosts) instead of a separate emulator wizard. This + overrides the shared wizard with a conditional step, and the old + `New Local Connection...` entry point is removed. +2. **Canonical port `10260` overrides the shared wizard's hardcoded `10255`** + (`PromptConnectionTypeStep.ts`, `PromptPortStep.ts`). This is a pre-ship fix + that changes existing manual-connection defaults — not Quick-Start-local. +3. **Legacy migration mutates the shared `ConnectionType.Emulators` storage + zone** (moves entries into a `Local Connections (Legacy)` folder; the old + zone is kept read-only for one release before removal as a rollback path). + +## Status + +Design only; no implementation in this PR. Open questions are tracked in +v2 §17; the full reviewer-comment → resolution map is in v2 §18. diff --git a/docs/ai-and-plans/PRs/732-index-dashboard/README.md b/docs/ai-and-plans/PRs/732-index-dashboard/README.md new file mode 100644 index 000000000..b0f37a499 --- /dev/null +++ b/docs/ai-and-plans/PRs/732-index-dashboard/README.md @@ -0,0 +1,82 @@ +# PR #732 — Index Management tab (Index Dashboard) + +**PR:** [microsoft/vscode-documentdb#732](https://github.com/microsoft/vscode-documentdb/pull/732) +· **Branch:** `dev/khelanmodi/index-management-ui` · **Base:** `main` + +This folder is the working archive for PR #732 (adds an **Indexes** tab to the +CollectionView). The documents accumulated over several stages — a design intro, a +CollectionView chrome redesign, wildcard/vector index support, two rounds of UX review, +and a code review. This index exists so a reviewer can find the right document without +reading all of them. + +> **Nothing here has been rewritten or merged.** The files were only renamed into the +> category-prefixed scheme below (and their cross-links updated) to make navigation easier. +> Each document remains the source of truth for its own topic and history. + +--- + +## How the work unfolded (reading order) + +1. **Feature intro & final design** — the overview and the decisions that shipped + ([feature-01](./feature-01-index-management-overview.md)). +2. **CollectionView chrome redesign** — moving the tab strip first and scoping the action + bar per tab ([feature-02](./feature-02-collectionview-toolbar-redesign.md)). +3. **Wildcard & vector index support** — the later index-family work + ([feature-03](./feature-03-vector-index-support.md)). +4. **UX review, iterations 1–2** — the first hands-on review pass and its fixes + ([ux-review-iteration-1-2](./ux-review-iteration-1-2.md)). +5. **UX review, iteration 3** — the follow-up review of the redesigned create drawer + ([ux-review-iteration-3](./ux-review-iteration-3-create-index-redesign.md)). +6. **Code review** — the technical/correctness review and its resolutions + ([code-review-2026-07-20](./code-review-2026-07-20.md)). + +Reference material ([reference-01](./reference-01-documentdb-supported-indexes.md), +[reference-02](./reference-02-operator-registry-scraper.md)) underpins the index metadata +used throughout and can be read on demand. + +--- + +## Files at a glance + +### Feature discussions + +| File | What it covers | +| ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [feature-01-index-management-overview.md](./feature-01-index-management-overview.md) | Consolidated design log: what shipped (index list/metrics, create drawer, advanced editors, confirmations, row status, previews), the **tried-and-abandoned** decisions, the dev-tooling `ResizeObserver`/CSP discovery, and follow-ups. Start here. | +| [feature-02-collectionview-toolbar-redesign.md](./feature-02-collectionview-toolbar-redesign.md) | The CollectionView chrome redesign — tab strip first, contextual per-tab action bar, layout/responsive plan, implementation progress, and the full-bleed chrome + SCSS refactor. | +| [feature-03-vector-index-support.md](./feature-03-vector-index-support.md) | Wildcard and vector index support — vector index concepts, service algorithms (IVF / HNSW / DiskANN), shared settings, commands, the proposed Vector drawer and typed model, validation rules, implementation progress, and the Atlas Search Index future reference. | + +### UX reviews (by iteration) + +| File | Iteration(s) | Date | +| -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| [ux-review-iteration-1-2.md](./ux-review-iteration-1-2.md) | Iterations 1–2 — the original UX review pack (findings 1–8 + J1), operator decisions, and the fixes implemented across both iterations. | 2026-07-22 | +| [ux-review-iteration-3-create-index-redesign.md](./ux-review-iteration-3-create-index-redesign.md) | Iteration 3 — follow-up review of the redesigned Standard / Wildcard / Vector create drawer, plus a carry-forward reconciliation of the earlier review's items. | 2026-07-27 | + +### Code review + +| File | What it covers | +| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [code-review-2026-07-20.md](./code-review-2026-07-20.md) | Technical/correctness review with an independent verifier re-assessment. Findings HIGH-1, MEDIUM-1…4, LOW-1…5, plus deeper-pass items NEW-1…4, each with severity, options, and a resolution (with commit links). | + +### Reference + +| File | What it covers | +| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| [reference-01-documentdb-supported-indexes.md](./reference-01-documentdb-supported-indexes.md) | DocumentDB-supported index types and properties (from documentation scraping) and how they map to the extension. | +| [reference-02-operator-registry-scraper.md](./reference-02-operator-registry-scraper.md) | The operator-registry scraper migration and the index type/property metadata it exposes to the webview. | + +--- + +## Finding things fast + +- **Why a decision was made (and what was rejected):** the "Tried and abandoned" section of + [feature-01](./feature-01-index-management-overview.md#tried-and-abandoned-and-why). +- **A specific UX finding and its fix:** the priority index in + [ux-review-iteration-1-2](./ux-review-iteration-1-2.md#priority-index) (findings 1–8) or + [ux-review-iteration-3](./ux-review-iteration-3-create-index-redesign.md#priority-index) + (create-drawer redesign). +- **A correctness/severity concern and its resolution:** the severity summary in + [code-review-2026-07-20](./code-review-2026-07-20.md#severity-summary). +- **Vector index behavior and open decisions:** + [feature-03](./feature-03-vector-index-support.md#open-decisions). diff --git a/docs/ai-and-plans/PRs/732-index-dashboard/code-review-2026-07-20.md b/docs/ai-and-plans/PRs/732-index-dashboard/code-review-2026-07-20.md new file mode 100644 index 000000000..35b37750b --- /dev/null +++ b/docs/ai-and-plans/PRs/732-index-dashboard/code-review-2026-07-20.md @@ -0,0 +1,536 @@ +# PR #732 Review: Index Management tab + +Review date: 2026-07-20 + +PR: https://github.com/microsoft/vscode-documentdb/pull/732 + +## Severity Summary + +| Severity | Count | Notes | +| -------- | ----: | --------------------------------------------------------------------------------------------------------------------------------------- | +| Critical | 0 | No extension-wide outage or confirmed broad data-loss path found. | +| High | 1 | TTL input can be silently converted to a much shorter retention period. | +| Medium | 4 | Three index correctness/resilience issues plus one voice-control failure on the primary Create Index action. | +| Low | 5 | One stale-refresh race plus inaccessible tooltip details, progress-bar noise, a missing defense check, and user-visible generated text. | + +## Verifier Re-Assessment (independent pass, 2026-07-21) + +Every finding below was independently re-verified against the current branch by reading the cited source. **No false alarms were found — all 10 findings are real.** Each finding now carries an inline **Verifier assessment** block with a corrected severity, concrete solution options (with examples), a pro/con evaluation, and a recommended approach. Two additional issues surfaced during the deeper second pass and are recorded in [Deeper Review (Independent Second Pass)](#deeper-review-independent-second-pass). + +Severity deltas from the original review: + +| Finding | Original | Verifier severity | Rationale for change | +| -------- | -------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| HIGH-1 | High | **Medium** | Impact is severe (data expiry) but `type="number"` already blocks separators/letters; realistic triggers (exponent/decimal) are rare. | +| MEDIUM-1 | Medium | Medium (agree) | Clear, high-confidence inconsistency with `createIndex`/`dropIndex`. | +| MEDIUM-2 | Medium | **Low–Medium** | Real silent-collapse, but low happy-path likelihood. | +| MEDIUM-3 | Medium | Medium (agree) | Auto-poll halts on one transient failure; manual refresh recovers. | +| MEDIUM-4 | Medium | **Low** | Valid WCAG 2.5.3 (A) violation, but single control and voice-input-only impact. | +| LOW-1..5 | Low | Low (agree) | All confirmed; severities unchanged. | + +New deeper-review items: **NEW-1** (shell/playground command loses BSON fidelity, Low), **NEW-2** (`"*"` index-name defense-in-depth, Low/security-adjacent), plus two informational notes. + +## Review Scope + +The review compared `dev/khelanmodi/index-management-ui` with `origin/main` and used the design context in this folder: + +- `feature-01-index-management-overview.md` +- `reference-01-documentdb-supported-indexes.md` +- `reference-02-operator-registry-scraper.md` + +The intended review bar is the documented **80% happy path**. Deliberately unsupported index types, deep index-build telemetry, stronger typed delete confirmation, and smart JSON completion are not treated as defects. Webview end-to-end test coverage is also explicitly excluded because it is already on the roadmap. + +The review focused on user input crossing into server commands, server-result handling, independent failure boundaries, optimistic state, and refresh/polling behavior. + +Copilot reviewer feedback was fetched with `gh api` from the PR review, review-comment, and issue-comment endpoints. Copilot submitted [an initial eight-comment review](https://github.com/microsoft/vscode-documentdb/pull/732#pullrequestreview-4421875246) and [a later three-comment review](https://github.com/microsoft/vscode-documentdb/pull/732#pullrequestreview-4735391512). There were no Copilot-authored issue-style PR comments. All 11 inline comments are linked and assessed below, including resolved/outdated threads whose underlying pattern recurs in the current implementation. + +## Findings + +### HIGH-1: TTL input is truncated instead of validated, which can shorten retention unexpectedly + +Files: + +- `src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx:337` +- `src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx:368` +- `src/webviews/documentdb/indexView/indexViewRouter.ts:87` + +The drawer validates and serializes the TTL value with `Number.parseInt(ttlSeconds, 10)`. This accepts only the integer prefix rather than validating the complete value. A native `type="number"` input can contain decimal or exponent notation, and the component does not use the input's validity state or otherwise reject those forms. The resulting truncated integer then passes the host's `z.number().int()` validation because the lossy conversion has already happened. + +This is higher risk than an ordinary malformed option because a TTL index deletes documents. The UI can submit a valid but materially different retention period from the value the user entered. + +Scenario: + +1. A user enables TTL and enters `1e3`, intending 1,000 seconds. +2. The form considers it valid because `Number.parseInt('1e3', 10)` is `1`, which is positive. +3. The payload contains `expireAfterSeconds: 1`; the router accepts it as an integer. +4. The server creates a one-second TTL index, making eligible documents expire roughly 1,000 times sooner than intended. + +The same issue occurs with decimals: `1.5` is silently sent as `1` rather than being rejected or preserved. + +> **Verifier assessment — VERIFIED. Severity: Medium (was High).** +> +> Confirmed at [CreateIndexDrawer.tsx:587](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx#L587) (`type="number"`, `min={1}`) with truncating validation/serialization at [line 337](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx#L337) and [line 368](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx#L368). `Number.parseInt('1e3', 10) === 1` and `Number.parseInt('3600.9', 10) === 3600`, and the truncated integer then passes the host's `z.number().int().nonnegative()` at [indexViewRouter.ts:87](../../../../src/webviews/documentdb/indexView/indexViewRouter.ts#L87). +> +> **Why downgrade to Medium:** the _impact_ is severe (a TTL index deletes documents), but the _trigger_ requires exponent/decimal text in a whole-seconds field. `type="number"` already yields `''` for thousands separators and letters, which the `ttlSeconds.trim() !== ''` guard blocks. The realistic residual cases are `1.5`→`1` (negligible) and pasted scientific notation (rare). Keep it prominent for impact, but it is not a broad happy-path defect. +> +> **Solution options** +> +> - **Option A — parse with `Number()` + explicit integrality check (client):** +> ```ts +> const parsed = Number(ttlSeconds); +> const ttlNumberValid = !ttlActive || (ttlSeconds.trim() !== '' && Number.isInteger(parsed) && parsed > 0); +> // payload: expireAfterSeconds: Number(ttlSeconds) +> ``` +> _Pros:_ turns silent truncation into a visible validation error; one source of truth; tiny change. _Cons:_ `1e3` (a mathematically integer value) is now rejected — arguably correct, but a power user might expect 1000. +> - **Option B — native validity + `step={1}` + `valueAsNumber`.** _Pros:_ leans on the browser. _Cons:_ Fluent `Input` does not surface `validity`/`valueAsNumber` cleanly through its `onChange` data; more wiring for marginal gain. +> - **Option C — tighten the host schema (defense in depth):** `expireAfterSeconds: z.number().int().positive().optional()` (current `.nonnegative()` also permits `0`). +> +> **Recommendation: Option A + Option C.** A is the minimal robust client fix; C stops the router from trusting the UI. Avoid B — Fluent's controlled input makes native validity awkward. + +> **Resolution (2026-07-21):** Fixed in [`5365d8e3`](https://github.com/microsoft/vscode-documentdb/commit/5365d8e3). The drawer now trims the input, parses it once, and accepts it only when the parsed integer's canonical string exactly matches the trimmed input and is positive. This rejects decimals, exponent notation, signs, leading-zero formatting, and mixed text instead of silently converting them. The validation message states that digits-only positive whole numbers are required, and the host schema now independently requires a positive integer so a crafted payload cannot submit zero. + +### MEDIUM-1: Hide and unhide report success when the server returns an error document + +Files: + +- `src/webviews/documentdb/indexView/indexViewRouter.ts:539` +- `src/webviews/documentdb/indexView/indexViewRouter.ts:563` +- `src/documentdb/LlmEnhancedFeatureApis.ts:589` + +The lower-level visibility API catches command exceptions and returns a document shaped like `{ ok: 0, errmsg: "..." }`. The tree-view commands already inspect `ok` and `errmsg`, but both new webview mutations discard the returned document and unconditionally return `{ ok: true, cancelled: false }`. + +This suppresses the exact server errors that are expected on tiers or engine versions without the required `collMod` support. Because no error reaches the webview, the action appears to finish normally and the subsequent refresh merely shows the unchanged state. + +Scenario: + +1. A user confirms **Hide** for an index on a cluster that rejects index visibility changes. +2. `modifyIndexVisibility` catches the server exception and returns `{ ok: 0, errmsg: "Failed to hide index: ..." }`. +3. The router ignores that result and returns success. +4. The UI waits for its normal action interval and refreshes without showing the failure. +5. The index remains visible, leaving the user with no explanation and no reliable indication that the action failed. + +> **Verifier assessment — VERIFIED. Severity: Medium (agree).** +> +> Confirmed: `modifyIndexVisibility` catches and returns `{ ok: 0, errmsg }` at [LlmEnhancedFeatureApis.ts:655-659](../../../../src/documentdb/LlmEnhancedFeatureApis.ts#L655-L659), while the router's `hideIndex`/`unhideIndex` discard the returned `Document` and return `{ ok: true, cancelled: false }` ([indexViewRouter.ts:539](../../../../src/webviews/documentdb/indexView/indexViewRouter.ts#L539), [line 563](../../../../src/webviews/documentdb/indexView/indexViewRouter.ts#L563)). This is a stark inconsistency: sibling `createIndex` ([line 377](../../../../src/webviews/documentdb/indexView/indexViewRouter.ts#L377)) and `dropIndex` ([line 467](../../../../src/webviews/documentdb/indexView/indexViewRouter.ts#L467)) both inspect `result.ok === 0 || result.note` and throw. `handleToggleHidden` awaits the mutation, so the masked failure produces a spinner + refresh showing unchanged state and no error. High-confidence, real finding. +> +> **Solution options** +> +> - **Option A — inspect the result in each mutation (mirror drop/create):** +> ```ts +> const result = await client.hideIndex(db, coll, name); +> if (result.ok === 0 || result.errmsg) { +> throw new Error(typeof result.errmsg === 'string' ? result.errmsg : l10n.t('Failed to hide index.')); +> } +> ``` +> _Pros:_ matches the established pattern; local to the new code; does not disturb existing consumers. _Cons:_ duplicated a few lines across two mutations. +> - **Option B — make `modifyIndexVisibility` re-throw instead of swallowing.** _Pros:_ centralizes. _Cons:_ the tree-view callers already read the returned `{ ok, errmsg }` document, so re-throwing risks regressions there and requires updating those callers. +> +> **Recommendation: Option A.** It matches the drop/create contract exactly, is confined to the new router code, and leaves the existing tree-view consumers of `modifyIndexVisibility` untouched. + +> **Resolution (2026-07-21):** Fixed in [`d6e437e2`](https://github.com/microsoft/vscode-documentdb/commit/d6e437e2). Both visibility mutations now inspect the returned command document and throw when `ok === 0` or `errmsg` is present, preferring the server's string error and falling back to an action-specific localized message. This mirrors create/drop behavior while preserving the lower-level return-document contract used by existing tree-view callers. + +### MEDIUM-2: Duplicate field rows silently collapse into a different index specification + +Files: + +- `src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx:356` +- `src/webviews/documentdb/indexView/indexViewRouter.ts:75` +- `src/webviews/documentdb/indexView/indexViewRouter.ts:181` + +Neither the drawer nor the host schema requires field names to be unique. `buildIndexSpec` then converts the rows into a JavaScript object using `key[entry.field] = ...`, so a later duplicate silently overwrites the earlier entry. The request can therefore succeed while creating a different index from the multi-row configuration shown in the drawer. + +Host-side validation matters here even if the UI later disables duplicate choices: the combobox is freeform, and the router is the final boundary before a server command. + +Scenario: + +1. A user adds two rows for `status`, selecting ascending on the first and descending on the second, and names the index `status_compound`. +2. The drawer submits two completed rows and treats the request as compound. +3. Object construction overwrites the first key, producing only `{ status: -1 }`. +4. The server successfully creates a single-field descending index named `status_compound`. +5. The success path gives no indication that one configured row was discarded. + +The same collapse occurs for names that differ only by surrounding whitespace because the drawer trims each name before submission. + +> **Verifier assessment — VERIFIED. Severity: Low–Medium (I lean Low).** +> +> Confirmed: `buildIndexSpec` does `key[entry.field] = ...` at [indexViewRouter.ts:181](../../../../src/webviews/documentdb/indexView/indexViewRouter.ts#L181), and neither `CreateIndexInputSchema` ([line 75](../../../../src/webviews/documentdb/indexView/indexViewRouter.ts#L75)) nor the drawer's `completedRows` dedupes. A later duplicate silently overwrites the earlier key. Real, but low happy-path likelihood (a user rarely adds two rows for the same field); impact is "a valid but different index is created without warning," which keeps it above cosmetic. +> +> **Solution options** +> +> - **Option A — reject duplicates at the router boundary (authoritative):** +> ```ts +> const CreateIndexInputSchema = z +> .object({ +> /* ... */ +> }) +> .superRefine((val, ctx) => { +> const seen = new Set(); +> val.fields.forEach((f, i) => { +> const n = f.field.trim(); +> if (seen.has(n)) +> ctx.addIssue({ code: 'custom', path: ['fields', i, 'field'], message: 'Duplicate field name.' }); +> seen.add(n); +> }); +> }); +> ``` +> _Pros:_ guarantees correctness regardless of client; the router is the final boundary before the server command. _Cons:_ surfaces as a generic tRPC validation error unless paired with UI feedback. +> - **Option B — disable/merge duplicate choices in the drawer.** _Pros:_ better inline UX. _Cons:_ insufficient alone (freeform combobox can still submit duplicates). +> +> **Recommendation: Option A** (the guarantee), optionally plus B for UX. Choose A because the router must never silently drop a configured row. + +> **Resolution (2026-07-21):** Fixed in [`5d19cae3`](https://github.com/microsoft/vscode-documentdb/commit/5d19cae3). The router schema now rejects repeated trimmed field names and attaches the validation issue to the later field row. Enforcing uniqueness at the host boundary prevents `buildIndexSpec` from silently overwriting an earlier key, including for whitespace-equivalent names or crafted webview requests. + +### MEDIUM-3: One transient polling failure permanently stops automatic build-state updates + +File: `src/webviews/documentdb/indexView/IndexesTab.tsx:162`, `src/webviews/documentdb/indexView/IndexesTab.tsx:275` + +The build polling effect schedules one `setTimeout` and relies on a changed `displayIndexes` dependency to run the effect again. A successful refresh installs a new `indexes` array and therefore re-arms the effect. A failed refresh, however, catches the error without changing `indexes` or `pendingCreates`. The timeout has already fired, the dependencies remain unchanged, and no next poll is scheduled. + +This lets one local, transient list failure stop the automatic state-resolution feature for the rest of that build. It also contradicts the design note that polling recursively continues until no index is building or creating. + +Scenario: + +1. A create request succeeds and the optimistic row displays **Creating**. +2. The first scheduled refresh encounters a temporary connection timeout. +3. `refresh()` shows a load error but leaves `displayIndexes` unchanged. +4. The polling effect is not re-run, so no second timeout is installed. +5. The row remains **Creating** indefinitely even after the server finishes the build, until the user manually refreshes the tab. + +The same failure leaves a server-reported **Building** row stale. + +> **Verifier assessment — VERIFIED. Severity: Medium (agree; arguably Low–Medium).** +> +> Confirmed at [IndexesTab.tsx:273-282](../../../../src/webviews/documentdb/indexView/IndexesTab.tsx#L273-L282): the effect schedules a single `setTimeout` and depends on `[displayIndexes, refresh]`. A successful `refresh` calls `setIndexes(rows)` with a fresh array → `displayIndexes` memo recomputes → effect re-runs → re-arms. A failed `refresh` ([lines 160-183](../../../../src/webviews/documentdb/indexView/IndexesTab.tsx#L160-L183)) catches without touching `indexes`/`pendingCreates`, so `displayIndexes` keeps the same reference and the effect never re-runs — polling halts until a manual refresh. Real; severity tempered because the toolbar refresh recovers it. +> +> **Solution options** +> +> - **Option A — decouple re-arm from the data via a tick counter:** +> ```ts +> const [pollTick, setPollTick] = useState(0); +> useEffect(() => { +> const active = displayIndexes.some((i) => i.state === 'building' || i.state === 'creating'); +> if (!active) return; +> const timer = setTimeout(async () => { +> await refresh(); // resolves whether it succeeds or fails +> setPollTick((t) => t + 1); // always re-arm +> }, BUILD_POLL_INTERVAL_MS); +> return () => clearTimeout(timer); +> }, [displayIndexes, refresh, pollTick]); +> ``` +> _Pros:_ minimal, idiomatic; guarantees the loop survives transient failures. _Cons:_ one extra harmless render per poll. +> - **Option B — ref-based recursive `setTimeout` chain outside React state.** _Pros:_ no re-renders. _Cons:_ more code, easy to leak the timer. +> - **Option C — `setInterval` while active.** _Cons:_ overlapping requests if a refresh outlives the interval (compounds LOW-1). +> +> **Recommendation: Option A.** Smallest change that keeps polling alive through failures, and it composes cleanly with the LOW-1 request-generation guard. + +> **Resolution (2026-07-21):** Fixed in [`cf0930d7`](https://github.com/microsoft/vscode-documentdb/commit/cf0930d7). The polling effect now advances a generation after every refresh attempt settles, including handled failures, which re-arms the timeout while an index remains active. Cleanup suppresses late state updates, and the next timeout is not scheduled until the previous request completes, avoiding overlapping polls. + +### MEDIUM-4: The Create Index button's accessible name omits its visible label + +File: `src/webviews/documentdb/collectionView/components/toolbar/ToolbarMainView.tsx:188` + +Copilot comment: [Create Index accessible-name mismatch](https://github.com/microsoft/vscode-documentdb/pull/732#discussion_r3351182778) + +The primary button visibly says **Create Index**, but `aria-label` replaces its accessible name with **Create a new index**. The visible label does not occur as a contiguous part of that name. This violates the label-in-name expectation and can prevent speech-input users from activating the primary index action with the words shown on screen. + +This comment is still open, not outdated, and points to the current line. The concern also matches the repository's accessibility guidance for visible labels and WCAG 2.5.3. + +Scenario: + +1. A speech-input user opens the Indexes tab and says “Click Create Index,” matching the visible button text. +2. The accessibility tree exposes the control as “Create a new index.” +3. Voice matching cannot reliably associate “Create Index” with that accessible name. +4. The user cannot trigger the tab's primary action by reading its visible label. + +> **Verifier assessment — VERIFIED. Severity: Low (downgraded from Medium).** +> +> Confirmed at [ToolbarMainView.tsx:188](../../../../src/webviews/documentdb/collectionView/components/toolbar/ToolbarMainView.tsx#L188): `aria-label={l10n.t('Create a new index')}` over visible text `Create Index` ([line 196](../../../../src/webviews/documentdb/collectionView/components/toolbar/ToolbarMainView.tsx#L196)). "Create Index" is not a contiguous substring of the accessible name → WCAG 2.5.3 Label in Name (Level A) violation for speech-input users. Real, but scoped to one control with voice-only impact, so Low. +> +> **Solution options** +> +> - **Option A — drop the redundant `aria-label`; let the child text be the name:** +> ```tsx +> } appearance="primary" onClick={...}> +> {l10n.t('Create Index')} +> +> ``` +> _Pros:_ simplest, self-maintaining, guaranteed match. _Cons:_ none meaningful. +> - **Option B — set `aria-label` to a string that begins with the visible words**, e.g. `l10n.t('Create Index')`. _Pros:_ allows extra description. _Cons:_ must keep the visible words contiguous or the violation returns. +> +> **Recommendation: Option A.** "Create Index" is already descriptive, so the override adds nothing but the violation. + +> **Resolution (2026-07-21):** Fixed in [`5f99a411`](https://github.com/microsoft/vscode-documentdb/commit/5f99a411). Removed the redundant `aria-label`, allowing the visible “Create Index” text to supply the button's accessible name. The label now matches exactly for voice control and remains self-maintaining if the visible localized text changes. + +### LOW-1: Out-of-order refresh responses can overwrite newer index state + +File: `src/webviews/documentdb/indexView/IndexesTab.tsx:162` + +`refresh()` has no request generation, cancellation, or in-flight guard. It can be invoked by initial load, the toolbar, build polling, create-failure reconciliation, and delete/hide/unhide completion. If two calls overlap, whichever response arrives last always wins, even when it was requested against older server state. + +This is temporary rather than destructive, but it can make a completed operation look as though it reverted and can keep an obsolete row actionable until the next refresh. + +Scenario: + +1. A slow toolbar refresh starts and reads an index list containing `legacy_1`. +2. The user deletes `legacy_1`; the mutation succeeds and its follow-up refresh returns the new list first. +3. The older toolbar request completes last and calls `setIndexes()` with the pre-delete list. +4. `legacy_1` reappears in the table until another refresh corrects the view. + +> **Verifier assessment — VERIFIED. Severity: Low (agree).** +> +> Confirmed: `refresh` ([IndexesTab.tsx:160-183](../../../../src/webviews/documentdb/indexView/IndexesTab.tsx#L160-L183)) has no in-flight/generation guard and is invoked from initial load, toolbar, build poll, create-failure reconciliation, and delete/hide/unhide completion. Last response wins. Transient and self-correcting on the next refresh, hence Low. +> +> **Solution options** +> +> - **Option A — request-generation guard:** +> ```ts +> const reqId = useRef(0); +> const refresh = useCallback( +> async () => { +> const id = ++reqId.current; +> // ... +> const rows = await trpcClient.mongoClusters.indexView.listIndexes.query(); +> if (id === reqId.current) setIndexes(rows); // ignore stale responses +> }, +> [ +> /* ... */ +> ], +> ); +> ``` +> _Pros:_ trivial; fully prevents stale writes. _Cons:_ does not cancel the wasted in-flight request. +> - **Option B — `AbortController`/`AbortSignal` into the query** (the repo's tRPC layer supports cancellation). _Pros:_ also cancels server-side work. _Cons:_ more plumbing. +> +> **Recommendation: Option A** now (fixes the observable bug with minimal surface); adopt B additionally if the list query becomes expensive. + +> **Resolution (2026-07-21):** Fixed in [`e860f60f`](https://github.com/microsoft/vscode-documentdb/commit/e860f60f). Each refresh now captures a monotonic generation and may update indexes, report an error, or clear loading state only while it remains the newest request. Older overlapping responses are ignored, preventing stale data and stale request state from replacing the latest view. + +### LOW-2: Tooltip-only details use triggers that cannot receive keyboard focus + +Files: + +- `src/webviews/documentdb/indexView/components/indexList/IndexPropertiesView.tsx:61` +- `src/webviews/documentdb/indexView/components/indexList/IndexTable.tsx:306` + +Copilot comments: + +- [Protected Delete tooltip wraps a disabled button](https://github.com/microsoft/vscode-documentdb/pull/732#discussion_r3351182602) +- [Protected Hide/Unhide tooltip wraps a disabled button](https://github.com/microsoft/vscode-documentdb/pull/732#discussion_r3351182632) +- [Property badges with tooltips are not keyboard-focusable](https://github.com/microsoft/vscode-documentdb/pull/732#discussion_r3614657951) + +The current table repeats two forms of the same accessibility problem. Property badges put the partial-filter, collation, or wildcard value only in a Tooltip around a non-focusable `Badge`. The protected `_id_` actions put the explanation for their disabled state in Tooltips whose direct triggers are disabled buttons; disabled controls do not receive hover or keyboard focus reliably. + +The two protected-action comments are resolved and outdated because they targeted an earlier table component, but the replacement `indexList/IndexTable.tsx` has the same structure. The property-badge comment is current and open. Expanded row details provide an alternate route to the property values, which limits this to Low severity, but the compact table affordance itself remains inaccessible to keyboard users. + +Scenario: + +1. A keyboard user tabs through a row with a `Partial` property badge. +2. The badge cannot receive focus, so its filter-expression tooltip never opens. +3. On the `_id_` row, the disabled Delete and Hide buttons also cannot receive focus. +4. The user cannot discover from those controls why the actions are disabled without finding a separate representation elsewhere. + +> **Verifier assessment — VERIFIED. Severity: Low (agree).** +> +> Confirmed: disabled `_id_` action buttons are wrapped in Tooltips at [IndexTable.tsx:309-349](../../../../src/webviews/documentdb/indexView/components/indexList/IndexTable.tsx#L309-L349) — a plain `disabled` Button is neither focusable nor reliably hoverable, so the explanation is unreachable by keyboard. Property `Badge`s ([IndexPropertiesView.tsx:58-79](../../../../src/webviews/documentdb/indexView/components/indexList/IndexPropertiesView.tsx#L58-L79)) are non-focusable and carry their value only in a Tooltip. Expanded-row details give an alternate route, so Low. +> +> **Solution options** +> +> - **Option A — Fluent focusable-disabled + focusable badge trigger:** +> ```tsx +> // action button: keep it in the tab order while inert +> + + ) : ( + <> + {l10n.t('No indexes match the current filters.')}{' '} + + + )} + + + ); +} +``` + +| Approach | Pros | Cons | +| ---------------------------------------------- | ------------------------------------------------------- | ------------------------------------------ | +| **In-table empty row (above)** | Names the cause where the user is looking; reuses Clear | Needs a `loadFailed` flag threaded through | +| **Footer text only, reword to \"No matches\"** | One-line change | Easy to miss below an empty grid | +| **Leave as-is** | No work | Header-only table looks broken | + +### 7. Manual (toolbar) refresh silently resets sort and expanded rows ⚠️ _(new)_ + +**Priority:** P3 · **Status:** ✅ Implemented · **✅ Verified in code** · **🔁 revisited** + +> **Revisited (2026-07-22):** softened from 🟠 to 🟡 (soft). This is a genuine nice-to-have — +> the refresh is _user-initiated_ and re-sorting/re-expanding is a single click, so the impact +> is a minor annoyance, not a broken flow. Worth doing only if the fix is cheap (e.g. the +> "keep rows, no skeleton" option below); otherwise acceptable as-is. + +> **Decision (Iteration 1):** fix it by **lifting and retaining** the sort + expanded state, +> with code comments explaining why. **Reason (operator):** _"I don't think it's the case. oh, +> indeed it is the case, this is unexpected."_ The reset is surprising and contradicts the +> documented intent, so state should persist across a manual refresh. + +> ✅ **Implemented (Iteration 1):** moved the sort state and the expanded-row set out of +> `IndexTable` and into `IndexList` (which stays mounted across the skeleton swap), passing +> them down as controlled props (`sortState`/`onSortChange`, `expanded`/`onToggleExpanded`). +> Fluent's `useTableSort` is now driven in controlled mode. Comments on the new props and the +> `IndexList` state explain the survive-a-refresh rationale. Files: +> [IndexTable.tsx](../../../../src/webviews/documentdb/indexView/components/indexList/IndexTable.tsx#L47), +> [IndexList.tsx](../../../../src/webviews/documentdb/indexView/components/indexList/IndexList.tsx#L74). +> Commit: see `fix(indexView): retain sort and expanded rows across manual refresh`. + +**Observation:** Sort by Size, expand a couple of rows, then press the toolbar **Refresh**. +The list snaps back to the default name-ascending sort and every row collapses — whereas the +automatic background poll leaves both untouched. + +**Finding:** + +- ⚠️ Sort state and the `expanded` set are `useState` **inside** `IndexTable` + ([IndexTable.tsx#L124](../../../../src/webviews/documentdb/indexView/components/indexList/IndexTable.tsx#L124)). + Manual refresh sets `isManualRefreshing`, which makes `IndexList` swap `IndexTable` for + `IndexTableSkeleton` — **unmounting** `IndexTable` and discarding that state. See + [IndexesTab.tsx#L462](../../../../src/webviews/documentdb/indexView/IndexesTab.tsx#L462) and + [IndexList.tsx#L158](../../../../src/webviews/documentdb/indexView/components/indexList/IndexList.tsx#L158). +- 🔍 This directly contradicts the [Implemented](#implemented) note that "sorting and + expansion survive ordinary data refreshes" — it holds for _background_ refresh only, not + the user-initiated one, which is the one a user will notice. + +💡 **Suggestion / solution:** Either lift sort/expanded state up so it survives the skeleton +swap, or keep the existing rows visible (with the thin progress bar) on manual refresh +instead of showing the full skeleton — matching how background reconciliation already +behaves. + +| Approach | Pros | Cons | +| ----------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------- | +| **Manual refresh keeps rows (thin bar, no skeleton)** | Sort/expansion preserved for free; consistent with poll | User loses the "something happened" skeleton cue | +| **Lift sort/expanded state into `IndexList`/tab** | Skeleton can stay; state persists across unmount | More plumbing; state now lives away from the table | +| **Leave as-is** | No work | Surprising reset that contradicts the documented goal | + +### 8. Create and Refresh toolbar buttons are not guarded against re-entry ⚠️ _(new)_ + +**Priority:** P3 · **Status:** 🚫 Closed (won't fix) · **✅ Verified in code** · **🔁 revisited** + +> **Decision (Iteration 1) — Closed / won't fix.** **Reason (operator):** _"leave as is."_ +> There is no correctness impact — the refresh generation guard already prevents stale data +> and the create/refresh opens are idempotent — so the missing busy affordance is acceptable. + +> **Revisited (2026-07-22):** softened from 🟠 to 🟡 (soft). This has **no correctness impact** +> — the refresh generation guard already prevents stale data, and the create/refresh opens are +> idempotent. The only cost is a missing busy affordance and a few duplicate prerequisite +> fetches. It is the weakest item in the review and a fair candidate to **acknowledge/close** +> rather than fix if it is not a quick win. + +**Observation:** Rapidly click **Refresh** (or **Create Index**) several times. Nothing +stops overlapping requests — the buttons never disable while work is in flight. + +**Finding:** + +- ⚠️ `IndexManagementToolbar` renders plain `ToolbarButton`s with no `disabled`/busy prop + tied to the in-flight state ([IndexManagementToolbar.tsx#L25-L35](../../../../src/webviews/documentdb/indexView/components/IndexManagementToolbar.tsx#L25)). + A refresh generation guard prevents _stale data_ from landing + ([IndexesTab.tsx#L168](../../../../src/webviews/documentdb/indexView/IndexesTab.tsx#L168)), + so this is not a correctness bug — but repeated `Create Index` clicks re-issue the + prerequisite `Promise.all` each time, and the UI gives no "already working" signal. +- 🔍 Low severity because the generation guard and idempotent opens keep the result correct; + the gap is purely perceived responsiveness / wasted requests. + +💡 **Suggestion / solution:** Pass the existing `isRefreshing` / prerequisite-loading flags +into the toolbar and disable (or show a spinner on) the relevant button while its action is +pending: + +```tsx + : } + disabled={isCreatePending} onClick={onCreateIndex}>{l10n.t('Create Index')} +} disabled={isRefreshing} onClick={onRefreshIndexes}>… +``` + +| Approach | Pros | Cons | +| --------------------------------- | -------------------------------------------- | -------------------------------------------------- | +| **Disable buttons while pending** | Clear feedback; no wasted duplicate requests | Must thread two flags into the toolbar | +| **Leave as-is (rely on guards)** | No work; results already correct | No busy affordance; duplicate prerequisite fetches | + +## Implemented + +The following relevant decisions are already implemented and documented; they are review +context, not open findings: + +- ✅ Delete, Hide, and Unhide share one detailed host-side modal across webview and Explorer. + The documented tradeoff is that tree delete no longer honors the configurable typed/word + confirmation style. See [Index Management UI notes](feature-01-index-management-overview.md#4-safe-host-side-confirmations-unified-across-webview--tree-view). +- ✅ The `_id_` action buttons use `disabledFocusable` and explanatory tooltips, so keyboard + users can reach the protected-state explanation. See + [IndexTable.tsx](../../../../src/webviews/documentdb/indexView/components/indexList/IndexTable.tsx#L300). +- ✅ TTL input has inline error state and a specific positive-whole-number message. See + [CreateIndexDrawer.tsx](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx#L493). +- ✅ Table sorting and expansion state survive **all** data refreshes — including a manual + toolbar refresh — because `IndexList` (which stays mounted across the skeleton swap) owns + that state and passes it to `IndexTable` as controlled props. See + [IndexList.tsx](../../../../src/webviews/documentdb/indexView/components/indexList/IndexList.tsx#L74) + (fixed in Iteration 1, finding 7). + +--- + +## Iteration log + +A running record of each fix pass. Items still 🟠 Open at the end of an iteration roll into +the next one; nothing is dropped without a terminal status. + +### Iteration 1 (2026-07-22) — operator-directed fixes + +The operator (TN) reviewed the document and gave a decision on every item; this iteration +implements them. **Each work item below is a dedicated commit** (with a matching inline +`Decision` + `Implemented` block on the finding itself). Operator's headline calls: + +- **Feedback surface rule (drives finding 3):** _"errors that happen as an effect of a user + interaction where the action fails should be modal; a notification that something completed + can be non-modal. e.g. create/hide/unhide **fails** → modal; index **created** fine → a + toast is enough."_ Unify the tree to match the (more-tweaked) webview. +- **Row spinner timing (findings 1 + O1):** keep it **one request** — set the row's + processing visual, call the backend, and on success hold ~2s more before finalizing, so a + fast operation is still perceptible. +- **Create-failure recovery (finding 2):** keep the fast drawer-close (the 80% happy path), + but add an **Edit & retry** action to the modal error that reopens the preserved form. +- **Raw-definition error (journey 1):** should be **modal**, not a toast. +- **Zero matches (finding 5):** fine with `Showing 0 of N`; see the open question below. +- **Schema prerequisites (finding 4):** must be able to proceed with no schema info (an empty + schema is expected when it isn't ready yet). +- **Accessibility (finding 6):** implement it — "accessibility is important for us." +- **Sort/expansion reset (finding 7):** unexpected — **lift and retain** the state, explain + in code comments. +- **Toolbar re-entry (finding 8):** **leave as-is.** + +Per-item Decision/Implemented blocks are recorded on each finding above. Anything not +resolved here rolls into Iteration 2. + +#### Iteration 1 outcome + +| Finding | Result | +| ------------------------- | ---------------------------------------------------------------------------------------------- | +| J1 · raw-definition error | ✅ Implemented (`fix(indexView): surface raw-definition open failure as a modal`) | +| 1 · row progress timing | ✅ Implemented (`fix(indexView): show row progress during the actual index operation`) | +| 2 · create-fail recovery | ✅ Implemented (`feat(indexView): add Edit and retry to the create-index failure modal`) | +| 3 · feedback matrix | ✅ Implemented (`fix(indexView): unify index-action feedback…`) | +| 4 · schema prerequisites | ✅ Implemented (`fix(indexView): open create drawer without blocking on schema prerequisites`) | +| 6 · accessibility | ✅ Implemented (`feat(indexView): announce list refresh lifecycle to screen readers`) | +| 7 · sort/expansion reset | ✅ Implemented (`fix(indexView): retain sort and expanded rows across manual refresh`) | +| 8 · toolbar re-entry | 🚫 Closed (won't fix) — operator: "leave as is" | +| 5 · empty-state | 🟡 **Deferred to Iteration 2** — needs clarification (resolved below) | + +**Open questions — all resolved in Iteration 2 (2026-07-22)** + +The five questions raised at the end of Iteration 1 have since been answered by the operator: + +1. **Finding 5 (empty-state):** ✅ Resolved — _"no buttons, just the could not load state"_ and + _"fine with showing 0 of xx"_. Implemented as a centered **"Could not load indexes."** + message for the load-failure case only. _(New: operator is now evaluating a **Retry** + variant via a non-committed simulation — may reopen; see the still-open audit item C.)_ +2. **Finding 1 (spinner behind modal):** ✅ Confirmed acceptable — _"fine with spinners behind + the modal and cleared on cancel."_ +3. **Finding 3 (modal scope):** ✅ Confirmed — _"modal only for the immediate user actions, so + pool, background, first load are non modal."_ Matches what shipped. +4. **Finding 3 (tree behavior):** ✅ Confirmed — _"this is ok."_ +5. **Gated success toasts:** ✅ Confirmed, and a helper was requested & added + (`showOperationSummary`). + +### Iteration 2 (2026-07-22) — clarifications applied + +- **Finding 5** implemented (centered could-not-load message, no buttons). Commit + `feat(indexView): show a could-not-load message instead of a bare table`. +- **`showOperationSummary` helper** added and the three success call sites refactored onto it. + Commit `refactor(indexView): add showOperationSummary helper for gated toasts`. + +**Still open after Iteration 2:** only the two items in the [Still-open audit](#still-open-audit-2026-07-22) +that are not code decisions — a **hands-on live pass** (item A) and the **Retry-button +question** (item C, pending the requested simulation). + +--- + +## Open ideas — options, pros & cons + +Genuinely open design questions with real trade-offs. Recommendations are suggestions to +react to, not decisions. + +### O1. Where should confirmation and operation progress be owned? (item 1) + +| Option | Pros | Cons | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| **A. Split confirm from the mutation** | Webview can set row busy immediately after confirmation and cover the real operation; cancellation remains explicit | Adds a second round trip or requires a separate confirmation procedure | +| **B. Return/stream operation phases from the host** | Host retains full ownership and can expose precise phase changes | More protocol and state complexity for short operations | +| **C. Use global VS Code progress around the existing mutation** | Small change; covers confirmation plus operation | Cannot identify the affected row precisely and may overstate progress while the modal is open | + +> 💡 **Suggested:** Option A. Confirmation remains host-native, while the visible row state +> can accurately begin only after the user has confirmed and before the server call starts. + +> **Decision (Iteration 1):** _None of A/B/C._ The operator chose to **keep one request** and +> accept the trade-off: set the row's processing visual before the (confirm + operate) +> mutation and hold it a short tail after success. The only cost is that the spinner is also +> visible behind the confirmation modal (cleared on cancel), which was deemed acceptable +> versus a second round trip. Implemented on finding 1. + +### O2. What should successful visibility changes announce? (item 3) + +| Option | Pros | Cons | +| ----------------------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------- | +| **A. Always show success notifications** | Consistent with create/delete and Explorer; explicit for assistive technology | Can feel noisy when the row change is already obvious | +| **B. Use row change + live announcement** | Keeps visual UI quiet while making completion perceivable | Webview and Explorer still use different visible surfaces | +| **C. Keep current behavior** | Lowest notification volume | Leaves sibling actions and entry points inconsistent | + +> 💡 **Suggested:** Option B for the webview, paired with an explicit rationale for why +> Explorer retains configured notification behavior. + +> **Decision (Iteration 1):** **B mixed with a gated toast.** **Reason (operator):** _"we can +> have toast notifications, assistive technology will be happy, and we still have table +> changes. We can also leverage `documentDB.userInterface.ShowOperationSummaries` — mix it in +> with option B."_ So hide/unhide success now shows a completion toast **gated by that +> setting** (screen readers hear the toast; the row still changes visually); the deeper +> in-webview live-region announcements land in finding 6. Implemented on finding 3. + +--- + +## Appendix A — current flow (reference) + +### Phase 1: Enter and load + +The user opens Collection View normally and selects **Indexes**, or double-clicks the +Explorer's **Indexes** node to open directly on that tab. `IndexesTab` mounts, starts the +list query, and shows a table skeleton plus metrics loading state. A successful response +populates metrics, filters, sortable rows, and the update timestamp. A failed initial load +shows a non-modal error and leaves the same zero-row table used for a valid empty result. + +### Phase 2: Inspect and narrow + +Text search matches index names and field names. Hidden and Unused are independent toggles; +the latter means a non-default index with known zero usage. Rows can be sorted, expanded to +show fields and properties, or opened as a raw live definition in an untitled JSON editor. +The footer announces shown/total counts through a polite live region. + +### Phase 3: Create + +Opening the drawer first waits for schema field suggestions and collection document count. +The form supports compound fields, unique/sparse/TTL/custom-name options, relaxed JSON for +partial filters and collation, direct creation, or handoff to a playground/shell for review +before execution. Direct creation closes the drawer immediately and inserts an optimistic +row; success is reconciled by five-second polling, while failure removes the row, shows a +modal, and preserves the hidden form for the next drawer open. + +### Phase 4: Change visibility or delete + +Webview and Explorer actions share `confirmIndexAction`, including name, collection, size, +usage, and an effect warning. Explorer shows temporary status during the actual client call. +The webview waits for the complete host mutation and then shows a two-second row spinner +before refreshing. Delete additionally shows a success notification; webview hide/unhide +do not. + +### Phase 5: Reconcile + +Manual refresh replaces the table with a skeleton. Background reconciliation keeps rows +visible, and a five-second poll re-arms while any index is creating or building. A request +generation guard prevents older list responses from overwriting newer results. Any fetch +failure appears as a non-modal error and leaves the previous rows when available. diff --git a/docs/ai-and-plans/PRs/732-index-dashboard/ux-review-iteration-3-create-index-redesign.md b/docs/ai-and-plans/PRs/732-index-dashboard/ux-review-iteration-3-create-index-redesign.md new file mode 100644 index 000000000..2cc4acf7c --- /dev/null +++ b/docs/ai-and-plans/PRs/732-index-dashboard/ux-review-iteration-3-create-index-redesign.md @@ -0,0 +1,560 @@ +# Index Management Create Drawer Redesign — UX Review Iteration 3 + +> **Who this is for:** anyone about to do a hands-on UX review of the redesigned +> **Create Index** experience in Index Management, or anyone triaging the findings. +> **What this is:** a pre-seeded follow-up to [the original UX review](ux-review-iteration-1-2.md). +> It maps the current Standard / Wildcard / Vector journeys, records code-backed risks +> introduced by the new iteration, and carries forward every unresolved verification item +> from the earlier review. + +- **Feature area:** `src/webviews/documentdb/indexView/`, especially + `components/CreateIndexDrawer.tsx`, `indexCreation.ts`, `indexViewRouter.ts`, and the + create lifecycle in `IndexesTab.tsx` +- **PR / branch:** [microsoft/vscode-documentdb#732](https://github.com/microsoft/vscode-documentdb/pull/732) · + `dev/khelanmodi/index-management-ui` +- **Related design docs:** [Index Management UI notes](feature-01-index-management-overview.md) · + [Vector index support](feature-03-vector-index-support.md) · [Original UX review](ux-review-iteration-1-2.md) +- **Scope:** the redesigned create drawer, mode switching, validation, progressive + disclosure, preview and command hand-off, feedback, accessibility, narrow layouts, and + regressions in the already-reviewed list/action journeys +- **Review date:** 2026-07-27 +- **Iteration:** 3 (new hands-on pass; Iterations 1–2 are recorded in the original review) + +## How this review was run + +This document is the **pre-assessment**, not the hands-on verdict. The current branch was +traced from every create entry and control to its success, failure, cancellation, and +degraded terminal state. Findings below are code-backed **Flags** or explicitly marked +**Open (soft)** checks that need visual/runtime confirmation. The operator should now walk +the journeys in the running extension; observations and decisions will be added here. + +The second half of the preparation cross-checks the original `ux-review-iteration-1-2.md` item by item. +Implemented items remain regression checks, the deliberately closed toolbar re-entry item +stays closed, and the unresolved Retry question is carried into this iteration rather than +being silently dropped. + +## Legend + +### Priority + +| Priority | Meaning | +| -------- | -------------------------------------------------- | +| **P0** | Blocking — the user gets stuck | +| **P1** | Broken / misleading, or a consistency & safety gap | +| **P2** | Polish, expectation, or a smaller feature gap | +| **P3** | Nice-to-have / cosmetic / acknowledged | + +### Status + +| Status | Meaning | +| ------------------ | ------------------------------------------------------------------------ | +| 🟠 **Open** | Recorded + analyzed; carries a recommendation but stays a _suggestion_ | +| 🟡 **Open (soft)** | Open, but depends on an investigation or is a soft "leave as-is" | +| ✅ **Implemented** | Changed on this branch and verified (Decision + commit link recorded) | +| 🚫 **Closed** | Won't fix — with a mandatory one-line reason | +| 🔗 **Tracked** | Deferred to a repo issue (linked); dropped from the active priority list | + +> Anything still Open at the end of this pass moves to the next iteration. An item leaves +> the ledger only as Implemented, Closed with a reason, or Tracked with an issue link. + +### Markers (inline) + +| Marker | Meaning | +| ----------------- | ------------------------------------------------------- | +| ⚠️ **Flag** | Confirmed gap or bug | +| 💡 **Suggestion** | A design/wording recommendation to react to | +| 🔍 **Answered** | A "how does this work?" question answered from the code | +| 🔁 **Regression** | A prior fix that must be rechecked in the new UI | + +--- + +## User interaction map + +### ASCII flow + +```text +Indexes tab → Create Index + └─ drawer opens immediately; suggestions/count settle independently + ├─ Standard + │ ├─ fields + type(s) + │ ├─ options: unique / sparse / TTL / custom name + │ └─ More options → Advanced / JSON preview + ├─ Wildcard + │ ├─ all fields OR parent path → generated-key preview + │ ├─ optional projection + │ │ └─ enabled + no completed fields → projection omitted silently ⚠️ F2 + │ └─ independent name / partial filter / collation draft + └─ Vector (always visible today) + ├─ field + HNSW / IVF / DiskANN + dimensions / similarity + ├─ Advanced → tuning + compatible compression + └─ unsupported deployment/tier → discovered only after create/run ⚠️ F1 + +Main page + ├─ Create Index + │ ├─ success → drawer closes → optimistic row → gated success toast + │ └─ failure → modal → Edit and retry → preserved draft reopens + ├─ Create in Playground / Shell + │ ├─ success → target opens with generated command + │ └─ hand-off failure → modal; drawer remains open + ├─ Advanced / Preview + │ └─ DOM page replaced; no explicit focus move/restore ⚠️ F4 + ├─ Hide / Escape / outside close → drawer closes; all drafts preserved + └─ Reset form → all three drafts reset +``` + +### Mermaid + +```mermaid +flowchart TD + A[Indexes tab: Create Index] --> B[Drawer opens immediately] + B --> K{Choose index kind} + K -- Standard --> S[Fields and index options] + K -- Wildcard --> W[Scope and optional projection] + K -- Vector --> V{Deployment supports DocumentDB vector indexes?} + W --> WP{Projection enabled with a completed field?} + WP -- yes --> WM[Projection included] + WP -- no --> WO([Broader wildcard index; projection silently omitted ⚠️ F2]):::warn + V -- yes --> VM[Algorithm, dimensions, tuning, compression] + V -- unknown / no --> VF([Unsupported flow remains fully enabled ⚠️ F1]):::warn + S --> M[Main page actions] + WM --> M + VM --> M + VF --> M + M --> P[Advanced settings or JSON preview] + P --> PF([Page changes without managed focus ⚠️ F4]):::warn + M --> C{Create directly?} + C -- yes --> R{Host result} + R -- success --> OK([Optimistic row + gated success toast]) + R -- failure --> ER([Modal error + Edit and retry]) + C -- prepare --> T{Playground / Shell hand-off} + T -- success --> TO([Target opens with command]) + T -- failure --> TE([Modal error; drawer remains open]) + M --> H([Hide/cancel; draft preserved]) + classDef warn fill:#5a1e1e,stroke:#e06c75,color:#fff; +``` + +### Interaction inventory + +| # | User action (entry) | Where it lives | Terminal state(s) | Surface | ⚠️ | +| --- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ---------------------- | --- | +| 1 | Click **Create Index** | [IndexManagementToolbar.tsx](../../../../src/webviews/documentdb/indexView/components/IndexManagementToolbar.tsx) | Drawer opens; optional context settles asynchronously | Webview drawer | | +| 2 | Switch Standard / Wildcard / Vector | [CreateIndexDrawer.tsx](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx#L977) | Active focused form; all drafts retained | Drawer tabs | ⚠️ | +| 3 | Add/remove/clear Standard fields | [CreateIndexDrawer.tsx](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx#L1016) | Valid compound key or disabled Create with requirement | Drawer form | | +| 4 | Configure Wildcard scope/path | [CreateIndexDrawer.tsx](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx#L1183) | `$**` / `path.$**` preview or inline path error | Drawer form | | +| 5 | Enable Wildcard projection | [CreateIndexDrawer.tsx](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx#L1274) | Projection included, or selected option silently omitted | Drawer form / none | ⚠️ | +| 6 | Configure Vector algorithm | [CreateIndexDrawer.tsx](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx#L1430) | Roving radio-card selection; tuning draft retained | Drawer form | | +| 7 | Enter Vector dimensions/similarity | [CreateIndexDrawer.tsx](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx#L1479) | Valid input or inline error / disabled Create | Drawer form | | +| 8 | Reveal a custom name input | [CreateIndexDrawer.tsx](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx#L837) | Input appears without its own programmatic label | Drawer form | ⚠️ | +| 9 | Open Advanced settings | [CreateIndexDrawer.tsx](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx#L880) | Pushed page replaces main page | Drawer page | ⚠️ | +| 10 | Open Preview as JSON | [CreateIndexDrawer.tsx](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx#L905) | Read-only generated specification | Drawer / Monaco | | +| 11 | Create directly | [IndexesTab.tsx](../../../../src/webviews/documentdb/indexView/IndexesTab.tsx#L377) | Optimistic row + toast, or modal + Edit and retry | Table / VS Code modal | | +| 12 | Create in Playground / Shell | [IndexesTab.tsx](../../../../src/webviews/documentdb/indexView/IndexesTab.tsx#L532) | Target opens, or modal with drawer retained | Editor / shell / modal | | +| 13 | Hide, Escape, or dismiss drawer | [CreateIndexDrawer.tsx](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx#L957) | Drawer closes; active page and all drafts remain preserved | Drawer | | +| 14 | Reset form | [CreateIndexDrawer.tsx](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx#L1847) | All Standard/Wildcard/Vector drafts reset | Drawer | | +| 15 | First-load failure | [IndexList.tsx](../../../../src/webviews/documentdb/indexView/components/indexList/IndexList.tsx#L191) | Passive "Could not load indexes." state; no Retry | Webview status | ⚠️ | + +### Feedback-surface matrix + +| Event | Current terminal surface | Assessment | +| -------------------------------------------------- | ------------------------------------------------------------------------------- | ---------------------------------- | +| Missing required main-form input | Disabled actions + live requirement status | Consistent | +| Invalid visible numeric tuning | Inline `Field` error + disabled actions | Consistent | +| Invalid relaxed JSON / reserved name at submission | Host rejection → modal; direct create offers Edit and retry | Existing deliberate behavior | +| Direct create success | Optimistic row + configured operation-summary toast | Prior-review rule preserved | +| Direct create failure | Modal + Edit and retry | Prior-review rule preserved | +| Playground/Shell hand-off failure | Modal; drawer retained | Prior-review rule preserved | +| Optional suggestion/count lookup failure | Silent degraded enhancement | Accepted in prior review | +| Enabled projection with no completed fields | **No feedback; projection omitted from the submitted index** | ⚠️ New silent semantic degradation | +| Unsupported Vector environment | No up-front signal; direct create fails later / prepared command fails when run | ⚠️ New capability gap | + +> **Iteration 3 decision note:** the diagrams above preserve the pre-fix review baseline. +> Item 1 is now tracked for a patch release, item 2 is accepted, and items 3–6 are fixed in +> the working tree. The item sections and Iteration log are the current source of truth. + +## The story in one paragraph + +The create experience is now a substantial three-mode workflow rather than the single +Standard form covered by the original review. Iteration 3 accepts the current ungated Vector +flow for the initial release while tracking proper platform/capability gating for the next +patch, and accepts an enabled empty Wildcard projection as a valid no-projection definition. +The accessibility, focus, draft-isolation, and narrow-layout findings are fixed in the +working tree. The prior could-not-load Retry question is closed without action; every other +implemented old item remains below as a regression journey. + +## Priority index + +| # | Priority | Item | Origin | Status | +| --- | -------- | ------------------------------------------------------------------ | ----------------------- | --------------------------- | +| 1 | **P1** | Vector creation is exposed without capability gating | New Vector iteration | 🔗 Tracked | +| 2 | **P1** | Enabled empty Wildcard projection is silently omitted | New Wildcard iteration | 🚫 Closed | +| 3 | **P1** | Revealed custom-name inputs have no accessible name | New drawer iteration | ✅ Implemented (`414c0d2c`) | +| 4 | **P1** | Advanced/Preview page changes do not manage or restore focus | New drawer iteration | ✅ Implemented (`414c0d2c`) | +| 5 | **P2** | Standard and Wildcard option drafts must be independent | New drawer iteration | ✅ Implemented (`414c0d2c`) | +| 6 | **P2** | Fixed-width rows and non-wrapping footer need narrow-panel support | New drawer iteration | ✅ Implemented (`414c0d2c`) | +| 7 | **P3** | First-load failure still has no Retry affordance | Original review audit C | 🚫 Closed | + +## P0 — Blocking (the user gets stuck) + +No code-level P0 candidate was found. The hands-on run should try to disprove this by +exercising keyboard-only Advanced/Preview navigation, every cancel path, an invalid create +followed by Edit and retry, and all three mode drafts after close/reopen. + +## P1 — Broken / misleading, or consistency & safety + +### 1. Vector creation is exposed without capability gating ⚠️ + +**Priority:** P1 · **Status:** 🔗 Tracked in [#816](https://github.com/microsoft/vscode-documentdb/issues/816) + +> **Decision (Iteration 3):** accept the current limitation for the initial release and +> implement proper platform/capability gating in the upcoming patch release. **Reason +> (operator):** the feature can ship with server-side failure as the temporary boundary, but +> platform gating needs a dedicated follow-up and should align with planned Atlas Search +> Index support rather than inventing a competing provider switch. + +**Observation to confirm:** Open Index Management against a connection or service tier that +does not support Azure DocumentDB `cosmosSearch` indexes. The Vector tab still presents a +complete, enabled workflow; support is discovered only after the user submits or runs a +prepared command. + +**Finding:** + +- ⚠️ The drawer always renders the Vector tab; its props carry no capability or environment + signal. See [CreateIndexDrawer.tsx](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx#L977). +- ⚠️ `createIndex` validates the shape, builds the vector specification, and sends it directly + to the selected client without an environment/tier capability check. See + [indexViewRouter.ts](../../../../src/webviews/documentdb/indexView/indexViewRouter.ts#L239). +- 🔍 The design contract says to implement this form for Azure DocumentDB, not substitute an + Atlas search-index command, and records the authoritative capability source as an open + decision. See [vector-index-support.md](feature-03-vector-index-support.md#open-decisions). + +💡 **Suggestion:** Until a reliable capability response exists, label Vector as a preview +with explicit Azure DocumentDB scope and explain that server support is verified on create. +Once capability data is available, gate the tab/options in the host router and return a +specific unsupported reason before the user fills the form. See [O1](#o1-how-should-vector-capability-be-communicated-item-1). + +> 🔗 **Tracked:** [#816 — Gate vector index creation by platform capabilities](https://github.com/microsoft/vscode-documentdb/issues/816) +> is targeted at the upcoming patch release and cross-references [#815 — Future work: add +> MongoDB Atlas Search Index tab](https://github.com/microsoft/vscode-documentdb/issues/815). +> A repository-wide search found no issues created in the previous seven days, so #816 does +> not duplicate recent work. + +### 2. Enabled empty Wildcard projection is silently omitted ⚠️ + +**Priority:** P1 · **Status:** 🚫 Closed + +> **Decision (Iteration 3) — Closed / won't fix:** leave the current behavior. **Reason +> (operator):** an empty projection still produces a valid Wildcard index definition; it is +> acceptable for the projection to be omitted and the index to cover all fields. + +**Observation to confirm:** Choose Wildcard → All fields, enable **Include or exclude +specific fields**, leave the only field row blank, and create. The UI visibly says the +projection option is on, but the created index has no projection and therefore covers all +fields. + +**Finding:** + +- ⚠️ Blank projection rows are skipped and an all-blank projection becomes `undefined` in + [wildcardIndexForm.ts](../../../../src/webviews/documentdb/indexView/wildcardIndexForm.ts#L151). +- ⚠️ `canSubmit` validates only the Wildcard path; it does not require a completed projection + field when `wildcardProjectionEnabled` is true. The payload includes the option only when + the collapsed object exists. See + [CreateIndexDrawer.tsx](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx#L599) + and [CreateIndexDrawer.tsx](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx#L647). +- ⚠️ This is silent semantic degradation: the request succeeds but creates a broader index + than the selected control communicates. + +💡 **Suggestion:** While the projection switch is on, require at least one non-empty field, +mark the field list required, and use the existing footer requirement line to explain what +blocks Create. See [O2](#o2-what-should-an-empty-enabled-projection-mean-item-2). + +### 3. Revealed custom-name inputs have no accessible name ⚠️ + +**Priority:** P1 · **Status:** ✅ Implemented in commit `414c0d2c` + +> **Decision (Iteration 3):** fix the accessible name. **Reason (operator):** the revealed +> custom-name field is an interactive input and must announce its purpose independently of +> the switch that reveals it. + +**Observation to confirm:** With a screen reader, enable **Name - use a custom index name** +in Standard/Wildcard and Vector. Move into the revealed edit field and listen for whether +its purpose is announced. + +**Finding:** + +- ⚠️ Both revealed inputs sit inside a `` with no `label`, while the `` has no + `aria-label`/`aria-labelledby`. The preceding switch label does not programmatically name + the newly revealed input. See + [CreateIndexDrawer.tsx](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx#L837). +- 🔍 Dimensions and TTL use labeled Fluent `Field`s, so the custom-name controls diverge from + the drawer's own accessible form pattern. + +💡 **Suggestion:** Give each revealed input a visible `Field` label such as **Index name**. +That is clearer for sighted users scanning the indented control and provides the accessible +name without a separate ARIA-only string. + +> ✅ **Implemented (Iteration 3):** added a visible localized **Index name** `Field` label to +> the Standard/Wildcard and Vector custom-name inputs. File: +> [CreateIndexDrawer.tsx](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx). +> Committed in `414c0d2c`. Verified by TypeScript and focused +> formatting checks; full validation is recorded in the iteration outcome. + +### 4. Advanced/Preview page changes do not manage or restore focus ⚠️ + +**Priority:** P1 · **Status:** ✅ Implemented in commit `414c0d2c` + +> **Decision (Iteration 3):** manage focus on every pushed-page transition. **Reason +> (operator):** keyboard and screen-reader users need an explicit signal that the drawer body +> changed, and Back must return them to the control that opened the page. + +**Observation to confirm:** Use only the keyboard to activate **Advanced settings** or +**Preview as JSON**, then use either Back affordance. Check where focus lands and whether a +screen reader announces the new page title and context. + +**Finding:** + +- ⚠️ Activating either entry changes `page`, replacing the focused main-page button with a + different DOM subtree. Neither transition moves focus to the new page. See + [CreateIndexDrawer.tsx](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx#L880). +- ⚠️ Back changes `page` to `main` but does not restore focus to the entry that opened the + sub-page. The component has refs for algorithm cards, but no page heading/entry refs or + focus effect. See + [CreateIndexDrawer.tsx](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx#L943). +- 🔍 Fluent owns the drawer's initial focus behavior, but these custom in-drawer route changes + still need explicit focus placement/restoration. + +💡 **Suggestion:** Record the opening entry, focus the pushed page heading (or first field) +after navigation, and restore focus to that entry on Back. Confirm the final behavior with +keyboard traversal and Narrator/Screen Reader. + +> ✅ **Implemented (Iteration 3):** Advanced and Preview now record their opening entry, +> focus the pushed-page title, and restore focus to the matching entry on Back. The focused +> title has a visible focus ring. Files: +> [CreateIndexDrawer.tsx](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx) · +> [indexView.scss](../../../../src/webviews/documentdb/indexView/indexView.scss). Committed in +> `414c0d2c`. Verified by TypeScript and focused formatting +> checks; complete keyboard/screen-reader confirmation remains part of the live pass. + +## P2 — Polish, expectation, or feature gap + +### 5. Standard and Wildcard option drafts must be independent ⚠️ + +**Priority:** P2 · **Status:** ✅ Implemented in commit `414c0d2c` + +> **Decision (Iteration 3):** make Standard, Wildcard, and Vector behave as three completely +> independent dialogs. **Reason (operator):** switching index kind must not carry names, +> partial filters, or collations into another kind; each tab represents a separate creation +> intent. + +**Observation:** Configure a custom name, partial filter, and collation in Standard, then +switch to Wildcard. The values carry over even though the tabs represent independent index +definitions. + +**Finding:** + +- ⚠️ Name, partial filter, and collation were one shared draft for Standard and Wildcard, + unlike Vector's independent values. This contradicted the three-dialog mental model. +- 🔍 The payload and preview builders already branch by active kind, so separating draft + storage does not change the host contract. + +💡 **Suggestion:** Keep per-kind values in separate state and select the active draft when +rendering, previewing, and submitting. + +> ✅ **Implemented (Iteration 3):** added independent Wildcard name, partial-filter, and +> collation fields while retaining the existing Standard and Vector drafts. Rendering, +> preview, and payload assembly now read and update only the active kind. Files: +> [wildcardIndexForm.ts](../../../../src/webviews/documentdb/indexView/wildcardIndexForm.ts) · +> [CreateIndexDrawer.tsx](../../../../src/webviews/documentdb/indexView/components/CreateIndexDrawer.tsx) · +> [wildcardIndexForm.test.ts](../../../../src/webviews/documentdb/indexView/wildcardIndexForm.test.ts). +> Committed in `414c0d2c`. Focused Jest result: 19 tests pass. + +### 6. Fixed-width rows and non-wrapping footer need narrow-panel proof ⚠️ + +**Priority:** P2 · **Status:** ✅ Implemented in commit `414c0d2c` + +> **Decision (Iteration 3):** add wrapping support where the layout permits it; accept the +> existing layout only if wrapping is not practical. **Reason (operator):** controls must +> remain reachable in narrow panels without introducing a larger responsive redesign. + +**Observation to confirm:** Narrow the Collection View until the drawer occupies the full +available width. Check every mode at 200% zoom and with long localized labels: field/type +rows, algorithm cards, Advanced entries, requirement text, and all footer actions must stay +visible and reachable without overlapping. + +**Finding:** + +- ⚠️ Standard field rows remain a single flex row while the type dropdown reserves `210px`. + See [indexView.scss](../../../../src/webviews/documentdb/indexView/indexView.scss#L528). +- ⚠️ The main footer is a single non-wrapping row containing the primary action, two icon + actions, and Reset. See [indexView.scss](../../../../src/webviews/documentdb/indexView/indexView.scss#L426). +- 🔍 Vector's dual fields and algorithm cards already wrap, so the responsive fallback is + inconsistent across sibling controls rather than wholly absent. + +💡 **Suggestion:** Confirm visually first. If controls clip, let the field row and footer +wrap at constrained widths, keeping the primary action first and Reset last; ensure the +requirement line wraps independently above them. + +> ✅ **Implemented (Iteration 3):** field and projection rows now wrap, the type selector can +> shrink from its preferred width, footer actions wrap, and the requirement line aligns +> correctly when it spans multiple lines. File: +> [indexView.scss](../../../../src/webviews/documentdb/indexView/indexView.scss). Committed in +> `414c0d2c`. Focused formatting and TypeScript checks pass; +> narrow-panel and 200% zoom inspection remain in the live pass. + +## P3 — Nice-to-have / cosmetic / acknowledged + +### 7. First-load failure still has no Retry affordance 🔁 + +**Priority:** P3 · **Status:** 🚫 Closed + +> **Decision (Iteration 3) — Closed / won't fix:** ignore the Retry variant. **Reason +> (operator):** the persistent toolbar already exposes Refresh, and this low-priority +> duplicate affordance is not worth additional UI. + +**Observation to confirm:** Force the initial list request to fail. Decide whether the +centered **Could not load indexes.** message provides enough recovery when the normal +Refresh toolbar remains visible, or whether an in-context Retry action is still warranted. + +**Finding:** + +- 🔁 Original finding 5 was implemented as a message with no button by operator decision, + then the original review's final audit explicitly left a Retry variant under + reconsideration. See [ux-review-iteration-1-2.md](ux-review-iteration-1-2.md#still-open-audit-2026-07-22). +- 🔍 The current state still renders only the message in + [IndexList.tsx](../../../../src/webviews/documentdb/indexView/components/indexList/IndexList.tsx#L191). + +💡 **Suggestion:** Judge this live rather than reopening it automatically. If the persistent +toolbar Refresh action is obvious in the failed state, close the question with that reason; +if not, add one compact Retry action to the state. + +## Implemented context + +These are current design decisions or completed work, not open findings: + +- ✅ Standard, Wildcard, and Vector retain fully independent drafts across tab switches, + including name and Advanced settings. +- ✅ Vector algorithm cards implement an ARIA radiogroup with roving `tabIndex` and arrow-key + selection. +- ✅ Vector algorithm tuning, numeric ranges, HNSW cross-field constraints, and compression + compatibility are validated before submission. +- ✅ JSON preview, direct create, Playground, and Shell all build from the same payload. +- ✅ Direct create still closes optimistically and preserves the complete active draft on + failure; Edit and retry reopens it. +- ✅ The drawer opens before optional field suggestions/document count complete, preserving + the prior review's no-blocking decision. +- ✅ The requirement line explains why create actions are disabled and uses `role="status"`. +- ✅ Large-collection guidance, list lifecycle announcements, modal user-action failures, + gated success summaries, and sort/expanded-row persistence remain in the current code. + +## Previous-review reconciliation + +The old review is not superseded silently. This table records the full carry-forward story. + +| Prior item | Previous terminal status | Iteration 3 treatment | +| ------------------------------------ | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| J1 · raw-definition failure surface | ✅ Implemented | 🔁 Recheck modal behavior from an expanded row | +| 1 · row progress timing | ✅ Implemented | 🔁 Run a slow delete/hide/unhide and confirm the spinner covers the real wait plus intentional tail | +| 2 · create-failure recovery | ✅ Implemented | 🔁 Reject Standard, Wildcard, and Vector creates; confirm Edit and retry restores the correct complete draft | +| 3 · feedback matrix | ✅ Implemented | 🔁 Recheck direct create, prepared hand-off, delete, hide/unhide, and raw-definition failures against the modal/toast rule | +| 4 · prerequisite degradation | ✅ Implemented | 🔁 Delay/fail suggestions and document count; drawer must open immediately and remain usable | +| 5 · could-not-load state | ✅ Implemented; Retry reconsidered | Item 7 closed without a Retry button; toolbar Refresh remains the recovery | +| 6 · accessibility announcements | ✅ Implemented | 🔁 Screen-reader pass for load/refresh plus new drawer status/focus changes | +| 7 · refresh preserves sort/expansion | ✅ Implemented | 🔁 Sort, expand, refresh, and confirm both remain stable | +| 8 · toolbar re-entry | 🚫 Closed | Keep closed: generation guards preserve correctness; do not reopen without new evidence | +| Audit A · no hands-on run | Open verification | This iteration is the requested hands-on run | +| Audit B · stale diagrams | Documentation hygiene | Superseded for current behavior by the diagrams in this file | +| Audit C · Retry question | Open product decision | Closed in item 7; no additional Retry affordance | +| Audit D · accepted residuals | Acknowledged | Recheck only if they are strongly felt live; do not relitigate by default | + +## Iteration log + +### Iteration 3 (2026-07-27) — pre-assessment seeded + +| # | Item | Decision (why) | Outcome | +| --- | --------------------------------- | --------------------------------------------------------------------------------- | --------------------------- | +| 1 | Vector capability gating | Defer proper gating to the patch release; coordinate provider switching with #815 | 🔗 Tracked in #816 | +| 2 | Empty enabled Wildcard projection | Accept omission because the resulting index definition is valid | 🚫 Closed | +| 3 | Custom-name accessible labels | Add visible labels so each revealed input has an independent accessible name | ✅ Implemented (`414c0d2c`) | +| 4 | Pushed-page focus management | Focus pushed-page title and restore the opening entry on Back | ✅ Implemented (`414c0d2c`) | +| 5 | Independent per-kind drafts | Treat the three kinds as fully independent dialogs | ✅ Implemented (`414c0d2c`) | +| 6 | Narrow-panel layout | Add wrapping where practical | ✅ Implemented (`414c0d2c`) | +| 7 | Could-not-load Retry | Ignore; toolbar Refresh is sufficient | 🚫 Closed | + +**Iteration 3 validation:** `npm run l10n`, `npm run prettier-fix`, `npm run lint`, +`npx jest --no-coverage` (165 suites, 2,747 tests), and `npm run build` all pass. Lint +reports only the existing ESLint v10 migration warning in `webpack.config.views.js`. +Runtime keyboard/screen-reader and narrow-panel visual checks remain part of the hands-on +review; they are verification of the implemented fixes, not open design decisions. + +## Open ideas — options, pros & cons + +### O1. How should Vector capability be communicated? (item 1) + +| Option | Pros | Cons | +| ---------------------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------- | +| **A. Capability-gate the tab** | Prevents unsupported work; clearest contract | Needs an authoritative capability source that does not exist yet | +| **B. Show Vector as Azure DocumentDB Preview** | Honest now; keeps live testing available | Still lets unsupported tiers reach a late server failure | +| **C. Leave fully enabled** | No extra UI or probing | A complete-looking form promises support the extension has not established | + +> 💡 **Suggested:** B now, then A when a reliable capability response exists. The host router +> should remain the enforcement point so Playground/Shell and direct create share the rule. + +> **Decision (Iteration 3):** keep the current experience for the initial release and track +> Option A for the upcoming patch in [#816](https://github.com/microsoft/vscode-documentdb/issues/816). +> **Reason:** proper platform gating needs an authoritative capability source and should be +> designed with Atlas provider switching in [#815](https://github.com/microsoft/vscode-documentdb/issues/815), +> while the temporary server-error boundary is acceptable for this release. + +### O2. What should an empty enabled projection mean? (item 2) + +| Option | Pros | Cons | +| ----------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------ | +| **A. Require at least one field while enabled** | Selected option always affects the result; straightforward validation | Adds one more disabled-submit state | +| **B. Automatically switch projection off when empty** | Request matches visible effective state | Automatic toggle can feel surprising while editing | +| **C. Treat empty as no projection** | Matches current serializer; no code change | Silently creates a broader index than the enabled control suggests | + +> 💡 **Suggested:** A. Reuse the existing footer requirement and field-row pattern so the +> correction is local and predictable. + +> **Decision (Iteration 3):** Option C; leave the current behavior. **Reason:** omitting an +> empty projection produces a valid all-fields Wildcard index, which is acceptable even when +> the projection switch was enabled before submission. + +## Appendix A — current flow reference + +### Phase 1: Enter and choose a kind + +Create Index opens the drawer before optional schema suggestions and collection count have +settled. The main page starts on Standard and exposes Wildcard and Vector as peer tabs. Each +kind owns a fully independent draft across tab switches and drawer hide/reopen, including +name and Advanced settings. Reset clears every kind. + +### Phase 2: Configure the main form + +Standard builds one or more typed keys and reveals only compatible TTL/sparse behavior. +Wildcard selects `$**` or a generated `path.$**` key and may add an include/exclude +projection. Vector selects one field, an algorithm, dimensions, and similarity. A live +requirement line explains incomplete required state; valid forms enable all three create +targets. + +### Phase 3: Inspect progressive options + +Advanced and Preview replace the drawer body with pushed pages. Standard/Wildcard Advanced +contains separate relaxed-JSON partial filter and collation editors for the active kind. +Vector Advanced contains the selected algorithm's tuning and compatible compression. Preview +renders the exact generated specification in read-only Monaco. On entry, focus moves to the +pushed-page title; Header and Footer Back restore focus to the opening entry. + +### Phase 4: Submit or hand off + +Direct create inserts an optimistic row and closes the drawer immediately. Success produces +a configured operation-summary toast and polling reconciles the row. Failure removes the +row, refreshes, and opens a modal whose Edit and retry action restores the preserved draft. +Playground/Shell preparation keeps the drawer open on failure and closes it when the target +opens successfully. + +### Phase 5: Regress the original journeys + +After the create-drawer pass, exercise list load/failure, filter-to-zero, sort/expansion +retention, raw-definition open failure, slow delete/hide/unhide, cancellation, success +summaries, modal failures, and screen-reader lifecycle announcements. These are not new +findings unless current runtime evidence shows that the redesign regressed them. diff --git a/docs/ai-and-plans/PRs/733-atlas-mongodb-discovery/decisions.md b/docs/ai-and-plans/PRs/733-atlas-mongodb-discovery/decisions.md new file mode 100644 index 000000000..33220eb86 --- /dev/null +++ b/docs/ai-and-plans/PRs/733-atlas-mongodb-discovery/decisions.md @@ -0,0 +1,270 @@ +# PR #733 — Atlas MongoDB Discovery: Work Items and Design Decisions + +**Branch:** `dev/bchoudhury/atlas-mongodb-discovery` +**Plugin path:** `src/plugins/service-atlas-mongodb/` +**Date:** 2026-06-15 + +--- + +## What Was Built + +A new **Service Discovery provider** for MongoDB Atlas, enabling users to browse their Atlas +**Projects → Clusters** hierarchy directly in the VS Code extension's Discovery View — alongside +the existing Azure DocumentDB provider. + +### Scope + +| Area | Files | +| ------------------------------------- | --------------------------------------------------------------------------- | +| Plugin registration | `AtlasDiscoveryProvider.ts`, `config.ts` | +| Auth: Quick Pick + two flows | `AtlasAuthQuickPick.ts`, `AtlasApiKeyFlow.ts`, `AtlasServiceAccountFlow.ts` | +| Auth: session state machine + storage | `AtlasSession.ts`, `AtlasSessionManager.ts`, `AtlasServiceAccountClient.ts` | +| Atlas Admin API client | `api/AtlasApiClient.ts`, `api/AtlasDigestAuth.ts` | +| Tree items | `AtlasServiceRootItem.ts`, `AtlasProjectItem.ts`, `AtlasClusterItem.ts` | +| Data models | `AtlasClusterModel.ts`, `AtlasProjectModel.ts` | +| New Connection Wizard integration | `SelectAtlasSteps.ts`, `AtlasExecuteStep.ts` | + +--- + +## Work Items + +### 1. Initial plugin scaffold and Atlas tree + +Created the full plugin structure: `DiscoveryProvider` implementation, tree items (root / project / cluster), Atlas Admin API client, session management, and the two auth flows (API key, service account). + +### 2. Service Account session refresh on expiry + +Added silent token refresh: when the Atlas API returns 401, the caller asks `sessionManager.tryRefreshIfPossible()` to mint a new Service Account token (from the stored `client_id`/`client_secret`) before giving up and signing the user out. `AtlasSessionManager.getSession()` also detects expiry from the stored `expiresAt` timestamp and refreshes before returning to callers. + +--- + +## Design Decisions + +### 1. Plugin folder, not a service (`src/plugins/` vs `src/services/`) + +The Atlas provider lives under `src/plugins/service-atlas-mongodb/` rather than `src/services/`. The rationale: it is self-contained (auth, API client, tree items, models all together) and follows the same pattern as any future third-party discovery source. A `services/` placement would imply a singleton shared across the whole extension; this is scoped to Discovery View only. + +### 2. Two authentication methods via a single QuickPick entry point + +The `AtlasAuthQuickPick` presents two options: API Key, Service Account. Both map to the same `AtlasSession` union type consumed by `AtlasApiClient`. + +**Why two methods, not one?** + +- **API Key (HTTP Digest)** — long-lived, no expiry; good for users who already manage Atlas API keys. +- **Service Account** — machine-to-machine (CI/CD, team shared credentials); uses `client_credentials` grant. + +A design that forced a single auth method would exclude large groups of Atlas users. + +### 3. Interactive browser sign-in (OAuth): rejected — no officially supported third-party path today + +An interactive, browser-based sign-in would be the most user-friendly option for human use. We investigated it and **rejected it for now**: MongoDB Atlas does not expose an officially supported, documented path that a third-party tool like this extension can rely on (no supported redirect-URI registration for unregistered apps, and no public device-authorization client registration). Building on undocumented or unsupported endpoints would create a fragile dependency we are not willing to ship. + +**Decision:** Ship with the two officially supported mechanisms — **API Key** (HTTP Digest) and **Service Account** (`client_credentials`). Revisit interactive sign-in as **future work** if and when MongoDB provides a supported integration path. + +### 4. HTTP Digest authentication required a custom implementation + +The Atlas Admin API uses **HTTP Digest Auth** for API Key authentication — not HTTP Basic and not Bearer. The native `fetch` API in Node.js does not handle Digest challenges automatically (unlike browsers or `curl`). A small custom implementation was written: + +- `api/AtlasDigestAuth.ts` — parses the `WWW-Authenticate: Digest ...` challenge header and computes the `Authorization: Digest ...` response per RFC 7616 (MD5, qop=auth). +- `AtlasApiClient` makes two requests per call for API key sessions: one unauthenticated to obtain the challenge, one authenticated with the computed header. + +**Considered alternative:** Use a third-party Digest-auth library. Rejected: adds a dependency for ~80 lines of well-understood crypto (just MD5 + nonce counting), which would need to be vetted for security and kept up to date. + +### 5. Two-layer auth model: Atlas Admin API ≠ MongoDB wire protocol + +Atlas has two completely independent authentication layers: + +- **Layer 1 — Atlas Admin API** (API Key / Service Account): used only for discovery — listing projects and clusters. This is `AtlasSession`. +- **Layer 2 — MongoDB wire protocol** (SCRAM username/password): used to actually connect to a cluster's database. This is handled by the existing `CredentialCache` / `ClustersClient` / `ClusterItemBase` machinery. + +`AtlasClusterItem` extends the shared `ClusterItemBase`, which already knows how to prompt for Layer 2 credentials. `getCredentials()` returns the connection string from the Atlas API response; the rest of the auth flow (username/password prompt) is inherited from `ClusterItemBase`. + +This separation is intentional: an Atlas Admin API session does not grant database-level access. Users must still authenticate with SCRAM credentials. + +### 6. `clusterId` format: stable, slash-free composite key + +The `BaseClusterModel` dual-ID pattern requires a stable `clusterId` for credential and client caching. Atlas cluster identifiers from the API are in the form `/`, which contains `/`. Since `/` is used as a path separator in tree IDs, the `clusterId` is constructed as: + +``` +atlas-mongodb-discovery_{projectId}_{clusterName} +``` + +This is stable even if the user moves the connection to a different folder (the `treeId` changes; `clusterId` does not). The provider prefix ensures no collision with Azure or other future discovery sources. + +### 7. Session state stored across VS Code restarts + +- **Secrets** (tokens, private keys, client secrets): stored in `vscode.SecretStorage` (OS-level keychain encryption). +- **Preferences** (auth method, selected projects, user display name): stored in `vscode.Memento` (globalState, plaintext, non-sensitive). + +`AtlasSessionManager.restoreSession()` rehydrates from SecretStorage on extension activation, so users do not have to re-authenticate every time they open VS Code. + +### 8. Silent Service Account token refresh before giving up on 401 + +When `getChildren()` receives a 401 from the Atlas API, the first action is `tryRefreshIfPossible()` — for a Service Account session, mint a fresh access token from the stored `client_id`/`client_secret`. Only if that refresh fails is the session cleared and the user shown a sign-in node. + +This prevents a jarring sign-out when the access token simply expired between sessions. + +**The opposite strategy (always re-prompt on 401) was rejected** because it would require users to re-authenticate whenever the short-lived token expired, making the discovery view unusable across a normal working day. + +### 9. 401 vs 403 handled differently + +- **401 Unauthorized**: the session is invalid/expired and refresh failed. Sign out completely (`sessionManager.signOut()`), reset to None state, show a "Sign in" node. +- **403 Forbidden**: the session authenticated, but the credentials lack the required permissions (e.g., an API key without the right project/org roles). For Service Account sessions, a silent token refresh is attempted first; if it still fails, the cached session is cleared via `sessionManager.signOut()` and an error node with the API message is shown. + +> **Revised (see Bug 5):** The original design did **not** sign out on 403, on the assumption that the credentials were correct and the user merely needed to be added to the project. In practice, the common 403 case is an under-privileged API key. Leaving the session cached meant "Manage Credentials" took the already-signed-in path and never let the user re-enter credentials. The session is now cleared on 403 so that "Manage Credentials" re-prompts for authentication. + +### 10. User display name loaded lazily, fire-and-forget + +The `getCurrentUser()` call is deliberately non-blocking (a `void`-dispatched promise). If it fails (network blip, partial permissions), the display name simply stays empty — no error is surfaced to the user. This avoids making the tree load feel slow for a cosmetic UI element. + +Service Accounts do not have a user profile, so the call is skipped entirely for `type === 'serviceaccount'`. + +### 11. Organization filter lives in "Manage Credentials", project filter in the Filter icon + +Two separate filtering mechanisms were built: + +- **Org filter** (`Manage Credentials` command): persisted in `globalState`, scopes the visible projects to those belonging to a selected Atlas organization. Intended for users who belong to many orgs. +- **Project filter** (tree `enableFilterCommand` context): further narrows which projects are shown within the already-scoped org view. + +These are stored independently because they serve different use cases (org scoping is a credential-level setting; project filtering is a view preference). + +### 12. Failed-children cache must be explicitly cleared after successful authentication + +`DiscoveryBranchDataProvider` (inherited from `BaseExtendedTreeDataProvider`) caches the error nodes that were returned by `getChildren()` for a given tree path. Without explicit clearance, a successful authentication followed by a tree `refresh()` would re-serve the cached sign-in error node rather than re-calling `getChildren()`. + +The `onDidChangeSession` listener in `AtlasDiscoveryProvider` therefore calls `resetNodeErrorState(rootId)` **before** `refresh()`: + +``` +transitionTo(Active) + → onDidChangeSession fires + → resetNodeErrorState(rootId) // clear cached error/sign-in node + → refresh() // VS Code re-calls getChildren() +``` + +Without the `resetNodeErrorState()` call, a user who authenticates successfully would still see the "Sign in" node until they manually collapsed and re-expanded the tree. + +### 13. Projects and organizations fetched in parallel + +`AtlasServiceRootItem.fetchProjectItems()` issues both `client.listProjects()` and `client.listOrganizations()` concurrently via `Promise.all`. The organization list is needed to resolve org names for the org-filter label and the "Manage Credentials" display — but it is not needed to build the project tree items themselves. Fetching them in parallel shaves one full round-trip off the perceived load time. + +**Considered alternative:** Fetch organizations lazily only when the "Manage Credentials" flow is opened. Rejected because the organization list is small (rarely more than a handful) and the parallel fetch cost is negligible, while a lazy fetch would add a noticeable delay to the manage-credentials dialog at a moment when the user is actively waiting. + +### 14. Atlas Admin API version pinned via `Accept` header + +All requests to the Atlas Admin API include: + +``` +Accept: application/vnd.atlas.2023-02-01+json +``` + +Atlas uses versioned media types to gate breaking schema changes. By pinning to `2023-02-01`, the extension is insulated from future response-shape changes (new required fields, renamed properties) that could silently break parsing. If Atlas introduces a newer, preferable schema in a future version, opting in is a deliberate one-line change in `AtlasApiClient`. + +### 15. Provider info resolved from `providerSettings` (legacy) with fallback to `replicationSpecs` (API v2) + +The Atlas Admin API v2 moved cloud provider metadata from the top-level `providerSettings` object to `replicationSpecs[].regionConfigs[]`. Both shapes appear in real responses depending on the cluster's age and tier: + +- **Legacy / free-tier clusters**: return `providerSettings: { providerName, regionName, instanceSizeName }` at the top level. +- **Newer clusters / API v2**: return `replicationSpecs[0].regionConfigs[0]` with the same fields nested inside. + +`createAtlasClusterModel()` checks `providerSettings` first; if absent, walks down into `replicationSpecs`. This ensures tier/provider/region labels (`M10, AWS, us-east-1`) display correctly for both old and new clusters without requiring two separate code paths. + +### 16. SRV connection string preferred over standard + +`AtlasClusterModel.connectionString` is populated as: + +```typescript +cluster.connectionStrings.standardSrv ?? cluster.connectionStrings.standard; +``` + +SRV records (`mongodb+srv://`) encode replica set membership and routing dynamically via DNS — the driver resolves the current set of replica nodes at connection time, and failover is handled transparently. The `standard` (host-list) format hard-codes the initial seed list, which can become stale after cluster scaling events or node replacements. + +Free-tier (M0) clusters and some legacy deployments do not publish an SRV record; for those the `standard` URI is the fallback. + +### 17. Sign-in placeholder node and "Manage Credentials" share a single command entry point + +When no session exists, `AtlasServiceRootItem` returns a `createSignInNode()` placeholder. That node is wired to the `discoveryView.manageCredentials` command with the root item as argument — the same command that appears in the right-click context menu on the "Atlas MongoDB" root node. + +This was intentional: rather than adding a bespoke `atlas.signIn` command, the sign-in node re-uses the existing credential management flow. There is one code path through `AtlasDiscoveryProvider.configureCredentials()` for all authentication triggers (initial sign-in, re-authentication, account switch), which makes the state machine easier to reason about and test. + +--- + +## Bugs Encountered and Fixed + +### Bug 1 — 401 on project-level cluster fetch signed the user out entirely + +**Symptom:** If the Atlas API returned 401 when expanding a **project** node (to load its clusters), the session was cleared and the root "Atlas MongoDB" node reverted to the sign-in state — even though the access token for the root-level project list had been working fine moments earlier. + +**Root cause:** `AtlasProjectItem.getChildren()` in the initial implementation handled 401 errors by calling `sessionManager.signOut()` immediately: + +```typescript +// BEFORE (broken — in AtlasProjectItem) +if (error instanceof AtlasApiError && error.statusCode === 401) { + await this.sessionManager.signOut(); + return [this.createSignInNode()]; +} +``` + +There was no attempt to refresh the token first. A 401 on a cluster fetch (e.g., after the access token expired mid-session while projects were already displayed) would destroy the entire session. + +**Fix:** Added the same refresh-then-retry logic to `AtlasProjectItem.getChildren()` that `AtlasServiceRootItem` already had: + +```typescript +// AFTER (fixed — in AtlasProjectItem) +if (error instanceof AtlasApiError && error.statusCode === 401) { + const refreshedSession = await this.sessionManager.tryRefreshIfPossible(); + if (refreshedSession) { + try { + const retryClient = new AtlasApiClient(refreshedSession); + const retryClusters = await retryClient.listClusters(this.project.id); + return retryClusters.sort(...).map(...); + } catch { + // Refresh succeeded but retry still failed — fall through to sign out + } + } + await this.sessionManager.signOut(); + return [this.createSignInNode()]; +} +``` + +**Why only the root item had the retry in the initial commit:** The refresh logic was added to `AtlasServiceRootItem` during design, but `AtlasProjectItem` was written later as a separate class and the pattern wasn't duplicated. This highlights a latent maintenance risk: if a new tree level is added (e.g., a database-level item that also calls the Atlas API), the refresh-then-retry pattern must be applied there too. A future refactor could centralise this in `AtlasApiClient` itself. + +--- + +### Bug 2 — 403 "Access denied" left a stale session cached, so "Manage Credentials" never re-prompted for credentials + +**Symptom:** When the Atlas Admin API returned `403 Forbidden` ("Access denied. Verify your API key has the required permissions.") — typically because the supplied API key lacked the required project/org roles — the tree showed an error node, but the under-privileged session stayed cached as `Active`. Clicking **Manage Credentials** then took the "already signed in" path (showing the account with a _Sign Out_ option) instead of letting the user re-enter their credentials. The only way to recover was to manually sign out first, then sign back in. + +**Root cause:** `AtlasServiceRootItem.getChildren()` treated 403 as a non-destructive "lacks permissions" case and returned an error node **without** clearing the session: + +```typescript +// BEFORE (broken — in AtlasServiceRootItem) +if (error.statusCode === 401) { + await this.sessionManager.signOut(); + return [this.createSignInNode()]; +} + +// 403 — genuinely lacks permissions +return [this.createErrorNode(error.message)]; +``` + +Because the session remained `Active`, `AtlasDiscoveryProvider.configureCredentials()` short-circuited into the signed-in branch and never reached `authenticateAndFetchUserInfo()` (which prompts for the auth method and credentials). `AtlasProjectItem.getChildren()` had the same gap — it only cleared the session on 401, not 403. + +**Fix:** On a 403, clear the cached session via `sessionManager.signOut()` in both tree levels (after the token refresh-then-retry attempt still fails). With the session reset to `None`, the next **Manage Credentials** invocation skips the already-signed-in path and prompts for authentication again. + +```typescript +// AFTER (fixed — in AtlasServiceRootItem) +if (error.statusCode === 401) { + await this.sessionManager.signOut(); + return [this.createSignInNode()]; +} + +// 403 — genuinely lacks permissions. Clear the cached session so that +// "Manage Credentials" re-prompts for authentication instead of showing +// the already-signed-in path with a stale, under-privileged session. +await this.sessionManager.signOut(); +return [this.createErrorNode(error.message)]; +``` + +`AtlasProjectItem.getChildren()` received the matching 403 branch (sign out + error node) alongside its existing 401 handling. + +**Why the original design was reconsidered:** The initial reasoning (Decision #9) assumed a 403 always meant "valid credentials, missing project membership," where signing out would be destructive. In practice the dominant 403 case is an API key that was never granted sufficient permissions — and for that case the user genuinely needs to supply different credentials, which the cached session was actively blocking. diff --git a/docs/ai-and-plans/PRs/733-atlas-mongodb-discovery/multi-credential-poc-plan.md b/docs/ai-and-plans/PRs/733-atlas-mongodb-discovery/multi-credential-poc-plan.md new file mode 100644 index 000000000..2be2abe3d --- /dev/null +++ b/docs/ai-and-plans/PRs/733-atlas-mongodb-discovery/multi-credential-poc-plan.md @@ -0,0 +1,1194 @@ +# Multi-credential Atlas discovery — feasibility POC plan & API research report + +> **Status:** Research + design proposal (no production code yet). +> **Scope:** Step 0 (POC) of the [Iteration 3 open-work ledger](./ux-review-iteration-3.md#step-0--multi-credential-feasibility-poc-do-first), +> covering review items **#7** (multi-credential management), **#8** (org level + tree/list), +> and **#12** (credential identity/label lifecycle). +> **Audience:** engineers deciding whether to build multi-credential Atlas discovery, and +> whether the effort is proportionate to the value (this feature may be scrapped if the +> effort proves disproportionate — hence this up-front research). +> **Author aid:** file references use paths relative to this document so they resolve on GitHub. + +--- + +## 0. TL;DR / recommendation + +**Feasible, and lower-risk than expected.** The Atlas Admin API and the extension's existing +storage/tree infrastructure support everything the POC needs. The single most important +finding reframes the whole feature: + +> **Each Atlas API Key and each Service Account belongs to exactly one organization, and can +> be granted access to _any subset_ of that org's projects (0…all, decided by its roles).** +> To see _N_ organizations you _must_ hold _N_ credentials — so multi-credential support is a +> prerequisite for multi-org support (item #8). But it is **not** a strict 1:1 "credential = +> org": within a single org you can legitimately hold several least-privilege credentials that +> each expose a _different subset_ of projects, and the org's full project set is their +> **union**. The data model must therefore key orgs/projects by Atlas ID and merge across +> credentials — see [§3.1](#31-credential-scoping--the-load-bearing-fact) and +> [§8 Q5](#8-answers-to-the-seven-poc-questions-ledger-step-0). + +Recommended shape: + +1. A **`AtlasCredentialStore`** built on the shared `StorageService` (the Kubernetes + `sourceStore` pattern), holding _N_ credential records (non-secret metadata in `properties`, + secrets in `SecretStorage`). +2. A **per-credential session/token layer** (`AtlasSessionManager` becomes one-per-credential, + owned by the store) so token refresh, expiry, and auth-method are isolated. +3. A **single aggregation API** — `AtlasDiscoveryService.listAll()` — that fans out across + credentials with `Promise.allSettled`, **never throws**, and returns + `{ organizations, projects, clusters, credentialErrors }`. Partial failure of one + credential never hides another's results. +4. **Parallel fan-out** across credentials (bounded by a concurrency limiter, cap ~4–5) is + safe and ~8× faster than sequential — proven by [Experiment 3](#experiment-3--parallel-vs-sequential-fan-out). + +**Effort:** medium. See [§11 effort & decision gates](#11-effort-estimate-decision-gates--scrap-criteria). +**Confidence in feasibility:** high (>90%). The blocking scope, aggregation, and error-taxonomy +assumptions passed the [live experiments](#102-experiments-requiring-a-live-atlas-account). + +--- + +## 1. Why this research exists + +The reviewer explicitly flagged this as an "API redesign" and the ledger deliberately puts a +**disposable POC first** so we validate the data/identity model before building UX on top of +it. If Atlas behaviour makes the model impractical (e.g. no stable identity, hostile rate +limits, or unavoidable all-or-nothing failure), we revise #7/#8 or scrap the feature rather +than sink UI effort into an unsupported assumption. This document does that validation on +paper and with isolated experiments, and lists the few checks that genuinely need a live +account. + +--- + +## 2. Current architecture (single-session baseline) + +| Concern | Today | File | +| -------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Session | Exactly **one** `AtlasSession` (API key _or_ SA) | [AtlasSessionManager](../../../../src/plugins/service-atlas-mongodb/auth/AtlasSessionManager.ts) | +| Secret storage | **Fixed single-slot keys** (`atlas-mongodb.apikey.publicKey`, …) | [AtlasSessionManager.ts#L15-L20](../../../../src/plugins/service-atlas-mongodb/auth/AtlasSessionManager.ts#L15-L20) | +| API client | One `AtlasApiClient` bound to one session; silent SA token refresh on 401/403 | [AtlasApiClient](../../../../src/plugins/service-atlas-mongodb/api/AtlasApiClient.ts) | +| Tree root | Fetches `listProjects()` + `listOrganizations()` for the single session | [AtlasServiceRootItem](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts) | +| Recovery UX | Per-session `sign-in` / `retry` / `update-credentials` error nodes | [AtlasServiceRootItem.ts#L136-L170](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts#L136-L170) | + +The single-slot secret keys are the structural blocker: they physically allow only one API key +and one Service Account. Everything else (client, tree, error nodes) is already +credential-agnostic enough to generalise. + +--- + +## 3. Atlas Admin API — research findings + +Sources: [Get Started with the Atlas Administration API](https://www.mongodb.com/docs/atlas/configure-api-access/), +[API Reference](https://www.mongodb.com/docs/atlas/api/atlas-admin-api-ref/), +[API Rate Limits](https://www.mongodb.com/docs/atlas/api/api-rate-limit/), +[API Authentication Methods](https://www.mongodb.com/docs/atlas/api/api-authentication/), +[Atlas User Roles](https://www.mongodb.com/docs/atlas/reference/user-roles/). + +### 3.1 Credential scoping — the load-bearing fact + +**API Keys and Service Accounts share one scoping model — the difference between them is only +the authentication _mechanism_ (Digest vs OAuth2), not scope.** The official wording is nearly +identical for both: + +- Service accounts: _"Each service account belongs to **exactly one organization**, and you can + grant it access to **any number of projects within that organization**."_ +- API keys: _"Each pair of API keys belongs to **only one organization**, and can grant access + to **any number of projects in that organization**."_ +- The org boundary is hard: the credential _"must be a member of the organization that hosts the + project. Otherwise, Atlas responds with a **401** error."_ ⇒ **to span _N_ orgs you need _N_ + credentials.** + +**Which projects a credential can see is decided by its _roles_, not by whether it is a key or a +service account** ([user-roles](https://www.mongodb.com/docs/atlas/reference/user-roles/)): + +| Role on the credential | Projects visible via `GET /groups` | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| `ORG_OWNER` | _"Project Owner access to **all projects** in the organization"_ → **every** project | +| `ORG_READ_ONLY` | _"read-only access to the settings, users, and **projects in the organization**"_ → **every** project (read-only) | +| `ORG_MEMBER` (+ project roles) | _"can only access projects they have been **explicitly added to**"_ → **subset** | +| project roles only (`GROUP_*`) | only the explicitly granted projects → **subset** | + +**Design implications (this corrects an earlier oversimplification):** + +- A credential maps to **exactly one org** (hard) but to a **subset of that org's projects** + (0…all, role-dependent). It is _not_ safe to treat "one credential" as "one whole org". +- Therefore **multiple credentials can share the same org** and each surface a _different_ + subset of projects; the org's full project list is their **union**. This is a legitimate, + common least-privilege pattern (a scoped key per team/project instead of one org-owner key). +- Consequently the tree's org level must be keyed by **`orgId`** and **merge/dedup projects + across all credentials** that resolve to that org, remembering which credential(s) can reach + each project (drives ownership for subsequent API calls — POC Q5/Q6). It is _not_ a simple + `credential → org node`. +- `GET /api/atlas/v2/orgs` for a credential returns the org(s) it belongs to (normally its one + org); `GET /api/atlas/v2/groups` returns exactly the project subset above. + +### 3.2 Authentication mechanics (already implemented, must go per-credential) + +| Method | Mechanism | Expiry | Refresh | +| --------------- | ----------------------------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| API Key | HTTP **Digest** (public key = user, private key = password) | Never expires | N/A — keys are long-lived | +| Service Account | OAuth2 **client_credentials** → Bearer token | **Access token: 3600 s (1 h)** | **Not refreshable.** Mint a _new_ token from `client_id`/`client_secret` at `POST https://cloud.mongodb.com/api/oauth/token` | + +Confirmed from docs: _"The access token is valid for 1 hour (3600 seconds). You can't refresh +an access token. When this access token expires, repeat this step to generate a new one."_ The +current code already does exactly this in +[`tryRefreshServiceAccount`](../../../../src/plugins/service-atlas-mongodb/auth/AtlasSessionManager.ts#L291-L317) +and [`AtlasServiceAccountClient`](../../../../src/plugins/service-atlas-mongodb/auth/AtlasServiceAccountClient.ts). +The client secret itself has a separate, user-chosen expiry (months) — when it lapses, token +minting fails with a 401 and the credential needs re-entry. + +### 3.3 Pagination + +`results` + `totalCount` + `links` (`prev`/`next`/`self`). Query params: `pageNum` (1-based, +default 1), `itemsPerPage` (default 100, **max 500**), `includeCount`. The current client reads +only page 1 ([`AtlasApiClient.listProjects`](../../../../src/plugins/service-atlas-mongodb/api/AtlasApiClient.ts#L50-L53)), +which silently truncates accounts with >100 projects/clusters. **The aggregation layer should +follow `links.next` (or loop `pageNum`) — a latent bug worth fixing while we are here.** + +### 3.4 Rate limits (Token Bucket) — parallelism is safe + +Atlas rate-limits per **endpoint set** and **scope** (`USER`, `GROUP`, `ORGANIZATION`, `IP`), +each with its own bucket. The endpoints discovery uses: + +| Endpoint | Scope | Capacity | Refill | +| ----------------------------- | --------- | -------- | ----------- | +| `GET /orgs` (list orgs) | **USER** | 300 | 100 / 60 s | +| `GET /groups` (list projects) | **USER** | 1200 | 500 / 60 s | +| `GET /groups/{id}/clusters` | **GROUP** | 10000 | 5000 / 60 s | + +Because `/orgs` and `/groups` are **USER-scoped**, and a credential is its own programmatic +"user", **each credential has an independent bucket**. Fanning discovery out across _N_ +credentials in parallel does **not** contend on a shared bucket. On 429 Atlas returns a +`Retry-After` header (and `RateLimit-Limit`/`RateLimit-Remaining`, which _may be absent_) and +an errorCode `RATE_LIMITED_TOKEN_BUCKET`. [Experiment 4](#experiment-4--token-bucket-headroom) +shows discovery spends ~1–2 tokens per credential per refresh — three orders of magnitude below +capacity. **Conclusion: parallel is fine; a bounded limiter and 429/Retry-After handling are +defensive, not load-bearing.** + +### 3.5 Error model — distinguishing "empty" from "broken" + +Error body fields: `detail`, `error` (int status), `errorCode` (stable constant), `parameters`, +`reason`. Two facts are critical for the "should I suggest a refresh?" UX: + +- **An empty list is `200` with `results: []`, _not_ `404`.** So "no projects" is an + authoritative, healthy answer — not an error. 404 is only returned when the _context_ does + not exist (e.g. projects of a non-existent org). +- **An enforced IP Access List rejection is `403`.** Atlas only enforces an empty API Access + List when the organization's **Require IP Access List for the Atlas Administration API** + setting is enabled. If that setting is disabled, an empty list permits requests from any + internet address; adding an entry makes the list restrictive. A credential can therefore be + valid yet return `403` when the requirement is enabled or a non-matching entry exists. This is + per-credential and recoverable by editing the Atlas API Access List — it must be reported as a + credential error, not a global sign-out. + +This lets the aggregation layer tag each credential result as one of: +`ok-with-data` · `ok-empty` · `auth-error(401)` · `forbidden(403)` · `rate-limited(429)` · +`network/other`. The "suggest a refresh" affordance is only shown for the recoverable error +states, never for `ok-empty`. + +--- + +## 4. Azure prior art in this repo — what to copy and what to avoid + +(From a full read of `src/plugins/api-shared/azure/`; captured in repo memory.) + +| Aspect | Azure implementation | Verdict for Atlas | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Aggregation entry point | Single `getSubscriptions(true)` flattens all tenants → one list | ✅ Copy the "one aggregation surface" idea | +| Fan-out | `Promise.all` over tenants for `isSignedIn` checks | ❌ **All-or-nothing** — one failing tenant discards _every_ account ([SelectAccountStep.ts#L135-L182](../../../../src/plugins/api-shared/azure/credentialsManagement/SelectAccountStep.ts#L135-L182)) | +| Concurrency | Shared limiter, cap 5, across wizard steps | ✅ Reuse [`createConcurrencyLimiter`](../../../../src/utils/concurrencyLimiter.ts) | +| Ordering gotcha | `getTenants` + `getSubscriptions` **must be sequential** — running them in parallel returned incorrect data (documented in-code) | ⚠️ Heed within a credential; across credentials it doesn't apply | +| Token refresh | Delegated to `@microsoft/vscode-azext-azureauth` (opaque) | ➖ Atlas has no such library; we own SA token minting (already do) | +| Per-account error node | **None** — a global "configure credentials" retry node only | ❌ Weaker than Atlas's existing per-session nodes; **do better** | + +**Net:** Azure gives us the "single aggregation surface + concurrency limiter" pattern to copy, +and a concrete anti-pattern to avoid (`Promise.all` all-or-nothing, no per-account error +surface). Atlas's _existing_ per-session error nodes are already better than Azure's; we +generalise them to per-credential. + +--- + +## 5. Proposed API-level design + +### 5.1 Storage — `AtlasCredentialStore` (StorageService, K8s `sourceStore` pattern) + +Model each credential as a `StorageItem` under `StorageService.get('atlas-mongodb-discovery')` +in a `credentials` workspace, mirroring [sourceStore.ts](../../../../src/plugins/service-kubernetes/sources/sourceStore.ts): + +```ts +interface AtlasCredentialRecordProps extends Record { + readonly authMethod: 'apikey' | 'serviceaccount'; + readonly label?: string; // user-supplied friendly name (optional) + readonly orgId?: string; // cached from first successful listOrgs() + readonly orgName?: string; // cached; primary display label (see §5.5) + readonly order: number; // stable display order + readonly version: '1'; // schema version for future migrations +} +// secrets[] (SecretStorage-backed): +// apikey → { publicKey, privateKey } +// serviceaccount→ { clientId, clientSecret, accessToken?, expiresAt? } +``` + +- **Stable ID:** a generated `randomUUID()` per credential (like K8s sources). Never derived + from the secret, so rotating a key keeps the same record ID and the same tree paths / saved + connections. (Answers POC Q1 + Q6.) +- **No migration:** Atlas discovery has never shipped, so the single-slot keys carry no user + data; the new store starts clean (matches the ledger's note). +- In-memory cache with explicit invalidation, exactly like `sourceStore`. + +### 5.2 Identity & per-credential session + +Refactor `AtlasSessionManager` from a **singleton holding one session** into a +**per-credential instance** owned by the store, _or_ keep one manager but key all its state by +`credentialId` (a `Map` + per-credential secret keys). Either +way: + +- Each credential restores independently after reload (separate secret slots ⇒ no cross-write). + (Answers POC Q2.) +- Each credential's SA token refresh is isolated: an expired token on credential A never + touches credential B. (Answers POC Q3.) +- `AtlasApiClient` is already constructed _per session_ + ([AtlasServiceRootItem.ts#L49](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts#L49)); + we simply build one per credential. + +### 5.3 The single "list everything" aggregation API + +```ts +interface CredentialError { + readonly credentialId: string; + readonly label: string; + readonly kind: 'auth' | 'forbidden' | 'rateLimited' | 'network' | 'other'; + readonly status?: number; + readonly message: string; + readonly retryable: boolean; // false only for unrecoverable/removed +} + +interface AtlasDiscoverySnapshot { + readonly organizations: Array; + readonly projects: Array; + readonly clusters: Array; + readonly credentialErrors: CredentialError[]; // partial-failure descriptors + readonly credentialsQueried: number; +} + +class AtlasDiscoveryService { + // Never throws. One call powers both tree modes and the wizard. + async listAll(signal?: AbortSignal): Promise; +} +``` + +Implementation (validated by [Experiment 2](#experiment-2--single-list-all-api-with-per-credential-isolation)): + +- Fan out across credentials with a **concurrency limiter (cap ~4–5)** wrapping + `Promise.allSettled`. +- Within a credential: `listOrgs()` + `listProjects()` (parallel is fine here — they are + different endpoints and different scopes), then clusters per project (bounded). +- A whole-credential auth failure is captured as a `CredentialError` and does **not** abort the + fleet. A single project's cluster-list failure is captured at project scope. +- Reuse the existing silent SA-token-refresh-and-retry in `AtlasApiClient.request` + [AtlasApiClient.ts#L100-L120](../../../../src/plugins/service-atlas-mongodb/api/AtlasApiClient.ts#L100-L120) + — it already does the "refresh once, retry once, else surface" dance per credential. + +### 5.4 Parallel vs sequential — decision + +**Parallel across credentials, bounded.** Justification: + +- Different credentials = different USER-scoped buckets ⇒ no shared rate limit (§3.4). +- ~8× wall-clock improvement for an 8-credential fleet ([Experiment 3](#experiment-3--parallel-vs-sequential-fan-out)). +- The Azure "sequential to avoid wrong data" gotcha is about `getTenants` vs + `getSubscriptions` _within one provider_; it does not apply across independent Atlas + credentials. **Keep org/projects sequencing sane _within_ a credential; parallelise + _across_ credentials.** +- Cap the fan-out (limiter) purely as defence against a user with dozens of credentials, not + because Atlas requires it. + +### 5.5 Display label (POC Q4) + +Atlas exposes **no user profile for Service Accounts** and only a programmatic identity for API +keys. Label resolution order: + +1. **User-supplied label** captured in the add/edit webview (item #6) — always wins if present. +2. **Org name** from the first successful `listOrgs()` (cached in `orgName`). Meaningful and + usually unique per credential — but note two credentials can share one org (§3.1), so the + org name alone is **not guaranteed unique**; disambiguate with the role/key hint below when + two labels collide. +3. **Fallbacks:** API key → `publicKey` prefix (e.g. `abcd1234…`); SA → `clientId` prefix. + Never render the secret. + +This also removes the current global `STATE_USER_DISPLAY_NAME` +([config.ts#L38](../../../../src/plugins/service-atlas-mongodb/config.ts#L38)), which is a +single-slot concept that cannot survive multi-credential. + +### 5.6 Token-refresh maintenance across the fleet + +- **On demand (lazy):** `getSession(credentialId)` checks SA expiry (existing + [`isExpired`](../../../../src/plugins/service-atlas-mongodb/auth/AtlasSessionManager.ts#L319-L323), + 60 s skew) and mints a fresh token if needed. API keys need nothing. +- **On 401/403 during a request:** existing refresh-once-retry-once in `AtlasApiClient`. +- **No background timer needed:** tokens are only needed at discovery/expand time; minting is + cheap (one POST) and the `oauth/token` endpoint is separate from the discovery buckets. + Refreshing all credentials at once is safe (Experiment 4). + +### 5.7 Auth-method strategy — offer both, default to Service Account, eye interactive OAuth + +Sources: [API Authentication Methods](https://www.mongodb.com/docs/atlas/api/api-authentication/), +[Connect from the Atlas CLI](https://www.mongodb.com/docs/atlas/cli/current/connect-atlas-cli/), +[Rotate Service Account Secrets](https://www.mongodb.com/docs/atlas/tutorial/rotate-service-account-secrets/), +[Terraform provider](https://registry.terraform.io/providers/mongodb/mongodbatlas/latest/docs). + +**Decision: keep both programmatic methods; make Service Account the recommended default and +API Key the simple fallback. Do _not_ collapse to a single method.** Rationale, grounded in +how MongoDB's own tools behave: + +- MongoDB's own interactive tool (Atlas CLI) offers **three** methods with explicit use cases: + `UserAccount` (browser device login) — _"best for non-programmatic use"_; `ServiceAccount` — + programmatic/CI; `APIKeys` — programmatic, _"doesn't require manual login"_. Both the + Terraform provider and the API docs mark **Service Accounts as recommended** and **API keys + as a "legacy" method** (not deprecated — still fully supported). +- The two methods have **different lifecycles**, which is the whole reason to keep both: + + | | Service Account | API Key | + | ------------- | --------------------------------------------------------------- | -------------------------------------- | + | Auth | OAuth2 client_credentials → 1 h token | HTTP Digest, no token | + | Secret expiry | **8 h – 365 d** (rotation required; Atlas alerts before expiry) | **Never expires** | + | Posture | Recommended, short-lived tokens, rotatable | Legacy, long-lived password-equivalent | + | Best fit | security-conscious / enterprise / org mandates SAs | set-and-forget personal desktop use | + +- **Why both, not one:** (1) SAs are new (GA ~2024) — a large installed base still uses API + keys; dropping them strands users. (2) Org policy varies — some orgs disable API-key + creation, others standardize on them; supporting both means the tool works regardless. + (3) The two paths **converge into one `AtlasApiClient`** right after auth (Bearer vs Digest + header — already abstracted), so the second path is near-free. (4) Ecosystem parity — Atlas + CLI and Terraform both support both. +- **UX obligation this creates:** because the SA client secret expires, the "recommended" path + must **handle secret expiry gracefully** (detect it, surface a clean "re-enter credentials" + recovery — the existing per-credential error nodes in §6 already cover this). Otherwise the + recommended method becomes the more annoying one for a long-lived desktop tool. +- **Strategic note — the "ideal" third path is currently _blocked_, not just deferred:** the + genuinely best single path for a **human-facing** UI tool would be the interactive + **`UserAccount` browser-OAuth device flow** (what the Atlas UI and `atlas auth login` use): + no stored secret, auto-refreshing session, and it spans **all** of the user's orgs/projects + automatically — which would largely dissolve the multi-credential problem this document + addresses. **However, there is no official way for a third-party application (this extension) + to register as an OAuth app with Atlas**, so this flow **cannot be implemented today** — it is + blocked on MongoDB providing public app registration, not merely on our effort. Until that + exists, the two programmatic methods above are the only viable options, which is _why_ the + multi-credential model and its UX ([§7](#7-credential-management--tree-ux-selected-design)) are + necessary rather than optional. + +--- + +## 6. Error reporting model — partial results with per-credential attribution + +This is the crux of the reviewer's concern ("how do we report an error while still returning +all other data?") and the hardest UX question. + +### 6.1 The principle + +`listAll()` returns **data _and_ errors together**. The tree renders both: + +- Healthy credentials → their org/project/cluster branches. +- Each failed credential → a scoped, actionable error node **under that credential's own org + node** (or, in list mode, a top-level row), reusing the existing + [`retry`/`update-credentials`/`sign-in` nodes](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts#L136-L170). + +### 6.2 The "no projects → suggest refresh" problem, generalised + +Today the logic is simple because there is one session: no projects ⇒ show an info node / suggest +retry ([AtlasServiceRootItem.ts#L96-L114](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts#L96-L114)). +With many credentials this must become **per-credential**, and it must distinguish _authoritative +emptiness_ from _failure_ (enabled by §3.4's `200 []` vs `403`/`401`): + +| Per-credential outcome | Tree presentation | Suggest refresh? | +| ------------------------ | -------------------------------------------------------------------------------------------------- | --------------------------- | +| `ok-with-data` | org → projects → clusters | no | +| `ok-empty` (`200`, `[]`) | org node + muted "No projects visible to this credential" | **no** (it's a true answer) | +| `forbidden (403)` | org node (if known) or credential row + "Access denied — check IP access list / roles" + **retry** | yes | +| `auth (401)` | credential row + "Credentials rejected — update credentials" + **update** | yes (via update) | +| `rateLimited (429)` | credential row + "Rate limited — retry shortly" (honour `Retry-After`) | auto-retry after delay | +| `network/other` | credential row + generic + **retry** | yes | + +A **fleet-level summary** is only shown when it adds signal, e.g. a status-bar / root +description like _"2 of 4 credentials failed to load"_, so the user notices partial degradation +without a modal per credential. Modals (the item #3 pattern) are reserved for the _single- +credential_ empty case to avoid modal storms. + +### 6.3 How Azure answers the same question (and why we go further) + +Azure's `getSubscriptions(true)` returns a flat list and, on a per-tenant auth problem, relies +on the auth library to prompt re-sign-in; the extension's own wizard uses `Promise.all` and +**collapses to an empty list on any failure** (§4). Azure's discovery tree shows a _single_ +global "configure credentials" node, not per-tenant errors. Our proposal is deliberately +**stronger**: `allSettled` + per-credential error descriptors + per-credential retry nodes, so +one dead credential degrades gracefully instead of blanking the view. [Experiment 1](#experiment-1--aggregation-semantics) +demonstrates the concrete difference. + +--- + +## 7. Credential management & tree UX (selected design) + +> **Scope:** happy path **1–4 credential sets**, not 100+; large-fleet concerns (search, grouping, +> virtualization) are out of scope. Earlier tree-view approaches that were considered and +> **dropped** are preserved in §7.7 with the reason for each. + +The selected design in one line: **credentials are managed in a QuickPick wizard (kept out of the +tree); the tree and list stay quiet on the happy path; a single `Click here to revisit +credentials` node appears whenever anything is wrong; and refresh simply retries everything.** + +### 7.1 Principles (what was chosen) + +- **Management lives outside the tree** — an Azure-style QuickPick wizard that launches the + item-#6 webview to add / edit credentials (§7.2). +- **Quiet tree** — healthy nodes carry **no** `via ` descriptions; a cluster shows its + **state only when it is not `IDLE`** (`Updating…`, `Paused`). +- **One consolidated error node** — on any credential failure the view shows a single actionable + **`⚠ Click here to revisit credentials`** row. The label is short; the **tooltip** carries the + detail (which credentials, and why). Clicking opens the management wizard (§7.2). +- **Identical in Tree and List modes** — the same error node appears in both; there is **no** + view-mode switching or blocking (§7.4). +- **Empty state = the standard `empty` placeholder** already used in the Connections view + (`$(indent)` icon, label `empty`, detail in the tooltip) — not a sentence (§7.3). +- **Refresh retries everything** — one refresh re-attempts every credential; no modals (§7.5). +- **Every row is a real resource or an action** — no bare informational nodes. + +### 7.2 Credential management wizard (QuickPick + webview) — paths & flows + +Management mirrors [`configureAzureCredentials`](../../../../src/plugins/api-shared/azure/credentialsManagement/configureAzureCredentials.ts). +Entry points: the discovery root's inline **gear** (the existing `manageCredentials` command), +the root context menu, and the **`Click here to revisit credentials`** error node (§7.3). + +**The list (what you have):** + +```text +Manage MongoDB Atlas Credentials +────────────────────────────────────────────────────────── +$(key) Acme Corp — API Key Signed in +$(cloud) Beta Ltd — Service Account ⚠ Secret expired +$(cloud) Gamma Inc — Service Account Signed in +────────────────────────────────────────────────────────── +$(add) Add a credential… +$(sign-out) Sign out of all +$(close) Exit +``` + +**Per-credential actions** (select a row → submenu, Azure's `TenantActionStep` shape): + +```text +Beta Ltd — Service Account +────────────────────────────────────────────────────────── +$(refresh) Retry +$(key) Update credentials… ▸ opens webview (edit) +$(trash) Remove ▸ deletes only this credential's secrets +$(arrow-left) Back +``` + +**Add** launches the item-#6 webview, which gains a **method-choice first step** (the reviewer's +request) — the natural home for the "which should I use?" guidance from §5.7: + +```text +┌ Add a MongoDB Atlas credential ─────────────────────────────┐ +│ How do you want to connect? │ +│ │ +│ ◉ Service Account (recommended) │ +│ OAuth2 client ID + secret. More secure; the secret │ +│ expires (8h–365d) and must be rotated periodically. │ +│ │ +│ ○ API Key (legacy · simplest) │ +│ Public + private key. Never expires — good for a │ +│ personal, set-and-forget setup. │ +│ │ +│ [ Cancel ] [ Continue ▸ ] │ +└─────────────────────────────────────────────────────────────┘ + │ Continue + ▼ + Step 2: the method-specific form (existing AtlasCredentialsView), + with inline "where to find this in Atlas" help + validation. +``` + +Keeping the chooser **inside** the webview (not a separate QuickPick) makes the whole add-flow one +guided surface, and lets the toggle live-swap the form fields + help text. + +**Add flow (happy path + failure):** + +```mermaid +flowchart TD + Q["Manage Credentials QuickPick"] -->|"Add a credential…"| W1["Webview · choose method"] + W1 -->|"Continue"| W2["Webview · enter secret, submit"] + W2 --> V{"Validate host-side
(listProjects)"} + V -->|"200 OK"| OK["Store credential · toast · tree refreshes"] + V -->|"401 / 403 / network"| ERR["Inline MessageBar in webview
secret retained · webview stays open"] + ERR -->|"correct & resubmit"| W2 + ERR -->|"close webview"| CX["Cancelled · nothing stored"] +``` + +- **Validation is host-side before storing** (§5.3): a Service Account that can mint a token but + cannot `listProjects` is still rejected with an inline hint, so we never store a credential that + cannot discover anything. +- **Failure keeps the webview open** with the entered secret retained, so the user fixes the + Atlas-side issue (roles / IP access list) and resubmits — no restart. +- **Cancel / close stores nothing** and leaves any previously-working credential untouched. + +**Recover an existing credential (from the error node):** + +```mermaid +flowchart LR + N["Tree/List: ⚠ Click here to revisit credentials"] --> Q["Manage Credentials QuickPick
(failed credentials flagged)"] + Q -->|"pick failed credential"| A["Retry · Update · Remove"] + A -->|"Retry"| RT["re-run listAll for it · clears on success"] + A -->|"Update credentials…"| WV["webview (edit) → validate → replace secret"] + A -->|"Remove"| RM["drop credential · its nodes disappear"] +``` + +- **Update** replaces the stored secret only **after** the new one validates, so a failed update + never destroys the previous working credential (§5.2, item #12). The Public Key or Client ID is + the immutable identity of the record: edit mode displays that field disabled and allows only the + Private Key or Client Secret to rotate. To use a different identity, remove the old entry and add + a new credential. +- **Remove** deletes only that credential's secrets and its tree nodes; other credentials are + untouched. +- **Sign out of all** clears every credential after a single confirm. + +### 7.3 Tree mode — the quiet tree + +The org level is the natural top level (each credential resolves to one org; §3.1); projects merge +across credentials by `orgId` (§3.1, §8 Q5). On the happy path the tree is **pure structure** — no +descriptions, cluster state shown only when it is not `IDLE`: + +```text +🌩 MongoDB Atlas +├─ 🏢 Acme Corp +│ └─ 📁 Payments +│ └─ 🍃 payments-prod +├─ 🏢 Beta Ltd +│ ├─ 📁 Web +│ │ └─ 🍃 web-cluster +│ └─ 📁 Analytics +│ └─ 🍃 analytics-rs Updating… ← state shown only when not IDLE +└─ 🏢 Gamma Inc + └─ 📁 Research + └─ 🍃 research-flex +``` + +**On any credential failure — one node, not many.** All failing credentials collapse into a single +top row; the detail lives in its tooltip and in the wizard it opens: + +```text +🌩 MongoDB Atlas +├─ ⚠ Click here to revisit credentials ← tooltip: "2 credentials need attention: +├─ 🏢 Acme Corp Beta (session expired), Gamma (access denied)" +│ └─ 📁 Payments +│ └─ 🍃 payments-prod +└─ 🏢 Gamma Inc + └─ 📁 Research + └─ 🍃 research-flex +``` + +**Partially-affected org** (two credentials for one org, one fails). The org keeps rendering its +healthy projects; a **warning icon only** (no description) marks it, and the single error node +handles recovery: + +```text +├─ 🏢 Acme Corp ⚠ ← tooltip: "Some projects may be hidden — a credential for +│ ├─ 📁 Payments this org needs attention. Click 'revisit credentials'." +│ └─ 📁 Web +``` + +- **≥ 1 healthy credential for the org** → it renders its (partial) merged projects + the warning + icon; the error node handles recovery. +- **No healthy credential for the org** → its data is absent; only the error node represents it. +- **Attribution** uses the failed credential's **cached** `orgId`/`orgName` (§5.1); a project both + credentials can see is deduped by `projectId` (§8 Q5). + +**Empty** (a credential authenticates but sees no projects, `200 []`). Reuse the **`empty` +placeholder** from an empty folder in the Connections view — `$(indent)` icon, label `empty`, +explanation in the tooltip — nothing more: + +```text +├─ 🏢 Delta Co +│ └─ $(indent) empty ← tooltip: "This credential can't see any projects yet. +│ Check its project access / roles in Atlas." +``` + +### 7.4 List mode — same error node, no switch + +List mode (item #8) flattens to clusters with `org · project` in the description — that context +**stays** (it is useful). The quiet-tree rules apply equally: no per-cluster noise, and the **same** +`Click here to revisit credentials` node sits at the top when anything fails. No view switching, no +blocking. + +**Happy path:** + +```text +🌩 MongoDB Atlas ☰ List +├─ 🍃 payments-prod Acme Corp · Payments +├─ 🍃 web-cluster Beta Ltd · Web +├─ 🍃 analytics-rs Beta Ltd · Analytics Updating… +└─ 🍃 research-flex Gamma Inc · Research +``` + +**With a failure — identical error node, in place:** + +```text +🌩 MongoDB Atlas ☰ List +├─ ⚠ Click here to revisit credentials ← same node as Tree mode; same tooltip + action +├─ 🍃 payments-prod Acme Corp · Payments +└─ 🍃 research-flex Gamma Inc · Research +``` + +Because the error node is just another row, List mode needs no special-casing — the earlier +"force Tree / disable List" workaround is dropped (§7.7). The empty state uses the same `empty` +placeholder as Tree mode. + +### 7.5 Refresh & retry behavior + +**Refresh retries everything.** A tree refresh re-runs `listAll()` across **all** credentials — +healthy and failed alike — and re-renders. No modals, no per-credential prompts: one click, whole +fleet re-attempted. + +**Accepted trade-off.** Elsewhere the design deliberately gates retries behind the explicit error +node, so a persistently-failing credential is not hammered on every tree expansion. A manual +**refresh** intentionally bypasses that gate and retries all — including credentials the user has +not touched. For the 1–4 credential happy path this is fine: a refresh is a deliberate action, and +the cost is a handful of requests against independent per-credential rate buckets (§3.4). We accept +it as a **niche** trade-off rather than tracking per-credential retry-eligibility through a manual +refresh. + +- **On expand / passive load** — a failed credential stays collapsed behind its error node and is + **not** silently re-attempted (prevents retry storms). +- **On explicit refresh** — everything is re-attempted (the user asked for it). +- **On the error node → Retry** — only that one credential is re-attempted (§7.2). + +### 7.6 Scenario catalog — what renders + +At happy-path scale every state maps to a small set of renderings (building on §6.2). The +"two credentials, one org, one fails" case (§7.3) is the only one that keeps an org **partially** +visible; all other failures collapse to the single error node. + +| Scenario | Tree mode | List mode | +| --------------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------- | +| all healthy | org → project → cluster (quiet) | flat clusters, `org · project` | +| a cluster not `IDLE` | state shown on that cluster only | state in the row | +| a credential `200 []` (no projects) | org → `empty` placeholder | (org absent from a flat list) | +| any credential `401` / `403` / network | one `⚠ Click here to revisit credentials` node | the same node, at the top | +| one org, 2 creds, one fails | org renders its healthy projects + **warning icon**; plus the error node | clusters from the healthy cred + the error node | +| org reachable via no healthy credential | its data is absent; plus the error node | absent; plus the error node | +| whole fleet failed | only the error node under the root | only the error node | + +The error node's **tooltip** always enumerates the affected credentials and reasons; the **label** +never changes (`Click here to revisit credentials`), so the view stays quiet no matter how many +credentials failed. + +### 7.7 Archive — tree-view approaches considered and dropped + +Kept for the record. Each was viable; the operator chose the quieter model above. + +**A1 — Verbose merged tree (descriptions + per-credential inline attention nodes).** The original +form of §7.3: healthy nodes carried `via API Key` / `via Service Account`, the root description +counted failures, and each failed credential expanded into its own attention node with child +**retry** + **update credentials** rows: + +```text +🌩 MongoDB Atlas 2 of 3 credentials need attention +├─ 🏢 Acme Corp via API Key +│ └─ 📁 Payments +│ └─ 🍃 payments-prod IDLE +├─ ⚠ Beta Ltd — session expired Service Account +│ ├─ ↻ Click here to retry +│ └─ 🔑 Click here to update credentials +└─ ⚠ Gamma Inc — access denied Service Account + └─ ↻ Click here to retry (check IP access list / roles) +``` + +**Why dropped:** description noise — warnings accumulated across the root description **and** every +affected node **and** child action rows. Superseded by the single `Click here to revisit +credentials` node + tooltip (§7.3), with recovery handled in the wizard. _(In-situ recovery nodes +remain a possible progressive-disclosure enhancement — expand the single node into these on click +— but are not the default.)_ + +**A2 — List mode: auto-switch to Tree + disable List on error.** An earlier version of List mode +treated a flat +list as unable to host an error, so any failure forced Tree mode and greyed out the List toggle: + +```text +🌩 MongoDB Atlas ☰ List (unavailable) 2 of 3 credentials need attention + ⓘ Switched to Tree view — resolve the flagged credentials to re-enable List view. +``` + +**Why dropped:** it was a "magic switch" — the layout changed under the user. Because the +consolidated error node is just another row, it drops into a flat list unchanged (§7.4), so List +mode needs no special-casing at all. + +**A3 — Separate "group by credential" view mode, and a permanent credential level.** Considered +early and dropped before this iteration: a second toggle duplicated the existing item-#8 Tree/List +toggle, and a permanent credential root (a connection-manager level) added a hop the common +1-credential user never needed and duplicated the wizard. The org-keyed merged tree (§7.3) plus the +wizard (§7.2) cover both goals without either. + +**A4 — Actionable deep-link empty node.** An earlier iteration proposed replacing the bare +"No projects visible" row with an actionable `🔗 Grant this credential access to a project…` +deep-link into the Atlas console (plus variants: fold into the parent tooltip, surface in the +wizard, or merge into the error node). + +**Why dropped:** the operator chose the **existing `empty` placeholder** convention instead +(Connections view: `$(indent)` icon, label `empty`, detail in the tooltip; §7.3) — a deep-link URL +cannot always be constructed reliably, whereas `empty` is a known, quiet pattern already used in +the extension. The permissions hint lives in the tooltip. + +--- + +## 8. Answers to the seven POC questions (ledger §Step 0) + +1. **Stable non-secret ID + secrets in SecretStorage via StorageService?** ✅ Yes — `randomUUID` + record ID, secrets in `secrets[]`, exactly the K8s `sourceStore` shape (§5.1). +2. **Independent restore after reload without cross-overwrite?** ✅ Yes — per-credential secret + slots keyed by ID replace the fixed single-slot keys (§5.1–5.2). +3. **Independent per-credential discovery incl. SA token refresh, no global session?** ✅ Yes — + per-credential session + per-credential `AtlasApiClient`; refresh isolated (§5.2, §5.6). +4. **Stable user-facing label without a user profile (esp. SA)?** ✅ Org name (cached) with + user-label override and key/clientId-prefix fallback (§5.5). +5. **Duplicate org/project/cluster across credentials — merge, once+attribution, or per + credential?** → **This is now a first-class case, not a rare one:** two least-privilege + credentials can belong to the **same org** and expose overlapping or disjoint project subsets + (§3.1). **Recommended:** key every resource by its Atlas ID (`orgId` / `projectId` / + `clusterId`); the org node is keyed by `orgId` and its project children are the **union** + across all credentials resolving to that org, deduped by `projectId`; each merged node + remembers the **set of credentials** that can reach it and picks the first healthy one as the + action owner. In **list mode / wizard**, dedup clusters by `clusterId` (or SRV string) the + same way. Alternative (simpler for POC): no merge, group strictly per credential — accept that + the same org/project may appear under two credentials. (See [alternatives](#12-alternatives).) +6. **Which credential owns a node / subsequent request, retained through refresh/retry/connect?** + ✅ Every snapshot row carries `credentialId`; tree items store it; the wizard threads it into + connection creation. Stable because the ID is secret-independent (§5.1). +7. **One valid + one expired/denied/removed — failure must not hide others.** ✅ `allSettled` + aggregation with per-credential `CredentialError`; proven by [Experiment 1](#experiment-1--aggregation-semantics) + and [Experiment 2](#experiment-2--single-list-all-api-with-per-credential-isolation). + +--- + +## 9. Reference architecture (diagram) + +```mermaid +flowchart TD + Store["AtlasCredentialStore
StorageService: N records"] --> Svc["AtlasDiscoveryService.listAll"] + Svc -->|"Promise.allSettled + limiter cap ~5"| C1["Credential 1
session + AtlasApiClient"] + Svc --> C2["Credential 2 ..."] + Svc --> Cn["Credential N"] + C1 -->|"orgs + projects + clusters, or error"| Agg["Snapshot
organizations / projects / clusters
+ credentialErrors"] + C2 --> Agg + Cn --> Agg + Agg --> Merge["Merge by Atlas ID
orgId / projectId / clusterId
(union across credentials)"] + Merge --> Tree["Tree: org -> project -> cluster
each node remembers owning credential(s)
+ per-credential retry/update nodes"] + Merge --> Wizard["Add-connection wizard
dedup clusters by id"] +``` + +--- + +## 10. Experiments + +### 10.1 Experiments performed in isolation (no live account, no secrets) + +Run with `node experiment.mjs` (throwaway script kept out of the repo). Full script text is in +[Appendix A](#appendix-a--experiment-script). Verbatim results: + +#### Experiment 1 — aggregation semantics + +Models a fleet of 4 credentials (2 API keys + 2 SAs) with one 403 and one 401. + +``` +Promise.all → {"ok":false,"reason":"401 Token expired / client secret rotated"} +Promise.allSettled → {"orgs":2,"healthy":["Acme","Gamma"], + "failures":[{credentialId:k2,403},{credentialId:s2,401}]} +``` + +**Finding:** `Promise.all` loses _all_ data on the first rejection; `allSettled` yields the two +healthy orgs **and** two per-credential error descriptors. Validates §6. + +#### Experiment 2 — single "list all" API with per-credential isolation + +``` +Aggregated in 140ms → {"orgs":["Acme","Gamma"],"projects":4,"clusters":4, + "errors":["credential:s2 (401)","credential:k2 (403)"]} +``` + +**Finding:** healthy credentials expand fully (2 orgs → 4 projects → 4 clusters); broken ones +surface as credential-scoped errors; **no throw escapes** `listAll()`. Validates §5.3. + +#### Experiment 3 — parallel vs sequential fan-out + +8 healthy credentials, 100 ms simulated latency each. + +``` +Sequential: 1604ms Parallel(cap8): 201ms speedup: 8.0x +``` + +**Finding:** parallel fan-out is ~8× faster; safe because USER-scoped buckets are +per-credential. Validates §5.4. + +#### Experiment 4 — token-bucket headroom + +``` +4 credentials, one full "refresh all" = 1 /orgs + 1 /groups token PER credential (separate buckets). +50 back-to-back refreshes = 50/300 /orgs and 50/1200 /groups per credential — far below capacity. +``` + +**Finding:** rate limiting is a non-issue for discovery; the limiter and 429 handling are +defensive only. Validates §3.4. + +### 10.2 Experiments requiring a live Atlas account + +These cannot be run autonomously because the security policy forbids routing secrets. The +operator completed the blocking API-key and Service Account scope checks on 2026-07-25. + +| # | Hypothesis to confirm | Method | Status | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| L1 | A credential belongs to exactly one org; `/groups` returns only the project subset its roles allow (all for `ORG_OWNER`/`ORG_READ_ONLY`, else explicit projects) | Create keys in 2 orgs + a scoped `ORG_MEMBER` key; call `/orgs` and `/groups` with each; confirm 1 org each and the expected project subset | **Passed:** different-org, same-org subset, healthy-empty, and Service Account parity observed | +| L2 | A valid credential rejected by an **enforced** API Access List returns **403**, not 401 | Enable the organization's API-access-list requirement or add a non-matching entry, omit the caller IP, and probe list plus detail calls for organizations, projects, and clusters | **Passed:** controlled non-match `403`, matching-IP `200`, and invalid-secret `401` observed | +| L3 | `>100` projects paginate as documented via `links.next` | **Not planned:** a representative live account is not feasible; implement pagination from the API contract and cover it with mocked multi-page tests instead | Mocked contract coverage required | +| L4 | SA token mint under concurrent refresh has no surprising throttle on `oauth/token` | **Telemetry-deferred:** do not manufacture live load; instrument production token-mint throttling/failures and review observed frequency | Production telemetry required | +| L5 | Two least-privilege credentials in the **same** org expose overlapping/disjoint project subsets (merge/union path) | Add 2 scoped keys to 1 org with different project roles; run `listAll()`; confirm the union merges by `projectId` | **Passed:** overlapping and disjoint project sets produced the expected deduplicated union | + +> **Conclusion:** L1, L2, and L5 close the live feasibility gates. Proceed with production +> implementation and automated acceptance coverage. L3 will not be run live; use mocked +> pagination contract tests. L4 will be observed through privacy-reviewed production telemetry +> around token-mint throttling/failure classification instead of a synthetic live stress test. + +#### Live evidence recorded 2026-07-25 + +The submitted reports cover these exact combinations. Resource identifiers below refer to the +stable fingerprints in the sanitized reports; no raw Atlas identifiers or secrets are recorded. + +| Evidence | Organization/project setup | API Access List setup | Endpoint result | Verdict | +| -------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| L1-01 | Org-wide API keys in two organizations; two projects in organization A and one in B | Not under test | Each key returned one different organization; all organization/project details succeeded; cluster list/detail succeeded where a cluster existed | **Passed:** different-organization attribution and detail retrieval | +| L1-02 | Org-wide key, one-project key, and no-project key in organization A | Not under test | All shared one org fingerprint; visible project counts were 2, 1, and 0; union contained two projects and overlap contained only the scoped project | **Passed:** role subset, overlap, deduplication, and healthy `200 []` emptiness | +| L1-03 | Two scoped API keys in organization A, each assigned a different project | Not under test | Each returned one distinct project under the same organization; union contained both and overlap was empty | **Passed:** disjoint same-org union | +| L1-04 | API key and Service Account assigned the same project | Not under test | Both returned the same organization and project fingerprints; organization/project details and zero-result cluster lists succeeded | **Passed:** Service Account scope parity; cluster detail parity remains optional because this project had no cluster | +| L2-01 | Org-wide API key with two projects | Empty API Access List while the organization requirement was disabled | Organization/project list and detail calls returned `200`; cluster list/detail returned `200` where data existed | **Passed baseline:** empty non-required list is unrestricted; no detail-only restriction exists in the accepted state | +| L2-02 | Same valid key and roles as baseline | Organization requirement enabled; only a non-matching IP allowed | Both `/orgs` and `/groups` returned `403`; dependent detail and cluster probes could not run because no IDs were returned | **Passed rejection:** enforced non-match is forbidden, not authentication failure | +| L2-03 | Same valid key and roles | Actual caller IP allowed | All organization/project list and detail probes returned `200`; cluster list/detail returned `200` where data existed | **Passed recovery control:** changing only the allowlist restored access | +| L2-04 | Same public key with intentionally invalid private key; caller IP allowed | Matching IP | `/orgs` and `/groups` returned `401` | **Passed auth control:** invalid credentials are distinguishable from enforced-list `403` | + +#### L1/L2 assumption verdicts + +- **Verified:** an API key in each tested organization returned exactly one organization. +- **Verified:** credentials from different organizations returned different organization + fingerprints and independently attributed projects. +- **Verified:** project visibility is role-filtered; org-wide, one-project, disjoint-project, and + no-project (`200 []`) scopes returned the expected subsets and deduplicated union. +- **Verified:** API-key and Service Account credentials with equivalent roles returned the same + organization and project scope. +- **Disproved:** "caller IP absent from an empty API Access List" is sufficient to test rejection. + It is unrestricted while the organization requirement is disabled. +- **Disproved:** no project membership should surface as `401` or `403`; the observed response is + healthy `200 []`. +- **Verified for the controlled L2 run:** changing only the enforced allowlist produced `403`, + and allowing the caller restored `200`. Production must still retain the generic + `forbidden(403)` classification because unrelated role/authorization failures can also be + forbidden. +- **Disproved in the accepted state:** list calls and their follow-up detail calls all succeeded; + no detail-only restriction was observed. Under enforced rejection the list calls failed before + resource IDs were available, so dependent detail calls were intentionally not attempted. + +#### Residual live matrix + +| Priority | Combination | Minimum setup and expected evidence | Purpose | +| ----------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| **Optional parity** | Service Account enforced non-match/match | Repeat L2-02 and L2-03 with the tested Service Account. Token mint may succeed while Admin API calls return `403`; allowing the caller should restore `200`. | Confirms Service Account behavior through the Administration API refresh/retry path | +| **Optional parity** | Service Account cluster detail | Give the tested Service Account access to a project containing a cluster and rerun L1. | Exercises the only successful detail surface not reached in L1-04 | +| **Optional diagnostic** | Authorization-forbidden detail call | With a known resource ID outside a valid credential's role scope, call a detail endpoint and record `403`/`404`. Do not interpret either as IP-specific in production. | Documents provider behavior but does not block the generic forbidden UX | + +No remaining live check blocks implementation. The first two rows are recommended production +acceptance coverage; the final row is informational only. + +--- + +## 11. Effort estimate, decision gates & scrap criteria + +### 11.1 Effort (relative) + +| Slice | What | Size | +| ----- | ------------------------------------------------------------------------------------------------------------------------------- | ---- | +| A | `AtlasCredentialStore` on StorageService (copy `sourceStore`) | S–M | +| B | Per-credential session/token refactor of `AtlasSessionManager` | M | +| C | `AtlasDiscoveryService.listAll` aggregation + pagination fix | M | +| D | Tree: `orgId`-keyed org nodes merging projects across credentials + per-credential error/retry nodes (list mode later, item #8) | M | +| E | Manage-credentials QuickPick (Azure-style) + wire to add/edit webview (item #6) | M | +| F | Wizard: dedup + credential attribution through connect | S–M | +| G | Tests (unit for store/aggregation/error taxonomy) + l10n | M | + +No slice is "L". The refactor (B) touches the most files but is mechanical (single-slot → +keyed-by-ID). The aggregation (C) is the intellectually load-bearing piece and is already +prototyped here. + +### 11.2 Decision gates (build only if all hold) + +1. **L1 passed:** each tested credential mapped to one org with stable role-authorized project + subsets; same-org overlap/disjoint union and Service Account parity were observed. +2. **L2 passed:** enforced non-match `403`, matching-IP `200`, invalid-secret `401`, and healthy + `200 []` emptiness are empirically distinguishable. +3. **Product direction confirmed:** keep multiple organizations visible simultaneously through + the selected multi-credential design. + +**Blocking open questions: none.** L3 mocked pagination coverage, L4 production telemetry, and +the residual Service Account parity checks are implementation or acceptance work; they do not +block starting slices A–C. + +### 11.3 Scrap / de-scope criteria + +- If L1 shows credentials cannot be stably attributed to an org **and** the same-org merge + proves messy, **de-scope item #8's org tree** and ship item #7 as a flat, per-credential + cluster list only. +- If the manage-credentials UX (E) balloons, ship the **store + aggregation (A–C)** behind the + existing single-session UX first (invisible internal refactor), then add multi-credential UI + as a fast follow. A–C alone fixes the pagination bug and the all-or-nothing risk with zero + UX surface, so it is low-regret even if the feature is later cut. +- If none of the above and effort still feels disproportionate to demand, **scrap** — the + single-credential path already works and this document is the sunk cost, not the UI. + +--- + +## 12. Alternatives considered + +1. **Keep one global session, add a "switch credential" command.** Cheapest, but defeats the + reviewer's goal (see many orgs _at once_) and keeps the single-slot storage. Rejected as the + primary path; acceptable **fallback** if item #8 is de-scoped. +2. **No merge, always per-credential grouping.** Simplest aggregation (skip the union in Q5): + render each credential's org/project/cluster subtree independently, so the same org/project + may appear more than once when credentials share an org. Good **POC-stage** choice to defer + the merge; promote to the ID-keyed union (§8 Q5) once L5 confirms same-org overlap in practice. +3. **Bespoke secret store instead of `StorageService`.** Rejected — reinvents the K8s solution, + more migration risk, no upside. +4. **Background token-refresh timer.** Rejected — lazy refresh at expand time is sufficient and + avoids a lifecycle to manage (§5.6). +5. **Sequential fan-out** (Azure-style, for "safety"). Rejected — 8× slower for no benefit; + the Azure ordering gotcha doesn't apply across independent credentials (§5.4). + +--- + +## 13. Next actions + +1. Land **slices A–C** as an internal refactor (no UX change): credential store, + per-credential sessions, `listAll()` with pagination + `allSettled`. This is low-regret and + independently valuable. +2. Then build the manage-credentials QuickPick (E) + wire the item-#6 webview, followed by the + tree work (D) and item #8 modes. +3. Add the mocked L3 pagination contracts and L4 production telemetry while implementing the + corresponding slices; treat residual Service Account L2/detail checks as acceptance coverage. + +--- + +## Appendix A — experiment script + +The isolated experiment (no secrets, no network) used for §10.1. Kept out of the repo; reproduced +here for auditability. Run with `node experiment.mjs` on Node ≥ 18. + +```js +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// A tiny copy of src/utils/concurrencyLimiter.ts +function createConcurrencyLimiter({ concurrency }) { + const cap = Number.isFinite(concurrency) ? Math.max(1, Math.floor(concurrency)) : 1; + let active = 0; + const waiters = []; + const release = () => { + active--; + const resume = waiters.shift(); + if (resume) resume(); + }; + return async (fn) => { + if (active >= cap) await new Promise((res) => waiters.push(res)); + active++; + try { + return await fn(); + } finally { + release(); + } + }; +} + +class ApiError extends Error { + constructor(message, statusCode) { + super(message); + this.statusCode = statusCode; + } +} + +function makeCredential({ id, kind, orgName, latencyMs = 60, fail }) { + return { + id, + kind, + label: orgName ? `${orgName} (${kind})` : id, + async listOrgs() { + await sleep(latencyMs); + if (fail) throw fail(); + return [{ id: `${id}-org`, name: orgName ?? `${id}-org` }]; + }, + async listProjects() { + await sleep(latencyMs); + if (fail) throw fail(); + return [ + { id: `${id}-p1`, name: `${orgName}-proj-A`, orgId: `${id}-org` }, + { id: `${id}-p2`, name: `${orgName}-proj-B`, orgId: `${id}-org` }, + ]; + }, + async listClusters(projectId) { + await sleep(latencyMs); + if (fail) throw fail(); + return [{ id: `${projectId}-c1`, name: `${projectId}-cluster` }]; + }, + }; +} + +// Fleet: 2 API keys + 2 Service Accounts, one of each broken (403 / 401). +function makeFleet() { + return [ + makeCredential({ id: 'k1', kind: 'apikey', orgName: 'Acme', latencyMs: 50 }), + makeCredential({ + id: 'k2', + kind: 'apikey', + orgName: 'Beta', + latencyMs: 40, + fail: () => new ApiError('Access denied (IP access list)', 403), + }), + makeCredential({ id: 's1', kind: 'serviceaccount', orgName: 'Gamma', latencyMs: 70 }), + makeCredential({ + id: 's2', + kind: 'serviceaccount', + orgName: 'Delta', + latencyMs: 30, + fail: () => new ApiError('Token expired / client secret rotated', 401), + }), + ]; +} + +// EXPERIMENT 1 — Promise.all (all-or-nothing) vs Promise.allSettled (partial success) +async function experiment1() { + const fleet = makeFleet(); + let a; + try { + const orgs = await Promise.all(fleet.map((c) => c.listOrgs())); + a = { ok: true, orgs: orgs.flat().length }; + } catch (err) { + a = { ok: false, reason: `${err.statusCode ?? ''} ${err.message}`.trim() }; + } + console.log('Promise.all →', JSON.stringify(a)); + + const settled = await Promise.allSettled(fleet.map((c) => c.listOrgs())); + const orgs = [], + failures = []; + settled.forEach((res, i) => { + const cred = fleet[i]; + if (res.status === 'fulfilled') orgs.push(...res.value.map((o) => ({ ...o, credentialId: cred.id }))); + else + failures.push({ + credentialId: cred.id, + label: cred.label, + error: res.reason.message, + status: res.reason.statusCode, + }); + }); + console.log( + 'Promise.allSettled →', + JSON.stringify({ orgs: orgs.length, healthy: orgs.map((o) => o.name), failures }), + ); +} + +// EXPERIMENT 2 — single "list all" API: never throws, returns data + per-credential errors +async function aggregateAll(fleet, { credentialConcurrency = 4, perCredConcurrency = 4 } = {}) { + const credLimit = createConcurrencyLimiter({ concurrency: credentialConcurrency }); + const result = { orgs: [], projects: [], clusters: [], errors: [] }; + await Promise.all( + fleet.map((cred) => + credLimit(async () => { + try { + const [orgs, projects] = await Promise.all([cred.listOrgs(), cred.listProjects()]); + orgs.forEach((o) => result.orgs.push({ ...o, credentialId: cred.id })); + const clusterLimit = createConcurrencyLimiter({ concurrency: perCredConcurrency }); + await Promise.all( + projects.map((p) => + clusterLimit(async () => { + result.projects.push({ ...p, credentialId: cred.id }); + try { + const clusters = await cred.listClusters(p.id); + clusters.forEach((c) => result.clusters.push({ ...c, projectId: p.id, credentialId: cred.id })); + } catch (err) { + result.errors.push({ + scope: 'project', + credentialId: cred.id, + projectId: p.id, + error: err.message, + status: err.statusCode, + }); + } + }), + ), + ); + } catch (err) { + result.errors.push({ + scope: 'credential', + credentialId: cred.id, + label: cred.label, + error: err.message, + status: err.statusCode, + }); + } + }), + ), + ); + return result; +} +async function experiment2() { + const fleet = makeFleet(); + const t0 = Date.now(); + const agg = await aggregateAll(fleet); + console.log( + `Aggregated in ${Date.now() - t0}ms →`, + JSON.stringify({ + orgs: agg.orgs.map((o) => o.name), + projects: agg.projects.length, + clusters: agg.clusters.length, + errors: agg.errors.map((e) => `${e.scope}:${e.credentialId} (${e.status})`), + }), + ); +} + +// EXPERIMENT 3 — parallel vs sequential wall-clock (healthy fleet of 8) +async function experiment3() { + const healthy = Array.from({ length: 8 }, (_, i) => + makeCredential({ id: `c${i}`, kind: i % 2 ? 'apikey' : 'serviceaccount', orgName: `Org${i}`, latencyMs: 100 }), + ); + const tSeq = Date.now(); + for (const c of healthy) { + await c.listOrgs(); + await c.listProjects(); + } + const seqMs = Date.now() - tSeq; + const tPar = Date.now(); + await aggregateAll(healthy, { credentialConcurrency: 8 }); + const parMs = Date.now() - tPar; + console.log(`Sequential: ${seqMs}ms Parallel(cap8): ${parMs}ms speedup: ${(seqMs / parMs).toFixed(1)}x`); +} + +// EXPERIMENT 4 — token-bucket headroom (USER scope is per-credential) +function experiment4() { + const ORGS_CAPACITY = 300, + GROUPS_CAPACITY = 1200, + credentials = 4; + console.log( + `With ${credentials} credentials, one refresh spends 1 /orgs + 1 /groups token per credential (separate USER buckets).`, + ); + console.log( + `50 back-to-back refreshes = 50/${ORGS_CAPACITY} /orgs and 50/${GROUPS_CAPACITY} /groups per credential — far below capacity.`, + ); +} + +await experiment1(); +await experiment2(); +await experiment3(); +experiment4(); +``` + +_Prepared as the Step-0 feasibility POC for MongoDB Atlas multi-credential discovery +(review items #7/#8/#12). Isolated experiments executed 2026-07-24. Live API-key evidence on +2026-07-25 partially verified L1 and established healthy-empty/unrestricted-list behavior, while +same-org aggregation, enforced-list L2 controls, detail probes, and Service Account parity remain +open. L3 is not planned as a live test; L4 is telemetry-deferred._ diff --git a/docs/ai-and-plans/PRs/733-atlas-mongodb-discovery/ux-review-iteration-1-k8s-alignment.md b/docs/ai-and-plans/PRs/733-atlas-mongodb-discovery/ux-review-iteration-1-k8s-alignment.md new file mode 100644 index 000000000..13e53f112 --- /dev/null +++ b/docs/ai-and-plans/PRs/733-atlas-mongodb-discovery/ux-review-iteration-1-k8s-alignment.md @@ -0,0 +1,439 @@ +# PR #733 — Atlas MongoDB Discovery: UX Review (Iteration 1) — Alignment with the Kubernetes + Azure Discovery Conventions + +**Branch:** `dev/bchoudhury/atlas-mongodb-discovery` +**Plugin path:** `src/plugins/service-atlas-mongodb/` +**Reviewer focus (this iteration):** user experience / UX only — not architecture, security, or correctness. +**Date:** 2026-06-17 + +--- + +## 0. What this document is + +The Atlas MongoDB discovery provider is the fourth service-discovery plugin in the +extension (after the three Azure plugins and the recently-finalized Kubernetes plugin). +The Kubernetes plugin went through a long, deliberate UX pass documented in +[bugbash-090-kubernetes-ux-review.md](https://github.com/microsoft/vscode-documentdb/blob/main/docs/ai-and-plans/PRs/621-kubernetes-discovery/bugbash-090-kubernetes-ux-review.md) +(30 bug-bash issues + 14 iterations). That pack is the closest, most current statement +of the team's discovery-tree UX conventions. + +This review walks every decision in the Kubernetes pack and asks one question: **does it +apply to Atlas, and how much effort would it take to align?** It deliberately ignores the +Kubernetes-only items (port-forward tunnels, kubeconfig sources, namespaces) and focuses +on the conventions that generalize: empty states, error surfacing, retry affordances, +tooltips, labels, icons, and wizard dead-ends. + +The Kubernetes pack itself notes that some of its 30 items were bug-bash artifacts that +don't generalize; those are marked **N/A** below and not analyzed in depth. + +**Expanded in this revision (Azure family).** The original draft compared Atlas only against +the Kubernetes pack. This revision adds a code-level analysis of the **three shipped Azure +discovery plugins** — `service-azure-mongo-vcore`, `service-azure-mongo-ru`, and +`service-azure-vm` — plus their high-level user-manual pages +([Service Discovery overview](../../../user-manual/service-discovery.md), +[Managing Azure Discovery](../../../user-manual/managing-azure-discovery.md), and the three +per-provider pages). The headline result: **Kubernetes and the three Azure plugins +independently converged on the same conventions** (modal-on-load + a canonical "Click here to +retry" node; an always-present "Manage Accounts…/Sign in…" item in the subscription picker; +stable provider-identity icons; no destructive inline actions). So the recommendations below +are **not Kubernetes-specific preferences — they are the established house style across every +discovery provider that ships today**, and Atlas is the lone outlier on a handful of them. +Where Azure and Kubernetes genuinely _differ_, this revision presents both as **options to +choose from** rather than a single prescription (see §4.3). + +> Scope note: This is a research + recommendation document. **No code was changed.** Every +> recommendation is a suggestion for the author/reviewer to react to. + +--- + +## 1. Executive summary + +The Atlas plugin is in good shape and already follows several conventions the Kubernetes +plugin had to learn the hard way (no destructive inline trash icons; neutral `info`-icon +empty states; a single shared `manageCredentials` command entry point; `ClusterItemBase` +inheritance so the cluster node is a first-class cluster). The gaps are concentrated in +**three areas**, in priority order: + +| Priority | Theme | Applicability | Effort | One-line summary | +| -------- | ---------------------------------------- | ------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **P1** | Error surfacing & retry affordance (§F) | **Strong** | Medium | Atlas surfaces auth/load failures as _passive in-tree error rows_ — the exact pattern Kubernetes deliberately removed. Move to **modal-on-load + a canonical "Click here to retry" first-child node**, reusing the inherited error-node cache to avoid modal spam. | +| **P1** | Wizard dead-end with no session (§B/#12) | **Strong** | Medium | `SelectAtlasProjectStep` throws `'Atlas session not available'` and closes the wizard when the user isn't signed in. Kubernetes keeps the user in flow with an always-present "Add a kubeconfig source…" item. Atlas should prepend an inline "Sign in to MongoDB Atlas…" entry. | +| **P2** | Product-name & wording consistency | **Strong** | Low | Root node reads **"Atlas MongoDB"** but the official product name (and the auth prompt) is **"MongoDB Atlas"**. Two different strings describe the same "signed-out" state. Cheap, high-visibility fixes. | +| **P2** | Tooltips & reveal-on-sign-in | **Medium** | Low–Med | Project node has _no_ tooltip; cluster tooltip is decent but pre-`---`-grouping. After a successful sign-in the tree refreshes but does not reveal/expand the root (Kubernetes reveals the new source). | +| **P3** | List/tree toggle and icon parity | **Low–Med** | Low | Nice-to-haves; mostly already acceptable. | + +**The single most important finding:** Atlas's error UX is built on _passive in-tree error +rows that are themselves the retry button_. The Kubernetes review spent three iterations +(§F/#2/#19/#25) concluding that this is the wrong pattern and converging on **modal error + +a dedicated "Click here to retry" node**, matching the Connections view. **Crucially, all +three Azure plugins already ship exactly this pattern** — `askToConfigureCredentials()` raises +a modal on an empty/failed load and returns a single `'Click here to retry'` node (`refresh` +icon, `internal.retry` command). So this is not a Kubernetes opinion; it is the unanimous +behaviour of **all four** shipped discovery providers, and Atlas is the only one that does it +differently. Atlas should adopt the converged end-state directly rather than re-deriving it. + +A cross-plugin convention matrix (§4) shows, dimension by dimension, where Atlas matches the +four siblings and where it diverges. A later forward-looking chapter (§9) covers UX +improvements beyond the sibling plugins. + +--- + +## 2. Methodology & applicability legend + +Each Kubernetes item is mapped to Atlas with an **applicability** rating based on reading the +Atlas code (tree items, auth flows, wizard, `package.json` menus) and the +[PR #733 decisions doc](./decisions.md): + +| Rating | Meaning | +| ---------- | ------------------------------------------------------------------------------ | +| **Strong** | The same user-facing problem exists in Atlas; aligning materially improves UX. | +| **Medium** | A related problem exists or the convention would be a worthwhile polish. | +| **Low** | Minor, cosmetic, or already largely satisfied. | +| **N/A** | Kubernetes-specific (tunnels, kubeconfig, namespaces) or a bug-bash artifact. | + +Effort is **Low** (string/wording or a few lines), **Medium** (new node/command + wiring + +tests), or **High** (cross-cutting refactor). All effort estimates assume the inherited +`ClusterItemBase` / `BaseExtendedTreeDataProvider` machinery is reused. + +--- + +## 3. Atlas UX inventory (verified against current branch) + +A condensed snapshot of the surfaces this review evaluated. File references are to the +current branch. + +**Tree nodes** + +| Node | Label | Icon | Description | Tooltip | +| ------- | ----------------- | -------------------------------------------------------------------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------- | +| Root | `'Atlas MongoDB'` | `cloud` / `warning` (expired) / `loading~spin` (authenticating) | — | none | +| Project | `project.name` | `project` | `'{org} · {N} clusters'` | **none** | +| Cluster | `cluster.name` | state circle (`circle-filled` green IDLE / `loading~spin` / red DELETING / `circle-outline`) | `'M10, AWS, us-east-1'` | rich markdown (State, Type, MongoDB, Tier, Provider, Region, Project + "expand to connect") | + +**Special nodes** + +| Node | Label | Icon | contextValue | Behaviour | +| ----------------------- | ------------------------------------------------------------------------------------------- | --------- | ------------ | ------------------------ | +| Sign-in | `'Sign in to view Atlas clusters'` | `sign-in` | `error` | runs `manageCredentials` | +| No projects | `'No projects found'` / `'Create a project in the Atlas console'` | `info` | `info` | passive | +| All filtered | `'All projects are hidden by filter'` / `'No projects found for the selected organization'` | `filter` | `info` | passive | +| No clusters | `'No clusters found in this project'` | `info` | `info` | passive | +| Root error | `error.message` (raw) | `error` | `error` | runs `internal.retry` | +| Project session-expired | `'Session expired. Please sign in again.'` | `warning` | `error` | passive | +| Project auth error | `'Authentication expired. Please sign in again.'` | `error` | `error` | passive | +| Project load error | `'Failed to load clusters: {0}'` | `error` | `error` | passive | + +**Error surfacing today** + +- Root + project **load/auth failures** → **passive in-tree error rows** (clickable = retry). +- Cluster **connection failure** → **modal** (`showErrorMessage(…, { modal: true })`). ✅ already aligned with the Kubernetes direction. +- All **auth-flow** failures (API-key / service-account) → **non-modal toasts**. +- `manageCredentials` info/warnings ("Please sign in to Atlas first.", "No projects found…") → toasts. + +**Command titles / menus (`package.json`)** + +- `manageCredentials` = "Manage Credentials…" (inline `key` icon + context menu) +- `filterProviderContent` = "Filter Entries…" (inline `filter` + context) +- `learnMoreAboutProvider` = "Learn More" (inline `info` + context) +- `addConnectionToConnectionsView` = "Save To DocumentDB Connections" (inline `save` + context) +- No destructive inline trash on any row. ✅ + +--- + +## 4. The sibling-plugin baseline (3 Azure plugins + Kubernetes) + +This section is the new material in this revision. It distils a code-level read of the three +Azure discovery plugins and their user-manual pages into a single comparison, so the +recommendations in §6 are anchored to **what already ships** rather than to one plugin's +review. + +### 4.1 Cross-plugin convention matrix + +> Legend: ✅ = follows the convention · ⚠️ = partial / diverges · ❌ = does not follow · +> n/a = not applicable to that provider. + +| UX dimension | vCore (`azure-mongo-vcore`) | RU (`azure-mongo-ru`) | Azure VM (`azure-vm`) | Kubernetes | **Atlas (today)** | +| ---------------------------------------- | ------------------------------------------------------------------------- | ---------------------------------- | ----------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------- | +| **Root label** | "Azure DocumentDB" | "Azure Cosmos DB for MongoDB (RU)" | "Azure VMs (DocumentDB)" | "Kubernetes Clusters" | ⚠️ **"Atlas MongoDB"** (product is "MongoDB Atlas") | +| **Root icon** | stable `azure` | stable `azure` | stable `vm` | stable `layers` | ⚠️ **state-dependent** `cloud`/`warning`/`loading~spin` | +| **Load/empty error** | ✅ modal + retry node | ✅ modal + retry node | ✅ modal + retry node | ✅ modal + retry node | ❌ **passive in-tree error row** (label = raw error) | +| **Canonical "Click here to retry" node** | ✅ (`refresh`, `internal.retry`) | ✅ | ✅ | ✅ | ❌ (error label _is_ the button) | +| **Connection failure** | ✅ modal `Failed to connect to "{x}"` | ✅ modal | ✅ modal `Failed to connect to VM "{x}"` | ✅ modal | ✅ **modal** (already aligned) | +| **Wizard no-session/empty** | ⚠️ always-show "Manage Accounts…" + modal then clean `UserCancelledError` | ⚠️ same as vCore | ⚠️ throws on no-VMs (suppressReportIssue) | ✅ always-show "Add a kubeconfig source…" + inline continue | ❌ **throws `'Atlas session not available'`** (raw) | +| **Picker header item** | ✅ always-show "Manage Azure Accounts…" (`key`) | ✅ same | ✅ same | ✅ always-show "Add…" | ❌ no always-show header | +| **Inline destructive actions** | ✅ none | ✅ none | ✅ none | ✅ none (after #1) | ✅ **none** | +| **Cluster-node menu parity** | ✅ base `treeitem_documentdbcluster` | ✅ | ✅ | ✅ (after §9) | ✅ **aligned** | +| **Tooltip** | plain-text (Sub ID, Tenant) | plain-text | **markdown** bold labels | grouped `---` markdown | cluster ✅ markdown · **project ❌ none** | +| **Status in description** | n/a | n/a | ✅ "No Connectivity" when unreachable | ✅ reachability text | tier/region badges (no auth-state text) | +| **Filter affordance** | ✅ funnel (tenants/subs) | ✅ funnel | ✅ funnel + tag | ✅ funnel | ✅ funnel (org + project) | +| **Filter persistence** | ✅ across sessions | ✅ | ✅ (incl. tag) | ✅ | ⚠️ confirm org+project filters persist | +| **Inline root actions** | Manage Creds / Filter / Learn More | same | same | same | ✅ **same** (key / filter / info) | + +**Reading the matrix:** Atlas is already aligned on the _hard-won_ structural items +(no inline destructive actions, cluster-node parity, modal connection failure, the shared +inline action set). Its divergences cluster in exactly the rows the §6 recommendations target: +the **root label/icon**, the **load-error presentation + retry node**, the **wizard +no-session path**, and the **missing always-show picker header / project tooltip**. + +### 4.2 Conventions all four siblings agree on (adopt without debate) + +1. **Modal-on-load + a single "Click here to retry" node.** Every Azure plugin's + `AzureServiceRootItem.getChildren()` raises `askToConfigureCredentials()` (a **modal** with + "Manage Accounts" / "Adjust Filters" buttons) and, if dismissed, returns **one** node: + `label = 'Click here to retry'`, `icon = ThemeIcon('refresh')`, `contextValue = 'error'`, + `command = 'vscode-documentdb.command.internal.retry'`. Kubernetes reaches the identical + end-state. This is the reference implementation Atlas should copy verbatim. +2. **An always-show header item in the picker** that lets the user fix the "no source" state + without leaving the flow ("Manage Azure Accounts…" for Azure; "Add a kubeconfig source…" + for Kubernetes), followed by a `QuickPickItemKind.Separator`. +3. **Stable provider-identity root icons** (`azure`/`vm`/`layers`) — none of the four change + the root icon to reflect transient auth state. +4. **No destructive inline actions**; "Disable Registry"/"Remove" live in the context menu + under a `rootItem` gate, never as an inline trash button. +5. **Clean product-name root labels** that match the vendor's own naming. + +### 4.3 Where the siblings genuinely differ → choose an option + +These are the only places the conventions diverge. For each, Atlas can pick the option that +fits best; both are already proven in the codebase, so neither is risky. + +| Decision | Option A — **Kubernetes style** | Option B — **Azure style** | Recommendation for Atlas | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Wizard with no session** | Prepend an `alwaysShow` "Sign in…" item; on selection, run auth **inline** and **re-prompt** in the same wizard (user never leaves). | Prepend the "Manage Accounts…" item; on selection, run auth, show a modal "completed — please retry discovery", then exit cleanly with `UserCancelledError`. | **Either beats today's raw `throw`.** Option A is the smoother UX; Option B is the lighter change and is what 3 of 4 siblings already do. Pick B for fastest parity; pick A for best UX. | +| **Tooltip format** | Grouped markdown with `\n\n---\n\n` separators, most-important-first. | Azure VM uses **markdown bold labels** (`**Name:** …`); Azure RU/vCore use **plain-text** key/value. | Atlas cluster tooltip is already markdown — keep it; **add a project tooltip** in whichever style the team standardizes on (recommend the Kubernetes grouped style for cross-provider consistency). | +| **Surfacing transient state** | Reachability text in the description/tooltip; stable icon. | Azure VM puts **"No Connectivity"** in the node **description** (stable icon kept). | Prefer the Azure-VM/K8s approach: keep a **stable `cloud` root icon** and move "expired / authenticating" into the **description or tooltip**, rather than swapping the root icon. | + +### 4.4 High-level conventions from the user manual + +The shipped Azure providers are documented in +[managing-azure-discovery.md](../../../user-manual/managing-azure-discovery.md) and three +per-provider pages. Two documented conventions are worth mirroring in Atlas, and one doc gap +should be closed: + +- **"Manage Credentials" is a staged Account → Tenant flow** with explicit `Back` and `Exit` + rows and per-item status (e.g. "2 tenants available (1 signed in)", "✅ Signed in" / + "🔐 Select to sign in"). Atlas's Manage Credentials QuickPick (account / sign-out / exit) is + analogous; align its **status wording and Back/Exit affordances** with the documented Azure + pattern. _Note one intentional divergence:_ the Azure flow **delegates sign-out to the VS Code + Accounts icon** ("the wizard does not provide a sign-out option"), whereas Atlas owns its + session and **does** offer sign-out — which is correct for Atlas (it is not part of VS Code's + Azure account system). Keep Atlas's sign-out, but document it. +- **Dual-context filtering rule:** _"From the Service Discovery panel, filters are applied + automatically; from the Add New Connection wizard, no filtering is applied — all + subscriptions from all tenants are shown."_ Atlas should follow the same rule: the + **Add-Connection wizard should show all orgs/projects unfiltered**, while the panel honours + the org/project filter. Confirm the Atlas wizard does not silently inherit the panel filter. +- **Filter persistence across sessions** is a documented promise for Azure; confirm Atlas's + org and project filters persist and pre-select on reopen (relates to §9.2). +- **Documentation gap:** [service-discovery.md](../../../user-manual/service-discovery.md) + lists only the three Azure providers under "Available Service Discovery Plugins"; **Atlas is + not yet listed and has no per-provider manual page.** Add a `service-discovery-atlas-mongodb` + page (covering the two auth methods and the org/project model) and + link it from the overview — mirroring the Azure pages. This also gives "Learn More" (§9.3) a + real target. + +--- + +## 5. Theme-by-theme mapping + +### A. First run & empty state + +| K8s item | Atlas applicability | Effort | Notes / recommendation | +| ---------------------------------------------------- | --------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------- | +| #3 Default source shown even when missing | **N/A** | — | Atlas has no auto-seeded source; signed-out root shows a clean "Sign in" node. Already in the spirit of #3. | +| #13 All providers visible by default | **N/A (inherited)** | — | Provider-visibility lives in the shared discovery layer, not the Atlas plugin. Already resolved on `main` (#13). | +| Neutral empty-state nodes (`info` icon, action hint) | **Low (already satisfied)** | — | "No projects found" + "Create a project in the Atlas console" with an `info` icon is exactly the convention Kubernetes converged on. ✅ Keep. | + +### B. Adding a source / getting connected + +| K8s item | Atlas applicability | Effort | Notes / recommendation | +| ----------------------------------------------------------------------- | ------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **#12 Wizard dead-ends when no source configured** | **Strong** | Medium | **Top wizard finding.** `SelectAtlasProjectStep` throws `'Atlas session not available'` (and `SelectAtlasClusterStep` throws `'No active clusters found…'`) which closes the New-Connection wizard. Kubernetes (`SelectContextStep`) **always prepends an `alwaysShow` "Add a kubeconfig source…" item** with a separator, runs the add-flow inline, then re-prompts instead of dead-ending. **Recommendation:** prepend an `alwaysShow` "Sign in to MongoDB Atlas…" item to `SelectAtlasProjectStep`. **All three Azure plugins already do this** ("Manage Azure Accounts…" + separator). Two proven variants exist — see **§4.3** (Option A: K8s inline-continue · Option B: Azure launch-then-clean-exit). Either beats today's raw `throw`. | +| #9 Picker: secondary text in `detail` not `description`; per-item icons | **Medium** | Low | The auth QuickPick already has per-option icons (`key`/`server`) ✅, but its secondary text is in `description` (inline, truncates). #9's lesson: move it to `detail` (second line, wraps). Quick polish on `AtlasAuthQuickPick`. | +| #17 Contradicting messages (success + error together) | **Medium** | Low | Audit the two auth flows so a _failed_ auth never shows a success toast. Today success/failure are separate branches (looks fine), but the Kubernetes principle "an aborted add is an **error**, framed as one" suggests promoting auth _failures_ to a clearer, possibly modal, error (see §F). | +| #16 File-dialog default location | **N/A** | — | No file dialog. | +| #4 Clipboard read without consent | **N/A** | — | Neither auth flow reads the clipboard; there is no silent clipboard access. | +| #26 Drag-and-drop of config files | **N/A** | — | No file sources. | +| #22 Reveal/expand node after it's added | **Medium** | Low–Med | After a successful sign-in, `AtlasDiscoveryProvider` resets the error cache and calls `refresh()` (decisions §12) but does **not** `reveal()`/expand the root. Kubernetes reveals + selects the newly-added source (#22). **Recommendation:** after `transitionTo(Active)`, reveal+expand the Atlas root so projects appear without a manual expand. | + +### C. Tree structure, labels & icons + +| K8s item | Atlas applicability | Effort | Notes / recommendation | +| ----------------------------------------------------- | --------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| #10 Root node icon | **Low–Med** | Low | Kubernetes settled on a stable provider-identity icon. Atlas root uses a **state-dependent** icon (`cloud`/`warning`/`loading~spin`). The `warning` glyph on "expired" doubles as a status hint, which is reasonable, but it means the provider's identity icon changes under the user. Consider keeping a **stable** identity icon (`cloud`) and moving the expired/authenticating signal into the tooltip/description. **All four siblings use a stable root icon** (`azure`/`azure`/`vm`/`layers`); Azure VM surfaces transient state as a `"No Connectivity"` **description** instead of changing the icon (see §4.3). | +| #1 Inline trash too destructive | **Low (already satisfied)** | — | No destructive inline actions on Atlas rows. ✅ | +| #5 Redundant labels / counts / empty-namespace bucket | **Low** | — | Atlas is only two levels deep (Project → Cluster); there is no namespace wall and no redundant per-row count (the `{N} clusters` on a project is the _only_ place that count appears, so it's informative, not redundant). The "Others — no targets" bucket has no analogue. Keep as-is. | +| #8/#11 Unified source icon | **N/A** | — | Atlas has a single source kind. | +| #18 Uneditable path in description | **N/A** | — | No file paths. | +| Iteration 14 list/tree toggle | **Low** | — | Justified for Kubernetes (context → namespaces → clusters, 3 levels with empty namespaces). Atlas is already flat-ish (2 levels). Low value; revisit only if users with many projects ask for a flat all-clusters view. | + +### D. Tooltips & wording + +| K8s item | Atlas applicability | Effort | Notes / recommendation | +| ---------------------------------------------------------- | ------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| #6 Tooltip trimmed; no internal/un-actionable fields | **Medium** | Low | Atlas **cluster** tooltip is already reasonable (no secrets, ends with an actionable "expand to connect" line). Two improvements: (1) the **project node has no tooltip** — add one (org name, project ID, cluster count); (2) standardize the tooltip shape — Azure VM uses **markdown bold labels**, Azure RU/vCore use plain-text, Kubernetes uses grouped `\n\n---\n\n` sections (see §4.3). Recommend the Kubernetes grouped style for cross-provider consistency. | +| §9.5 Terminology: "MongoDB Cluster" → "DocumentDB cluster" | **Strong** | Low | Repo terminology rule: never "MongoDB" alone as a product name. **Nuance for Atlas:** "MongoDB Atlas" _is_ a legitimate product name, and "MongoDB: v{version}" in the cluster tooltip is the genuine server version — both are fine. But **generic** uses ("a MongoDB cluster", "MongoDB connection") must read "DocumentDB cluster". **Action:** audit all Atlas user-facing strings and split "MongoDB Atlas" (product, keep) from generic "MongoDB" (→ DocumentDB). | +| Root label = product name | **Strong** | Low | Root node is **"Atlas MongoDB"**; the official product (and the auth-QuickPick placeholder, "…authenticate with MongoDB Atlas?") is **"MongoDB Atlas"**. **Rename the root node to "MongoDB Atlas"** for correctness and internal consistency. | +| #24 / #23 Path/OS wording | **N/A** | — | No filesystem paths. | + +### E. Service nodes, reachability & cluster-node parity + +| K8s item | Atlas applicability | Effort | Notes / recommendation | +| ----------------------------------------------------------- | --------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| #21 Reachability / port-forward transparency | **N/A** | — | Atlas clusters are directly reachable via `mongodb+srv://`. No tunnel nuance. | +| §9 Cluster-node parity (discovery node = full cluster menu) | **Low (already satisfied)** | — | `AtlasClusterItem extends ClusterItemBase` and keeps the base `treeItem_documentdbcluster` contextValue (plus `enableAddToConnectionsCommand`), with **no negative-lookahead exclusions** — so it behaves like the Azure vCore discovery node, which is the end-state Kubernetes had to refactor _toward_ in §9. ✅ Confirm during hands-on testing that Copy Connection String / Create Database / Open Shell behave on a discovered Atlas node after expand/connect. | +| Dual-auth nuance (Admin API session ≠ SCRAM db creds) | **Medium (docs)** | Low | Decisions §5 captures this well. Worth a one-line tooltip/doc note so users understand that "signed in to Atlas" (discovery) does **not** mean "authenticated to the database" (they'll still be prompted for SCRAM creds on expand). | + +### F. Errors, recovery & refresh — **the priority theme** + +This is where Atlas most diverges from **all four** siblings. Kubernetes spent +§F/#2, #19, #25 (three iterations) reaching the conclusion below — and the three Azure +plugins already implement it (`askToConfigureCredentials()` modal + `'Click here to retry'` +node). Atlas is the only provider still using passive error rows: + +> A failing discovery node should show its failure as a **modal** on a _real_ load attempt +> (expand or explicit retry), keep **only** a canonical **"Click here to retry"** node +> (first child, `refresh` icon) in the tree, and **never** leave a passive classified error +> row under the node. The inherited **error-node cache** stops `getChildren()` from re-running +> on passive refreshes, so the modal fires at most once per real attempt (no modal spam). + +| K8s item | Atlas today | Applicability | Effort | Recommendation | +| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| #2/#25 Error as modal, not a passive in-tree row | Root + project failures render **passive in-tree error rows** whose label is the raw error and which double as the retry button. (Cluster connection failure already uses a modal ✅.) | **Strong** | Medium | Move root/project load+auth failures to `showErrorMessage(…, { modal: true })` on a real load attempt, and stop rendering the classified error text as a passive row. **All three Azure plugins already implement this exact flow** (`askToConfigureCredentials()` modal + retry node) — copy it. The error-node cache (decisions §12, `resetNodeErrorState`) already exists, so modal-spam protection is free. | +| #19 Canonical "Click here to retry" node, first child, `refresh` icon | Retry is implicit — the **error message itself** is the clickable node (`internal.retry`). No dedicated retry node; wording differs per error. | **Strong** | Medium | Add the canonical first-child **"Click here to retry"** node (`refresh` icon) used by the Connections view and Kubernetes, instead of overloading the error label as the button. | +| #19 Refresh reuses cache; only Retry re-runs | Inherited from `BaseExtendedTreeDataProvider` (failed-children cache). | **Low (already satisfied)** | — | Confirm passive `Refresh` does not re-fire auth, and only explicit retry clears the cache. | +| Wording consistency of error states | Two near-duplicate strings: `'Session expired. Please sign in again.'` (warning icon) and `'Authentication expired. Please sign in again.'` (error icon) for similar situations; root error shows the **raw** `error.message`. | **Medium** | Low | Unify the signed-out/expired wording and icon; route raw API messages to the output channel + a friendly summary, the way Kubernetes does. | +| 401 vs 403 handling | Already thoughtfully handled (decisions §9 + Bug 5: 403 now clears the session so "Manage Credentials" re-prompts). | **Low (already good)** | — | No change; just keep the modal/retry presentation consistent across 401/403/5xx. | + +### G. Backend bugs / H. Won't-fix + +**N/A.** Atlas has its own backend bugs (decisions Bugs 1–5, all auth-token lifecycle). The +Kubernetes "won't-fix" items (double-click-to-expand, query-table contrast) are unrelated. + +--- + +## 6. Prioritized recommendation list + +1. **(P1, Medium) Error surfacing parity (§F).** Replace passive root/project error rows with + _modal-on-load + a canonical "Click here to retry" first-child node_. Reuse the existing + error-node cache for modal-spam protection. This is the single biggest alignment win. +2. **(P1, Medium) Wizard no-session inline sign-in (§B/#12).** Prepend an `alwaysShow` + "Sign in to MongoDB Atlas…" item to `SelectAtlasProjectStep`; run auth inline; never + dead-end the wizard. +3. **(P2, Low) Rename root "Atlas MongoDB" → "MongoDB Atlas"** and audit generic "MongoDB" + vs the "MongoDB Atlas" product name per the repo terminology rule. +4. **(P2, Low) Unify the signed-out/expired error wording + icon** (one string, one icon). +5. **(P2, Low–Med) Reveal + expand the Atlas root after a successful sign-in (#22).** +6. **(P2, Low) Add a project-node tooltip and adopt the grouped (`---`) tooltip shape (#6).** +7. **(P3, Low) Move auth-QuickPick secondary text from `description` to `detail` (#9).** +8. **(P3, Low) Consider a stable root identity icon**, moving expired/authenticating state + into the tooltip/description (#10). +9. **(P2, Low–Med, docs) Add a `service-discovery-atlas-mongodb` user-manual page** and list + Atlas in [service-discovery.md](../../../user-manual/service-discovery.md) (§4.4); gives + "Learn More" a target. +10. **(P2, Low) Confirm the Add-Connection wizard shows orgs/projects unfiltered** and that + org/project filters persist across sessions, per the documented Azure rule (§4.4). + +Items 3–4 are near-free and high-visibility; do them regardless of the larger items. + +--- + +## 7. What Atlas already gets right (no action) + +- No destructive inline trash icons on tree rows (Kubernetes #1). +- Neutral `info`-icon empty states with an actionable hint (Kubernetes #5/#20 "Others" spirit). +- A single shared `manageCredentials` command entry point reused by the sign-in node and the + context menu (Kubernetes' "one code path" goal, §17 of decisions). +- `AtlasClusterItem extends ClusterItemBase` with the base contextValue and **no** negative- + lookahead command exclusions — the cluster-node parity end-state Kubernetes refactored + _toward_ in §9. +- Cluster connection failure already uses a **modal** (the §F direction). +- Cluster tooltip already avoids internal/un-actionable fields and ends with an actionable line. +- Matches the shared inline-action set (Manage Credentials / Filter / Learn More) and the + `rootItem`-gated "Disable Registry" of all three Azure plugins (§4.1). + +--- + +## 8. Suggested hands-on review checklist (UX) + +1. Cold start, signed out: root shows a single "Sign in" affordance; confirm wording reads + "MongoDB Atlas". +2. Auth QuickPick: two options with icons; secondary text legible (not truncated). +3. Auth entry: confirm the API Key and Service Account prompts validate input and store + credentials; cancelling leaks nothing. +4. New-Connection wizard while signed out: should offer an inline "Sign in…" entry, not a + dead-end error (this is the #12 gap today). +5. Break discovery (revoke the token / disconnect network), expand the root: confirm whether + you get a passive error row (today) vs the target modal + "Click here to retry". +6. Hit Refresh repeatedly on a broken root: confirm no auth re-fires / no modal spam. +7. Empty project: "No clusters found in this project" with an `info` icon. ✅ +8. Cluster tooltip: trimmed, actionable; project tooltip currently absent. +9. 403 (under-privileged key): confirm "Manage Credentials" re-prompts (decisions Bug 5). + +--- + +## 9. Other suggested UX improvements (beyond the sibling plugins) + +### 9.1 "Signed in as …" affordance on the root + +When `Active`, the root could surface the signed-in identity (display name / org) in its +**description** or tooltip, so users can tell _which_ account/org is active without opening +"Manage Credentials". The data is already fetched (`getCurrentUser`, decisions §10) and the +org filter is already tracked. **Applicability: Medium. Effort: Low.** + +### 9.2 Make the org/project filter state visible + +Two independent filters exist (org filter via Manage Credentials; project filter via the +filter icon — decisions §11). When a filter is hiding projects, the only signal is the +"All projects are hidden by filter" empty state. Consider a small **filter badge/description** +on the root when a filter is active (mirrors VS Code's own "filtered" affordances), so users +don't think projects are _missing_. **Applicability: Medium. Effort: Low–Med.** + +### 9.3 Consistent "Learn more" docs target + +Kubernetes added a dedicated user-manual section and pointed "Learn more" at an `aka.ms` +slug (§11/§12). Atlas's "Learn More" should likewise point at an Atlas-discovery manual +section covering the two-layer auth model (Admin API session vs SCRAM db creds), the two +auth methods, and the org/project filters. **Applicability: Medium (docs). Effort: Low–Med.** + +--- + +## 10. Appendix — Kubernetes item → Atlas applicability index + +| K8s # | Topic | Atlas applicability | Effort | +| ------- | ---------------------------------------- | ------------------------------------- | ------- | +| 1 | Inline trash too destructive | Low (already satisfied) | — | +| 2 | Error shown as passive tree row | **Strong** | Medium | +| 3 | Default source shown when missing | N/A | — | +| 4 | Clipboard read without consent | N/A | — | +| 5 | Noisy tree / counts / empty bucket | Low | — | +| 6 | Tooltip trimmed / no internal fields | **Medium** | Low | +| 7 | Wrong API hook for source mgmt | N/A | — | +| 8/11 | Unified source icon | N/A | — | +| 9 | Picker detail vs description + icons | **Medium** | Low | +| 10 | Root node icon | Low–Med | Low | +| 12 | Wizard dead-ends with no source | **Strong** | Medium | +| 13 | All providers visible by default | N/A (inherited) | — | +| 14/15 | Backend init bugs | N/A | — | +| 16 | File-dialog default location | N/A | — | +| 17 | Contradicting add messages | Medium | Low | +| 18 | Uneditable path in description | N/A | — | +| 19 | Refresh re-runs discovery / retry node | **Strong** (retry node) / Low (cache) | Medium | +| 20 | Settings surface | Low | — | +| 21 | Port-forward transparency | N/A | — | +| 22 | Reveal node when added | **Medium** | Low–Med | +| 23/24 | Path/OS wording | N/A | — | +| 25 | "Retry" semantics / modal error | **Strong** | Medium | +| 26 | Drag-and-drop config | N/A | — | +| 27 | Query-table contrast | N/A | — | +| 28 | Double-click expand | N/A | — | +| 29 | Shell error formatting | N/A | — | +| 30 | Port-forward after restart | N/A | — | +| §9 | Cluster-node parity | Low (already satisfied) | — | +| §9.5 | "MongoDB Cluster" → "DocumentDB cluster" | **Strong** | Low | +| Iter 14 | List/tree toggle | Low | — | + +--- + +_Prepared for the Atlas MongoDB discovery (PR #733) UX review, iteration 1. Code references +verified against the `dev/bchoudhury/atlas-mongodb-discovery` branch. No code was modified; +all items are recommendations._ diff --git a/docs/ai-and-plans/PRs/733-atlas-mongodb-discovery/ux-review-iteration-2-cluster-item.md b/docs/ai-and-plans/PRs/733-atlas-mongodb-discovery/ux-review-iteration-2-cluster-item.md new file mode 100644 index 000000000..ecd49eef6 --- /dev/null +++ b/docs/ai-and-plans/PRs/733-atlas-mongodb-discovery/ux-review-iteration-2-cluster-item.md @@ -0,0 +1,105 @@ +# PR #733 — Atlas MongoDB Discovery: UX Review (Iteration 2) — Cluster Item Presentation + +**Branch:** `dev/bchoudhury/atlas-mongodb-discovery` +**Plugin path:** `src/plugins/service-atlas-mongodb/` +**Reviewer focus (this iteration):** discovery-tree cluster item presentation — icon stability and `description` content. +**Date:** 2026-06-30 + +> Note: The authentication portion of this review iteration has been removed. Interactive +> browser sign-in was researched and **deferred to future work** (no supported third-party +> path today — see the design-decisions doc, §3). The shipped auth methods are **API Key** and +> **Service Account**, so this iteration focuses on the cluster-item presentation findings. + +--- + +## Topic — Cluster item icon uses cluster lifecycle state + +### Finding 2-A — Icon changes with `stateName`; violates the stable-icon convention + +`AtlasClusterItem.getStateIcon()` maps the `stateName` field returned by the Atlas Admin API to a VS Code `ThemeIcon`: + +``` +IDLE → circle-filled (green, testing.iconPassed) +CREATING / UPDATING / REPAIRING → loading~spin (animated spinner) +DELETING → circle-filled (red, testing.iconFailed) + → circle-outline +``` + +**Where `stateName` comes from:** The Atlas Admin API v2 (`GET /api/atlas/v2/groups/{groupId}/clusters`) returns a `stateName` field. The extension models it as: + +```ts +type AtlasClusterState = 'IDLE' | 'CREATING' | 'UPDATING' | 'DELETING' | 'REPAIRING' | 'UNKNOWN'; +``` + +Reference: [Atlas API — Advanced Clusters](https://www.mongodb.com/docs/atlas/reference/api-resources-spec/v2/#tag/Advanced-Clusters/operation/listClusters) (`stateName` enum field on the cluster response object). + +**Why this is a problem:** The Kubernetes UX review (iteration 1 of this review series, §4.3 "Surfacing transient state") established that **all sibling discovery plugins use stable provider-identity icons**. Dynamic icons that change with transient state cause the tree to feel unstable — icons flash between states on every refresh. Azure VM expresses "No Connectivity" via the `description` property while keeping its icon constant. The Kubernetes plugin does the same. + +`AtlasClusterItem` is the **only** tree node across all discovery plugins that uses a state-driven icon. + +#### Work item — Replace state-driven icon with a static icon; surface state via `description` and tooltip + +> **Status:** Open + +The cluster item icon should be a fixed, provider-identity icon (e.g. a generic database/server icon). The `stateName` should be surfaced through the existing VS Code `description` property (the grey secondary text rendered to the right of the label) and the tooltip should explain what each state means. + +**Proposed `description` behaviour:** +| `stateName` | Shown in `description` | +|---|---| +| `IDLE` | _(omit — normal state, no annotation needed)_ | +| `CREATING` | `Creating…` | +| `UPDATING` | `Updating…` | +| `REPAIRING` | `Repairing…` | +| `DELETING` | `Deleting…` | +| `UNKNOWN` | `Unknown state` | + +**Proposed tooltip addition:** For non-`IDLE` states, append a human-readable explanation of what the state means and what the user can/cannot do (e.g. _"This cluster is being created. It will be available to connect once creation is complete."_). + +**Files to change:** + +- `src/plugins/service-atlas-mongodb/discovery-tree/AtlasClusterItem.ts` — replace `getStateIcon()` with a fixed icon; update `buildDescription()` to prepend the state string; update `buildTooltip()` with per-state explanations. +- `src/plugins/service-atlas-mongodb/models/AtlasClusterModel.ts` — `stateName` is currently typed as `string`; tighten it to use `AtlasClusterState` (already defined in `AtlasProjectModel.ts`) for safety. + +**Prior art in this codebase:** Iteration 1 of this UX review (`ux-review-iteration-1-k8s-alignment.md`) §4.3 documents the same finding for the root item's state-driven icon. + +--- + +### Finding 2-B — Cluster item `description` carries too much noise + +`buildDescription()` currently produces: + +``` +M10, AWS, us-east-1 +``` + +Three fields — tier, cloud provider, region — all joined with commas into a single flat string. This is a lot of secondary text to scan for every row, and much of it duplicates what the tooltip already shows in detail. + +The Kubernetes review established the same principle for cluster items: the `description` field should carry **the single most useful discriminator** — enough to tell entries apart at a glance — and the tooltip is the right place for the full detail. + +For Atlas clusters, the most useful at-a-glance discriminator is the **tier** (`instanceSizeName`, e.g. `M10`). Provider and region are secondary; users rarely have two clusters of the same name differing only in cloud or region, but they often have a mix of tiers. + +#### Work item — Trim `description` to tier only; keep provider + region in tooltip + +> **Status:** Open + +Simplify `buildDescription()` to return only `instanceSizeName` (e.g. `M10`). The full `providerName` + `regionName` are already present in the tooltip — no information is lost. When combined with Finding 2-A (state surfaced in description), the final `description` column would read: + +| State | `description` shown | +| ---------- | ------------------- | +| `IDLE` | `M10` | +| `CREATING` | `M10 · Creating…` | +| `UPDATING` | `M10 · Updating…` | +| `DELETING` | `M10 · Deleting…` | + +If `instanceSizeName` is absent (e.g. serverless clusters), fall back to the cloud/region pair as today. + +**File to change:** `AtlasClusterItem.buildDescription()` in `src/plugins/service-atlas-mongodb/discovery-tree/AtlasClusterItem.ts`. + +--- + +### Summary table + +| # | Finding | Severity | Effort | Owner | +| --- | ---------------------------------------------------------------------------------------- | ---------- | ------ | ----- | +| 2-A | Cluster icon is state-driven; replace with static icon + `description`/tooltip for state | **Medium** | Low | — | +| 2-B | `description` shows tier + provider + region — too noisy; trim to tier only | Low | Low | — | diff --git a/docs/ai-and-plans/PRs/733-atlas-mongodb-discovery/ux-review-iteration-3.md b/docs/ai-and-plans/PRs/733-atlas-mongodb-discovery/ux-review-iteration-3.md new file mode 100644 index 000000000..e32445520 --- /dev/null +++ b/docs/ai-and-plans/PRs/733-atlas-mongodb-discovery/ux-review-iteration-3.md @@ -0,0 +1,1617 @@ +# MongoDB Atlas Discovery — UX Review Pack (Iteration 3) + +> **Who this is for:** anyone about to do a hands-on UX review of the **MongoDB Atlas +> discovery provider**, or anyone triaging the findings. +> **What this is:** a single catch-up document that captures a round of runtime UX +> feedback, states what the code _actually does today_ (verified against the current +> branch), and — for each item — offers a **suggestion** and a **status**. Items are +> **sorted by priority** (P0 → P3). + +- **Feature area:** [src/plugins/service-atlas-mongodb/](../../../../src/plugins/service-atlas-mongodb) +- **PR / branch:** [microsoft/vscode-documentdb#733](https://github.com/microsoft/vscode-documentdb/pull/733) · `dev/tnaum/atlas-discovery-review-iteration-2` +- **Related design docs:** [decisions.md](./decisions.md) · [ux-review-iteration-1-k8s-alignment.md](./ux-review-iteration-1-k8s-alignment.md) · [ux-review-iteration-2-cluster-item.md](./ux-review-iteration-2-cluster-item.md) · [atlas-mongodb-discovery-flow.md](../../../atlas-mongodb-discovery-flow.md) +- **Scope:** the UX-facing surface (tree structure, wording, icons, webviews, lifecycle + actions, error recovery). Backend internals appear only where they explain a + user-visible symptom. +- **Review date:** 2026-07-13 + +## How this review was run + +A person exercised the real feature and dictated observations; an AI assistant did the +code-checking, root-cause tracing, and write-up. Each finding is backed by the exact code +path that produces the behavior, so a later implementation pass doesn't have to re-derive +it. Items are grouped and ordered **by priority**; each carries an **Observation** (what +the reviewer saw), a **Finding** (what the code does and why), a **Suggestion**, and a +**Status**. Heavier design questions with real trade-offs are pulled into +[Open ideas](#open-ideas--options-pros--cons). + +> **This is iteration 3.** Iterations 1 (K8s/Azure alignment) and 2 (cluster-item +> presentation) have largely **landed** — the root node, cluster icon/description, and the +> root-level modal+retry error flow are all implemented (see [Implemented](#implemented)). +> This pass re-verifies the surface against the current branch and concentrates on the +> gaps that remain: **error-surface asymmetry at the _project_ tree level**, the +> **wizard raw-throw dead-ends**, and a handful of polish items. + +## Legend + +### Priority + +| Priority | Meaning | +| -------- | -------------------------------------------------- | +| **P0** | Blocking — the user gets stuck | +| **P1** | Broken / misleading, or a consistency & safety gap | +| **P2** | Polish, expectation, or a smaller feature gap | +| **P3** | Nice-to-have / cosmetic / acknowledged | + +### Status + +| Status | Meaning | +| ------------------ | ------------------------------------------------------------------------ | +| 🟠 **Open** | Recorded + analyzed; carries a recommendation but stays a _suggestion_ | +| 🟡 **Open (soft)** | Open, but depends on an investigation or is a soft "leave as-is" | +| ✅ **Implemented** | Changed on this branch and verified (Decision + commit link recorded) | +| 🚫 **Closed** | Won't fix — with a mandatory one-line reason | +| 🔗 **Tracked** | Deferred to a repo issue (linked); dropped from the active priority list | + +> **Items are worked in iterations.** Anything still 🟠 Open at the end of an iteration +> **moves to the next one** — an item leaves this ledger only as ✅ Implemented, 🚫 Closed, +> or 🔗 Tracked. Each fix records **why it was chosen** (Decision) and **how it was done** +> (Implemented + commit link). + +### Complexity (≈ files touched) + +A rough sizing so work can be distributed: the approximate number of files each item touches +(source + tests + `package.json` / l10n), **bucketed in groups of five**. It is an _effort +signal for parallelizing the work_, not a contract. + +| Bucket | ≈ Files | Rough scope | +| ------- | ------- | -------------------------------------------------------- | +| **~5** | 1–5 | One or two files + tests; a localized change | +| **~10** | 6–10 | Several files across one area (e.g. remove a feature) | +| **~15** | 11–15 | A new surface (webview / tree level) spanning many files | +| **~20** | 16–20 | Cross-cutting redesign (storage + API + tree + wizard) | + +### Markers (inline) + +| Marker | Meaning | +| ----------------- | ------------------------------------------------------- | +| ⚠️ **Flag** | Confirmed gap or bug | +| 💡 **Suggestion** | A design/wording recommendation to react to | +| 🔍 **Answered** | A "how does this work?" question answered from the code | + +> **For the operator:** items below are **Open** by default — each records a recommendation +> that is a **suggestion, not a final decision**. Disagree freely; where there are real +> trade-offs, see [Open ideas](#open-ideas--options-pros--cons). + +--- + +## User interaction map _(seed now)_ + +Where every user action **starts** and where it **terminates**. Divergent terminations +(modal vs. non-modal vs. silent vs. raw throw) are flagged here and re-checked live. + +**ASCII flow** + +```text +DISCOVERY PANEL + Expand "MongoDB Atlas" root + ├─ no session ──────────────► auth QuickPick ──► API Key / Service Account flow + │ │ ├─ success ► toast + tree refresh + │ │ └─ fail ───► MODAL error ✅ + │ └─ cancel ────────► [Sign in] node (sign-in icon) + ├─ session ok ─────────────────► list projects + │ ├─ 0 projects ────────► [info] "No projects found" (passive ✅) + │ ├─ all filtered ──────► [filter] "All projects are hidden…" (passive ✅) + │ └─ load/auth failure ─► MODAL + [Click here to retry] node ✅ (root) + └─ Expand a PROJECT + ├─ 0 clusters ────────► [info] "No clusters found…" (passive ✅) + ├─ no session ────────► [warning] "Please sign in… again." ⚠️ PASSIVE + ├─ 401/403 (None) ────► [error] "Please sign in… again." ⚠️ PASSIVE + ├─ 401/403 (intact) ──► [error] raw error.message ⚠️ PASSIVE + └─ load failure ──────► [error] "Failed to load clusters: …" ⚠️ PASSIVE + └─ Expand a CLUSTER (Layer-2 SCRAM auth) + ├─ success ► databases + └─ fail ───► MODAL "Failed to connect…" ✅ + +ADD-CONNECTION WIZARD (Atlas provider) + getDiscoveryWizard + ├─ no session ──► auth QuickPick ─► success ► continue │ cancel ► UserCancelledError ✅ + ├─ Select project step + │ └─ session missing ─────► THROW "Atlas session not available" ⚠️ RAW → closes wizard + └─ Select cluster step + ├─ filters to IDLE-only clusters (hides CREATING/UPDATING) ⚠️ tree/wizard mismatch + └─ 0 IDLE clusters ─────► THROW "No active clusters found…" ⚠️ RAW → closes wizard +``` + +**Mermaid** + +```mermaid +flowchart TD + Root[Expand 'MongoDB Atlas' root] --> RSess{Session valid?} + RSess -- no --> AuthQP[Auth QuickPick] + AuthQP -- success --> RList[List projects] + AuthQP -- fail --> RModal([MODAL error ✅]) + AuthQP -- cancel --> SignIn([Sign in node]) + RSess -- yes --> RList + RList -- load/auth fail --> RootErr([MODAL + 'Click here to retry' ✅]) + + RList --> Proj[Expand a Project] + Proj -- no session --> P1([PASSIVE warning row ⚠️]) + Proj -- 401/403 --> P2([PASSIVE error row ⚠️]) + Proj -- load fail --> P3([PASSIVE 'Failed to load clusters' ⚠️]) + Proj -- ok --> Clus[Expand a Cluster] + Clus -- connect fail --> CModal([MODAL ✅]) + + Wiz[Add-Connection wizard] --> WProj{Session?} + WProj -- missing --> WThrow([RAW throw → wizard closes ⚠️]) + Wiz --> WClus[Select cluster] + WClus -- 0 IDLE clusters --> WThrow2([RAW throw → wizard closes ⚠️]) +``` + +The diagram makes the asymmetry obvious: **root-level** failures terminate in a **modal + +canonical retry node** (the house style), while **project-level** failures terminate in +**passive in-tree rows**, and the **wizard** terminates in **raw thrown errors** that +close the flow. The reviewer also flagged the **entry edge** itself: expanding the +signed-out root **auto-opens the auth QuickPick** rather than waiting for the user to click +the sign-in node (item 1). + +**Interaction inventory** + +| # | User action (entry) | Where it lives | Terminal state(s) | Surface | ⚠️ | +| --- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ----------------- | --- | +| 1 | Expand root (signed out) | [AtlasServiceRootItem.getChildren](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts#L39) | **Auto-opens** auth QuickPick → success/`Sign in` node | quickpick / tree | ⚠️ | +| 2 | Root load / auth failure | [AtlasServiceRootItem.showLoadFailure](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts#L205) | **Modal** + `Click here to retry` node | modal + tree | ✅ | +| 3 | Expand project, load clusters | [AtlasProjectItem.getChildren](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasProjectItem.ts#L36) | **Passive** error/warning rows | tree only | ⚠️ | +| 4 | Expand cluster (SCRAM connect) | [AtlasClusterItem.authenticateAndConnect](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasClusterItem.ts#L100) | Databases / **modal** on failure | modal | ✅ | +| 5 | Auth (API Key / Service Account) | [AtlasApiKeyFlow.executeApiKeyFlow](../../../../src/plugins/service-atlas-mongodb/auth/AtlasApiKeyFlow.ts#L15) | Toast on success / **modal** on failure | toast + modal | ✅ | +| 6 | Manage Credentials (signed in) | [AtlasDiscoveryProvider.configureCredentials](../../../../src/plugins/service-atlas-mongodb/AtlasDiscoveryProvider.ts#L169) | QuickPick (account / sign out / exit) | quickpick | | +| 7 | Organizations filter | [AtlasDiscoveryProvider.showOrganizations](../../../../src/plugins/service-atlas-mongodb/AtlasDiscoveryProvider.ts#L221) | Tree refresh / **modal** on fetch failure | quickpick + modal | ✅ | +| 8 | Project filter (funnel icon) | [AtlasDiscoveryProvider.configureTreeItemFilter](../../../../src/plugins/service-atlas-mongodb/AtlasDiscoveryProvider.ts#L104) | Tree refresh / info toast on empty | quickpick + toast | | +| 9 | Add-Connection wizard: select project | [SelectAtlasProjectStep.prompt](../../../../src/plugins/service-atlas-mongodb/discovery-wizard/SelectAtlasSteps.ts#L22) | QuickPick / **raw throw** closes wizard | quickpick / throw | ⚠️ | +| 10 | Add-Connection wizard: select cluster | [SelectAtlasClusterStep.prompt](../../../../src/plugins/service-atlas-mongodb/discovery-wizard/SelectAtlasSteps.ts#L57) | QuickPick (IDLE-only) / **raw throw** closes wizard | quickpick / throw | ⚠️ | + +--- + +## The story in one paragraph + +The Atlas provider has come a long way: root label, stable icons, trimmed cluster +descriptions, and the modal+retry error pattern all landed from iterations 1–2. The +hands-on pass (iteration 3) surfaced a cluster of **release-blocking** first-run problems +that all trace back to the **authentication experience**: expanding the root **auto-opens +an auth picker** the user didn't ask for; that picker is a bare QuickPick that **doesn't +tell the user where to get the keys**; when a key is **wrong or under-permissioned** there +is **no retry / update-credentials path** (you must restart the whole wizard); and an +under-permissioned key is silently mis-reported as **"No projects found"** (a 200 with an +empty list, not an error) with a long, unreadable description. Underneath sits the same +structural gap from earlier iterations — **project-level failures are still passive rows** +and the **wizard throws raw errors**. Beyond the blockers, the reviewer scoped two larger +design directions to plan now and build next: a **guided webview** for credential entry, +and **multi-credential management** modeled on the Azure accounts flow — plus a +**tree/list view toggle** (with an org level) mirroring Kubernetes. + +--- + +## Priority index + +> **P0/P1 block a release.** Everything in the P0 and P1 sections must be resolved (✅ +> Implemented, 🚫 Closed with a reason, or 🔗 Tracked with a committed follow-up) **before +> this PR can ship**. P2/P3 are strongly desired but do not gate the release. + +| # | Priority | Item | ≈ Files | Reviewer? | Status | +| --- | -------- | ----------------------------------------------------------------------------- | ------- | ---------- | ------------------------------------------------------------------------------------------- | +| 1 | **P1** | Root auto-opens the auth picker on expand — should just show the sign-in node | ~5 | 🗣️ #1 | ✅ Implemented | +| 2 | **P1** | Auth-recovery tree node wording is inconsistent | ~5 | 🗣️ #3/live | ✅ Implemented (Iteration 4) — consolidated "revisit credentials" row ([9c8baa0f](https://github.com/microsoft/vscode-documentdb/commit/9c8baa0f)) | +| 3 | **P1** | No-projects result uses a non-actionable information row | ~5 | 🗣️ #4/live | ✅ Implemented (Iteration 4) — classified empty/401/403 handling ([ee2bf417](https://github.com/microsoft/vscode-documentdb/commit/ee2bf417), [9c8baa0f](https://github.com/microsoft/vscode-documentdb/commit/9c8baa0f)) | +| 4 | **P1** | Project-level failures are passive rows (root uses modal + retry) | ~5 | — | ✅ Implemented | +| 5 | **P1** | Wizard steps throw raw errors → close the flow (no in-flow recovery) | ~5 | (🗣️ #3) | ✅ Implemented ([313950f2](https://github.com/microsoft/vscode-documentdb/commit/313950f2)) | +| 14 | **P1** | Remove all filtering (org + project) and its storage — release cleanup | ~10 | 🗣️ live | ✅ Implemented ([a7737b70](https://github.com/microsoft/vscode-documentdb/commit/a7737b70)) | +| 6 | **P2** | Rework credential entry as a guided webview (where to get keys) | ~15 | 🗣️ #2 | ✅ Implemented | +| 7 | **P2** | Multi-credential management like the Azure accounts flow (add/remove) | ~20 | 🗣️ #6 | 🟡 Open (soft) | +| 8 | **P2** | Tree/List view toggle + org level (Kubernetes-style) | ~15 | 🗣️ #5 | 🟡 Open (soft) | +| 9 | **P2** | Wizard hides non-IDLE clusters the tree shows (tree/wizard mismatch) | ~5 | — | ✅ Implemented ([368a4cff](https://github.com/microsoft/vscode-documentdb/commit/368a4cff)) | +| 10 | **P2** | Project node has no tooltip | ~5 | — | ✅ Implemented ([41ec69f2](https://github.com/microsoft/vscode-documentdb/commit/41ec69f2)) | +| 11 | **P2** | No reveal/expand of the Atlas root after a successful sign-in | ~5 | — | ✅ Implemented ([41ec69f2](https://github.com/microsoft/vscode-documentdb/commit/41ec69f2)) | +| 12 | **P3** | Root shows no "signed in as…" identity when Active | ~5 | — | ✅ Implemented ([41ec69f2](https://github.com/microsoft/vscode-documentdb/commit/41ec69f2)) | +| 13 | **P3** | ~~Active-filter state not visible on the root~~ — superseded by #14 | — | — | 🚫 Closed | + +--- + +## Work bundles (grouped & ordered) + +The same items, **bundled by the code they touch** and ordered so a contributor can pick a +self-contained chunk. No items are added or removed here — every active item from the index +appears in exactly one bundle (closed #13 is noted where it belongs). Bundles A–C are the +release blockers; D is quick polish; E is the sequenced follow-up redesign. Each item carries +its **≈ files** estimate (from the [Complexity legend](#complexity--files-touched)) so work can +be sized and distributed. + +### Bundle overview — what runs in parallel + +The four release-blocker bundles (**A, B, C, D**) touch **disjoint files** and have **no +cross-bundle dependency** — they can be picked up by four contributors **at the same time**. +Bundle **E** is the only one that must wait (it builds on C's cleanup and A's single sign-in +entry, and is internally sequential). + +| Bundle | Theme | Priority | Items (in order) | \u2248 Files (sum) | Parallelizable with | +| ------ | ----------------------------- | -------- | ---------------------------------------------------------------------------------- | ------------------ | ----------------------- | +| **A** | Sign-in & error surfacing | P1 | 1 ✅ → 4 ✅ → {2 ‖ 3} | ~20 | **B, C, D** | +| **B** | Add-Connection wizard | P1 | 5 ✅ → 9 | ~10 | **A, C, D** | +| **C** | Filtering removal | P1 | 14 ✅ ([a7737b70](https://github.com/microsoft/vscode-documentdb/commit/a7737b70)) | ~10 | **A, B, D** (completed) | +| **D** | Tree/root presentation polish | P2–P3 | 10 ‖ 11 ‖ 12 | ~15 | **A, B, C** | +| **E** | Credential & view redesign | P2 | 6 → 7 → 8 | ~50 | after **C** (& **A**) | + +> Legend for the ordering column: `→` = must be done in sequence; `‖` = order does not matter +> (safe to parallelize); `{ }` = a parallel group. So Bundle A is _1, then 4, then 2 and 3 in +> parallel_; Bundle D is _all three in any order / in parallel_. + +### Bundle A — Sign-in & error surfacing (root + project) · **P1 · do first** + +The first-run authentication cluster: one sign-in node, one retry story, consistent error +surfacing. All live in `AtlasServiceRootItem` / `AtlasProjectItem` / the auth flow. + +| Order | Item | Touches | \u2248 Files | Parallel within bundle? | +| ----- | ------------------------------------------------------------------------- | ------------------------------------------------------------- | ------------ | ------------------------------------------------------ | +| 1 | **Item 1** — remove auto-prompt; expand shows only the sign-in node | `AtlasServiceRootItem` (+ delete `consumeSuppressAutoPrompt`) | ~5 | ✅ Implemented — establishes the single sign-in entry | +| 2 | **Item 4** — project errors → modal + single retry node; detail to output | `AtlasProjectItem`, shared `showLoadFailure` helper | ~5 | ✅ Implemented — defines the shared modal+retry helper | +| 3a | **Item 2** — align auth-recovery tree action wording | `AtlasServiceRootItem`, shared tree-action wording | ~5 | ✅ Implemented (Iteration 4) — consolidated **revisit credentials** row ([9c8baa0f](https://github.com/microsoft/vscode-documentdb/commit/9c8baa0f)) | +| 3b | **Item 3** — no-projects result → modal + canonical retry node | `AtlasServiceRootItem.fetchProjectItems`, retry-node cache | ~5 | ✅ Implemented (Iteration 4) — empty/401/403 classification ([ee2bf417](https://github.com/microsoft/vscode-documentdb/commit/ee2bf417), [9c8baa0f](https://github.com/microsoft/vscode-documentdb/commit/9c8baa0f)) | + +> Sequence: 1 establishes the single sign-in entry, 4 defines the shared modal+retry helper, +> then 2 and 3 reuse that helper for the auth-failure and empty-state cases **in parallel**. + +### Bundle B — Add-Connection wizard · **P1** + +Both items live in `SelectAtlasSteps` / `getDiscoveryWizard`. + +| Order | Item | Touches | \u2248 Files | Parallel within bundle? | +| ----- | ------------------------------------------------------------------------------------------------ | --------------------------------------------- | ------------ | -------------------------------------------------------------------------------------------- | +| 1 | **Item 5** — raw throws → Azure-style "Manage MongoDB Atlas Credentials…" + `UserCancelledError` | `SelectAtlasSteps`, `getDiscoveryWizard` | ~5 | ✅ Implemented — completed; unblocks item 9 | +| 2 | **Item 9** — reconcile the IDLE-only cluster filter to match the tree | `SelectAtlasSteps` (`SelectAtlasClusterStep`) | ~5 | ✅ Implemented in [368a4cff](https://github.com/microsoft/vscode-documentdb/commit/368a4cff) | + +### Bundle C — Filtering removal · **P1 · implemented** + +| Order | Item | Touches | \u2248 Files | Parallel within bundle? | +| ----- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------ | -------------------------------------------------------------------------------------------------------------------- | +| 1 | **Item 14** — remove all org/project filtering + storage (**closes #13**) | `AtlasDiscoveryProvider`, `AtlasServiceRootItem`, `AtlasSessionManager`, `config.ts` | ~10 | ✅ Implemented in [a7737b70](https://github.com/microsoft/vscode-documentdb/commit/a7737b70); `npm run build` passed | + +> Completed independently of A/B: it deleted code that the credential/view redesign (Bundle +> E) would otherwise have had to carry forward. The shared `filterProviderContent` command +> remains because Azure discovery providers still use it; Atlas no longer contributes its +> `enableFilterCommand` context token. + +### Bundle D — Tree/root presentation polish · **P2–P3 · quick wins** + +Small, independent touches to the tree items and root description. **All three touch different +files — order does not matter and they can be done in parallel.** + +| Order | Item | Touches | \u2248 Files | Parallel within bundle? | +| ----- | --------------------------------------------------------------- | ------------------------------------------ | ------------ | ------------------------------------------------------------------------------------------- | +| ‖ | **Item 10** — add a project-node tooltip | `AtlasProjectItem.getTreeItem` | ~5 | ✅ Implemented ([41ec69f2](https://github.com/microsoft/vscode-documentdb/commit/41ec69f2)) | +| ‖ | **Item 11** — reveal/expand the root after a successful sign-in | `AtlasDiscoveryProvider` | ~5 | ✅ Implemented ([41ec69f2](https://github.com/microsoft/vscode-documentdb/commit/41ec69f2)) | +| ‖ | **Item 12** — show the signed-in identity in the root (P3) | `AtlasServiceRootItem.getStateDescription` | ~5 | ✅ Implemented ([41ec69f2](https://github.com/microsoft/vscode-documentdb/commit/41ec69f2)) | + +### Bundle E — Credential & view redesign · **P2 · sequenced follow-up PRs** + +The three larger reviewer design directions; **strictly sequential** because each depends on +the previous (see [Sequencing](#sequencing-suggested)). + +| Order | Item | Touches | \u2248 Files | Parallel within bundle? | +| ----- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------ | ---------------------------------------------- | +| 1 | **Item 6** — guided webview for credential entry (hosts "Add" / "Update creds") | new webview (React + tRPC router + controller), `AtlasApiKeyFlow`, `AtlasServiceAccountFlow` | ~15 | ✅ Implemented — do first | +| 2 | **Item 7** — multi-credential management on the shared `StorageService` | `AtlasSessionManager` → N-credential store, `configureCredentials` wizard, API client, tree attribution | ~20 | After 6 — the webview is the "Add" surface | +| 3 | **Item 8** — Tree/List view toggle + org level | new `AtlasOrgItem`, `config.ts`, 2 commands, `package.json`, `AtlasProjectItem`/`AtlasClusterItem` | ~15 | After 7 — needs the org-aware credential model | + +> Bundle E benefits from Bundle C having landed (fewer filter surfaces to migrate) and from +> Bundle A's single sign-in entry point. + +--- + +## P0 — Blocking (the user gets stuck) — release blocker + +_None classified P0 outright. The four P1 items below are grouped first-run auth blockers; +if the reviewer decides any single one leaves a user with **no way forward** (e.g. a bad +key with no recovery path), promote it to P0. **P0 and P1 both block the release.**_ + +--- + +## P1 — Broken / misleading, or consistency & safety — release blocker + +> These gate the release. The first three are the **first-run authentication** cluster the +> reviewer hit live; items 4–5 are the pre-existing structural gaps they build on; item 14 +> is a completed scope-reduction cleanup (filtering removed in +> [a7737b70](https://github.com/microsoft/vscode-documentdb/commit/a7737b70)). + +### 1. Root auto-opens the auth picker on expand — should just show the sign-in node ⚠️ 🗣️ + +**Priority:** P1 · **Status:** ✅ Implemented · **Complexity:** ~5 files · **Reviewer #1** + +> 🤖 **Automatic audit note (2026-07-23): Accept as closed.** Code inspection confirms +> that expanding a signed-out root returns only the explicit sign-in node and does not launch +> the authentication QuickPick. The implementation follows the recorded decision. + +**Observation:** _"When I attempt to expand the Atlas discovery, the auth wizard shows — +don't do this. We already have an error node that lets a user sign in. That is enough."_ + +**Finding:** + +- ⚠️ [AtlasServiceRootItem.getChildren](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts#L39) calls `promptAuthentication()` **automatically** on expand when no session exists — opening the auth-method QuickPick as a side effect of expanding a tree node. Only if the user _cancels_ does it fall back to `createSignInNode()` (via the `consumeSuppressAutoPrompt()` latch). +- 🔍 This is the "no magic" convention (checklist §12): expanding a node should not launch a modal picker the user didn't request. All Azure siblings render a passive placeholder and wait for an explicit "Manage Credentials" / sign-in click. +- 🔍 The [sign-in node](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts#L183) already exists and already routes to `manageCredentials` — so the auto-prompt is redundant. + +💡 **Suggestion:** Remove the auto-prompt branch; on "no session" simply return +`createSignInNode()`. The user signs in explicitly by clicking that node (or the inline +"Manage Credentials" action). This also deletes the `consumeSuppressAutoPrompt()` +work-around, since there is no longer an auto-prompt to suppress. **Influences items 2, 6, +7** (all sign-in entry points funnel through the same node/flow). + +> **Decision (Iteration 3):** Remove the auto-prompt. Expanding the signed-out root shows +> **only** the "Sign in to view MongoDB Atlas clusters" error node — no QuickPick fires on +> expand. **Reason:** the sign-in node is already a sufficient, explicit call to action; +> auto-opening a picker the user didn't request is surprising ("no magic") and inconsistent +> with the Azure siblings. + +✅ **Implemented (Iteration 3):** `AtlasServiceRootItem.getChildren()` now returns the existing +sign-in node immediately when no session exists. The automatic auth QuickPick path and the +`AtlasSessionManager.consumeSuppressAutoPrompt()` cancellation latch were removed. **Verification:** +`npm run build` passed. + +--- + +### 2. Auth failure / bad key has no retry or "update credentials" path ⚠️ 🗣️ + +**Priority:** P1 · **Status:** ✅ Implemented (Iteration 4) · **Complexity:** ~5 files · **Reviewer #3/live** + +> 🤖 **Automatic audit note (2026-07-23): Further implementation and hands-on testing +> required — do not accept as closed yet.** The recovery actions exist, but the second tree +> action is currently labeled **Update credentials**. The established wording used by other +> actionable error nodes is **Click here to update credentials**. + +**Observation:** _"When auth fails (I tried the API key path), it just fails and I have no +retry / update-creds path — I had to restart the wizard. A retry node and an 'update +credentials' node would be better. Retry, because maybe the user will change permissions or +IP filters on the cluster. Simple retry is enough."_ + +**Finding:** + +- ⚠️ [executeApiKeyFlow](../../../../src/plugins/service-atlas-mongodb/auth/AtlasApiKeyFlow.ts#L59) shows a modal on rejection and `return false`. Back in [AtlasServiceRootItem](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts#L51), a failed `promptAuthentication()` renders the generic **sign-in** node — there is **no dedicated "retry" affordance** that re-runs the _same_ credentials, and no "update credentials" node to correct a typo without starting over. +- 🔍 The root already has a canonical `Click here to retry` node ([createRetryNode](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts#L192)) for _load_ failures — but the **auth-flow failure** path doesn't reuse it. +- 🔍 Reviewer's rationale matters: an auth failure is frequently **transient/fixable outside the extension** (add the key to the project, allow the current IP in the Access List — the API-key modal already hints at this). A one-click retry lets the user fix Atlas-side and re-list without re-typing. + +💡 **Suggestion:** After an auth-flow failure, return the existing **`Click here to retry`** +node (re-attempts with the stored key) **plus** a **"Click here to update credentials"** node +(re-opens the entry flow). "Simple retry is enough" per the reviewer, so retry is the +must-have; update-credentials is the strong-nice-to-have. This lands even better once entry is a +webview (item 6). **Merges with item 4** (unify the retry/error presentation across root + +project). + +✅ **Implemented (Iteration 3):** Submitted API key and Service Account credentials are now +stored in secure storage before validation, preserving a retry path when Atlas-side access is +corrected. A failed authentication renders **Click here to retry** and **Update credentials** +at the root. Retry uses the stored credential; update credentials opens the existing credential +management flow. The future multi-credential storage redesign remains in Bundle E. **Verification:** +`npm run build` passed. + +#### Follow-up observation — Iteration 4.1 (2026-07-23) 🗣️ + +**Observation:** The two auth-recovery rows are actionable error nodes, but their labels do +not follow the same sentence-style call-to-action wording. The screenshot shows the established +wording used elsewhere in the extension. + +**Finding:** The retry node already uses the canonical **Click here to retry** label. The +credential action in `AtlasServiceRootItem.createUpdateCredentialsNode()` instead uses the +shorter **Update credentials** label. Elsewhere, actionable error nodes use +**Click here to update credentials**, so the two Atlas recovery rows currently look unrelated +and inconsistent. + +💡 **Suggestion:** Use these exact labels for the two auth-recovery error nodes: + +1. **Click here to retry** — retry with the stored credential after Atlas-side permissions or + access-list settings have been corrected. +2. **Click here to update credentials** — open credential management so the submitted values + can be replaced. + +Keep both rows styled as actionable error/recovery nodes. Do not shorten the second label to +**Update credentials** and do not use the wizard-only **Manage MongoDB Atlas Credentials...** +wording in this tree context. + +**Status:** ✅ **Superseded → Implemented (Iteration 4).** The interim label fix was replaced by the +consolidated **Click here to revisit credentials** row; both recovery actions moved into the credential +manager it opens (see the Decision and implementation below). + +> **Decision (Iteration 4, Step 4):** The interim label fix is **superseded**. The two recovery +> rows are replaced by the selected design's single consolidated row, **Click here to revisit +> credentials**, which is itself a sentence-style call to action and therefore satisfies the +> wording concern. Both recovery actions the reviewer asked for still exist - they moved from the +> tree into the credential-management QuickPick the row opens, where **Retry** re-attempts the +> selected credential only and **Update credentials…** reopens the guided entry surface. +> +> Reason for the deviation: with several credentials, per-credential recovery rows multiply in the +> tree (two rows per failed credential). The consolidated row keeps the label constant no matter +> how many credentials failed and moves the detail into a tooltip, which is exactly the "reduce +> description noise" goal recorded in the POC's archived alternative A1. + +✅ **Implemented (Iteration 4, Step 4):** [9c8baa0f](https://github.com/microsoft/vscode-documentdb/commit/9c8baa0f) +— `createRevisitCredentialsNode` renders one warning row whose tooltip enumerates every affected +credential and reason; clicking it opens the credential manager +([c0c49ce6](https://github.com/microsoft/vscode-documentdb/commit/c0c49ce6)) with per-credential +Retry, Update credentials, and Remove. A scoped project-level cluster failure still uses the +canonical **Click here to retry** row, because a retry is the accurate action for a failure that +is not necessarily credential-related. + +--- + +### 3. Under-permissioned key mis-reported as "No projects found" (+ unreadable description) ⚠️ 🗣️ + +**Priority:** P1 · **Status:** ✅ Implemented (Iteration 4) · **Complexity:** ~5 files · **Reviewer #4/live** + +> 🤖 **Automatic audit note (2026-07-23): Further implementation and hands-on testing +> required — do not accept as closed yet.** Iteration 3 implemented its original plan, but +> the resulting **No projects visible to this API key** information row is non-actionable. +> The live review has superseded that presentation with a modal + retry recommendation. + +**Observation:** _"An unexpected node with a long description said no projects were found in +the Atlas org, with long text that's not readable because it's too long. And it was wrong — +it was just the permissions of the API key; I had to add more to see an existing project."_ + +**Finding:** + +- ⚠️ Atlas returns **200 with an empty `results` array** for an under-permissioned key — [AtlasApiClient.listProjects](../../../../src/plugins/service-atlas-mongodb/api/AtlasApiClient.ts#L49) surfaces no error, so [fetchProjectItems](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts#L114) hits the `projects.length === 0` branch and renders **"No projects found"** / "Create a project in the Atlas console". That is a **misdiagnosis**: the account _has_ projects, the key just can't see them. +- ⚠️ The **long text lives in the node `description`**, which VS Code truncates in the tree — so it is both _wrong_ and _unreadable_ (checklist: detail belongs in a tooltip, not a truncating description). +- 🔍 The extension already fetches organizations in parallel ([fetchProjectItems](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts#L112)). "Orgs visible but zero projects" is a strong signal of a **permissions scope** problem rather than a genuinely empty account. + +💡 **Suggestion:** Disambiguate the empty case: if `orgs.length > 0 && projects.length === 0`, +show a **permissions-oriented** empty state ("No projects visible to this API key — check the +key's project access / roles") with the actionable hint, rather than "Create a project…". +Keep the label short; move any longer explanation into a **tooltip**, not the `description`. +Optionally offer a **Click here to update credentials** affordance here too (ties to item 2). **Merges +with item 4** as part of the project/empty-state presentation pass. + +✅ **Implemented (Iteration 3):** The empty result now distinguishes a genuinely empty account +from a permissions problem. When the API key can see organizations but no projects, the tree +shows **No projects visible to this API key** and places the project-access guidance in its +tooltip. The generic empty-account guidance also moved from `description` to a tooltip. +**Verification:** `npm run build` passed. + +#### Follow-up observation — Iteration 4.1 (2026-07-23) 🗣️ + +**Observation:** When the API key can see organizations but no projects, the tree shows an +information item — **No projects visible to this API key** — with a long tooltip. The item +cannot be acted on. Non-actionable status rows should not occupy the discovery tree. + +**Finding:** [AtlasServiceRootItem.fetchProjectItems](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts#L90) +currently returns an `info` tree item for both no-visible-projects and genuinely empty-account +results. The root already has the established error-recovery contract: a modal explanation, +the canonical **Click here to retry** action from `createRetryNode()`, and a retry-node cache +that prevents the modal from repeating until the user explicitly retries. + +💡 **Suggestion:** Remove the non-actionable no-projects information item and its long tooltip. +When the Atlas request returns no visible projects: + +1. Show a concise **modal error dialog** explaining whether the account appears empty or the + API key appears unable to see projects, with permissions/access-list guidance in the modal + detail rather than in the tree. +2. Return only the canonical error action **Click here to retry** — this is the repository's + established label, rather than **Refresh** or **Reload**. +3. Let the existing retry-node cache suppress repeated dialogs during passive tree refreshes. + When the user clicks **Click here to retry**, clear the cached failure and load again; if + the result is still empty, show the explanatory modal again and restore the retry node. + +**Status:** ✅ **Superseded → Implemented (Iteration 4).** `200 []` is treated as an authoritative `empty` +placeholder (no misleading retry); only `401`/`403`/rate-limit/network raise the consolidated recovery +action (see the Decision and implementation below). + +> **Decision (Iteration 4, Steps 2 and 4):** The Iteration 4.1 proposal to show a modal plus +> **Click here to retry** for every no-projects result is **superseded**, exactly as this +> document's own roadmap section already anticipated. The live L2 checks confirmed that Atlas +> distinguishes the two cases at the protocol level: a healthy but unprivileged credential returns +> `200 []`, while an enforced access list returns `403` and a bad secret returns `401`. +> +> Because `200 []` is an authoritative answer rather than a failure, retrying it can never change +> the result, so offering a retry would train the user to click something that does nothing. +> Emptiness therefore uses the standard `empty` placeholder (`$(indent)` icon, label `empty`, +> permissions explanation in the tooltip), and only `401`, `403`, rate-limit, and network failures +> raise the consolidated recovery action. + +✅ **Implemented (Iteration 4):** [ee2bf417](https://github.com/microsoft/vscode-documentdb/commit/ee2bf417) +classifies each outcome (`auth`, `forbidden`, `rateLimited`, `network`, `other`) and keeps a healthy +empty list out of the error path; +[9c8baa0f](https://github.com/microsoft/vscode-documentdb/commit/9c8baa0f) renders the `empty` +placeholder under an organization with the permissions hint in its tooltip and no retry suggestion. +The non-actionable sentence row is gone. + +--- + +### 4. Project-level load/auth errors render as passive in-tree rows ⚠️ + +**Priority:** P1 · **Status:** ✅ Implemented ([313950f2](https://github.com/microsoft/vscode-documentdb/commit/313950f2)) · **Complexity:** ~5 files + +> 🤖 **Automatic audit note (2026-07-23): Accept as closed.** Code inspection confirms +> that project failures use the shared modal/output-channel helper and leave a single retry +> node instead of passive raw-error rows, matching the recorded decision. + +**Observation:** Break discovery after projects are already listed (revoke the key / drop +the network), then expand a **project** — you get a plain error row, not the modal + +"Click here to retry" the root gives. + +**Finding:** + +- ⚠️ [AtlasProjectItem.getChildren](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasProjectItem.ts#L36) surfaces **four** failure classes as passive + `createGenericElementWithContext` rows with **no modal and no canonical retry node**: + - no session → `warning` icon, "Please sign in to MongoDB Atlas again." + - 401/403 with session cleared → `error` icon, "Please sign in to MongoDB Atlas again." + - 401/403 transient → `error` icon, **raw** `error.message` + - generic → `error` icon, "Failed to load clusters: {0}" +- 🔍 The **root** was already migrated to the house style — [AtlasServiceRootItem](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts#L80) calls `showLoadFailure()` (modal) + a single `Click here to retry` node. The project item is the **lone outlier** across the feature. +- 🔍 Same inconsistency iteration 1 §F flagged for _both_ levels; the root half shipped, the project half did not. **This is the shared home for the retry work in items 2 and 3.** + +💡 **Suggestion:** Mirror the root pattern in `AtlasProjectItem`: on a real load attempt, +raise a modal (reuse a `showLoadFailure`-style helper) and return **one** `Click here to +retry` node instead of a passive classified row. The inherited error-node cache +(`resetNodeErrorState`) already prevents modal spam. Route raw `error.message` to the +output channel + a friendly summary. See [O2](#o2-project-level-error--retry-presentation-items-2-3-4). + +> **Decision (Iteration 3):** **All** the passive "failed to load clusters" / session / auth +> error tree nodes go away. On a project load failure: show an **error modal** and leave +> **only a single retry node** in the tree; push the full detail to the **`ext.outputChannel`**. +> **Reason:** passive error rows are the last inconsistency left — the root already does modal +> +> - retry, and the tree should never carry raw, truncating error strings when the output +> channel can hold the detail. + +✅ **Implemented (Iteration 3):** Root and project load failures now use a shared +`showAtlasLoadFailure()` helper. The helper logs the technical detail to `ext.outputChannel` +and shows a concise modal; `AtlasProjectItem` now returns only the canonical retry node and +participates in the inherited retry-node cache. **Verification:** `npm run build` passed. + +--- + +### 5. Add-Connection wizard steps throw raw errors that close the flow ⚠️ + +**Priority:** P1 · **Status:** ✅ Implemented · **Complexity:** ~5 files + +> 🤖 **Automatic audit note (2026-07-23): Further investigation and targeted testing +> required — do not accept as closed yet.** The planned pinned recovery action and clean +> empty-state exits exist, but project/cluster API requests still run before the QuickPick is +> shown, so a stale session, 401/403, or network failure can bypass the recovery action and +> close the wizard. The flow also shows **Credential management completed** even when +> authentication returns `false`. Test both failure paths and correct the outcome messaging. + +**Observation:** Start the Add-Connection wizard with a dropped session, or pick a project +whose clusters are all mid-provision — the wizard **closes with a raw error** instead of +keeping you in flow. (Reviewer #3's "I had to restart the wizard" pain shows up here too.) + +**Finding:** + +- 🔍 The old raw-throw dead-ends in `SelectAtlasProjectStep` / `SelectAtlasClusterStep` have been removed from the expected recovery paths. +- ✅ Both steps now inject an Azure-style top `alwaysShow` action — **Manage MongoDB Atlas Credentials...** — with key icon and separator, followed by the normal project/cluster options. +- ✅ Selecting the manage-credentials action runs the Atlas credential flow, records telemetry, shows a modal retry instruction, and exits with `UserCancelledError` rather than a generic `Error`. +- ✅ Empty-state dead-ends now terminate cleanly: missing project/cluster selection and no-connectable-clusters paths use `UserCancelledError` with clear guidance instead of raw thrown errors that surface as hard wizard failures. + +💡 **Suggestion:** Replace the raw throws with an in-flow affordance (Azure style: an +always-show header row + clean `UserCancelledError`; or K8s style: inline "Sign in…" and +re-prompt). For the empty-cluster case, keep the user in the wizard with a clear "no +connectable clusters in this project" step rather than throwing. See +[O1](#o1-wizard-no-session--empty-cluster-recovery-item-5). + +> **Decision (Iteration 3):** Adopt the **Azure style** (Option A). The Add-Connection wizard +> **always** shows a top `alwaysShow` item that opens credential management — mirror Azure's +> smart wording from [SelectSubscriptionStep](../../../../src/plugins/api-shared/azure/wizard/SelectSubscriptionStep.ts#L120): +> label **"Manage MongoDB Atlas Credentials…"**, detail _"Sign in with a different API key or +> Service Account to see more projects and clusters."_, `key` icon, followed by a separator +> and the project/cluster list. On selection, run credential management, show a short +> "completed — retry discovery" notice, then exit cleanly with `UserCancelledError` (never a +> raw `throw`). **Reason:** the user should always have a way to fix/switch credentials from +> inside the wizard; this is the established, well-worded pattern across all three Azure +> siblings. + +✅ **Implemented (Iteration 3):** [313950f2](https://github.com/microsoft/vscode-documentdb/commit/313950f2) +landed Item 5 in +[SelectAtlasSteps.ts](../../../../src/plugins/service-atlas-mongodb/discovery-wizard/SelectAtlasSteps.ts) and follows the Azure wizard contract end-to-end. + +- Added typed quick-pick item models for project and cluster steps, including explicit + `manageCredentials` and empty-state item types. +- Added the top **Manage MongoDB Atlas Credentials...** `alwaysShow` item (with separator) + to both project and cluster pickers. +- Replaced raw throw paths in recovery scenarios with clean `UserCancelledError` exits. +- Added wizard-scope credential management handlers that run auth, emit telemetry + (`credentialConfigActivated`, `initiatedFrom`, `authMethod`, `authSuccess`), and show a + modal "retry discovery" instruction before returning control. +- Replaced the old no-IDLE dead-end with a guided no-connectable-clusters message and + graceful cancellation path. + +**Verification:** `npm run l10n`, `npm run prettier-fix`, `npm run lint`, +`npx jest --no-coverage` (2668 tests / 159 suites), and `npm run build` all passed. + +--- + +### 14. Remove all filtering (org + project) and its storage — release cleanup ⚠️ 🗣️ + +**Priority:** P1 · **Status:** ✅ Implemented ([a7737b70](https://github.com/microsoft/vscode-documentdb/commit/a7737b70)) · **Complexity:** ~10 files · **Reviewer (live pass)** + +> 🤖 **Automatic audit note (2026-07-23): Accept as closed.** The Atlas-specific project +> and organization filter UI, context token, persisted selections, and storage keys are absent +> from the current code. The remaining organization lookup is read-only, as allowed by the plan. + +**Observation:** _"Filtering — I think we can skip this completely, at least for now. Users +can't log in as themselves; they use scoped Service Accounts and keys. So clean up everything +filter-related for the Atlas discovery, including any associated storage."_ + +**Finding:** Filtering is spread across several surfaces, all of which would be removed: + +- ⚠️ **Project filter** — [AtlasDiscoveryProvider.configureTreeItemFilter](../../../../src/plugins/service-atlas-mongodb/AtlasDiscoveryProvider.ts#L104) (the "Filter Entries…" QuickPick) and the `enableFilterCommand` token on the root [contextValue](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts#L29). +- ⚠️ **Org filter** — [AtlasDiscoveryProvider.showOrganizations](../../../../src/plugins/service-atlas-mongodb/AtlasDiscoveryProvider.ts#L221) and the "account → organizations" branch of [configureCredentials](../../../../src/plugins/service-atlas-mongodb/AtlasDiscoveryProvider.ts#L169). +- ⚠️ **Filter application + empty state** — the org/project filter logic and the "All projects are hidden by filter" node in [fetchProjectItems](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts#L114). +- ⚠️ **Session-manager API + storage** — `getSelectedOrgId` / `setSelectedOrgId` / `getSelectedProjectIds` / `setSelectedProjectIds` in [AtlasSessionManager](../../../../src/plugins/service-atlas-mongodb/auth/AtlasSessionManager.ts), plus the `STATE_SELECTED_PROJECTS` and `STATE_SELECTED_ORG_ID` keys in [config.ts](../../../../src/plugins/service-atlas-mongodb/config.ts#L37). +- 🔍 Atlas discovery **has never shipped**, so there is **no persisted user state to migrate** — the storage keys can simply be deleted. + +💡 **Suggestion:** Remove all of the above (command registration, provider methods, session +API, storage keys, and the `enableFilterCommand` token). Keep a **minimal org lookup only if +item 3 needs it** for the "orgs present but no projects → permissions" disambiguation — that +is a read, not a filter, and can be a lightweight count check rather than a stored selection. + +> **Decision (Iteration 3):** **Remove filtering entirely for now.** **Reason:** with scoped +> Service Accounts / API keys (no interactive personal login), a key already sees only what +> it's authorized for, so org/project filtering adds UI and storage that don't earn their +> keep. Revisit only if interactive sign-in (many orgs per user) ever lands. + +✅ **Implemented (Iteration 3):** [a7737b70](https://github.com/microsoft/vscode-documentdb/commit/a7737b70) +removes the Atlas project-filter QuickPick, credential-menu organization picker, selected +organization/project storage APIs and keys, root `enableFilterCommand` context token, and +filtered empty state. Organization lookup remains read-only for project descriptions and +future permissions diagnostics. **Verification:** `npm run build` passed. + +--- + +## P2 — Polish, expectation, or feature gap + +### 6. Rework credential entry as a guided webview (tell the user where to get the keys) 🗣️ + +**Priority:** P2 · **Status:** ✅ Implemented · **Complexity:** ~15 files · **Reviewer #2** + +> 🤖 **Automatic audit note (2026-07-23): Further investigation, fixes, and targeted +> testing required — do not accept as closed yet.** The hybrid QuickPick + guided-webview +> surface was implemented, but credentials are persisted before validation, contrary to the +> recorded decision; a failed update can replace previously valid credentials. Service Account +> submission verifies token acquisition but not Atlas Admin API access. Also test panel-close +> cancellation and screen-reader announcements for validation/loading states before closure. + +**Observation:** _"The sign-in QuickPick will have to be redone as a webview. It's currently +too hard for the user to know what to do — the QuickPick doesn't share enough context on +where to get the data from. It will be reworked as a webview where each step has some intro +and info on where the data is to be taken from."_ + +**Finding:** + +- 🔍 Today entry is a bare [QuickPick](../../../../src/plugins/service-atlas-mongodb/auth/AtlasAuthQuickPick.ts#L19) → a sequence of [`showInputBox`](../../../../src/plugins/service-atlas-mongodb/auth/AtlasApiKeyFlow.ts#L18) prompts (public key, private key). There is nowhere to explain _where in the Atlas console_ to create/find a key, what an Access List is, or the difference between an API Key and a Service Account. The QuickPick secondary text is also in `description` (truncates) rather than `detail`. +- 🔍 The repo already has a React webview stack ([packages/vscode-ext-react-webview](../../../../packages/vscode-ext-react-webview), [src/webviews/](../../../../src/webviews)) and a tRPC messaging pattern (see the `webview-trpc-messaging` skill), so a guided form is well-supported. +- 🔍 This **subsumes the earlier P3 "move secondary text to `detail`" item** — a webview replaces that surface entirely. + +💡 **Suggestion:** Build a small guided webview: step 1 chooses the method (with real +"where to get this" copy + a deep link to the Atlas console API-keys page); step 2 collects +the credential with inline help and validation. Keep the **non-secret orchestration** in the +extension host and only render the form in the webview (per Reviewer #6's "I don't want to +move everything into a webview"). This is the natural home for the **retry / update-credentials** +affordance from item 2. See [O3](#o3-credentialentry-surface-webview-vs-quickpick-item-6). +_Likely a follow-up PR, not a release blocker — confirm scope._ + +> **Decision (Iteration 3 — Option C):** Adopt the **hybrid approach**: the auth-method +> chooser (`promptAtlasAuthMethod`) and the manage-credentials list (`configureCredentials`) +> **remain QuickPicks** — they are simple list selections that need no extra help text. +> Only the **add/edit credential form** becomes a guided webview. This keeps the existing +> `executeAtlasAuthFlow(method, sessionManager): Promise` contract intact so every +> caller — the discovery tree, the retry node, the wizard — works without change. +> Single-session storage is retained (multi-credential store is deferred to item 7). +> The webview opens as an **editor-tab panel** (not a modal); credentials are validated +> host-side before storage; the panel self-closes on success and resolves `true` to the +> caller, or resolves `false` when the user closes it without completing. +> **Reason:** the QuickPick method-chooser is already an appropriate surface (two options, +> no help needed); replacing it with a multi-step webview wizard would add friction without +> adding value. The form itself is where users need guidance — that is what becomes a webview. + +✅ **Implemented (Iteration 3):** Seven files were created or modified: + +_New files:_ + +- [`src/webviews/documentdb/atlasCredentials/atlasCredentialsRouter.ts`](../../../../src/webviews/documentdb/atlasCredentials/atlasCredentialsRouter.ts) — host-side tRPC router. `RouterContext` carries a live `AtlasSessionManager` reference and a `onCredentialsStored` one-shot callback (both survive the shallow context clone in `attachTrpc`). Two mutations: `submitApiKey` (trims keys, stores for retry first via `storeApiKeyCredentialsForRetry`, validates against `AtlasApiClient.listProjects`, then calls `storeApiKeyCredentials` + `onCredentialsStored`) and `submitServiceAccount` (same pattern via `fetchServiceAccountToken`). On 401/403, `describeAtlasError` appends an Access-List/permissions hint. Telemetry records `authMethod` and `authSuccess`. +- [`src/webviews/documentdb/atlasCredentials/atlasCredentialsController.ts`](../../../../src/webviews/documentdb/atlasCredentials/atlasCredentialsController.ts) — exports `AtlasCredentialsWebviewConfig` (JSON-safe, carries only `authMethod`) and `openAtlasCredentialsWebview(authMethod, sessionManager): Promise`. Wraps the panel in a `Promise`; `onCredentialsStored` resolves `true` and disposes the panel on the next tick (so the mutation response reaches the webview first); `onDisposed` resolves `false`. +- [`src/webviews/documentdb/atlasCredentials/AtlasCredentialsView.tsx`](../../../../src/webviews/documentdb/atlasCredentials/AtlasCredentialsView.tsx) — React form (Fluent UI v9). Layout: title → brief intro text → collapsible `
` step-by-step guide (4 steps; step 2 has a nested sub-list for org creation) → documentation + console links → form fields → Connect button + spinner → permission hint. The step-by-step guide covers the full journey from sign-in through IDENTITY & ACCESS → Applications → the correct tab. Both auth methods share steps 1–3; step 4 diverges (API Keys tab vs. Service Accounts tab). Inline `MessageBar` shows validation errors without closing the form. On success the host disposes the panel — no client-side navigation needed. + +_Modified files:_ + +- [`src/webviews/_integration/appRouter.ts`](../../../../src/webviews/_integration/appRouter.ts) — registered `atlasCredentialsRouter` as a top-level key alongside `common` and `mongoClusters`. +- [`src/webviews/_integration/WebviewRegistry.ts`](../../../../src/webviews/_integration/WebviewRegistry.ts) — registered `atlasCredentials: AtlasCredentialsView`. +- [`src/plugins/service-atlas-mongodb/auth/AtlasApiKeyFlow.ts`](../../../../src/plugins/service-atlas-mongodb/auth/AtlasApiKeyFlow.ts) — rewritten: calls `sessionManager.setAuthenticating()`, opens `openAtlasCredentialsWebview('apikey', sessionManager)`, on `false` calls `cancelAuthentication()` and returns `false`; on `true` shows the success toast and returns `true`. All input-box and standalone-validation logic removed. +- [`src/plugins/service-atlas-mongodb/auth/AtlasServiceAccountFlow.ts`](../../../../src/plugins/service-atlas-mongodb/auth/AtlasServiceAccountFlow.ts) — rewritten analogously for `'serviceaccount'`. + +**Verification:** `npm run l10n` (1619 keys), `npm run prettier-fix`, `npm run lint`, `npx jest --no-coverage` (2668 tests / 159 suites), and `npm run build` all passed. + +> **Decision (Iteration 4, Step 3):** The Iteration 3 note above deliberately kept the auth-method +> chooser as a separate QuickPick. That is now **reversed**: the chooser became the webview's first +> step. **Reason:** with multiple credentials, the choice is no longer a one-time setup detail but +> a recurring decision that needs the "which should I use?" guidance from the POC's auth-method +> strategy (Service Account recommended and rotatable, API Key legacy and never expiring). A +> QuickPick cannot carry that guidance, and keeping the chooser inside the panel makes the whole +> add flow one guided surface whose toggle live-swaps the fields and the help text. +> +> The "store first, then validate" order is also **reversed**: the credential is now validated with +> a real discovery call **before** anything is written. **Reason:** storing first was only there to +> keep a single-session retry node alive. With per-credential records, storing an unvalidated +> secret would either create a junk credential or, worse, overwrite a working one during an update. + +✅ **Implemented (Iteration 4, Step 3):** [c0c49ce6](https://github.com/microsoft/vscode-documentdb/commit/c0c49ce6) +— the webview opens on the method choice (Service Account preselected and marked recommended, API +Key labelled legacy and simplest), supports an edit mode that replaces an existing credential's +secret in place, validates before storing, keeps the panel open with the entered values on failure, +and stores nothing when cancelled. +[caa1a823](https://github.com/microsoft/vscode-documentdb/commit/caa1a823) removed the now-unused +auth-method QuickPick and the flow wrappers. + +#### Follow-up — navigation footer & wizard accessibility (2026-07-30) + +Standardized the webview's step navigation and closed several wizard a11y gaps. Fluent UI v9 ships +**no** Wizard/Stepper component (v8's `@fluentui/react-wizard` was never ported); the sanctioned +approach is a composed `Breadcrumb` + Drawer/Dialog-style body/footer, which this view now follows. + +- **Standardized navigation footer** ([02c197c6](https://github.com/microsoft/vscode-documentdb/commit/02c197c6)) — a single footer pinned to the bottom; content scrolls beneath it. Buttons are left-aligned, **primary first, then Back** (Back always present, disabled where there's nowhere to go). The primary label stays `Verify & Save` (disabled) across the verifying/failed states so **Back never shifts**; `Retry` stays in the error `MessageBar` where it's actionable. Adds a local `body { padding: 0 }` reset so the footer spans edge-to-edge past VS Code's default 20px webview gutter — generalization tracked in [#825](https://github.com/microsoft/vscode-documentdb/issues/825). +- **Dynamic footer separator** ([f98d4c0d](https://github.com/microsoft/vscode-documentdb/commit/f98d4c0d)) — the footer's top border + upward shadow fade in only while content is still scrollable beneath it and fade out once everything fits or the user reaches the bottom, matching Fluent's Drawer. Detection is a single measurement (`scrollTop + clientHeight < scrollHeight - 1`) on scroll + a `ResizeObserver`; a transparent 1px border reserves space so toggling never shifts layout. +- **Responsive breadcrumb (overflow)** ([99937624](https://github.com/microsoft/vscode-documentdb/commit/99937624)) — the step breadcrumb is wrapped in Fluent's `Overflow`, so it collapses into a `…` menu when it doesn't fit instead of clipping/wrapping. The **current step** is given the highest `OverflowItem` priority, so it is the last item overflow ever removes — the active step never hides. Hidden steps appear in the menu (navigable earlier steps stay clickable; the rest are shown disabled for discoverability). +- **Breadcrumb semantics** ([f966bf37](https://github.com/microsoft/vscode-documentdb/commit/f966bf37)) — `aria-current="step"` on the active step (Fluent's `current` prop otherwise emits `aria-current="page"`), a descriptive `aria-label` ("Credential setup progress"), and `disabledFocusable` for non-navigable steps so they stay in tab order. +- **Focus management** ([ac9fe92c](https://github.com/microsoft/vscode-documentdb/commit/ac9fe92c)) — on step change, focus moves to the new step's `

` (content ref, `tabIndex=-1`) instead of falling back to ``; skipped on initial render. Complements the existing `Announcer` live regions for the checking/success phases, partially addressing the audit note's screen-reader concern for validation/loading states. + +_Research:_ a Sonnet subagent confirmed Fluent v9 has no Wizard/Stepper and that our composed Breadcrumb + pinned footer is the sanctioned pattern; the footer separator and breadcrumb overflow above were then matched to Fluent's own DrawerFooter and Breadcrumb-overflow implementations. + +_Deferred a11y follow-ups:_ live-region announcement on the choose→form transition; consider +`MessageBar intent="success"` as the done-step surface. + +#### Follow-up — safe shared URL diagnostics (2026-07-30) + +A GitHub Copilot review caught that the shared `common.openUrl` procedure logged arbitrary +webview-provided URLs at info level, including credentials, query values, and fragments. The +debugging added for Atlas deep links therefore affected every caller of the shared procedure. + +- [`src/utils/openUrl.ts`](../../../../src/utils/openUrl.ts) now validates external URLs as HTTP(S) + and formats diagnostics from `origin + pathname`, preserving query parameter names while + replacing every value with `` and replacing the entire fragment with ``. + Reconstructing from `origin` also strips URL userinfo. +- [`src/webviews/_integration/appRouter.ts`](../../../../src/webviews/_integration/appRouter.ts) + rejects malformed and non-HTTP(S) values before the mutation body runs, logs the sanitized URL + at trace rather than info, and opens the original validated URL. +- [`src/utils/openUrl.test.ts`](../../../../src/utils/openUrl.test.ts) covers accepted HTTP(S) + URLs, missing schemes, malformed values, unsupported schemes, credentials, repeated query + parameters, encoded parameter names, and fragments. The focused suite passes all 9 cases. + +--- + +### 7. Multi-credential management, modeled on the Azure accounts flow 🗣️ + +**Priority:** P2 · **Status:** ✅ Implemented (Iteration 4) · **Complexity:** ~20 files · **Reviewer #6** + +> 🤖 **Automatic audit note (2026-07-23): Keep open; implementation is still required.** +> Code inspection confirms that Atlas still uses fixed single-credential storage and a flat +> update/sign-out QuickPick. This item cannot be accepted as closed and should be revisited +> only after item 6's credential-entry contract is settled. + +**Observation:** _"Redesign credential management — support multiple API keys. Replicate the +Azure 'Manage Credentials' QuickPick: see what we have, a 'Remove' option in a submenu, and +when the user picks 'Add', the webview starts. A proper manage-credentials flow like Azure, +with many accounts / API keys. This leads to an API redesign since we'd have to iterate — but +we can do it."_ + +**Finding:** + +- 🔍 Atlas today is **single-session**: [AtlasSessionManager](../../../../src/plugins/service-atlas-mongodb/auth/AtlasSessionManager.ts) holds one `AtlasSession`, and [configureCredentials](../../../../src/plugins/service-atlas-mongodb/AtlasDiscoveryProvider.ts#L169) shows a flat account / sign-out / exit QuickPick. There is no concept of a credential list. +- 🔍 The Azure reference is [configureAzureCredentials](../../../../src/plugins/api-shared/azure/credentialsManagement/configureAzureCredentials.ts#L94) → an `AzureWizard` of `SelectAccountStep` → `AccountTenantsStep` → `TenantActionStep` (+ `ExecuteStep`), titled "Manage Azure Accounts", supporting multiple accounts with add/remove. That structure maps cleanly onto Atlas (accounts/keys instead of Azure accounts; orgs/projects instead of tenants/subscriptions). +- 🔍 **Reuse the Kubernetes storage stack (build on what we already have).** Atlas currently persists secrets with **fixed single-slot keys** — [AtlasSessionManager](../../../../src/plugins/service-atlas-mongodb/auth/AtlasSessionManager.ts) calls `secretStorage.store('atlas-mongodb.apikey.publicKey', …)` etc., which structurally allows **exactly one** API key and one Service Account. Kubernetes already solved "an ordered list of credentials, each with its own secrets" on top of the shared **[StorageService](../../../../src/services/storageService.ts)** — a per-workspace item store that persists **`properties` → `globalState`** and **`secrets` → `SecretStorage`** in a single typed API. The reference wrapper is [sourceStore.ts](../../../../src/plugins/service-kubernetes/sources/sourceStore.ts): each source is a `StorageItem` with an `order` field for stable display order, an inline secret in `secrets[]`, and an in-memory cache with explicit invalidation. +- 🔍 **API-redesign impact (as the reviewer noted):** `AtlasSessionManager` becomes a store of **N** credentials (each API key / Service Account), the API client is selected per credential, and the tree must attribute each org/project/cluster to the credential that surfaced it (relevant to the org level in item 8). This is the biggest structural change of the three design items. + +💡 **Suggestion:** Adopt the Azure `credentialsManagement/` wizard shape for the UI (a +"Manage MongoDB Atlas Credentials" QuickPick listing existing credentials with a per-item +**Remove** submenu and an **Add** action that launches the guided webview, item 6), and +adopt the **Kubernetes `sourceStore` + `StorageService` pattern for persistence** so we +build on the same secrets solution rather than a bespoke one: + +- Model each credential as a `StorageItem` under + `StorageService.get('atlas-mongodb-discovery')` in a `credentials` workspace — non-secret + metadata (auth method, user-facing label, selected org) in `properties`, and the + public/private key or client id/secret in `secrets[]` (SecretStorage-backed, exactly like + the K8s inline-YAML secret). +- Keep an `order` field for stable list ordering and an in-memory cache, mirroring + `sourceStore.ts`. +- **No migration needed** — Atlas discovery has never shipped, so the current single-slot + `AtlasSessionManager` keys carry no real user data; the new store starts clean. + +See [O4](#o4-multi-credential-model--api-redesign-item-7). **This supersedes the earlier +"staged Back/status wording" polish item** — that lands for free with the Azure-style flow. +_Follow-up PR; sequence after item 6._ + +> **Decision (Iteration 4, Steps 2 and 3):** Implemented as suggested, on both reference stacks: +> the Kubernetes `sourceStore` shape for persistence and the Azure `credentialsManagement/` wizard +> shape for the UI (`AzureWizard` prompt steps, `GoBackError` for Back, a sentinel +> `UserCancelledError` message for a graceful exit). Reusing both patterns verbatim is what keeps +> the two providers' credential flows maintainable side by side. +> +> Two deliberate deviations, both recorded here: +> +> 1. **Aggregation does not copy Azure's fan-out.** Azure's wizard uses `Promise.all`, so one +> failing account collapses the whole list. Atlas uses `Promise.allSettled` behind a bounded +> limiter and returns healthy data together with typed per-credential errors, because a dead +> credential must never blank the fleet. +> 2. **Re-entering the same Atlas identity updates the existing record instead of adding a +> duplicate.** The record ID stays stable across a secret rotation, which is what keeps tree +> paths and saved connections valid. A later live test exposed that the original implementation +> matched on the 8-character display hint, causing all Service Account IDs with the shared +> `mdb_sa_id_` prefix to collide. Matching now compares the complete Public Key or Client ID from +> SecretStorage. During update that identity field is populated and disabled; only the Private +> Key or Client Secret can rotate. Using another identity requires removing the entry and adding +> a new credential. + +✅ **Implemented (Iteration 4):** [ee2bf417](https://github.com/microsoft/vscode-documentdb/commit/ee2bf417) +(store, per-credential sessions, `listAll()` aggregation, pagination), +[c0c49ce6](https://github.com/microsoft/vscode-documentdb/commit/c0c49ce6) (Manage MongoDB Atlas +Credentials QuickPick with Add, Retry, Update, Remove, Sign out of all, Back, Exit), and +[caa1a823](https://github.com/microsoft/vscode-documentdb/commit/caa1a823) (retirement of the +single-session manager). Tests cover independent restore, token-refresh isolation, credential +removal, partial failure, pagination, duplicate/overlapping project access, stable ordering, +cancellation, and every management action. + +--- + +### 8. Tree/List view toggle with an org level (Kubernetes-style) 🗣️ + +**Priority:** P2 · **Status:** ✅ Implemented (Iteration 4) · **Complexity:** ~15 files · **Reviewer #5** + +> 🤖 **Automatic audit note (2026-07-23): Keep open; implementation is still required.** +> No organization tree node, flat-list mode, view-mode state, or toggle commands were added. +> This item cannot be accepted as closed and remains dependent on the org-aware +> multi-credential model in item 7. + +**Observation:** _"Replicate the modes from the Kubernetes view — the user can switch between +tree and list. The tree would show orgs → projects → clusters nested; the list would show all +clusters with project and org info in the description."_ + +**Finding:** + +- 🔍 Kubernetes implements exactly this: [config.ts](../../../../src/plugins/service-kubernetes/config.ts#L79) defines `KubernetesViewMode = 'list' | 'tree'` + a `DISCOVERY_VIEW_MODE_STATE_KEY` globalState key; [switchKubernetesViewMode.ts](../../../../src/plugins/service-kubernetes/commands/switchKubernetesViewMode.ts) provides the two commands; [KubernetesContextItem.getChildren](../../../../src/plugins/service-kubernetes/discovery-tree/KubernetesContextItem.ts#L168) branches on the mode; a `discoveryKubernetesViewModeTree` / `…List` contextValue marker drives an **inline toggle whose icon reflects the current mode** (package.json menus, [~L880](../../../../package.json#L880)). +- ⚠️ **Structural note:** Atlas today has **no org tree level** — the hierarchy is Project → Cluster, with org only used for filtering/labels ([AtlasProjectItem](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasProjectItem.ts)). Reviewer #5's "tree = orgs → projects → clusters" therefore **adds a new Org tree level**, which also interacts with multi-credential attribution (item 7). + +💡 **Suggestion:** Port the K8s view-mode scaffold verbatim (config key + two commands + +contextValue marker + inline toggle). **Tree mode:** new `AtlasOrgItem` → `AtlasProjectItem` +→ `AtlasClusterItem`. **List mode:** flat `AtlasClusterItem`s with `org · project` in the +description. Sequence **after** the org-aware credential model (item 7) so the org grouping +has a stable data source. See [O5](#o5-treelist-toggle--org-level-item-8). _Feature work; +follow-up PR._ + +> **Decision (Iteration 4, Steps 4 and 5):** Ported as suggested, sequenced after the credential +> model exactly as recommended. Tree mode is the default because the organization level is what +> makes several credentials legible; List mode stays one click away. +> +> Deviation worth noting: the earlier idea of forcing Tree mode and disabling the List toggle on +> error was **dropped**. Because the recovery row is just another row, it drops into a flat list +> unchanged, so List mode needs no special casing and the layout never changes under the user. + +✅ **Implemented (Iteration 4):** [9c8baa0f](https://github.com/microsoft/vscode-documentdb/commit/9c8baa0f) +adds the organization level and the merged tree; +[3674133d](https://github.com/microsoft/vscode-documentdb/commit/3674133d) adds the persisted +Tree/List toggle, the flat deduplicated cluster list with `organization · project` context, and the +same recovery row in both modes. + +--- + +### 9. Wizard shows only IDLE clusters — the tree shows all ⚠️ + +**Priority:** P2 · **Status:** ✅ Implemented ([368a4cff](https://github.com/microsoft/vscode-documentdb/commit/368a4cff)) · **Complexity:** ~5 files + +> 🤖 **Automatic audit note (2026-07-23): Accept as closed.** Code inspection confirms +> that all clusters appear, non-IDLE states are annotated, and selecting a non-IDLE entry +> explains the restriction before returning to the picker. This follows the UX review's +> recorded decision while allowing only `IDLE` clusters to proceed to connection. + +**Observation:** A cluster visible in the discovery tree (e.g. tagged `Updating…`) is +**absent** from the Add-Connection wizard's cluster list. + +**Finding:** + +- ✅ The wizard now lists **all** clusters returned by Atlas, matching the discovery tree's existence model. +- ✅ Non-IDLE clusters are kept visible but marked as unavailable in the wizard, with the current state surfaced directly in the item description. +- ✅ Selecting a non-IDLE cluster no longer creates a disappearance/mismatch problem; instead the user gets an in-flow explanation and returns to the picker. +- 🔍 This keeps the safer connectability rule from item 5 intact: the wizard still only proceeds with `IDLE` clusters, but it no longer hides clusters that the tree already shows. + +💡 **Suggestion:** Either show non-IDLE clusters in the wizard as **disabled/annotated** +items (so the list matches the tree and the reason is legible), or document the filter as +intentional and give the empty case a friendly in-flow message (ties into item 5). + +> **Decision (Iteration 3):** Keep the wizard's **IDLE-only connectability rule**, but stop +> hiding non-IDLE clusters. Show all clusters in the picker so the wizard matches the tree, +> annotate non-IDLE entries with their state, and if the user selects one, explain that it is +> not connectable until it returns to `IDLE`. **Reason:** the problem was not that the wizard +> rejected non-IDLE clusters; the problem was that the clusters disappeared entirely, making the +> wizard contradict the tree. This keeps the lower-risk connection rule while fixing the UX +> mismatch. + +✅ **Implemented (Iteration 3):** [368a4cff](https://github.com/microsoft/vscode-documentdb/commit/368a4cff) +updated [SelectAtlasSteps.ts](../../../../src/plugins/service-atlas-mongodb/discovery-wizard/SelectAtlasSteps.ts) +to align the wizard with the tree. + +- Removed the `IDLE`-only filter from the cluster list builder so all Atlas clusters now appear + in the picker. +- Added wizard-local state labels and explanations for non-IDLE cluster states. +- Annotated non-IDLE cluster items in-place instead of hiding them. +- Kept only `IDLE` clusters selectable for connection; selecting any non-IDLE cluster shows a + modal explanation and returns the user to the picker. +- Replaced the old "no connectable clusters" dead-end with a true "no clusters in this + project" empty state, since visible-but-unavailable clusters are now shown directly. + +**Verification:** `npm run l10n`, `npm run prettier-fix`, `npm run lint`, +`npx jest --no-coverage` (2668 tests / 159 suites), and `npm run build` all passed. + +--- + +### 10. Project node has no tooltip ⚠️ + +**Priority:** P2 · **Status:** ✅ Implemented · **Complexity:** ~5 files + +> 🤖 **Automatic audit note (2026-07-23): Further remediation and testing required — do +> not accept as closed yet.** The requested tooltip exists, but Atlas-provided project and +> organization values are interpolated into Markdown without escaping. Align with the existing +> cluster-tooltip escaping and test names containing Markdown syntax before closure. +> +> ✅ **Audit note resolved (Iteration 4, Step 6):** see the escaping entry at the end of this item. + +**Finding:** + +- ⚠️ [AtlasProjectItem.getTreeItem](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasProjectItem.ts#L112) sets `label`, `description`, `iconPath` but **no `tooltip`**. The cluster tooltip is rich markdown; the project has none (iteration 1 §D flagged this; still open). Related to item 3 — longer detail belongs in a tooltip, not a truncating description. + +💡 **Suggestion:** Add a grouped markdown tooltip (org name, project ID, cluster count) in +the same `---`-separated style as the cluster tooltip for cross-provider consistency. + +> **Decision (Iteration 3):** Add a `MarkdownString` tooltip with project name as the heading, organization name (when available), project ID, and cluster count — matching the same style as the cluster tooltip. + +✅ **Implemented (Iteration 3):** [41ec69f2](https://github.com/microsoft/vscode-documentdb/commit/41ec69f2) — `AtlasProjectItem` now has a private `buildTooltip()` method that returns a `vscode.MarkdownString` with project name (bold heading), org name (if present), project ID, and cluster count. `getTreeItem()` wires it in via the `tooltip` property. **Verification:** `npm run l10n` (1652 keys), `npm run prettier-fix`, `npm run lint`, `npx jest --no-coverage` (2668 tests / 159 suites), and `npm run build` all passed. + +> **Decision (Iteration 4, Step 6):** The escaping gap is closed by reusing the repository-wide +> [`escapeMarkdown`](../../../../src/webviews/utils/escapeMarkdown.ts) helper rather than the +> private copy that lived inside `AtlasClusterItem`. **Deviation from the literal instruction** +> ("use the same helper as the cluster tooltip"): the cluster tooltip's private copy was deleted +> and both tooltips now call the shared helper. Reason — the shared helper escapes a strict +> superset of characters (adds `<`, `>`, `&`), keeping two tooltips on one contract removes a +> silent drift risk, and the shared helper already has its own test suite. Confidence: high. + +✅ **Implemented (Iteration 4, Step 6):** [f53c0ca3](https://github.com/microsoft/vscode-documentdb/commit/f53c0ca3) +— `AtlasProjectItem.buildTooltip()` escapes project name, organization name, and project ID; +`AtlasClusterItem` drops its duplicated local helper and imports the shared one. New +`AtlasProjectItem.test.ts` covers emphasis (`**not bold**`), link-like organization names, +underscore-bearing project IDs, and asserts the tooltip stays `isTrusted = false`. + +--- + +### 11. No reveal/expand of the Atlas root after a successful sign-in ⚠️ + +**Priority:** P2 · **Status:** ✅ Implemented · **Complexity:** ~5 files + +> 🤖 **Automatic audit note (2026-07-23): Accept as closed against the documented scope.** +> Code inspection confirms that successful authentication through +> `authenticateAndFetchUserInfo()` refreshes and reveals the Atlas root with `expand: true`, +> and reveal failures are isolated and logged as planned. + +**Finding:** + +- ⚠️ [AtlasDiscoveryProvider](../../../../src/plugins/service-atlas-mongodb/AtlasDiscoveryProvider.ts#L44) `onDidChangeSession` calls `resetNodeErrorState(rootId)` + `refresh()` but never `reveal()`/expands the root, so after sign-in the user must manually expand to see projects (Kubernetes reveals the newly-added source — iteration 1 §B/#22). + +💡 **Suggestion:** After `transitionTo(Active)`, reveal + expand the Atlas root so projects +appear without a manual expand. + +> **Decision (Iteration 3):** After a successful authentication in `authenticateAndFetchUserInfo()`, call a new private `revealAtlasRoot()` method that finds the root node and calls `ext.discoveryTreeView.reveal(atlasRoot, { select: false, focus: false, expand: true })`. Mirror the Kubernetes pattern from `refreshKubernetesRoot.ts`. Wrap in try/catch so a reveal failure never breaks the sign-in outcome. + +✅ **Implemented (Iteration 3):** [41ec69f2](https://github.com/microsoft/vscode-documentdb/commit/41ec69f2) — `AtlasDiscoveryProvider` has a new private `revealAtlasRoot()` method. After a successful `executeAtlasAuthFlow` call in `authenticateAndFetchUserInfo()`, `void this.revealAtlasRoot()` is called after the tree refresh. The method resolves the Atlas root from `ext.discoveryBranchDataProvider.getChildren()` and calls `ext.discoveryTreeView.reveal()` with `expand: true`. Non-critical failures log a warning to `ext.outputChannel` and do not surface to the user. **Verification:** `npm run build` passed. + +--- + +## P3 — Nice-to-have / cosmetic / acknowledged + +### 12. Root shows no "signed in as…" identity when Active ⚠️ + +**Priority:** P3 · **Status:** 🚫 Closed (superseded, Iteration 4) · **Complexity:** ~5 files + +> 🤖 **Automatic audit note (2026-07-23): Further investigation and targeted testing +> required — do not accept as closed yet.** The Active-state text was added, but credential +> replacement does not clear the previously stored display name. Switching API keys or moving +> from an API key to a Service Account can therefore show stale identity. Test credential +> replacement and correct the display-name lifecycle before closure. + +**Finding:** [getStateDescription](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts#L221) only annotates `Expired` / `Authenticating`; when `Active` the description is blank even though `getUserDisplayName()` is available (iteration 1 §9.1). Gains extra value under multi-credential (item 7): the root could show _which_ credential is active. + +💡 **Suggestion:** Surface the signed-in display name / org in the root description or +tooltip when Active. + +> **Decision (Iteration 3):** Add an `Active` case to `getStateDescription()` that returns `"Signed in as {displayName}"` when a display name is stored, or `"Signed in"` as a fallback (covers Service Accounts for which no user-profile endpoint exists). + +✅ **Implemented (Iteration 3):** [41ec69f2](https://github.com/microsoft/vscode-documentdb/commit/41ec69f2) — `AtlasServiceRootItem.getStateDescription()` now handles `AtlasSessionState.Active`: it returns `vscode.l10n.t('Signed in as {0}', displayName)` when `getUserDisplayName()` returns a value, or `vscode.l10n.t('Signed in')` as the fallback. Service Accounts that have no resolvable display name show the fallback gracefully. **Verification:** `npm run build` passed. + +> **Decision (Iteration 4):** 🚫 **Closed as superseded.** The audit note above is exactly right +> that the display-name lifecycle was broken, and the multi-credential model resolves it by +> deleting the concept: a single global "signed in as" slot cannot describe a fleet of +> credentials, and the root description is the one place the quiet-tree design specifically wants +> to stay empty. Identity moved to where it is actionable - the Manage MongoDB Atlas Credentials +> QuickPick lists every credential with its resolved label (user label, then cached organization +> name, then a non-secret identity hint) and its live status. +> +> 🚫 **Reason:** a per-credential identity list replaces a single stale root description. +> Implemented by [caa1a823](https://github.com/microsoft/vscode-documentdb/commit/caa1a823), which +> removed the `userDisplayName` state slot together with the single-session manager. + +--- + +### 13. Active filter state is not visible on the root 🚫 + +**Priority:** P3 · **Status:** 🚫 Closed + +> 🤖 **Automatic audit note (2026-07-23): Accept as closed.** Item 14 removed Atlas +> filtering and its persisted state, so there is no active-filter state left to represent. +> Closing this item as superseded matches the documented decision. + +**Finding:** Two independent filters existed (org via Manage Credentials, project via the +funnel), with no "filtered" badge on the root (iteration 1 §9.2). + +🚫 **Closed (Iteration 3):** Superseded by **item 14** — filtering was removed in +[a7737b70](https://github.com/microsoft/vscode-documentdb/commit/a7737b70), so there is no +filter state left to surface. **Reason:** no filtering, no filter indicator. + +--- + +--- + +## Implemented + +Items resolved in iterations 1–2, re-verified against the current branch (do not re-open +without cause): + +- ✅ **Root renamed to "MongoDB Atlas"** — [config.ts LABEL](../../../../src/plugins/service-atlas-mongodb/config.ts#L15) + [root label](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts#L163). +- ✅ **Stable root identity icon** (`cloud`); transient state moved to `description` — [getStateDescription](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts#L221). +- ✅ **Cluster uses a static brand-mark icon**; state moved to description + tooltip (iteration 2 Finding 2-A) — [AtlasClusterItem.getTreeItem](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasClusterItem.ts#L213). +- ✅ **Cluster `description` trimmed to tier + state** with `·` separators (iteration 2 Finding 2-B) — [buildDescription](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasClusterItem.ts#L266). +- ✅ **Root load failures use modal + canonical "Click here to retry" node** (iteration 1 §F) — [AtlasServiceRootItem](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts#L92). +- ✅ **Auth-flow failures use modals** (not toasts) — [AtlasApiKeyFlow](../../../../src/plugins/service-atlas-mongodb/auth/AtlasApiKeyFlow.ts#L59). +- ✅ **Cluster connection failure uses a modal** — [authenticateAndConnect](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasClusterItem.ts#L196). +- ✅ **Wizard pre-authenticates** (no session → auth QuickPick, clean `UserCancelledError` on cancel) — [promptSignInForWizard](../../../../src/plugins/service-atlas-mongodb/AtlasDiscoveryProvider.ts#L84). +- ✅ **No destructive inline actions**; single shared `manageCredentials` entry point. + +--- + +## Iteration log + +A running record of each fix pass. Items still 🟠 Open at the end of an iteration roll into +the next one; nothing is dropped without a terminal status. + +### Iteration 3 (this pass) + +| # | Item | Decision (why) | Outcome | +| --- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| 1 | Root auto-opens auth picker on expand (🗣️ #1) | Remove auto-prompt; show only the sign-in node ("no magic"; the node is enough) | 🟠 Decided — **release blocker** | +| 2 | Auth failure has no retry / update-creds path (🗣️ #3) | Store submitted credentials, then show retry and update credentials recovery nodes | ✅ Implemented | +| 3 | "No projects found" masks under-permissioned key (🗣️ #4) | Distinguish visible organizations with no visible projects; move guidance to tooltip | ✅ Implemented | +| 4 | Project-level passive error rows | Remove all passive rows → error modal + single retry; detail to `ext.outputChannel` | 🟠 Decided — **release blocker** | +| 5 | Wizard raw-throw dead-ends | Azure-style always-show "Manage MongoDB Atlas Credentials…" + clean `UserCancelledError` | ✅ Implemented in [313950f2](https://github.com/microsoft/vscode-documentdb/commit/313950f2) | +| 14 | Remove all filtering + storage (🗣️ live) | Removed entirely; scoped keys make filtering pointless; no migration (never shipped) | ✅ Implemented in [a7737b70](https://github.com/microsoft/vscode-documentdb/commit/a7737b70); `npm run build` passed | +| 6–8 | Design items: webview (🗣️ #2), multi-credential (🗣️ #6), tree/list (🗣️ #5) | _pending_ | 🟡 Open (soft) — likely follow-up PRs | +| 9 | Wizard/tree cluster mismatch | Show all clusters in the wizard, annotate non-IDLE states, keep only `IDLE` connectable | ✅ Implemented in [368a4cff](https://github.com/microsoft/vscode-documentdb/commit/368a4cff) | +| 10 | Project node tooltip | Add markdown tooltip (org name, project ID, cluster count) — `buildTooltip()` in `AtlasProjectItem` | ✅ Implemented in [41ec69f2](https://github.com/microsoft/vscode-documentdb/commit/41ec69f2) | +| 11 | Reveal/expand root after sign-in | `revealAtlasRoot()` after successful auth; mirrors K8s `revealKubernetesSource` pattern | ✅ Implemented in [41ec69f2](https://github.com/microsoft/vscode-documentdb/commit/41ec69f2) | +| 12 | "Signed in as…" root identity | `Active` case in `getStateDescription()` with display name or "Signed in" fallback | ✅ Implemented in [41ec69f2](https://github.com/microsoft/vscode-documentdb/commit/41ec69f2) | +| 13 | Active filter state not visible on root | #13 closed (filtering removed) | 🚫 Closed | + +> 🗣️ = raised by the reviewer in the live pass. "Decided" items have an agreed direction (see +> the Decision block on each) but are not yet implemented. Items 6–8 are dependent (see +> [Sequencing](#sequencing-suggested)) and larger than a single release. + +### Iteration 4.1 follow-up (2026-07-23) + +| # | Item | Recommendation | Outcome | +| --- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | +| 2 | Auth-recovery error-node wording | Keep **Click here to retry** and rename **Update credentials** to **Click here to update credentials**, matching established actionable tree rows | ✅ Implemented (Iteration 4) — superseded by the consolidated **revisit credentials** row ([9c8baa0f](https://github.com/microsoft/vscode-documentdb/commit/9c8baa0f)) | +| 3 | Non-actionable **No projects visible to this API key** information row | Replace it with a concise modal explanation + canonical **Click here to retry** node; show the modal again only after an explicit retry still returns no projects | ✅ Implemented (Iteration 4) — `200 []` is an authoritative `empty` placeholder; only 401/403/rate-limit/network raise recovery ([ee2bf417](https://github.com/microsoft/vscode-documentdb/commit/ee2bf417), [9c8baa0f](https://github.com/microsoft/vscode-documentdb/commit/9c8baa0f)) | + +--- + +## Open ideas — options, pros & cons + +**What these are (and when they must be answered).** Each `O`-block is a **decision aid** for +one item: it lays out the realistic options with pros/cons and a 💡 **Suggested** pick. They +are _not_ extra work items and they are _not_ all gating. Two categories: + +- **Already decided** — for the P1 release blockers, the choice is **made** and recorded in + that item's **Decision** block (the `O`-table just preserves the alternatives that were + weighed). No further sign-off needed; a contributor can start from the Decision. This covers + **O1** (item 5 → Option A) and **O2** (items 2/3/4 → Option A). +- **Must be answered before Bundle E starts** — for the P2 follow-up redesign, the direction is + still a _suggestion_. **O3, O4, O5** need an explicit pick **before** the corresponding + Bundle E item is implemented — but they do **not** block Bundles A–D, which can proceed now. + +| Block | Item(s) | Bundle | Priority | Answer needed before… | State | +| ------ | ------- | ------ | -------- | ---------------------------------------- | ------------------------------ | +| **O1** | 5 | B | P1 | already answered | ✅ Implemented — Option A | +| **O2** | 2, 3, 4 | A | P1 | already answered | ✅ Decided — Option A | +| **O3** | 6 | E | P2 | starting **Bundle E · item 6** | 🟡 Open — 💡 suggests Option C | +| **O4** | 7 | E | P2 | starting **Bundle E · item 7** (after 6) | 🟡 Open — 💡 suggests Option A | +| **O5** | 8 | E | P2 | starting **Bundle E · item 8** (after 7) | 🟡 Open — 💡 suggests Option A | + +> So: nothing here blocks the release-blocker bundles (A–D). Only **O3/O4/O5** need a decision, +> and only at the point Bundle E's sequenced work reaches each item. + +### O1. Wizard no-session / empty-cluster recovery (item 5) · ✅ Implemented (Option A — see [item 5](#5-add-connection-wizard-steps-throw-raw-errors-that-close-the-flow-)) + +| Option | Pros | Cons | +| -------------------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------- | +| **A. Azure style** — always-show header + clean `UserCancelledError` | Matches 3 of 4 shipped siblings; smallest change; no dead-end error | User leaves the wizard to fix state, then re-opens | +| **B. K8s style** — inline "Sign in…" + re-prompt in the same wizard | Smoothest UX; user never leaves the flow | More wiring; must re-enter the step after auth | +| **C. Keep throw, improve message** | Trivial | Still a dead-end; still closes the wizard — least aligned | + +> 💡 **Suggested:** Option A for fastest parity with the Azure siblings; Option B if the +> team wants the best UX. Either beats today's raw throw (Option C). + +### O2. Project-level error + retry presentation (items 2, 3, 4) · ✅ Decided (Option A — see [item 4](#4-project-level-loadauth-errors-render-as-passive-in-tree-rows-)) + +| Option | Pros | Cons | +| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------- | +| **A. Full root parity** — modal + `Click here to retry` (+ optional `Click here to update credentials`) | Feature-wide consistency; directly answers Reviewer #3's retry ask; house style | Slightly more code; must reuse the error cache | +| **B. Retry node only** (no modal) | Quieter; still gives a way out | Diverges from the root's modal-on-load behaviour | +| **C. Leave passive rows** | No work | Perpetuates the last remaining asymmetry; blocks release | + +> 💡 **Suggested:** Option A — the root already proves the pattern, and a single shared +> retry/error helper covers items 2, 3, and 4 at once. Retry is the must-have (Reviewer #3: +> "simple retry is enough"); **Click here to update credentials** is the strong nice-to-have. + +### O3. Credential-entry surface: webview vs QuickPick (item 6) · 🟡 Open — decide before **Bundle E · item 6** + +| Option | Pros | Cons | +| -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| **A. Guided webview form** (Reviewer #2) | Room for "where to get the key" copy, deep links, inline validation; best onboarding | New surface to build/maintain; must keep secrets out of the webview | +| **B. Enrich the QuickPick / input boxes** | Cheap; `detail` + `prompt` + validation link can carry _some_ guidance | Still cramped; can't show images/steps; truncation persists | +| **C. Hybrid** — QuickPick to choose method, webview only for the credential form | Keeps orchestration in host (Reviewer #6's constraint); webview only where it adds value | Two surfaces to reason about | + +> 💡 **Suggested:** Option C — matches Reviewer #6's "I don't want to move everything into a +> webview." The method chooser and manage-credentials list stay QuickPicks; only the +> add/edit credential form is a webview. Do Option B's `detail` tweak as a cheap stopgap if +> the webview slips past this release. + +### O4. Multi-credential model + API redesign (item 7) · 🟡 Open — decide before **Bundle E · item 7** + +| Option | Pros | Cons | +| ---------------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | +| **A. Full Azure-style multi-credential store** | Matches Reviewer #6's target; parity with Azure; supports teams with many keys | Largest change; `AtlasSessionManager` → N-credential store; tree must attribute nodes to a credential | +| **B. Single credential + easy switch/replace** | Much smaller; covers "wrong key, fix it" without a list | No simultaneous multi-key browsing; diverges from Azure | +| **C. Keep single session (today)** | No work | Reviewer explicitly wants multi-key; blocks the tree/list org grouping in item 8 | + +> 💡 **Suggested:** Option A as the destination, staged after item 6 (the webview is the +> "Add" surface). If the release timeline is tight, ship Option B first (replace/retry a +> single credential) and grow into A — the `AtlasSessionManager` interface change is the +> gating dependency for the org level in item 8. +> +> **Persistence — reuse, don't reinvent.** Whichever option, build the storage on the +> shared **[StorageService](../../../../src/services/storageService.ts)** the way Kubernetes +> does in [sourceStore.ts](../../../../src/plugins/service-kubernetes/sources/sourceStore.ts) +> (ordered list of `StorageItem`s; `properties` → globalState, `secrets` → SecretStorage; +> in-memory cache). No migration is required — Atlas discovery has never shipped, so the +> current single-slot [AtlasSessionManager](../../../../src/plugins/service-atlas-mongodb/auth/AtlasSessionManager.ts) +> keys carry no real user data and the new store starts clean. This keeps Atlas on the same +> secrets solution as the rest of the extension. + +### O5. Tree/List toggle + org level (item 8) · 🟡 Open — decide before **Bundle E · item 8** + +| Option | Pros | Cons | +| ----------------------------------------------------------------------------- | -------------------------------------------------------------- | ----------------------------------------------------------------- | +| **A. Port the K8s scaffold + add an Org level** | Proven pattern; matches Reviewer #5; consistent cross-provider | Requires the new `AtlasOrgItem` level and org-aware data (item 7) | +| **B. List/Tree toggle only, no org level** (Project→Cluster tree / flat list) | Smaller; reuses today's hierarchy | Doesn't deliver Reviewer #5's "orgs → projects → clusters" tree | +| **C. Defer** | No work | Feature gap vs Kubernetes | + +> 💡 **Suggested:** Option A, sequenced last — it depends on the org-aware credential model +> (item 7). Reuse [switchKubernetesViewMode.ts](../../../../src/plugins/service-kubernetes/commands/switchKubernetesViewMode.ts), +> the `config.ts` mode key, and the inline contextValue toggle verbatim. + +--- + +## Sequencing (suggested) + +The three reviewer design items are dependent, not parallel: + +```text +item 6 (guided webview) ──► item 7 (multi-credential + API) ──► item 8 (tree/list + org level) + hosts add/update UI org-aware data model feeds org grouping needs a stable + the org tree level per-credential org source +``` + +The **release-blocking** P1 work (items 1–5) is independent of the above and can land first. + +--- + +## Appendix A — current flow (reference) + +See the full data-flow write-up in +[atlas-mongodb-discovery-flow.md](../../../atlas-mongodb-discovery-flow.md) and the +decision rationale in [decisions.md](./decisions.md). The two-layer auth model (Atlas Admin +API session for discovery vs. SCRAM database credentials for connection) is the key mental +model: "signed in to Atlas" (Layer 1) does **not** mean "authenticated to the database" +(Layer 2) — the user is still prompted for SCRAM credentials on cluster expand. + +--- + +_Prepared for the MongoDB Atlas discovery (PR #733) UX review, iteration 3. Code references +verified against the `dev/tnaum/atlas-discovery-review-iteration-2` branch. No code was +modified in this pre-assessment; all items are recommendations to react to during the +hands-on pass._ + +--- + +## Open work summary and proposed order (2026-07-24) + +This section reconciles the open statuses, Iteration 4.1 follow-ups, and automatic-audit +notes into one execution order. It is the current hand-off list: an item remains here until +it is implemented and verified, explicitly closed with a reason, or moved to a linked issue. + + + +### Architecture decision now established + +The multi-credential feasibility POC and UX design are complete. See +[multi-credential-poc-plan.md](./multi-credential-poc-plan.md) for the Atlas Admin API research, +isolated experiments, selected tree/webview design, and alternatives that were rejected. +Production work should now implement these decisions rather than repeat the feasibility phase: + +- support multiple API Keys and Service Accounts in an `AtlasCredentialStore` built on the + shared `StorageService`, with stable random credential IDs, non-secret metadata in + `properties`, and credential material in `SecretStorage`; +- isolate session state and Service Account token refresh per credential; +- expose one non-throwing `AtlasDiscoveryService.listAll()` aggregation surface, using bounded + parallelism and `Promise.allSettled` so one failed credential does not hide healthy results; +- key and merge organizations, projects, and clusters by Atlas resource ID while retaining the + set of credentials that can reach each resource; +- manage credentials through an Azure-style QuickPick that opens the guided webview for add and + update; keep credential-management rows out of the healthy tree; +- render a quiet organization → project → cluster tree and a flat cluster list, with one + consolidated **Click here to revisit credentials** action whenever any credential fails; and +- treat a healthy `200 []` response as authoritative emptiness, not a retryable failure. The + final multi-credential UX uses the standard `empty` placeholder under the organization. + +The last point supersedes this review's provisional Iteration 4.1 recommendation to show a modal +plus **Click here to retry** for every no-projects result. Until the new tree ships, the existing +non-actionable sentence remains an open UX issue; its production replacement is the selected +`empty` placeholder, while `401`, `403`, rate-limit, and network failures use the consolidated +credential-recovery action. + +#### Agent hand-off: controlling POC sections and evidence + +Agents implementing this roadmap must treat the POC document as the source of truth for the +multi-credential architecture and UX. Start with these sections rather than reconstructing the +design from this review summary: + +| Need | Controlling POC section | Current evidence/status | +| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| Credential scope and same-org project union | [§3.1 — Credential scoping](./multi-credential-poc-plan.md#31-credential-scoping--the-load-bearing-fact) | ✅ Different-org, same-org subset/overlap/disjoint union, healthy emptiness, and Service Account scope parity live-confirmed | +| Store, session, aggregation, labels, and token lifecycle | [§5 — Proposed API-level design](./multi-credential-poc-plan.md#5-proposed-api-level-design) | Production design selected; no production implementation yet | +| Partial-result and error taxonomy | [§6 — Error reporting model](./multi-credential-poc-plan.md#6-error-reporting-model--partial-results-with-per-credential-attribution) | ✅ Healthy `200 []`, unrestricted/detail `200`, enforced non-match `403`, matching-IP `200`, and invalid-secret `401` live-confirmed | +| QuickPick, webview, tree, list, empty, and retry behavior | [§7 — Selected credential/tree UX](./multi-credential-poc-plan.md#7-credential-management--tree-ux-selected-design) | Selected design; archived alternatives in §7.7 are not implementation options | +| Answers to the seven original POC questions | [§8 — POC answers](./multi-credential-poc-plan.md#8-answers-to-the-seven-poc-questions-ledger-step-0) | All seven answered at design/isolated-experiment level | +| Component ownership and data flow | [§9 — Reference architecture](./multi-credential-poc-plan.md#9-reference-architecture-diagram) | Use as the implementation boundary map | +| Relative slices and decision gates | [§11 — Effort and gates](./multi-credential-poc-plan.md#11-effort-estimate-decision-gates--scrap-criteria) | Slices A–G define the intended dependency order | + +The POC's completed experiments are evidence for architecture decisions, not production test +coverage: + +| Experiment | Status | What an implementing agent may rely on | +| ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| [Experiment 1 — aggregation semantics](./multi-credential-poc-plan.md#experiment-1--aggregation-semantics) | ✅ Executed in isolation | `Promise.allSettled` preserves healthy credential results when peers fail; `Promise.all` does not | +| [Experiment 2 — non-throwing aggregation](./multi-credential-poc-plan.md#experiment-2--single-list-all-api-with-per-credential-isolation) | ✅ Executed in isolation | One aggregation surface can return organizations, projects, clusters, and credential-scoped errors together | +| [Experiment 3 — parallel fan-out](./multi-credential-poc-plan.md#experiment-3--parallel-vs-sequential-fan-out) | ✅ Executed in isolation | Parallel fan-out produced an approximately 8× improvement for eight simulated credentials; production must still use a bounded limiter | +| [Experiment 4 — token-bucket headroom](./multi-credential-poc-plan.md#experiment-4--token-bucket-headroom) | ✅ Executed analytically/in isolation | Discovery request volume is far below documented limits; retain defensive `429`/`Retry-After` handling | +| [Live-check matrix](./multi-credential-poc-plan.md#residual-live-matrix) | ✅ Blocking gates complete | Only optional Service Account L2/cluster-detail parity remains; L3 is mocked-contract-only and L4 uses production telemetry | + +The executable production tests listed in Steps 2–7 below are still required. Do not cite the +isolated experiment script as proof that storage, session restoration, webview cancellation, +tree rendering, wizard attribution, or live Atlas behavior works in the extension. + +### Open work at a glance + +| Order | Item(s) | Open work | Why it sits here | +| ----- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| 1 | **#7, #8, #12** | ✅ Live feasibility gates complete; preserved as production contract tests | L1/L2/L5 now confirm org/project attribution, union semantics, auth parity, and the `401`/`403` taxonomy | +| 2 | **#7, #12** | ✅ Done — credential storage, per-credential sessions, `listAll()` aggregation ([ee2bf417](https://github.com/microsoft/vscode-documentdb/commit/ee2bf417)) | Every UI surface depends on stable credential/resource attribution and partial-result behavior | +| 3 | **#6, #7, #12** | ✅ Done — credential-management QuickPick and guided add/edit webview ([c0c49ce6](https://github.com/microsoft/vscode-documentdb/commit/c0c49ce6)) | Builds the production lifecycle on the new store without coupling management to the tree | +| 4 | **#2, #3, #7, #8** | ✅ Done — merged organization tree, empty state, consolidated recovery action ([9c8baa0f](https://github.com/microsoft/vscode-documentdb/commit/9c8baa0f)) | Requires the aggregated model and management entry point | +| 5 | **#5, #8** | ✅ Done — List mode and credential ownership through the wizard ([3674133d](https://github.com/microsoft/vscode-documentdb/commit/3674133d)) | Reuses the merged snapshot and proves either view can connect through a valid owning credential | +| 6 | **#10** | ✅ Done — Atlas-provided Markdown escaped in project tooltips ([f53c0ca3](https://github.com/microsoft/vscode-documentdb/commit/f53c0ca3)) | Independent and safe to land in parallel with steps 1–5 | +| 7 | **All open items** | ✅ Automated tests and the full checklist run; ledger reconciled. **Outstanding: the hands-on UX matrix** | Verifies both auth methods, partial failures, empty results, duplicate resources, reload, and both modes | + +#### Iteration 4 implementation progress + +Tracked inline as the steps land. Order deviation: **Step 6 was landed first** because the plan +itself marks it as parallel-safe and it carries no dependency on the multi-credential foundation; +landing it early removes a security-relevant gap regardless of how far the larger steps get. + +| Step | State | Commit | +| ---- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | ✅ Complete | Documentation only (live gates) | +| 2 | ✅ Complete | [ee2bf417](https://github.com/microsoft/vscode-documentdb/commit/ee2bf417) | +| 3 | ✅ Complete | [c0c49ce6](https://github.com/microsoft/vscode-documentdb/commit/c0c49ce6) | +| 4 | ✅ Complete | [9c8baa0f](https://github.com/microsoft/vscode-documentdb/commit/9c8baa0f) | +| 5 | ✅ Complete | [3674133d](https://github.com/microsoft/vscode-documentdb/commit/3674133d) + [caa1a823](https://github.com/microsoft/vscode-documentdb/commit/caa1a823) cleanup | +| 6 | ✅ Complete | [f53c0ca3](https://github.com/microsoft/vscode-documentdb/commit/f53c0ca3) | +| 7 | ✅ Automated checks complete; hands-on UX matrix still outstanding | `npm run l10n` (1672 keys), `npm run prettier-fix`, `npm run lint`, `npx jest --no-coverage` (2743 tests / 166 suites), `npm run build` | + +**Remaining for a human:** the hands-on UX matrix in Step 7 (both auth methods, multiple +credentials in different organizations, overlapping credentials in one organization, mixed +valid/invalid credentials, retries, healthy empty results, the `401` vs `403` distinction, +extension reload, and both view modes). Everything above is covered by automated tests, which are +no substitute for a live pass. + +#### Post-implementation corrections (from live use) + +Found while exercising the branch against a real Atlas account. Each is a deviation from the plan +as written; the reasoning is recorded here rather than only in the commit message. + +| Change | Deviates from | Reason | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Re-derive every session on explicit refresh ([612522fa](https://github.com/microsoft/vscode-documentdb/commit/612522fa)) | The plan only required refresh to re-attempt every credential | A Service Account access token carries the roles it was minted with and is cached for its ~1h lifetime. Dropping the snapshot but reusing the token kept reporting the old scope after the user granted a role in Atlas. Organization and project items also gained their own `refresh()` hooks, without which the generic path only re-read the cache. | +| Recovery row picks its action from the error taxonomy ([a9aa1143](https://github.com/microsoft/vscode-documentdb/commit/a9aa1143)) | §7.3 specifies one fixed **Click here to revisit credentials** row for `401`, `403`, rate-limit **and** network failures | One row can only offer one verb, and the fixed wording is wrong for the catastrophic cases: offline or Atlas-down fails every credential with kind `network`, and the tree told the user to re-enter secrets that are fine. `auth`/`forbidden` keep the credentials wording; `network`/`rateLimited`/`other` become a retry; mixed leads to the manager. | +| Snapshot cache gets a 30s TTL ([810b44a6](https://github.com/microsoft/vscode-documentdb/commit/810b44a6)) | §7.3's "passive expansion must not repeatedly call known-failing credentials" was implemented as an invalidate-only cache | Invalidate-only put the burden on every node type to remember to invalidate, and the one that forgot served a frozen tree indefinitely. The cache only ever needed to cover a single interaction burst. The TTL keeps the anti-hammering property while removing the whole class of stale-tree bug; explicit refresh still bypasses it and re-derives sessions. | +| Fleet-wide **Retry all** in the credential manager ([9619d020](https://github.com/microsoft/vscode-documentdb/commit/9619d020)) | Step 3 lists only a per-credential **Retry** | With several failures the list is a snapshot of the last discovery pass, so rows the user is not looking at keep showing stale outcomes. Re-checking them one at a time is tedious and gives no fleet-level answer. | +| Credential manager always refreshes Atlas on exit ([36abaf65](https://github.com/microsoft/vscode-documentdb/commit/36abaf65)) | The shared discovery convention (and the Azure prior art) refreshes only when credential storage changed | Atlas holds state the extension cannot observe. The common flow is: open the manager, switch to the Atlas web UI to grant a role, come back. Nothing in local storage changed, so the `changed` guard skipped the refresh entirely. Explicitly accepted as an Atlas-only divergence. | +| **Open in MongoDB Atlas** deep link per credential ([a3c96ac7](https://github.com/microsoft/vscode-documentdb/commit/a3c96ac7)) | Not in the plan at all | A `403` from an enforced IP access list and a healthy `200 []` from a too-narrow role are the two failures the extension can name but not fix, and both are only resolvable in the Atlas console behind an organization picker. Service Accounts link to their own page, API keys to the organization key list, and a credential with no cached `orgId` (which is precisely the `403` case) falls back to the console root. | +| `403` no longer re-mints a token; concurrent refreshes deduplicated; `allSettled` per credential ([0da09f76](https://github.com/microsoft/vscode-documentdb/commit/0da09f76)) | Not in the plan; found by reading a live trace | One retry of a forbidden credential issued four requests and minted two throwaway tokens. `403` means authenticated but not permitted, so a new token carries the same roles and cannot help; only `401` refreshes now. `refreshSession` had no in-flight dedupe, so the parallel org/project calls each minted a token. `Promise.all` also orphaned the sibling request, whose failure then logged after the result was recorded and read like a second racing pass. | +| **Add a credential** promoted above **Retry all** ([60f8cfae](https://github.com/microsoft/vscode-documentdb/commit/60f8cfae)) | Step 3 lists the actions without an order | The credential manager is the everyday way to widen what discovery can see, not only a recovery surface, so the primary action reads first and now carries the same explanatory detail as its peers. | +| TLS connection failures get their own modal, deliberately without a diagnosis ([45626dc9](https://github.com/microsoft/vscode-documentdb/commit/45626dc9), [5eec3dea](https://github.com/microsoft/vscode-documentdb/commit/5eec3dea)) | Not in the plan | A raw OpenSSL `SSL alert number 80` was shown under "Revisit connection details", which is misleading straight after the user typed credentials. The first attempt over-corrected by naming the IP access list as the cause; MongoDB documents that the list gates cluster connections but never that a blocked address surfaces as this alert, so the wording was pulled back to what the signature actually proves plus a list of things to check. | +| Full API error envelope traced, with rate-limit headers ([0a07b4da](https://github.com/microsoft/vscode-documentdb/commit/0a07b4da)) | Not in the plan | `errorCode` was parsed away entirely, and it is the only stable machine-readable field: it separates `IP_ADDRESS_NOT_ON_ACCESS_LIST` from any other `403`, and throttling from a genuine refusal. It is now traced and carried on `AtlasApiError`. Failed responses also trace `retry-after`, `x-ratelimit-*` and the request id. | +| Logged durations moved to the monotonic clock ([1789949c](https://github.com/microsoft/vscode-documentdb/commit/1789949c)) | Not in the plan; found by reading a live trace | A wall-clock step backwards mid-request produced lines like `GET /orgs -> 200 in -157ms`. An impossible value in a diagnostic log discredits every other number on the line. The snapshot TTL moved too, and that was a real bug: a negative `age` satisfies `age < TTL`, so a stale snapshot could be served indefinitely. Token `expiresAt` stays on the wall clock because it is persisted across processes. | +| Per-credential **Remove** renamed to **Sign out** | Step 3 lists the action as **Remove** | The fleet-level action is **Sign out of all**, so a different verb for the identical single-credential operation made the two read as different things. Both delete the stored secret. The internal storage function keeps the name `removeAtlasCredential`, since removal is what it does; only the user-facing verb and the telemetry value (`signOut`, pairing with `signOutAll`) changed. | +| Atlas clusters drop the DocumentDB brand mark for the `server-environment` codicon | Step 4 gave the cluster row a static provider-identity icon copied from the Kubernetes plugin | `resources/icons/vscode-documentdb-cluster-{light,dark}-themes.svg` are byte-identical to `vscode-documentdb-icon-{light,dark}-themes.svg`, i.e. the DocumentDB product logo rather than a generic cluster glyph. The Kubernetes plugin is right to use it because it discovers real DocumentDB deployments; stamping it on somebody else's managed service is a branding claim the extension should not make. `server-environment` is what the Connections view already draws for a non-emulator cluster, so a discovered Atlas cluster and a saved one now read the same, with no new asset and no third-party trademark shipped. Pinned by `AtlasClusterItem.test.ts`. | +| Add/Update credential webview reworked (card method picker, breadcrumb, accordion guide, "connection" wording) and its error handling unified: `ORG_REQUIRES_ACCESS_LIST` now reads as an IP-access problem, the reconfigure error bar gained **Retry**, and add-flow deep links resolve the organization live | Extends item 6; broadens the per-credential deep link ([a3c96ac7](https://github.com/microsoft/vscode-documentdb/commit/a3c96ac7)) beyond `IP_ADDRESS_NOT_ON_ACCESS_LIST` | Detailed below under _The Add/Update credential webview was reworked, with unified IP-access handling_. | + +**Follow-up filed:** [#814](https://github.com/microsoft/vscode-documentdb/issues/814) tracks using the +Admin API access-list endpoints to turn these diagnostics into precise, actionable messages. + +##### The username prompt now offers the cluster's database users + +Sign-in used to open an empty username box, so the user had to recall a database user from memory +and a typo only surfaced later as an authentication failure. Atlas already knows the answer: +`GET /api/atlas/v2/groups/{groupId}/databaseUsers` needs only **Project Read Only**, the same level +the cluster listing already required, so the credential that discovered the cluster can also list +its users without any new sign-in or permission. + +The step is a convenience, so it is built to disappear rather than to block: + +| Situation | Behaviour | +| -------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| Two or more users | Grouped pick list, **Enter a username** first so an unlisted user is one keystroke away | +| Exactly one usable user, nothing else | No list; the normal username prompt opens with it prefilled and still editable | +| A single user we cannot sign in as | The list still shows, so the reason is visible instead of silent | +| No users, no permission, timeout, any error | Step is skipped entirely and the username prompt behaves exactly as before | +| An unsupported user is selected | A modal names the method and returns to the list; dismissing it also returns rather than cancels | + +Decisions worth recording: + +- **Users we cannot use are listed, not filtered out.** Only SCRAM (`databaseName: 'admin'`) users + can be used, because the flow has nothing but a username and a password to offer. Hiding the + `$external` ones would answer "my username is missing" with silence, when the honest answer is + "it is there, and its method is not supported yet". They sit under a **Not supported yet** + heading with the method named in the description, read from the `x509Type`, `awsIAMType`, + `ldapAuthType` and `oidcAuthType` discriminators. +- **The single-user shortcut counts usable users only.** One SCRAM user alongside four federated + ones still shows the list, because collapsing it would silently drop the fact that the others + exist. +- **The lookup runs in `configureBeforePrompt`.** It is the only hook that runs before the wizard + asks whether to prompt, which is what lets one lookup choose between list, prefill and skip. It + is wrapped in a status-bar progress message and an 8 second `AbortSignal.timeout`, and every + failure collapses to an empty list. +- **Accept versions are now per endpoint.** `databaseUsers` is still published at `2023-01-01` + while the client default is `2023-02-01`; Atlas versions each resource independently, so a + global header was not safe to reuse. +- **Scope filtering keeps unscoped users.** An absent or empty `scopes` array means the user + applies to every cluster in the project, so only an explicitly `CLUSTER`-scoped user naming a + different cluster is dropped. + +##### Field note: the intermittent `403` was a rotating egress address + +Worth recording, because the log looked like an Atlas bug and was not. A credential intermittently +returned `403 IP_ADDRESS_NOT_ON_ACCESS_LIST` naming a fixed address, while a sibling credential +succeeded in the same pass, seconds apart. Three documented behaviours combined to make this +opaque: + +1. The API access list is **per credential** and all-or-nothing, so one credential can be refused + while another works from the same machine at the same moment. +2. It is enforced when a Service Account token is **used**, not when it is minted, so the log shows + a successful mint followed immediately by `403` on every call with that token. +3. Atlas only reports the observed address when it rejects you, so successful requests give no + evidence about which address they left from. + +The machine's egress address was in fact rotating within a corporate NAT pool. The clock step that +exposed the negative-duration bug turned out to be a side effect of the same network transition +that changed the address. **Resolved by allowlisting the whole CIDR block rather than a single +address**, after which the behaviour was stable. No extension change was needed. + +##### The Add/Update credential webview was reworked, with unified IP-access handling + +The guided credential webview (item 6) was rebuilt around the Local Quick Start layout, and the +error handling behind it was tightened after exercising both auth methods against a live account. + +The surface: + +- **Method choice is two selectable Fluent cards** — Service Account (_Recommended_) and API Key + (_Legacy, simplest_) — with radio selection, replacing the plain method list. +- **A Breadcrumb tracks progress** across four phases: Choose method → Enter details → Verify → + Done. Edit mode opens on the form and drops the first step. +- **"Where do I find these values?" moved into a Fluent Accordion** with the key UI terms bolded and + the **Open MongoDB Atlas** link folded into step 1. +- **Titles read "Add a MongoDB Atlas connection" / "Update MongoDB Atlas connection".** "Connection" + rather than "credential" matches how the rest of the UI names a data source and pluralises cleanly + for the multi-credential list. The product name was dropped from the body copy, and every text + input is `.trim()`-ed. +- **The Verify screen is honest about progress.** One standard subtitle, and a per-method check list + built from the real host steps (API Key: verify + save; Service Account: sign in + check projects + + save). There is no fake timer, so the step that actually failed is the one marked, and the + error bar renders below the checks. + +The error handling — three related corrections found in live use: + +- **IP access-list detection is unified and broadened.** A live `403 ORG_REQUIRES_ACCESS_LIST` (the + organization mandates an access list) was being mis-classified as a missing-role "permissions" + failure. A single predicate `isAtlasIpAccessListError` on `AtlasApiError` now recognises any + `ACCESS_LIST` code, with a fallback on the human-readable detail/message, and self-guards on + `403`. The webview flow (`describeAtlasError`) uses it. The tree/discovery classifier + (`classifyAtlasError`) stays deliberately coarse — every `403` becomes `forbidden` and funnels to + the credential manager, which offers the same deep link — but both classifiers now carry + cross-referencing comments pointing at the shared predicate, so the two intentionally-separate + copies cannot drift on which codes count. +- **The reconfigure error bar gained Retry.** IP-access and missing-role failures are fixed in the + Atlas console and then re-tried with the same values, so the verify-screen error bar now offers + **Retry** (re-submits in place) next to the Atlas deep link and **Show details**. It shows only + for errors that carry the reconfigure deep link, since retrying a wrong secret cannot help. +- **Deep links resolve the organization in the add flow.** The per-credential deep link previously + had an `orgId` only for stored credentials, so a brand-new credential fell back to the console + root. `buildAtlasAccessUrl(record)` now delegates to a lower-level + `buildAtlasAccessUrlFor(authMethod, orgId, clientId)`, and during add the host resolves the + organization live via `listOrganizations()`, targeting + `…/org/{orgId}/access/serviceAccounts/{clientId}` (Service Account) or `…/apiKeys` (API key) — + matching the stored-credential link. Best-effort: a credential also barred from `/orgs` still + degrades to the console root. + +`isAtlasIpAccessListError` lives next to `AtlasApiError` in `AtlasApiClient.ts` as the single source +of truth; the router test reproduces it inside its module mock (the whole `AtlasApiClient` module is +mocked there) with a comment marking it a deliberate mirror. For `ORG_REQUIRES_ACCESS_LIST` the deep +link currently lands on the credential's own access page (the same target as the per-IP case), +consistent across the webview and the credential manager; precise org-access-list targeting is part +of the [#814](https://github.com/microsoft/vscode-documentdb/issues/814) follow-up. + +**Discovery (2026-07-28) — an IP-blocked _first_ add cannot resolve the organization at all, so its +deep link necessarily degrades to the console root.** A general `[openUrl]` trace was added to the +shared `openUrl` procedure in +[appRouter.ts](../../../../src/webviews/_integration/appRouter.ts) — it logs the exact URL right +before `openExternal`, so what the deep link points at can be confirmed from the DocumentDB output +channel. For a Service Account whose first add is blocked by the org's access list, the trace showed +`https://cloud.mongodb.com` (root): there is no stored record yet, so no cached `orgId`, and the one +call that would report it — `listOrganizations()` — is barred by the **same** access list that +produced the error (chicken-and-egg). Decoding the Service Account access token (a JWT) was +investigated as an offline fallback, but the **organization id is not present in the token's +claims**, so the precise link cannot be built without a successful Admin API call. Net: the +`…/org/{orgId}/access/serviceAccounts/{clientId}` link resolves only when the org is already known (a +stored credential, or after the user adds their IP and presses **Retry** so `listOrganizations()` +succeeds); a first-attempt IP-blocked add falls back to the console root by design. Precise targeting +stays under [#814](https://github.com/microsoft/vscode-documentdb/issues/814). + +### Step 1 — Live API gates closed + +The POC established feasibility, selected the architecture, and completed the blocking checks in +[§10.2 — live Atlas experiments](./multi-credential-poc-plan.md#102-experiments-requiring-a-live-atlas-account): + +1. **L1 passed:** different-org attribution, same-org subset/overlap/disjoint union, healthy + no-project scope, organization/project/cluster detail retrieval, and Service Account scope + parity behaved as designed. +2. **L2 passed:** an empty non-required list allowed list/detail requests; enabling enforcement + with a non-matching IP produced `403`; allowing the caller restored `200`; and an invalid + private key produced `401`. + +L5's overlap and disjoint-union paths were covered by the same L1 runs. L3 will not be run live; +implement the API pagination contract with mocked multi-page tests. L4 is telemetry-deferred: add +privacy-reviewed production telemetry for Service Account token-mint throttling/failure +classification and use observed frequency to decide whether further mitigation is needed. + +The exact sanitized evidence and residual optional Service Account enforcement/cluster-detail +checks are maintained in the [POC live matrix](./multi-credential-poc-plan.md#residual-live-matrix). +No remaining live check blocks Step 2. + +**Blocking open questions: none.** The remaining pagination tests, production telemetry, +Service Account parity checks, and hands-on UX matrix are implementation or acceptance work, +not prerequisites for starting the multi-credential foundation. + +### Step 2 — Build the multi-credential foundation (#7, #12) + +Land the POC's +[slices A–C](./multi-credential-poc-plan.md#111-effort-relative) before adding production UI: + +1. Add `AtlasCredentialStore` on `StorageService`, with one stable random ID per credential, + versioned non-secret metadata, independent secret slots, stable ordering, and cache + invalidation. +2. Refactor session and API-client ownership so authentication method, Service Account token, + expiry, refresh, and failure state are isolated per credential. Remove the global display-name + slot in favor of user label → cached org name → public-key/client-ID prefix fallback. +3. Add `AtlasDiscoveryService.listAll()` with cancellation, pagination, bounded concurrency, and + `Promise.allSettled`. Return healthy data and typed credential/project errors together; never + discard the fleet because one credential failed. +4. Merge resources by `orgId`, `projectId`, and `clusterId`, retaining all healthy owning + credential IDs and choosing a healthy owner for subsequent requests. + +Focused tests must cover independent restore, token refresh, credential removal, partial failure, +pagination, duplicate/overlapping project access, stable ordering, and cancellation. This step is +complete only when the existing single-credential path can run on the new foundation without a UX +regression. + +### Step 3 — Implement credential management and lifecycle (#6, #7, #12) + +Build the [selected credential-management flow](./multi-credential-poc-plan.md#72-credential-management-wizard-quickpick--webview--paths--flows) +on the new store: + +- add a **Manage MongoDB Atlas Credentials** QuickPick with credential status, Add, Retry, + Update, Remove, Sign out of all, Back, and Exit actions; +- make the item-#6 webview the add/edit surface, with the auth-method choice first, Service + Account recommended, and API Key retained as the legacy/simple option; +- validate with a real Admin API discovery operation before storing or replacing credentials; +- keep the webview open with inline errors after failed validation and retain entered values for + correction; +- replace a working secret only after the new credential validates; cancellation or webview + disposal stores nothing and cancels in-flight validation; and +- announce validation progress and inline errors accessibly. + +Close #6 and #12 only after add, update, removal, cancellation, denied access, secret expiry, +auth-method replacement, and extension-reload paths are tested. Removing one credential must not +delete another credential's secrets or healthy tree data. + +### Step 4 — Build the merged organization tree and recovery UX (#2, #3, #7, #8) + +Render the [selected quiet tree](./multi-credential-poc-plan.md#73-tree-mode--the-quiet-tree) +from the aggregated snapshot: + +- healthy path: organization → project → cluster, with duplicate resources merged by Atlas ID; +- show cluster state only when it is not `IDLE`; +- healthy `200 []`: show the standard `empty` placeholder under the organization, with the + permissions explanation in its tooltip and no retry suggestion; +- any `401`, `403`, rate-limit, or network failure: keep all healthy data visible and add one + top-level **Click here to revisit credentials** action whose tooltip summarizes affected + credentials and whose command opens the management QuickPick; and +- when one of several credentials for an organization fails, keep merged healthy projects and + mark the organization with a warning icon; do not create bare information rows or modal storms. + +Passive expansion must not repeatedly call known-failing credentials. Explicit tree refresh +retries all credentials; Retry in the management flow retries only the selected credential. If +the implementation is split across PRs and the old single-session recovery rows remain +temporarily, #2's interim labels stay **Click here to retry** and **Click here to update +credentials** until the consolidated action replaces them. + +### Step 5 — Add List mode and complete wizard attribution (#5, #8) + +Add the item-#8 [List mode](./multi-credential-poc-plan.md#74-list-mode--same-error-node-no-switch) +only after the merged snapshot powers Tree mode: + +- List mode renders deduplicated clusters with `organization · project` context and the same + consolidated recovery action; failures never force a view-mode switch; +- persist the selected mode using the established view-state pattern; and +- update the add-connection wizard to consume the aggregation service, deduplicate clusters, + carry a healthy owning `credentialId` through selection and connection creation, and preserve + the recovery action when project or cluster loading fails. + +Never report **Credential management completed** when authentication was cancelled or returned +`false`. Test the same resource visible through multiple credentials, mixed healthy/failed +credentials, switching modes during partial failure, and connection creation after an owning +credential is updated or removed. + +### Step 6 — Close the independent tooltip safety gap (#10) + +Escape project name, organization name, and project ID before appending them as Markdown, using +the same helper/contract as the cluster tooltip. Add focused tests with Markdown punctuation and +link-like names. This work may proceed in parallel with any production step above. + +### Step 7 — Final verification and ledger reconciliation + +Add focused tests for each contract above, then run the full localization, formatting, lint, +test, and build checklist. Complete a hands-on pass covering both auth methods, multiple +credentials in different organizations, overlapping credentials in one organization, mixed +valid/invalid credentials, retries, healthy empty results, `401`/`403` distinction, root identity, +extension reload, and both view modes. Update every affected item with its decision reason, +implementation commit, verification evidence, and terminal status; remove nothing from this +summary without one of those outcomes. diff --git a/docs/ai-and-plans/PRs/798-local-quickstart/code-review-2026-08-04.md b/docs/ai-and-plans/PRs/798-local-quickstart/code-review-2026-08-04.md new file mode 100644 index 000000000..191ee9858 --- /dev/null +++ b/docs/ai-and-plans/PRs/798-local-quickstart/code-review-2026-08-04.md @@ -0,0 +1,3039 @@ +# PR #798 — DocumentDB Local with Quick Start helpers — Code Review + +**Date:** 2026-08-04 +**PR:** [#798](https://github.com/microsoft/vscode-documentdb/pull/798) — `feature/local-quickstart` → `release/0.10.0` +**Scope reviewed:** 107 files, +20 897 / −230 (diff against `origin/release/0.10.0`, not `main`) + +**Review focus (as requested):** edge cases, and paths where invalid / unexpected input or state can break the +experience — on top of the standard code review. + +**External feedback merged:** the GitHub Copilot reviewer's feedback on the PR page has been fetched, assessed +and folded in — see **M7** and the thread tracker in [§6](#6-external-review-threads-to-respond-to). + +--- + +## 0. Status board — what to implement now vs. what is on hold + +> **If you are an implementation agent, this section is your entry point.** +> Implement **only** the ✅ TODO items. Everything marked 🛑 ON HOLD is blocked on a maintainer decision or on +> another package — do not start it, do not "helpfully" fix it in passing, and do not refactor code it will touch. +> +> **UPDATE (2026-08-06): every cleared package has landed.** WP-1 … WP-5 are implemented and committed, each +> with its own commit and an `IMPLEMENTED` note beside its finding in §3. Nothing in the ✅ column is outstanding. +> The next step is the §9.2 discussion (and the §9.3 confirmation), which unblocks WP-6/7/8 and M7's thread reply. + +### ✅ Cleared for implementation (all landed 2026-08-05/06) + +| WP | Title | Findings | Status | +| -------- | ------------------------------------ | -------------------------- | -------------------------------------------------------------- | +| **WP-1** | Tree refresh correctness | H1 | ✅ `fix(quickstart): fire the Missing status change only on…` | +| **WP-2** | TLS exception policy correction | H2, L4 | ✅ `fix(tls): keep a deliberate TLS bypass for public hosts` | +| **WP-3** | Provisioning durability & port model | H3, H4, L3, M5, L1, N5, N6 | ✅ `feat(quickstart): explicit port model and durable…` | +| **WP-4** | Localization | M1, M2, N2 | ✅ `fix(quickstart): localize the webview lookups and…` | +| **WP-5** | Command surface & small fixes | M3, L5, L6, L7, L8, L9 | ✅ `fix(quickstart): palette gating, log-follow disposal and…` | +| **WP-9** | Repository issues | — | ✅ Already done ([#864], [#865]) | + +[#864]: https://github.com/microsoft/vscode-documentdb/issues/864 +[#865]: https://github.com/microsoft/vscode-documentdb/issues/865 + +### 🛑 On hold — re-assessed and re-cut 2026-08-06 + +> **RE-ASSESSED 2026-08-06 — see [§10](#10-re-assessment-of-the-on-hold-items-after-wp-1--wp-5-2026-08-06).** +> **M4 → option E** and **M6 → option B** are now decided (§10.6). Three packages came off hold: **WP-6a**, +> **WP-7a** and **WP-8** (with **M6-b**). **L2** was resolved by WP-3. + +> **UPDATE 2026-08-06 (third pass): §9.2 is fully resolved — nothing is on hold any more.** The scope is fixed +> at **one managed instance** (multi-instance explicitly out of scope), which also settled the record shape and +> unblocked the credential-store consolidation. **WP-7b dissolved**: its tree-state half became the error-node +> work, its multi-instance half is out of scope. +> +> **➡ All three iterations are closed**, and every finding routed through §11 is resolved. Iteration 2 shipped +> nine items and closed L2 by verification; Iteration 3 shipped the credential-store consolidation; +> [§11.6][it-post] records three fixes found afterwards by actually running the extension, including one +> shipped bug and one finding (**N1**) that had been recorded as resolved when it was not. What is left is the +> deferred pool in [§11.5][it3] — none of it blocking, none of it release work. Start there, not from this +> table. + +| WP | Title | Findings | Status | +| --------- | --------------------------------------- | -------- | ----------------------------------------------------- | +| **WP-6a** | H5 fix only (prime the cache) | H5 | ✅ **CLEARED** — Iteration 1, **I1-1** | +| **WP-7a** | Recreate-vs-fresh choice (option **E**) | M4, N1 | ✅ **CLEARED** — Iteration 1, **I1-2** | +| **WP-8** | Tree render cost (option **B**) + M6-b | M6, M6-b | ✅ **CLEARED** — Iteration 1, **I1-5** / **I1-6** | +| **WP-6b** | Credential store consolidation | H5, M7 | ✅ **UNBLOCKED 2026-08-06** — Iteration 1, **I1-8** | +| **WP-7b** | _(dissolved)_ | N3 | Tree states → **I1-4**; multi-instance → out of scope | + +### ⛔ Explicitly not being fixed + +| ID | Reason | +| ------ | ----------------------------------------------------------------------------------------------- | +| **B1** | Footer user-test still running — keep the switch and the `USER-TEST PROTOTYPE` markers | +| **N2** | Copy is approved verbatim from documentdb.io — only add a code comment saying so (part of WP-4) | +| **N7** | Docs consolidation handled by a separate work item | + +**Workflow:** WP-1…WP-5 are done. The discussion now resumes on §9.2 (and the §9.3 confirmation), after which +WP-6/7/8 are unblocked and M7's GitHub thread reply is finalized. + +--- + +## 1. Verification performed + +| Step | Command | Result | +| ----------------------- | ------------------------ | --------------------------------------------- | +| Tests | `npx jest --no-coverage` | ✅ 202 suites / 3308 tests pass | +| Lint | `npm run lint` | ✅ clean (one pre-existing `eslint-env` warn) | +| Build | `npm run build` | ✅ clean (workspaces + `tsc`) | +| Localization extraction | (bundle inspected) | ✅ strings present in `l10n/bundle.l10n.json` | + +Note: green CI does **not** cover the findings below — most of them are integration/lifecycle behaviours that the +unit tests mock out (`IContainerRuntime`, `ext.secretStorage`, the tree provider), or are host/webview wiring +issues that no test exercises. + +--- + +## 2. Overall assessment + +The architecture is genuinely good. The service/runtime split (`IContainerRuntime` injected into +`QuickStartServiceImpl`) makes a Docker-heavy feature unit-testable; secret handling is careful (env-file instead +of argv, line-buffered output masking, metadata stripped at the tRPC boundary); the destructive paths are +defensively gated (label + alias ownership checks, `propagateErrors` on Delete so "can't verify" is never +mistaken for "already gone"); and the readiness-diagnosis subsystem is unusually thorough. + +The findings below are concentrated in three areas: + +1. **State-machine edges that only appear when the world changes underneath the extension** (container removed + externally, window reloaded mid-provision, stopped instance restarted after a reload, two windows racing). +2. **Localization wiring** — a large share of the new user-facing strings never reach the translation pipeline at + runtime. +3. **Release hygiene** — a user-test prototype toggle is currently shipped in the UI. + +--- + +## 3. Findings + +Severity scale: **Blocker** (must fix before merge) · **High** · **Medium** · **Low** · **Nit**. + +--- + +### B1 — Prototype "Footer experiment" switch + PREVIEW badge is shipped in the UI + +**Severity: Blocker (release hygiene)** + +`src/webviews/documentdb/localQuickStart/LocalQuickStart.tsx` renders an always-visible, absolutely-positioned +switch labelled "Footer experiment" with a `PREVIEW` badge, plus the `ResizeObserver`-driven measurement logic +behind it. The code is explicitly marked as temporary: + +```tsx +{/* USER-TEST PROTOTYPE: Remove this switch and badge with the footer experiment logic above. */} +
+ + PREVIEW +
+``` + +It is the tip commit of the branch (`feat: add experimental adaptive footer feature with toggle and measurement +logic`). It also overlays the content area (`position: absolute; top: 16px; right: 24px`), so on a narrow panel +it can sit on top of the hero text, and it adds a `ResizeObserver` observing four elements on every phase change. + +**Why it matters:** a shipped extension must not expose an unexplained A/B toggle. It also localizes two strings +("Footer experiment", "Footer experiment is in preview") that will end up in the translation bundle. + +**Fix options** + +| Option | Pros | Cons | +| ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | +| **A. Remove the switch, the `adaptiveFooterEnabled`/`footerDocked` state, `scrollAreaInlineFooter`, and keep only the elevation logic** (recommended) | Smallest surface, removes 2 strings from the bundle, drops one `ResizeObserver` target set. Decision can be made later from the user-test notes. | Loses the ability to A/B in the field. | +| **B. Gate it behind `process.env.NODE_ENV !== 'production'`** | Keeps the experiment usable in dev builds; consistent with the existing `installResizeObserverLoopDetector` pattern in `src/webviews/index.tsx`. | Dead code stays in the file; still needs removing later; the two l10n strings still extract. | +| **C. Move it behind a hidden VS Code setting** | Can be enabled for specific testers on a real build. | Contributes a setting that must then be deprecated; most ceremony for a temporary experiment. | + +> **DECISION (2026-08-05): WON'T FIX for now — leave the experiment in.** The footer user-test is still in +> progress, so the switch, the `PREVIEW` badge and the measurement logic stay. **Downgraded from Blocker to +> Informational.** The `USER-TEST PROTOTYPE` markers must remain in place so the code is removable once the test +> concludes; removal is tracked with the user-test, not with this review. + +--- + +### H1 — Infinite tree-refresh / `docker inspect` loop when the container is Missing + +**Severity: High** · Files: `src/services/localQuickStart/QuickStartService.ts`, +`src/tree/connections-view/LocalQuickStart/LocalQuickStartItem.ts`, `src/documentdb/ClustersExtension.ts` + +`refreshLiveState()` fires the status emitter **unconditionally** when the container is gone: + +```ts +if (!inspected) { + entry.missing = true; + this.statusEmitter.fire(); // fires even when `missing` was ALREADY true + continue; +} +``` + +The loop closes like this: + +``` +statusEmitter.fire() + → ClustersExtension: connectionsBranchDataProvider.refresh() (full-tree fire) + → VS Code re-queries the Expanded Quick Start node + → LocalQuickStartItem.getChildren() → await QuickStartService.refreshLiveState() + → container still gone → fire() again → … +``` + +`LocalQuickStartItem.getTreeItem()` returns `collapsibleState: Expanded`, so the node's children are always +re-queried, and `BaseExtendedTreeDataProvider.refresh()` with no argument does a full-tree fire. Every iteration +spawns a `docker inspect` child process. + +**Repro:** provision an instance, then `docker rm -f vscode-documentdb-local` outside VS Code, then look at the +Connections view. Expected: a "Missing · click to recreate" row. Actual: that row plus a continuous refresh / +`docker inspect` spawn loop for as long as the view is visible. + +Every other branch in `refreshLiveState` is correctly change-guarded (`if (entry.missing || entry.state !== nextState)`), +which is what makes this one stand out as an oversight rather than a design choice. The same unguarded pattern +exists in `ensureActionable()`'s missing branch, but that one is one-shot. + +**Fix options** + +| Option | Pros | Cons | +| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | +| **A. Guard the transition: `if (!entry.missing) { entry.missing = true; this.statusEmitter.fire(); }`** (recommended) | One-line fix, matches the change-guard used by every sibling branch, no behaviour change for the first transition. | Doesn't address the underlying "tree render triggers Docker I/O" coupling (see M6). | +| **B. Debounce/coalesce `statusEmitter` → `refresh()` in `ClustersExtension` (e.g. 250 ms trailing)** | Also protects against any _future_ unguarded `fire()`; reduces refresh churn generally. | Adds a timer to activation; hides rather than fixes the root cause; adds visible latency to legitimate state changes. | +| **C. Make `refreshLiveState()` re-entrancy-safe (skip if a refresh ran within N ms)** | Bounds the cost even if the loop reappears. | Introduces a staleness window; a real external change can take up to N ms to show. | + +Recommend **A**, plus **C** as cheap insurance given how often `refreshLiveState()` is called (see M6). + +> **DECISION (2026-08-05): accept A.** Also evaluate the provider's existing **cached-error-node** mechanism as +> a complementary/better guard: `BaseExtendedTreeDataProvider.wrapGetChildrenWithErrorAndStateHandling()` keeps a +> `failedChildrenCache` keyed by element id and, once a node's children are classified as an error state, it +> **returns the cached children and never calls `childrenFetchFunc()` again** until `resetNodeErrorState(nodeId)` +> is called. `ConnectionsBranchDataProvider` already opts into this wrapper. If the `Missing` (and possibly +> `CredentialsMissing`) rows are classified as an error state via the wrapper's `detectErrorState` hook, the +> re-entrant fetch is cut at the provider level rather than by a flag inside the service — which also gives the +> rows the standard error-recovery affordances for free. Explicit invalidation (`resetNodeErrorState`) would then +> have to be wired to `QuickStartService.onDidChangeStatus`, otherwise a recreate would not clear the row. +> Implement A first (it is the correctness fix), then assess the cached-error-node route on top. + +--- + +### H2 — An explicit `tlsAllowInvalidCertificates=true` is silently stripped for public hosts + +**Severity: High (behaviour regression for existing users)** · Files: `src/documentdb/utils/tlsException.ts`, +`src/commands/newConnection/ExecuteStep.ts`, `src/commands/updateConnectionString/ExecuteStep.ts`, +`src/commands/newConnection/PromptConnectionStringStep.ts` + +`canonicalizeTlsException()` strips **every** TLS-bypass URL param unconditionally, and only converts it into +`disableEmulatorSecurity: true` when _all_ hosts are local/private: + +```ts +const { stripped, bypassRequested } = stripTlsBypassParams(parsed); // strips regardless of host +const allHostsLocal = parsed.hosts.length > 0 && parsed.hosts.every(isLocalOrPrivateHost); +return { + connectionString: stripped ? parsed.toString() : connectionString, + disableEmulatorSecurity: bypassRequested && allHostsLocal, +}; +``` + +Both `newConnection/ExecuteStep` and `updateConnectionString/ExecuteStep` persist `canonicalTls.connectionString`. +So for a **public** host the user's deliberate `tlsAllowInvalidCertificates=true` is removed from storage and is +_not_ replaced by the stored flag → the connection now fails certificate validation. + +This directly contradicts the documented contract in the same file: + +> `resolveAllowInvalidCertificates` … "staying silent (rather than forcing `tlsAllowInvalidCertificates: false`) +> lets the MongoDB driver **still honor an explicit `tlsAllowInvalidCertificates=true` that a user deliberately +> put in their connection string**, so self-hosted databases on public hostnames keep working." + +That reasoning is only sound if the param survives in the stored string — and it doesn't. + +**Who is affected:** anyone with a self-hosted DocumentDB/Mongo-API server behind a public DNS name and a +self-signed / internal-CA certificate. Existing _stored_ connections are not rewritten, so the break appears on +(a) creating a new connection, and (b) editing an existing connection's string. + +**Fix options** + +| Option | Pros | Cons | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| **A. Only strip bypass params when the exception is actually adopted (all hosts local); leave them intact for public hosts** (recommended) | Restores the documented behaviour exactly; single source of truth still holds for the local case, which is what §7 is about; zero user-visible regression. | Two sources of truth remain for public hosts — but that is the pre-existing, working status quo. | +| **B. Keep stripping, but persist `emulatorConfiguration.disableEmulatorSecurity: true` for public hosts too, and drop the host gate in `resolveAllowInvalidCertificates`** | Genuinely one knob everywhere. | Removes the security gate that is the whole point of §7 — a pasted/deep-linked public URL could disable validation. **Not recommended.** | +| **C. Keep stripping, but warn the user (modal/notification) that the bypass was dropped and why** | Preserves the security posture, makes the change discoverable instead of silent. | Still breaks working setups; adds an interruption to a common flow; users have no in-product way to re-enable. | +| **D. A + an explicit "Allow invalid certificates" opt-in for public hosts, guarded by a strongly-worded confirmation** | Best long-term: one knob, no regression, informed consent. | Largest change; needs a new wizard step variant and copy review. Probably a follow-up, not this PR. | + +Recommend **A** for this PR, **D** as the follow-up. + +> **DECISION (2026-08-05): A only.** Do **not** implement D (no public-host opt-in step). Restore the +> pre-existing behaviour: only strip the TLS-bypass params when the exception is actually adopted (i.e. every +> host is local/private); for a public or mixed host, leave the user's params untouched in the stored connection +> string so the driver keeps honouring them. + +--- + +### H3 — A window reload during provisioning strands the container in an unrecoverable state + +**Severity: High** · Files: `src/services/localQuickStart/QuickStartService.ts`, +`src/services/localQuickStart/quickStartRegistry.ts` + +The credentials are persisted **only after** readiness succeeds: + +```ts +await this.waitForReadiness(connectionString, signal); // up to READINESS_TIMEOUT_MS = 180_000 +await this.finalizeReadyInstance(pending, cts.token, signal); // ← first ext.secretStorage.store(...) +``` + +If VS Code is closed/reloaded (or crashes) inside that window — up to **3 minutes**, and realistically longer on +the first pull-and-init — the container exists and is labelled, but no secret was written. On next activation +`reconcile()` → `reconcileAlias()` takes **Case 4**: + +```ts +// labelled container + no recoverable secret + no fresh lease ⇒ credential-unavailable +this.setStatus(alias, InstanceState.CredentialsMissing, undefined, credentialUnavailableMessage()); +``` + +The user's only exit is **Delete Container** (destroys the volume). The credentials were generated in memory and +are gone, so this is genuinely unrecoverable — but it was avoidable. + +The registry was explicitly designed to prevent this. `QuickStartInstanceRecord` carries `phase: 'provisioning'`, +`operationId` and `leaseAt`, `isProvisioningLeaseFresh()` exists, `PROVISIONING_LEASE_TTL_MS` is 20 minutes, and +`reconcileAlias()` has a dedicated fresh-lease branch. **Nothing in production code ever writes a +`'provisioning'` record** — `upsertInstanceRecord` is only ever called with `phase: 'ready'` (from +`finalizeReadyInstance` and `adoptContainer`). A grep confirms `phase: 'provisioning'` / `leaseAt` / `operationId` +appear only in the two test files. The lease machinery, the scavenge pass in `reconcile()`, and the +`freshLease` branches are all currently dead code (the comments acknowledge this: "WI-2e allocates a fresh +alias here", "WI-2e renews `leaseAt` per stage"). + +**Fix options** + +| Option | Pros | Cons | +| ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **A. Store the connection string in `SecretStorage` right after `createAndRunContainer` succeeds, before the readiness wait** (recommended) | Directly removes the unrecoverable window; a reload mid-wait now finds a reusable instance and adopts it. Also makes the retained `pendingReadiness` recoverable across a reload. Small, local change. | A failed provision now leaves a secret behind — but that is already the case for a timed-out instance, and `deleteContainer`/`discardTimedOutInstance` already clear it. Needs a matching cleanup in `provision`'s failure `finally`. | +| **B. Write the `phase: 'provisioning'` lease record (as designed) before create and renew it per stage** | Activates the machinery that already exists and is already tested; `reconcile()` then shows "Provisioning…" instead of a dead end, and scavenges a truly crashed run. | Doesn't by itself make the instance usable after a reload (credentials are still gone) — it only improves the _message_. Best combined with A. | +| **C. Leave as-is but change the `CredentialsMissing` copy to explain "setup was interrupted"** | Zero risk. | Still forces a destructive Delete for what was a normal window reload. Poor experience. | +| **D. Delete the dead lease machinery** | Removes ~80 lines of unused code + two test files' worth of coverage for behaviour that can't happen. | Throws away work that WI-2e will need; makes the multi-instance follow-up more expensive. | + +Recommend **A + B**. If the lease machinery is deliberately parked for WI-2e, add an explicit `FOLLOW-UP` comment +at `reconcileAlias`'s `freshLease` branches saying it is currently unreachable, so the next reader doesn't assume +it is live. + +> **DECISION (2026-08-05): A + B as recommended.** Store the connection string immediately after +> `createAndRunContainer` succeeds (before the readiness wait) **and** activate the designed lease machinery +> (write `phase: 'provisioning'` + `leaseAt` before create, renew per stage, promote to `'ready'` on finalize). +> Do not delete the lease code. + +--- + +### H4 — A cross-window provision race can delete the other window's container + +**Severity: High impact / low probability** · File: `src/services/localQuickStart/QuickStartService.ts` + +The `provisioning` guard is per-process in-memory state, so two VS Code windows can both enter `provision()`. +The orphan sweep in `provision`'s `finally` is **not scoped to the current run**: + +```ts +} else if (createAttempted && !containerId) { + // The CLI may have been killed after the daemon created the + // container but before its id was captured — sweep by label. + const orphan = await this.findManagedContainer(); // ← ANY container with our label + alias + if (orphan) { + await this.runtime.removeContainer(orphan.id).catch(() => undefined); + } +} +``` + +Sequence: + +1. Window A and Window B both pass the `findManagedContainer()` pre-check (no container exists yet). +2. A's `docker run --name vscode-documentdb-local` succeeds. +3. B's `docker run` fails — `The container name "/vscode-documentdb-local" is already in use`. +4. B's `catch` runs: `createAttempted === true`, `containerId === undefined`, `containerCreated === false`. +5. B's sweep finds **A's** container and removes it. + +A then fails readiness (its container is gone) or, worse, succeeds the readiness probe against a container that is +being removed. The `operationId` field on `QuickStartInstanceRecord` was designed exactly for this ("a destructive +pre-clean only acts on its own container") and is never written (see H3). + +Note the ordering is partly protected already: if A's container exists _before_ B's pre-check, B correctly stops +at the `CredentialsMissing` gate. The race window is only between B's pre-check and A's create. + +**Fix options** + +| Option | Pros | Cons | +| ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| **A. Stamp a per-run `operationId` label on the container and scope the sweep to it** (recommended) | Exactly the designed fix; the label is already a `Record` so no schema change; makes the sweep provably safe. | Requires the label to be set _before_ `docker run`, which it can be (`labels` is already built there). | +| **B. Skip the sweep entirely when the create failed with a name-conflict error** | Two-line change. | Error-string matching is fragile across Docker versions and locales; doesn't cover other concurrent-failure shapes. | +| **C. Take a cross-window lock (a `globalState` lease written before create, honoured by the other window)** | Prevents the double-create at the source, not just the cleanup. | `globalState` is not atomic across windows (the registry module's own header says so); a lock built on it is advisory at best. | +| **D. Skip the sweep when a container with our label was created _after_ this run started (`createdAt > provisionStartedAt`)** | No new labels; uses data `listByLabel` already returns. | Clock/precision sensitive; `createdAt` granularity is seconds in some Docker versions. | + +Recommend **A**. + +--- + +### H5 — After a reload, starting a stopped instance leaves it unbrowsable (credential cache never repopulated) + +**Severity: High** · Files: `src/services/localQuickStart/QuickStartService.ts`, +`src/tree/connections-view/LocalQuickStart/LocalQuickStartItem.ts`, +`src/tree/connections-view/DocumentDBClusterItem.ts` + +(Found while validating the GitHub Copilot reviewer's comment — see **M7**.) + +`CredentialCache` is in-memory, and there are exactly two places that repopulate it for a Quick Start instance: + +| Call site | Condition | +| -------------------------------------------- | -------------------------------------------------------- | +| `finalizeReadyInstance()` | after a successful provision / resume | +| `adoptContainer()` (reconcile at activation) | **only `if (running)`** — a stopped container is skipped | + +No other transition to `Running` populates it. So this everyday sequence breaks: + +1. Stop the instance from the tree. +2. Reload the window / restart VS Code. `reconcile()` → `adoptContainer()` → container is **exited** → state + `Stopped`, `metadata` is set, **cache not populated**. +3. Click **Start**. `QuickStartService.start()` → `setStatus(alias, InstanceState.Running)` — no cache write. +4. The tree now renders the browsable `QuickStartClusterItem`. Expanding it runs + `ClusterItemBase.getChildren()` → `CredentialCache.hasCredentials(this.cluster.clusterId)` is **false** → + falls through to `DocumentDBClusterItem.authenticateAndConnect()` → + `ConnectionStorageService.get('quickstart-vscode-documentdb-local', Clusters)` → **not found** (the managed + instance is deliberately not a stored connection) → `return null`. + +The user gets the generic "connection failed / click to retry" child node, and retrying can never succeed — +the auth wizard is never even reached, because the `!connectionCredentials` guard returns before it. + +The same gap is reachable from three more paths, all of which set `Running` without touching the cache: +`restart()` from a stopped container, `ensureActionable()`'s multi-window drift correction +(`setStatus(alias, live === 'running' ? Running : Stopped)`), and `refreshLiveState()` detecting a +Stopped → Running transition (started in another window or via the Docker CLI). + +Everything needed is already in hand — `metadata.connectionString` carries the credentials and +`metadata.username` the user — so this is a wiring omission, not a missing-data problem. + +**Fix options** + +| Option | Pros | Cons | +| ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **A. Populate the cache centrally inside `setStatus()` whenever the new state is `Running` and `metadata` is available** (recommended) | One choke point covers `start`, `restart`, `ensureActionable`, `refreshLiveState` and any future transition; impossible to forget again. | `setStatus` gains a side effect beyond "set state + fire"; needs the password parsed out of `metadata.connectionString` on each call. | +| **B. Call `populateCredentialCache()` explicitly in `start()` and `restart()`** | Smallest, most obvious diff; keeps `setStatus` pure. | Leaves the `ensureActionable` and `refreshLiveState` drift paths broken; the next transition added will miss it too. | +| **C. Always populate in `adoptContainer()`, regardless of running state** | Fixes the dominant reload→Start case at the single point where the secret is already read; one condition removed. | Caches credentials for an instance that may never be started (harmless — the cache is in-memory and keyed by an ephemeral id, but it is a wider cache footprint). Does not cover a Missing→recreate or a cross-window start. | +| **D. Make `QuickStartClusterItem` override `getCredentials()`/`authenticateAndConnect()` to read from `QuickStartService` instead of storage** | Removes the dependency on cache priming entirely; the node becomes self-sufficient and the base class's storage lookup (currently a dead path for this node) stops being misleading. | Largest change; duplicates a slice of the connect flow; needs care to keep the emulator TLS options identical. | + +Recommend **A** (or **C + B** if `setStatus` should stay side-effect-free). **D** is the cleanest long-term shape +and would also make **M7** trivial, since the tree model would no longer need to carry a connection string at all. + +Worth adding a regression test: `stop()` → clear the cache → `start()` → assert +`CredentialCache.hasCredentials(clusterId(DEFAULT_ALIAS))`. + +> **DECISION (2026-08-05): option D — the node delegates to `QuickStartService`, which owns its data in its own +> `StorageService` storage.** Confirmed after surveying how other subsystems use the storage layer (see +> [§9.1](#91-h5--where-should-the-managed-instances-credentials-live) for the full research). Summary of the +> decision: +> +> - **Do not add a `Managed` zone to `ConnectionStorageService`.** Zones are workspaces of the single +> `StorageNames.Connections` storage and are exactly what the Connections view enumerates as root items. +> - **Do** create a dedicated storage via `StorageService.get('local-quickstart')`, mirroring what +> `service-kubernetes` and `service-atlas-mongodb` already do. One `StorageItem` per instance: +> non-secret metadata in `properties`, the connection string in `secrets` (SecretStorage-backed). +> - `QuickStartClusterItem` overrides `getCredentials()` and `authenticateAndConnect()` to read from +> `QuickStartService`, so the inherited storage lookups are no longer on the path and the `CredentialCache` +> priming stops being load-bearing. +> - **Bonus consolidation:** this replaces _both_ the ad-hoc `documentdb.quickstart..connectionString` +> SecretStorage keys _and_ the `documentdb.quickstart.registry` `globalState` blob with one coherent store. +> +> **STATUS: ON HOLD** — cleared in principle, but it overlaps the **M4** state-model discussion (§9.2) and the +> **H3** registry/lease work (WP-3). Implement only after M4 is settled, so the record shape is designed once. +> See **WP-6** for the implementation sketch. +> +> **➤ RE-ASSESSED 2026-08-06 after WP-1…WP-5 landed — see [§10.1](#101-h5--wp-6--credential-source-of-truth).** +> Still reproduces; now the **only remaining High**. WP-6 is **split**: **WP-6a** (prime the cache on every +> transition into `Running` — option **A**) is **cleared, ship now**; **WP-6b** (the storage consolidation) stays +> on hold behind §9.2. +> +> **➤ IMPLEMENTED 2026-08-06 — but NOT as option A. WP-6a is cancelled.** Reviewing the mechanism with the +> maintainer established that `CredentialCache` is a plain in-memory map with no read-through; the fill happens +> one level up, in `DocumentDBClusterItem.authenticateAndConnect()`, which is hardwired to +> `ConnectionStorageService` and therefore returns `null` at its `!connectionCredentials` guard **before** ever +> reaching the cache-population call. Priming the cache would have entrenched that dead path rather than fixing +> it. **Option D was implemented directly instead**, and — crucially — _without_ the `StorageService` +> consolidation, which turned out to be separable: the credentials are already durably persisted in +> `ext.secretStorage` under `secretKey(alias)`, so no migration was required. +> +> What shipped: +> +> - `QuickStartService.readStoredConnectionString()` made **public** — the managed instance's credential source +> of truth. +> - `QuickStartClusterItem` now extends **`ClusterItemBase` directly** (not `DocumentDBClusterItem`) and +> implements `getCredentials()` / `authenticateAndConnect()` against `QuickStartService`. No +> `ConnectionStorageService` call remains on any path for this node, and the inherited +> `beforeCachedClientConnect()` storage lookup is gone with it (the base's default is a no-op). +> - Row presentation (`getTreeItem`, tooltip, TLS badge, host parsing) extracted from `DocumentDBClusterItem` +> into **`src/tree/connections-view/clusterItemPresentation.ts`**, consumed by both classes, so the split +> costs no duplicated display logic. +> - `CredentialCache` is now a cache again, not the source of truth — the H5 failure mode is gone by +> construction, with no `setStatus()` side effect and no throwaway code to delete later. + +--- + +### M1 — ~120 webview strings are extracted for translation but never localized at runtime + +**Severity: Medium (localization correctness)** · Files: `src/webviews/documentdb/localQuickStart/LocalQuickStart.tsx`, +`src/webviews/index.tsx`, `src/webviews/_integration/WebviewRegistry.ts` + +`LocalQuickStart.tsx` builds ~20 constant lookup maps at **module scope**: + +```ts +const STAGE_LABELS: Record = { checking: l10n.t('Checking Docker'), ... }; +const DOCKER_GUIDANCE: Readonly> = { installDocker: l10n.t('Install Docker Engine…'), ... }; +// …DOCKER_FAILURE_LABELS, DOCKER_GUIDES, DOCKER_DETAIL_*, DOCKER_HOST_ENVIRONMENT_VALUES, PLAN_ITEMS, … +``` + +But `l10n.config()` runs **inside** `render()` in `src/webviews/index.tsx`, and `WebviewRegistry` statically +imports `LocalQuickStart`: + +```ts +// WebviewRegistry.ts (eager import → module bodies execute at bundle load) +import { LocalQuickStart } from '../documentdb/localQuickStart/LocalQuickStart'; + +// index.tsx — runs LATER, when render() is called +export function render(...) { l10n.config({ contents: globalThis.l10n_bundle ?? {} }); … } +``` + +Every module-scope `l10n.t(...)` therefore evaluates before the bundle is configured and returns the English +source string permanently. The strings _are_ extracted into `l10n/bundle.l10n.json`, so this fails silently: the +translations exist and are simply never applied. This covers the stage labels, all Docker diagnosis copy, the +guidance/guides, the plan list — i.e. most of the new UI's text. + +(One pre-existing instance of the same trap: `DataViewPanelJSON.tsx`'s `monacoOptions.ariaLabel`.) + +**Fix options** + +| Option | Pros | Cons | +| --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| **A. Convert each map to a function called during render (`getStageLabels()`), memoized with `useMemo`** (recommended) | Correct and explicit; keeps the "one map, one lookup" shape; `useMemo` keeps the per-render cost at zero after the first. | ~20 mechanical edits in one file; slightly noisier call sites (`STAGE_LABELS[s]` → `stageLabels[s]`). | +| **B. Call `l10n.config()` before the registry import (e.g. in a module imported first, or at the top of the bundle entry)** | One-line-ish fix; also fixes the pre-existing `DataViewPanelJSON` case and any future one. | Depends on module evaluation order, which bundlers may reorder; fragile and invisible — the exact class of bug we are fixing. | +| **C. Lazy-import the view components in `WebviewRegistry` (`React.lazy`)** | Module bodies then run after `l10n.config()`; also code-splits the bundle. | Changes the webview bootstrap for all views; needs a `Suspense` boundary; broad blast radius for a localization fix. | +| **D. Add a lint rule / unit test asserting no `l10n.t` at module scope under `src/webviews/`** | Prevents recurrence permanently. | Doesn't fix the existing code; needs a custom rule. | + +Recommend **A** now and **D** as a follow-up. **B** is tempting but re-introduces an order dependency. + +> **DECISION (2026-08-05): A now.** Convert the module-scope maps to render-time functions (memoized with +> `useMemo`). **D is accepted but out of scope for this PR** — file a repository issue for the lint rule / guard +> test ("no `l10n.t` at module scope under `src/webviews/`"), since enforcing it will surface and require fixing +> other call sites across the webview code. + +--- + +### M2 — Service-produced user-facing strings are hardcoded English + +**Severity: Medium (localization)** · Files: `src/services/localQuickStart/QuickStartService.ts`, +`src/webviews/documentdb/localQuickStart/LocalQuickStart.tsx`, +`src/tree/connections-view/LocalQuickStart/LocalQuickStartItem.ts` + +`StageEvent.message` / `.error` and `QuickStartStatus.errorMessage` are rendered verbatim +(`setSuccessMessage(event.message)`, `setErrorMessage(event.error ?? event.message)`, and the tree row's +`description = status.errorMessage`). Many of them are plain template strings: + +```ts +`DocumentDB Local is running on localhost:${boundPort}.`; // success page subtitle +('Docker CLI was not found on your PATH. Install Docker and retry.'); +'Docker is installed but the daemon is not reachable. Start Docker and retry.'`Port ${explicitPort} is already in use. Choose a different port or free it, then retry.``Ports ${QUICK_START_PORT}-${QUICK_START_PORT_BAND_END - 1} are all in use. Free one and retry.`; +('The container started but exited shortly after. Check the Quick Start logs.'); // tree row description +('The container restarted but exited shortly after. Check the Quick Start logs.'); +'Setup is already in progress.' / 'Setup was cancelled.' / 'There is nothing to resume.'; +('Still initializing. Keep waiting, view the logs, or start over.'); +``` + +The file _does_ use `l10n.t` correctly elsewhere (`credentialUnavailableMessage`, `getReadinessTimeoutMessage`, +the port-fallback note, every `showInformationMessage`), so this is inconsistency rather than a missing pattern. +Also note the interpolated ones need `l10n.t('… {0} …', String(x))` form, not template literals, to be extractable. + +**Fix options** + +| Option | Pros | Cons | +| --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| **A. Wrap every message that can reach the UI in `l10n.t`, using `{0}` placeholders** (recommended) | Consistent with the rest of the file and the repo rule; `npm run l10n` then picks them up. | Touches ~15 call sites; must be careful to keep the _log-only_ strings (channel `appendLine`) unwrapped. | +| **B. Move to a typed message-key enum on `StageEvent` and localize in the webview** | Cleanly separates transport from presentation; the webview already does exactly this for the Docker diagnosis (`DockerGuidanceKey` etc.), so it matches the established pattern. | Larger refactor; needs a key for every message; interacts with M1 (the maps must then be render-time). | +| **C. Leave as-is** | No work. | Ships a partially-translated feature; the _success_ screen — the most-seen string — is English-only. | + +Recommend **A** for this PR; **B** is the right end state and matches how the Docker copy is already handled. + +> **DECISION (2026-08-05): A for this PR.** Wrap every UI-reachable service message in `l10n.t` with `{0}` +> placeholders. **B is accepted as the end state but deferred** — file a repository issue for the typed +> message-key refactor and assign it to the **0.10.1** milestone (i.e. after 0.10.0 ships). + +--- + +### M3 — Seven lifecycle commands leak into the Command Palette + +**Severity: Medium** · File: `package.json` + +The repo convention is to gate tree-only commands out of the palette with an explicit `"when": "never"` entry in +`menus.commandPalette` — there are 12 such entries today (`renameConnection`, `updateCredentials`, +`removeConnection`, `deleteFolder`, `atlas.openCluster`, …). None of the eight new +`vscode-documentdb.command.localQuickStart.*` commands has one. + +Consequences: + +- The palette now shows **"DocumentDB: Start"**, **"DocumentDB: Stop"**, **"DocumentDB: Restart"**, + **"DocumentDB: View Logs"**, **"DocumentDB: Copy Password"** — titles that are meaningless without the tree + row's context. +- Invoked with no instance, `start`/`stop`/`restart`/`copyPassword`/`viewLogs`/`copyConnectionString` **silently + no-op** (`const id = this.stateFor(alias).metadata?.containerId; if (!id) return;`) — a dead palette entry. +- **"DocumentDB: Delete Container…"** is the sharp one: it is reachable from the palette in any state, shows a + permanent-data-loss confirmation, and `deleteContainer()` will then remove _every_ label-matched container and + the `vscode-documentdb-local-data` volume regardless of the in-memory state. + +Only `localQuickStart.open` ("Local Quick Start") is a legitimate palette entry. + +**Fix options** + +| Option | Pros | Cons | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| **A. Add `"when": "never"` for the seven lifecycle commands, keep `open`** (recommended) | Matches the existing convention exactly; zero code change. | Power users lose keyboard-only access to Start/Stop (they can still use the tree). | +| **B. Keep them in the palette but disambiguate the titles ("DocumentDB Local: Start Container") and add a `when` context key set from `QuickStartService` state** | Keeps palette access; titles become self-explanatory. | Requires a new `setContext` key kept in sync with the service — more moving parts for little gain. | +| **C. Keep them, but make the no-op paths tell the user why nothing happened** | Cheap; removes the silent-failure part. | Doesn't fix the ambiguous titles or the palette-reachable destructive Delete. | + +Recommend **A**. If Start/Stop palette access is wanted later, do **B** properly with a context key. + +> **DECISION (2026-08-05): A only.** Add `"when": "never"` `commandPalette` entries for the seven lifecycle +> commands; keep `localQuickStart.open` visible. Do not implement B. + +--- + +### M4 — "Start DocumentDB Local" destroys and recreates a _running_ container, and the footer note says the opposite + +**Severity: Medium (UX / data expectation)** · Files: +`src/webviews/documentdb/localQuickStart/LocalQuickStart.tsx`, `src/services/localQuickStart/QuickStartService.ts` + +When stored credentials exist (`willReuse === true`), the Configure step relabels the _settings_ ("Kept from the +existing instance", "Reused from the existing instance") and adds a small note. But: + +- The primary button still reads **"Start DocumentDB Local"** — not "Recreate". +- The footer note still reads: _"Starting downloads the official image if needed, then creates and starts one + container named `vscode-documentdb-local`. **Nothing else on your machine is changed.**"_ +- There is **no confirmation**, and the service unconditionally removes the existing container first: + +```ts +if (existing) { + channel.appendLine(`Removing existing Quick Start container ${existing.id} for a clean run…`); + await this.runtime.removeContainer(existing.id).catch(() => undefined); // force: true +} +``` + +So a user who opens Quick Start out of curiosity while their instance is happily running, clicks through +Introduction → Configure → Start, force-stops and destroys the running container. The _volume_ survives, so +document data is safe — but connections are dropped, container-local state outside `/data` is lost, and the +footer note actively told them nothing would change. + +Compare with the tree's `Delete Container…`, which does have a proper `getConfirmationAsInSettings` dialog. + +**Fix options** + +| Option | Pros | Cons | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| **A. Make the recreate path self-describing: primary label → "Recreate DocumentDB Local", footer note → "Recreating stops and replaces the existing container. Your data volume is kept."** (recommended, minimum bar) | Honest copy, no new dialogs, uses state the webview already has (`isRecreate`). | Still one click away from destroying a running container. | +| **B. A + a confirmation when the existing instance is currently `Running`** | Matches the Delete flow's bar; the destructive case is the only one gated. | Needs `status.state` in the webview (currently `toWebviewStatus` keeps `state`, so it is available) — small wiring. | +| **C. Detect "already running and healthy" and offer "Open Connection" instead of a recreate** | Best outcome: the common accidental case becomes a no-op with a useful action. | New branch in the wizard; needs design input on what Configure even means then. | +| **D. Leave as-is** | No work. | The footer note is factually wrong in this state, which is worse than saying nothing. | + +Recommend **A + B** for this PR, **C** as a design follow-up. + +> **DECISION (2026-08-06): option E — an explicit choice in the Configure step.** _(Supersedes the 2026-08-05 +> "OPEN" note below, kept for the reasoning trail. Full specifics in +> [§10.6](#106-decisions-taken-2026-08-06-second-pass).)_ The wizard **asks**; nothing is inferred from +> `willReuse`. Two mutually exclusive choices, presented where the port is already chosen and validated: +> +> - **Use existing data** — recreate the container onto the existing volume, reusing its stored credentials and +> image (today's implicit `reusing === true` path). +> - **Start fresh (erases data)** — remove the container **and** its data volume, then provision new credentials. +> +> `provision()` takes the choice as an explicit flag instead of deriving `reusing` from +> `getReusableCredentials()`. The RR4 / §5.2 volume-wipe gate is unchanged — "Start fresh" is the **only** path +> allowed to drop a volume. Footer copy follows the choice (option **A**'s wording is the baseline). Resolves +> **N1** by construction. +> +> **Cleared for implementation as WP-7a.** Still open (does **not** block WP-7a): §9.2 **Q2** (behaviour when the +> instance is currently Running), **Q3** (per-instance state model), **Q4 / N3** (the `Error` tree row). + +> **(superseded) DECISION (2026-08-05): OPEN — under discussion, do not implement yet.** +> Direction given: the user must be able to **choose** between recreating onto the existing volume and starting +> fresh — it must not be inferred from `willReuse`. A full state/collision model is required first (existing but +> removed, existing but stopped, existing and running, credential-unavailable, …), including when the Quick Start +> tree item is visible at all, and it must not assume a single managed container. See +> [§9.2](#92-m4--recreate-vs-fresh-and-the-instance-state-model) for the state diagram and the open questions. +> **L2 and M7 are blocked on the outcome of this discussion.** +> +> **➤ RE-ASSESSED 2026-08-06 — see [§10.2](#102-m4--wp-7--recreate-vs-fresh).** WP-3 made this **cheaper** to +> implement (Configure is now a validated decision point), which is what made option **E** practical. +> **L2 is no longer blocked here — it was resolved by WP-3.** **M7** now waits on WP-6a only. + +--- + +### M5 — Port selection is TOCTOU, and the auto path has no retry + +**Severity: Medium** · Files: `src/services/localQuickStart/ContainerRuntime.ts`, +`src/services/localQuickStart/QuickStartService.ts` + +`isPortFree()` binds a throwaway `net.Server` on `127.0.0.1` and immediately closes it; the container is created +seconds later (after the image pull, which can take minutes on a cold cache). Anything can take the port in +between — including a second VS Code window running the same wizard. + +The failure surfaces as a raw Docker error string ("Bind for 127.0.0.1:10260 failed: port is already allocated") +in the `creating` stage, with no automatic recovery even in the _auto-port_ case where relocation is explicitly +allowed by design. + +Related: `findAvailablePort` burns an attempt on a duplicate candidate (`if (tried.has(candidate)) continue;` +does not decrement `i`), so with 10 attempts over a 100-port band the effective attempt count is lower than +intended. Minor, but it makes the "all ports busy" error reachable earlier than the constants suggest. + +**Fix options** + +| Option | Pros | Cons | +| ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| **A. Move the port selection to immediately before `createAndRunContainer` (after the pull)** (recommended) | Shrinks the window from minutes to milliseconds; no new retry logic; the pull no longer holds a claim on a port. | The `checking` stage can no longer report the port-fallback note — it moves to `creating`. Small UX shuffle. | +| **B. On a port-allocation failure in the auto path, re-pick and retry the create once or twice** | Actually recovers instead of failing; matches the "auto port with fallback" promise. | Needs Docker error classification (string matching, version/locale sensitive); adds a retry loop to the hottest path. | +| **C. Hold the probe socket open until `docker run` (reserve the port)** | Eliminates the race for other _host_ processes. | Docker cannot bind a port the extension is holding — this breaks the create outright. **Not viable.** | +| **D. Leave as-is, but classify the error and show "Port X was taken while the image downloaded — retry."** | Cheap; turns a raw Docker string into actionable copy. | Still a dead end requiring a manual retry. | + +Recommend **A** (+ the one-line `findAvailablePort` attempt-counting fix), with **B** or **D** as the follow-up. + +> **DECISION (2026-08-05): D only — and superseded in part by L3.** Treat the TOCTOU itself as an acceptable +> edge case: classify the port-allocation failure and surface actionable copy instead of a raw Docker string. +> Do **not** implement A, B or C. Note that **L3 removes the automatic port-relocation logic entirely**, so the +> "auto path has no retry" half of this finding disappears with it — after L3 there is only ever an explicit +> port, and a conflict is always a hard, explained error. + +--- + +### M6 — `refreshLiveState()` runs a `docker inspect` on every Connections-view render + +**Severity: Medium (performance)** · Files: +`src/tree/connections-view/LocalQuickStart/LocalQuickStartItem.ts`, +`src/webviews/documentdb/localQuickStart/localQuickStartRouter.ts` + +`LocalQuickStartItem.getChildren()` starts with `await QuickStartService.refreshLiveState()`, and +`getDockerStatus` (the webview query, also used by `pollDockerReadiness` on a 1–5 s backoff) calls it too. Each +call spawns a `docker inspect` child process per known alias and blocks the tree node's children on it. + +The Connections view refreshes on many unrelated events (connection add/remove/rename, folder ops, discovery +refresh, `ext.state` transitions). Every one of those now pays a process spawn plus Docker daemon round-trip +before the Quick Start node can render — for _all_ users who have ever provisioned an instance, including those +who never open the feature again. This is also what makes H1's loop expensive rather than merely noisy. + +**Fix options** + +| Option | Pros | Cons | +| ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| **A. Memoize `refreshLiveState()` for a short TTL (e.g. 2–5 s), mirroring `READINESS_MEMO_TTL_MS`** (recommended) | One small change, consistent with the readiness service's own memoization, kills the burst cost and caps H1's loop. | A genuine external change can take up to the TTL to appear. | +| **B. Render from cached state and refresh in the background (fire-and-forget), letting the emitter update the row later** | Tree render becomes instant; no user-perceived Docker latency at all. | The first render after a change shows stale state briefly; needs care not to re-trigger H1. | +| **C. Poll on a timer only while the Connections view is visible, and drop the per-render call** | Predictable, bounded cost; decouples Docker I/O from rendering entirely. | Needs view-visibility plumbing; a timer runs even when nothing changes. | +| **D. Leave as-is** | No work. | Silent, always-on cost paid by every user of the extension. | + +Recommend **A** now, **B** as the cleaner end state. + +> **DECISION (2026-08-06): option B — render from cached state, refresh in the background.** _(Supersedes the +> 2026-08-05 "OPEN" note below. Full specifics in [§10.6](#106-decisions-taken-2026-08-06-second-pass).)_ +> **Option A is dropped** — its only justification was capping H1's loop, which WP-1 already removed. +> `LocalQuickStartItem.getChildren()` must stop awaiting `refreshLiveState()`: render the row immediately from the +> last known state with a `"Refreshing…"` description, kick the probe off in the background, and let +> `onDidChangeStatus` update the row when it returns. +> +> Two implementation constraints: +> +> 1. **Reuse WP-1's transition guard.** The background update must fire the emitter only on an actual state +> change, or it rebuilds **H1**'s refresh loop in a new shape. +> 2. **Ship M6-b with it** — skip `suggestPort()` in `getDockerStatus` when `input.polled === true`. +> +> **Cleared for implementation as WP-8** (its other dependency, WP-1, has landed). + +> **(superseded) DECISION (2026-08-05): OPEN — leaning B, under discussion.** +> Maintainer's read: this cost is only paid when the Quick Start node's children are fetched, and once **H1** is +> fixed there is no tight loop, so **A**'s value drops sharply. Preferred direction is **B** — render immediately +> from cached state with a `"Refreshing…"` description, then update the row when the probe returns. See +> [§9.3](#93-m6--when-does-refreshlivestate-actually-run) for the verification of when `getChildren()` runs and +> what A would and would not buy. +> +> **➤ RE-ASSESSED 2026-08-06 — see [§10.3](#103-m6--wp-8--tree-render-cost).** A **new** sub-item **M6-b** was +> found: `getDockerStatus` now also calls `suggestPort()` on every _polled_ call — skip it when +> `input.polled === true`. + +--- + +### M7 — Credential-bearing connection string is stored on the tree model _(GitHub Copilot reviewer)_ + +**Severity: Medium (latent risk; not an active leak today)** · +File: `src/tree/connections-view/LocalQuickStart/LocalQuickStartItem.ts` (lines 106–110) +**Thread:** — reviewer: `Copilot` + +> `model.connectionString` is set to `metadata.connectionString`, which (per +> `quickStartCredentials.composeConnectionString`) is a credential-bearing URI (userinfo includes the generated +> username/password). Even if nothing renders it today, keeping a password-embedded connection string on the tree +> model increases the risk of accidental logging/telemetry/tooltip leakage later and diverges from the repo's +> broader "password-free base connectionString + password stored separately" pattern. +> +> Consider stripping username/password before assigning to `model.connectionString` (keeping hosts + params), and +> rely on `connectionUser` + the pre-populated `CredentialCache` for authentication. + +**Verification (done for this review — the comment is accurate but the risk is latent, not live).** I traced every +consumer of `cluster.connectionString`: + +| Consumer | Reads | Leaks password? | +| -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | --------------- | +| `DocumentDBClusterItem.getHosts()` → tooltip | `.hosts` only | No | +| `DocumentDBClusterItem.isTlsDisabled()` | `tls` / `ssl` search params | No | +| `resolveAllowInvalidCertificates(...)` (badge + tooltip) | `areAllHostsLocal()` → hosts only | No | +| `ClusterItemBase` | only declares the field, never reads it | No | +| Generic copy / rename / move / remove commands | gated off by `contextValue` (`treeItem_quickStartInstance`, not `treeitem_documentdbcluster`) | Not reachable | + +So there is **no** current code path that renders, logs or transmits the password from the tree model. The value +of fixing it is defense-in-depth plus consistency with the repo pattern (`EphemeralClusterCredentials` treats +`connectionString` as a password-free base and carries the password only in `nativeAuthConfig` — the same PR even +hardens `buildParsedConnectionString` with `parsedConnectionString.password = ''` for exactly this reason, so the +codebase is already asserting this invariant elsewhere). + +Two caveats that should go in the thread reply: + +1. The equivalent value on the **service** side (`InstanceMetadata.connectionString`) genuinely must keep the + password — `populateCredentialCache()` and `copyQuickStartPassword()` parse it back out. Only the **tree + model** copy is safe to strip. A fix that strips both would break those paths. +2. This is entangled with **H5**: the `QuickStartClusterItem` browse path only works because the cache is + pre-populated, and today that priming is missing after a reload-then-Start. Stripping the model's password + makes the cache the _sole_ source of truth, so **H5 must be fixed first or together with this** — otherwise + the broken case becomes harder to diagnose rather than easier. + +**Fix options** + +| Option | Pros | Cons | +| --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **A. Strip userinfo when building the tree model** (`parsed.username = ''; parsed.password = ''`), keep `connectionUser: metadata.username` (recommended) | Exactly what the reviewer asks; ~4 lines; every current consumer (hosts, TLS params, host gating) is unaffected; aligns with `buildQuickStartCopyCredentials`, which already does this for the copy flow. | Depends on the `CredentialCache` being primed — i.e. requires **H5**. Also removes the (currently unused) ability to recover the password from the tree model. | +| **B. Strip the password but keep the username** (`parsed.password = ''`) | Tooltip/telemetry can never carry the secret; the user is still visible in the URI for debugging. | Half-measure: the URI is still not the repo's "password-free base" shape, so the divergence the reviewer raised only partly goes away. | +| **C. Reuse `buildQuickStartCopyCredentials()`** (already exported from `localQuickStartCommands.ts`) to derive the model's string | One shared stripping implementation instead of two; it already fails closed (returns `undefined`) on an unparseable string. | Tree code would import from a command module — a slightly odd dependency direction; the helper returns a full `EphemeralClusterCredentials`, so only part of it is used. | +| **D. Do nothing, document the invariant with a comment** | Zero risk of breaking the browse path. | Relies on every future maintainer honouring an unenforced invariant — precisely the failure mode the reviewer is guarding against. | + +Recommend **A**, sequenced after (or in the same change as) **H5**. Extract the stripping into a tiny shared +helper so **A** and `buildQuickStartCopyCredentials` cannot drift. + +> **DECISION (2026-08-05): DEFERRED — re-assess last.** Do not act on this now. If **H5** is resolved by moving +> the instance's credentials into the storage layer (see §9.1), the tree model stops needing a credential-bearing +> string at all and this finding disappears rather than being "fixed". Re-evaluate once H5, M4 and L3 have landed, +> then reply on the thread with the outcome. +> +> **➤ RE-ASSESSED 2026-08-06 — see [§10.4](#104-m7--password-on-the-tree-model).** Unchanged in code +> (`LocalQuickStartItem` line 112). Re-evaluate **as soon as WP-6a lands**, not after WP-6b — with the cache +> primed on every `Running` transition, option **A** becomes a safe four-line change and the GitHub thread can be +> answered. + +--- + +### L1 — The Provisioning tree row hardcodes port 10260 + +**Severity: Low** · File: `src/tree/connections-view/LocalQuickStart/LocalQuickStartItem.ts` + +```ts +label: l10n.t('Provisioning… · localhost:10260'), +``` + +A user who set a custom port (or hit the auto-fallback band) sees the wrong address for the whole provisioning +window. Note it is also a hardcoded literal inside a localized string, so translators can't even see it is a port. + +**Fix options** + +- **A (recommended):** use `status.metadata?.boundPort ?? entry.port ?? QUICK_START_PORT` and the + `l10n.t('Provisioning… · localhost:{0}', String(port))` form, matching the other rows. Requires exposing the + chosen port on `QuickStartStatus` during provisioning (it is already tracked as `chosenPort`). + _Pro:_ correct and consistent. _Con:_ small plumbing to surface the port before `metadata` exists. +- **B:** drop the address entirely while provisioning (`l10n.t('Provisioning…')`). + _Pro:_ one line, cannot be wrong. _Con:_ loses a useful hint in the common (default-port) case. + +> **DECISION (2026-08-05): A.** Surface the real chosen port. Simplified by **L3**: once the port is always +> explicit and decided in the wizard, it is known before provisioning starts. + +--- + +### L2 — The Configure "Address" row shows 10260 for a recreate on a fallback port + +**Severity: Low** · File: `src/webviews/documentdb/localQuickStart/LocalQuickStart.tsx` + +`effectivePort` derives only from `advPort`, whose initial state is `String(QUICK_START_PORT)`. For a +recreate/`Missing` instance that actually lives on, say, 10312, the Configure step confidently says +`localhost:10260`. The recreate then re-runs auto-port selection and may genuinely land on a different port than +the user was shown. + +**Fix options** + +- **A (recommended):** seed `advPort` from the instance's known port when `willReuse` resolves (the status + already carries `port`; `toWebviewStatus` would need to pass it through — it is not sensitive). + _Pro:_ the summary matches reality. _Con:_ one more field over the wire; needs care not to clobber a value the + user already typed. +- **B:** for `isRecreate`, render the Address value as "Kept from the existing instance" like the Image row does. + _Pro:_ consistent with the sibling rows, no new data. _Con:_ the port _is_ still editable on a recreate, so + hiding the value is slightly misleading in the other direction. + +> **DECISION (2026-08-05): A — but BLOCKED on M4.** The recreate / `willReuse` concept is itself under +> discussion (§9.2), so the shape of "the instance's known port" depends on that outcome. Implement A only after +> the M4 decision is made, and confirm the exact behaviour with the maintainer at that point. +> +> **➤ RESOLVED 2026-08-06 by WP-3 — see [§10.2](#102-m4--wp-7--recreate-vs-fresh).** The Address row now renders +> `suggestedPort` from `QuickStartService.suggestPort()`, which returns the instance's **own recorded port** when +> it is still free, and `portTouchedRef` stops a host suggestion from clobbering a typed value. No longer blocked +> on M4. **Action: confirm and close.** + +--- + +### L3 — Typing the default port explicitly silently disables the "exact port" contract + +**Severity: Low** · File: `src/webviews/documentdb/localQuickStart/LocalQuickStart.tsx` + +```ts +if (advPort.trim() && advPort.trim() !== String(QUICK_START_PORT)) opts.port = Number(advPort.trim()); +``` + +The documented service contract is: an explicit port is honoured exactly and a conflict **errors**; an omitted +port auto-relocates. A user who deliberately types `10260` because they need that exact port gets the _auto_ +behaviour and is silently moved to a random port in the band. (`010260` is also `!== '10260'`, so it _is_ sent — +inconsistent.) + +**Fix options** + +- **A (recommended):** track "the user edited the port" as explicit state (`portTouched`) rather than inferring it + by comparing to the default; send `opts.port` whenever the field was touched and is valid. + _Pro:_ the intent is captured directly instead of guessed. _Con:_ one extra state flag. +- **B:** always send the port when the field is non-empty, and let 10260 mean "exact". + _Pro:_ one-line change, contract becomes trivially predictable. _Con:_ removes auto-fallback for everyone who + never opens the editor — the default _is_ 10260 in the box, so this silently makes conflicts hard errors. **Not + recommended.** +- **C:** leave the behaviour, but make the copy explicit ("Leave at 10260 to let setup pick a free port + automatically"). + _Pro:_ zero risk. _Con:_ documents a surprising rule instead of removing it. + +> **DECISION (2026-08-05): none of the above — remove the auto-port mechanism entirely.** +> Direction: **no magic after the user presses execute.** The port becomes a plain, always-explicit setting: +> +> 1. The **Configure wizard** detects a free port up front and pre-fills the field with it (starting at +> `QUICK_START_PORT`, falling forward if busy). The user sees the actual port that will be used, and may edit it. +> 2. Validate the field's availability **in the wizard**, while the user can still react. +> 3. `provision()` then treats the port as **always explicit**: no `findAvailablePort`, no fallback band, no +> "port X was busy, using Y" note. A conflict at create time is a hard, clearly-explained error (see **M5/D**). +> +> Code to delete/simplify: `IContainerRuntime.findAvailablePort`, `QUICK_START_PORT_BAND_END`, +> `QUICK_START_PORT_FALLBACK_ATTEMPTS`, the `portFallback` telemetry property and the fallback branch in +> `provision()`. `isPortFree` is kept and moves to the wizard. +> +> **Knock-on effects (deliberate):** removes the ambiguity behind **L3**, removes the auto half of **M5**, +> simplifies **L1** and **L2**, and removes the "Ports X–Y are all in use" message from **M2**. This also has to +> be reflected in the Configure-step copy and in `docs/user-manual/local-quick-start.md`. + +--- + +### L4 — `hostClassification` misses expanded and IPv4-mapped IPv6 loopback + +**Severity: Low** · File: `src/documentdb/utils/hostClassification.ts` + +`isLocalOrPrivateHost` special-cases the literal `'::1'` only. These are all loopback and all classified as +**public**: + +| Input | Path taken | Result | +| ------------------ | -------------------------------------------------- | ------- | +| `0:0:0:0:0:0:0:1` | first hextet `0` → no `fc00::/7` / `fe80::/10` hit | `false` | +| `::ffff:127.0.0.1` | contains `:` → first hextet `''` → `NaN` | `false` | +| `::` | first hextet `''` → `NaN` | `false` | + +Consequences: the TLS-exception step is not offered for those hosts, and (after H2) an existing +`disableEmulatorSecurity` flag is not honoured for them at runtime, so a working local connection written with an +expanded IPv6 address would start failing certificate validation. + +**Fix options** + +- **A (recommended):** normalize the IPv6 literal before classifying — e.g. `net.isIPv6()` + expand `::`, or + simply add the explicit cases (`0:0:0:0:0:0:0:1`, `::ffff:` → recurse on the IPv4 part). + _Pro:_ correct for all spellings; `net` is already imported elsewhere in the codebase. _Con:_ a few more lines + and test cases. +- **B:** add the two literal forms to the existing string checks. + _Pro:_ trivial. _Con:_ still misses `::ffff:10.0.0.5` and other mapped forms. + +The test file `hostClassification.test.ts` covers `::1`, `fc00::/7`, `fe80::/10` and the IDNA homograph cases — +worth extending with the rows above. + +> **DECISION (2026-08-05): accepted — implement A.** Normalize IPv6 literals properly (expanded form and +> IPv4-mapped `::ffff:`), and extend `hostClassification.test.ts` with those rows. + +--- + +### L5 — `MaskingLineBuffer` grows without bound on newline-less output + +**Severity: Low** · File: `src/services/localQuickStart/outputMasking.ts` + +`push()` only emits on `\n`. `followLogs` streams `docker logs -f` indefinitely; a container that emits a long +newline-free stream (progress bars with `\r`, a binary blob, a runaway single-line log) accumulates in +`this.buffer` until the follow ends. `\r` is only stripped as a _line terminator suffix_, not treated as a break. + +**Fix options** + +- **A (recommended):** cap the buffer (e.g. flush at 8–16 KB without a newline). + _Pro:_ bounded memory, no behaviour change for normal logs. _Con:_ a secret straddling a forced flush boundary + could theoretically escape masking — mitigate by keeping a small tail (≥ max secret length) in the buffer. +- **B:** also break on `\r`. + _Pro:_ handles the common progress-bar case; matches terminal semantics. _Con:_ doesn't bound the truly + pathological case. + +> **DECISION (2026-08-05): A.** Cap the buffer, keeping a tail of at least the longest secret's length so a +> forced flush can never split a secret past the masker. + +--- + +### L6 — A custom Advanced password may appear percent-encoded and unmasked + +**Severity: Low (defense-in-depth)** · Files: `src/services/localQuickStart/outputMasking.ts`, +`src/services/localQuickStart/quickStartCredentials.ts` + +`maskSecrets` does literal substring replacement of the raw password. Auto-generated passwords use the URL-safe +alphabet, so their raw and percent-encoded forms are identical — fine. A **custom** Advanced password may contain +`@`, `:`, `/`, `%`, `#`, which `composeConnectionString` percent-encodes; if a connection string ever reaches the +channel (a driver error echo, a future diagnostic), the encoded form would not be masked. + +**Fix options** + +- **A (recommended):** pass both the raw and `encodeURIComponent`-ed forms into the `secrets` array at the call + sites (`provision`'s `secrets`, `seedSampleData`, `followLogs`). + _Pro:_ one-line per call site, no API change. _Con:_ slightly longer secrets array. +- **B:** have `maskSecrets` derive the encoded variant itself. + _Pro:_ callers can't forget. _Con:_ couples a deliberately dependency-free module to URI semantics. + +> **DECISION (2026-08-05): A.** Pass both the raw and `encodeURIComponent`-ed forms into the `secrets` array at +> the call sites; keep `outputMasking.ts` dependency-free. + +--- + +### L7 — A non-transient Docker failure during "Start Docker" ends the wait with no message + +**Severity: Low** · File: `src/webviews/documentdb/localQuickStart/LocalQuickStart.tsx` + +`pollDockerReadiness` returns `'ready' | 'stopped' | 'deadline' | 'cancelled'`, but `handleStartDocker` only +handles `'ready'` and `'deadline'`. On `'stopped'` (Docker came up but reported e.g. `permissionDenied`) the +spinner disappears and `dockerActionMessage` stays `undefined`. The readiness card _does_ update via `onResult`, +so the user isn't left blind — but the transition is unannounced, and the assertive `Announcer` bound to +`dockerActionMessage` says nothing. + +**Fix options** + +- **A (recommended):** add a `'stopped'` branch setting a message such as "Docker started, but it is not usable + yet — see the details below." + _Pro:_ two lines; completes the announcement contract. _Con:_ one more string. +- **B:** treat `'stopped'` as `'deadline'`. + _Pro:_ zero new strings. _Con:_ the message ("did not become ready before the wait timed out") is factually + wrong — it _did_ answer. + +> **DECISION (2026-08-05): A.** Add the explicit `'stopped'` branch with its own message. + +--- + +### L8 — `activeLogFollow` is never disposed on deactivation + +**Severity: Low** · File: `src/commands/localQuickStart/localQuickStartCommands.ts` + +The module-level `activeLogFollow` `CancellationTokenSource` is cancelled/disposed only when _View Logs_ is +invoked again. It is never registered with `ext.context.subscriptions`, so a `docker logs -f` child process +started by the last invocation outlives extension deactivation until the container stops. + +**Fix options** + +- **A (recommended):** register a disposable at command-registration time in `ClustersExtension` + (`{ dispose: () => { activeLogFollow?.cancel(); activeLogFollow?.dispose(); } }`), next to the existing + `disposeQuickStartOutputChannel` registration. + _Pro:_ matches the pattern already used two lines away. _Con:_ needs a small exported helper. +- **B:** move the follow state into `QuickStartServiceImpl` (which is already pushed to `subscriptions`) and + clean it up in `dispose()`. + _Pro:_ one owner for all Docker streams. _Con:_ mixes a command-scoped concern into the lifecycle service. + +> **DECISION (2026-08-05): fix it.** Either option is acceptable; **A** is preferred for the smaller blast +> radius (register the disposable next to the existing `disposeQuickStartOutputChannel` registration). + +--- + +### L9 — A crash leaves a plaintext-password env file in the temp directory + +**Severity: Low** · File: `src/services/localQuickStart/QuickStartService.ts` + +`writeEnvFile` correctly uses `mode: 0o600` and a random name, and `provision`'s `finally` deletes it. If the +extension host is killed between the write and the `finally`, the file survives in `os.tmpdir()`. On Windows the +`mode` argument is ignored, so the file inherits directory ACLs (still user-scoped in practice). + +**Fix options** + +- **A (recommended):** sweep `documentdb-quickstart-*.env` from `os.tmpdir()` at activation (best-effort, + fire-and-forget). + _Pro:_ self-heals after any crash; a few lines. _Con:_ a stray `readdir` at activation. +- **B:** write into `ext.context.globalStorageUri` instead of `os.tmpdir()`. + _Pro:_ extension-scoped directory, easier to sweep, better ACLs on Windows. _Con:_ Docker must be able to read + the path — fine locally, but breaks if the daemon is remote/WSL with a different filesystem view. +- **C:** accept the risk and document it. + _Pro:_ no work. _Con:_ the whole point of the env-file design was keeping the password off disk-adjacent + surfaces. + +> **DECISION (2026-08-05): A.** Sweep `documentdb-quickstart-*.env` from `os.tmpdir()` at activation +> (best-effort, fire-and-forget). Do not move the file out of `tmpdir` (option B) — the daemon must be able to +> read it. + +--- + +### Nits + +| # | Item | Suggestion | +| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| **N1** | `willReuse` is fetched once per panel open and never refreshed. Deleting the instance from the tree while the panel is open leaves the wizard showing the recreate copy. **⚠ This was recorded as "resolved by construction" by M4/I2-2, which was wrong — see [§11.6][it-post].** | Re-query `getDockerStatus` when the panel regains focus, or subscribe to status changes. ✅ Done in `4a618d0b` (subscription). | +| **N2** | Terminology: _"an open-source, fully MongoDB-compatible database"_ in the introduction copy. The repo rule is to avoid "MongoDB" as a bare product name; here it reads as a compatibility descriptor, which is borderline acceptable. | Consider "fully compatible with the MongoDB API" to match the documented convention exactly. | +| **N3** | `LocalQuickStartItem` has a self-acknowledged `FOLLOW-UP` comment about reporting wizard failures in the tree when the user never opened the wizard from there. | Either resolve it or file it — a `FOLLOW-UP` with no tracking item tends to become permanent. | +| **N4** | `runStream`'s `FOLLOW-UP (retry stability)` comment documents an un-awaited unsubscribe race that the service now papers over by buffering terminal events. | Worth an explicit handshake (`await` the previous stream's completion) rather than relying on the buffer. | +| **N5** | `resumeReadiness`, `discardTimedOutInstance`, `willReuseExistingInstance` and `isBusy` all hardcode `DEFAULT_ALIAS` while `provision` threads an `alias` variable that is also `DEFAULT_ALIAS`. The mixture makes it hard to see what is and isn't multi-instance ready. | Either take `alias` consistently or drop the parameter until WI-2e — the half-state is the confusing part. | +| **N6** | `findAvailablePort` consumes an attempt on a duplicate random candidate. | `i--` on the `continue`, or draw from a shuffled range. | +| **N7** | The `docs/ai-and-plans/PRs/local-quickstart-poc/` and `653-local-quickstart-design/` folders both carry plan docs for this feature, now joined by `docs/ai-and-plans/local-quickstart/`. | Consolidate under one PR folder before merge so the next reader has one entry point. | + +**Decisions on the nits (2026-08-05):** + +| # | Decision | +| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **N1** | Fold into the **M4** discussion (§9.2) — `willReuse` staleness is a symptom of the recreate model, not a standalone fix. | +| **N2** | **Won't fix — copy is intentional.** The wording is taken verbatim from documentdb.io and has been approved. **Action:** add a short code comment next to that string in `LocalQuickStart.tsx` recording that it is approved upstream copy, so future terminology sweeps don't "correct" it. | +| **N3** | Fold into the **M4** discussion (§9.2) — whether a wizard failure belongs in the tree depends on the state model. | +| **N4** | Keep as recorded; revisit with the stream-handshake work if retry instability resurfaces. | +| **N5** | **Accepted — clean up.** Make the alias threading consistent (`resumeReadiness`, `discardTimedOutInstance`, `willReuseExistingInstance`, `isBusy`) rather than leaving the half-state. Note this aligns with the **H4** decision and the stated intent to support multiple containers. | +| **N6** | Moot after **L3** — `findAvailablePort` is being removed. | +| **N7** | **Accepted, but out of scope here** — documentation consolidation will be handled by a dedicated work item. | + +--- + +## 4. What's notably well done + +- **Destructive-path discipline.** `deleteContainer` opting into `propagateErrors` so a Docker lookup _failure_ + is never mistaken for "already gone", removing _every_ label-matched container rather than the first, and + refusing to touch a container that fails the ownership check — this is the kind of care that prevents data-loss + bug reports. +- **The volume-wipe gate.** Deciding `reusing` from live `SecretStorage` rather than in-memory `missing`, and + refusing to wipe when credentials are unrecoverable, is exactly right. +- **Secret handling.** Env-file instead of argv, `$USERNAME`/`$PASSWORD` expanded by the _container's_ shell via + strong quoting, line-buffered masking that survives chunk splits, and `toWebviewStatus` stripping metadata so + the password never enters the renderer heap. +- **`resolveStorageZone`.** Decoupling zone routing from `isEmulator` is a clean fix for a real latent bug, and + it was applied consistently across all eight call sites. +- **Rejection sampling in `generateToken`** with the CodeQL rule cited in the comment. +- **Accessibility.** `Announcer` coverage for every phase transition, focus management on step change and on the + provisioning start/end button swap, `aria-hidden` on the collapsed editor rows, tooltip-as-accessible-name for + the icon-only row actions. +- **Test depth** where it exists — `QuickStartService.test.ts` (1189 lines) and `DockerReadinessService.test.ts` + (917 lines) cover the state machine and diagnosis matrix thoroughly. + +--- + +## 5. Decision log (authoritative — 2026-08-05) + +Decisions made by the maintainer after reviewing §3. **This table supersedes the "Recommend …" line inside each +finding.** Where a finding says `OPEN`, do not implement it — read §9 first. + +| ID | Severity after decision | Decision | Notes | +| ------ | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| **B1** | Informational (was Blocker) | **Won't fix now** — footer user-test still running; keep the switch + `USER-TEST PROTOTYPE` markers | Removal tracked with the user-test | +| **H1** | High | **A** (guard the `missing` transition) + evaluate the provider's cached-error-node mechanism | See the decision note under H1 | +| **H2** | High | **A only** — do not strip TLS-bypass params for public/mixed hosts. No public-host opt-in step. | Widest blast radius; ship its own commit | +| **H3** | High | **A + B** — persist the secret before the readiness wait **and** activate the lease machinery | Do not delete the lease code | +| **H4** | High | **A** — per-run `operationId` label, scope the orphan sweep to it | Explicitly future-proofed for multiple managed containers | +| **H5** | High | 🛑 **ON HOLD** — approach decided (**D** + own `StorageService` storage, §9.1); timing blocked on §9.2 → ✅ **DONE 2026-08-06 — option D via override only**, `StorageService` part split off | Record shape depends on the M4 state model — moot: no migration needed, see §3 H5's IMPLEMENTED note | +| **M1** | Medium | **A** now; **file a repo issue for D** (lint/guard rule) | D will touch other webviews | +| **M2** | Medium | **A** now; **file a repo issue for B**, milestone **0.10.1** | Typed message keys after 0.10.0 ships | +| **M3** | Medium | **A only** — `"when": "never"` for the seven lifecycle commands | Keep `localQuickStart.open` in the palette | +| **M4** | Medium | ✅ **DECIDED 2026-08-06 — option E** (explicit "Use existing data" / "Start fresh" choice in Configure). §9.2 Q2–Q4 remain open. | Resolves N1 by construction; L2 resolved by WP-3 | +| **M5** | Medium | **D only** — classify + explain the failure. Auto half removed by L3. | TOCTOU itself accepted as an edge case | +| **M6** | Medium | ✅ **DECIDED 2026-08-06 — option B** (render cached + `"Refreshing…"`, update on result). **A dropped.** | WP-8 cleared; ship **M6-b** with it | +| **M7** | Medium | 🛑 **ON HOLD** — re-assess after WP-6; likely resolved implicitly by the H5 design | GitHub thread reply is on hold until then | +| **L1** | Low | **A** — show the real port | Simplified by L3 | +| **L2** | Low | 🛑 **ON HOLD** — **A**, but blocked on M4; re-confirm with the maintainer afterwards | | +| **L3** | Low → **Design change** | **Remove the auto-port mechanism entirely.** Wizard picks a free port up front; always explicit. | "No magic after execute." Knock-on effects across L1, L2, M2, M5, N6 | +| **L4** | Low | **A** — normalize expanded + IPv4-mapped IPv6; extend tests | | +| **L5** | Low | **A** — cap the buffer, keep a secret-length tail | | +| **L6** | Low | **A** — pass raw + percent-encoded secrets at the call sites | | +| **L7** | Low | **A** — explicit `'stopped'` branch + message | | +| **L8** | Low | **Fix** — prefer A (register the disposable) | | +| **L9** | Low | **A** — sweep stale `documentdb-quickstart-*.env` at activation | Do not move the file out of `tmpdir` | +| **N1** | Nit | Folded into **M4** | | +| **N2** | Nit | **Won't fix** — approved copy from documentdb.io; add a code comment saying so | | +| **N3** | Nit | Folded into **M4** | | +| **N4** | Nit | Keep recorded; revisit if retry instability resurfaces | | +| **N5** | Nit | **Clean up** — consistent alias threading | Aligns with H4 | +| **N6** | Nit | Moot after L3 | | +| **N7** | Nit | Accepted, handled by a dedicated work item | | + +**Repository issues to file (not code work):** + +1. ✅ **Filed:** [#864 — Guard against module-scope `l10n.t` in webviews](https://github.com/microsoft/vscode-documentdb/issues/864) + — from **M1/D**. No milestone. +2. ✅ **Filed:** [#865 — Replace free-text service messages with typed message keys](https://github.com/microsoft/vscode-documentdb/issues/865) + — from **M2/B**. Milestone **0.10.1**. + +--- + +## 6. External review threads to respond to + +Fetched from the PR page on 2026-08-04. Keep these URLs so the replies land in the right thread. + +| Thread | Reviewer | Subject | Our finding | Status | +| ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [`#discussion_r3714252974`](https://github.com/microsoft/vscode-documentdb/pull/798#discussion_r3714252974) | `Copilot` | Password-embedded `connectionString` on the Quick Start tree model | **M7** | Valid, **not** a duplicate of anything we found independently. **Reply is on hold** until H5/M4/L3 land — M7 may be resolved implicitly by the H5 outcome. See the M7 decision note. | +| [`#pullrequestreview-4856616060`](https://github.com/microsoft/vscode-documentdb/pull/798#pullrequestreview-4856616060) | `copilot-pull-request-reviewer[bot]` | Review summary (overview + per-file table, 103/107 files reviewed) | — | Informational only — no actionable feedback beyond the thread above. No reply needed. | + +Notes on completeness of the fetch: + +- The Copilot reviewer produced **one** inline comment in total; there is no "comments suppressed due to low + confidence" section in the review body. +- The remaining PR conversation is non-Copilot: two `tnaum-ms` comments mapping the #790 UX review onto #794/#798, + and the two automated bot reports (code-quality ✅ l10n / ESLint / Prettier, and the build-size report: + VSIX +133 KB / +1.6 %, `views.js` +226 KB / +3.7 %). +- **Overlap check:** none of our B1/H1–H5/M1–M6/L1–L9/N1–N7 findings were also raised by the Copilot reviewer, and + M7 was not independently found by us — so nothing needed de-duplicating; M7 was appended rather than merged. + +### Suggested reply for `#discussion_r3714252974` (ON HOLD — see the M7 decision) + +Do **not** post this yet. The reply below assumes the "strip the tree model, keep the cache" fix; if **H5** is +resolved by moving the instance's credentials into the storage layer (§9.1), the correct reply is instead +"resolved implicitly — the tree model no longer carries a connection string at all". Finalize after H5 lands. + +> Agreed, and thanks — we verified it is a latent risk rather than an active leak: the only readers of +> `cluster.connectionString` today are `getHosts()` (tooltip), `isTlsDisabled()` and +> `resolveAllowInvalidCertificates()`, all of which consume hosts/params only, and the generic +> copy/rename/move/remove commands are gated off this node by its `contextValue`. We'll strip the userinfo on the +> tree model and keep `connectionUser` + `CredentialCache`, matching what `buildQuickStartCopyCredentials` already +> does for the copy flow. +> +> Two things we want to land alongside it: +> +> 1. `InstanceMetadata.connectionString` on the **service** side must keep the password — +> `populateCredentialCache()` and `copyQuickStartPassword()` parse it back out — so only the tree-model copy is +> stripped. +> 2. We found that the `CredentialCache` is not repopulated when a **stopped** instance is started after a window +> reload (`adoptContainer` primes it only `if (running)`, and `start()`/`restart()`/`refreshLiveState()` don't). +> Today the browse path silently depends on the model's credentials never being needed; once the model is +> password-free the cache becomes the sole source of truth, so we're fixing that priming gap in the same +> change. + +--- + +## 7. Implementation plan — work packages + +> **Read this first if you are an agent picking up this work with fresh context.** +> [§0](#0-status-board--what-to-implement-now-vs-what-is-on-hold) tells you which packages are cleared. +> §5 is the authoritative decision log. §3 holds the evidence and reasoning behind each finding. §9 holds the +> design discussions — §9.1 is **resolved**, §9.2 and §9.3 are **on hold** and must not be implemented. + +### 7.0 Ground rules + +- **§0 is the entry point.** Implement only the ✅ TODO packages. Everything 🛑 ON HOLD is blocked — do not start + it, and do not refactor code it will touch. +- **§5 wins over §3.** Every finding in §3 ends with a "Recommend …" line written before the decisions were made. + Where §5 disagrees, follow §5. +- **Verification cadence (operator decision, 2026-08-06 — this OVERRIDES `.github/copilot-instructions.md` + for this workstream).** The repo instructions require all five checklist steps before an agent finishes; that + is too slow to run per item. Instead: + - **While working an item:** run **`npm run lint` only.** Nothing else. + - **At wrap-up:** run the full checklist in order — `npm run l10n` (if user-facing strings changed) → + `npm run prettier-fix` → `npm run lint` → `npx jest --no-coverage` → `npm run build`. All five must pass. + - **The agent must ASK after every iteration** whether the operator is wrapping up for the day. Only run the + full checklist when the operator says so. Do not decide this unilaterally, and do not "helpfully" run the + suite because the repo instructions say to — this override is deliberate and recorded here. +- **Baseline at the time of review:** 202 suites / 3308 tests green, lint clean, build clean. Any new failure is + yours. +- **Never use `git add -f`.** `docs/plan/` and `docs/analysis/` are intentionally ignored. + +### 7.1 Package overview + +| WP | Title | Findings | Status | Blocked by | Parallel-safe with | +| -------- | ------------------------------------ | -------------------------- | -------------- | ------------ | ------------------ | +| **WP-1** | Tree refresh correctness | H1 | ✅ **DONE** | — | all TODO packages | +| **WP-2** | TLS exception policy correction | H2, L4 | ✅ **DONE** | — | all TODO packages | +| **WP-3** | Provisioning durability & port model | H3, H4, L3, M5, L1, N5, N6 | ✅ **DONE** | — | WP-1, WP-2, WP-5 | +| **WP-4** | Localization | M1, M2, N2 | ✅ **DONE** | WP-3a (soft) | WP-1, WP-2, WP-5 | +| **WP-5** | Command surface & small fixes | M3, L5, L6, L7, L8, L9 | ✅ **DONE** | — | all TODO packages | +| **WP-6** | Credential source of truth | H5, M7 | 🛑 **ON HOLD** | §9.2 | — | +| **WP-7** | Recreate vs. fresh + state model | M4, L2, N1, N3 | 🛑 **ON HOLD** | §9.2 | — | +| **WP-8** | Tree render cost | M6 | 🛑 **ON HOLD** | §9.3, WP-1 | — | +| **WP-9** | Repository issues (no code) | M1/D, M2/B | ✅ **DONE** | — | — | + +### 7.2 Package detail + +#### WP-1 — Tree refresh correctness (H1) + +**Goal:** a container removed outside VS Code must not cause a self-sustaining refresh / `docker inspect` loop. + +1. In `QuickStartService.refreshLiveState()`, guard the `!inspected` branch so the emitter fires only on the + **transition** into `missing`, matching the change-guard every sibling branch already uses. +2. Audit `ensureActionable()`'s `missing` branch for the same pattern. +3. Then evaluate the provider-level alternative: classify the `Missing` (and `CredentialsMissing`) rows as an + error state through `BaseExtendedTreeDataProvider.wrapGetChildrenWithErrorAndStateHandling`'s + `detectErrorState` hook, so `failedChildrenCache` short-circuits the fetch entirely. If adopted, wire + `resetNodeErrorState(nodeId)` to `QuickStartService.onDidChangeStatus`, otherwise a recreate will not clear the + row. + +**Regression test:** simulate `inspectContainer → undefined` twice in a row and assert `onDidChangeStatus` fires +exactly once. + +**Watch out for:** `ConnectionsBranchDataProvider` already opts into the wrapper — check the existing +`errorRecoveryActions` gating before adding a new `detectErrorState`. + +> **IMPLEMENTED (2026-08-05) — commit `fix(quickstart): fire the Missing status change only on transition (H1)`.** +> +> **What was done** +> +> - `QuickStartService.refreshLiveState()`: the `!inspected` branch now fires `statusEmitter` only when +> `entry.missing` was previously `false`, matching the change-guard that every sibling branch already used. +> - Added the regression test `refreshLiveState() fires the status change only on the TRANSITION into Missing (H1)` +> in `QuickStartService.test.ts` — adopt a running container, delete it externally, call `refreshLiveState()` +> three times, assert exactly **one** `onDidChangeStatus` event and `missing === true`. +> +> **Why** the unconditional fire closed a loop through `ClustersExtension → refresh() → getChildren() → +refreshLiveState() → fire()`, spawning a `docker inspect` per iteration for as long as the Connections view was +> visible. The node renders `collapsibleState: Expanded`, so its children are always re-queried. +> +> **Step 2 — `ensureActionable()` audit: no change needed.** Its `missing` branch is reached only from a +> user-initiated lifecycle command (`start`/`stop`/`restart`), each of which is one-shot and additionally shows an +> information message; it cannot re-enter itself. The existing comment already explains why it sets the flag +> directly instead of delegating to `refreshLiveState()`. +> +> **Step 3 — provider-level cached-error-node route: evaluated and NOT adopted.** Options considered: +> +> | Option | Verdict | +> | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +> | Classify `Missing`/`CredentialsMissing` as an error via `detectErrorState` | **Rejected.** `failedChildrenCache` freezes the node's children until an explicit `resetNodeErrorState`, which would have to be wired to `onDidChangeStatus` — i.e. exactly the transition guard we just added, but with an extra cache to keep in sync. | +> | Keep the transition guard only (chosen) | The row is not an error: it is a valid, user-actionable state with its own affordances (click-to-recreate, Delete). The generic error-recovery "Retry" the wrapper adds would be the wrong action, and the frozen cache would also suppress a legitimate external recovery (container reappears). | +> +> Net: the transition guard alone is sufficient and strictly simpler. + +#### WP-2 — TLS exception policy correction (H2, L4) + +**Goal:** stop silently stripping a user's deliberate TLS-bypass params on public hosts, and classify IPv6 +loopback correctly. + +1. `canonicalizeTlsException()` / `stripTlsBypassParams()`: only strip when the exception is actually adopted + (every host local/private). For a public or mixed host, return the connection string **unchanged**. +2. Verify the two persisting call sites (`newConnection/ExecuteStep`, `updateConnectionString/ExecuteStep`) now + store the original string for public hosts. +3. `hostClassification.ts`: normalize IPv6 literals — expanded loopback (`0:0:0:0:0:0:0:1`), `::`, and + IPv4-mapped (`::ffff:127.0.0.1`, `::ffff:10.0.0.5` → classify on the embedded IPv4). +4. Extend `tlsException.test.ts` and `hostClassification.test.ts` with the new rows. + +**Ship this as its own commit/PR.** It is the only package that changes behaviour for users who never touch Quick +Start, so it must be revertible in isolation. + +**Watch out for:** the docblock on `resolveAllowInvalidCertificates` already describes the post-fix behaviour — +it becomes true again after this change; do not "fix" the comment to match the old code. + +> **IMPLEMENTED (2026-08-05) — commit `fix(tls): keep a deliberate TLS bypass for public hosts (H2, L4)`.** +> +> **H2 — what was done** +> +> - `canonicalizeTlsException()` now decides `allHostsLocal` **first** and returns the input string **verbatim** +> for a public or mixed host. `stripTlsBypassParams()` runs only when the exception is actually adopted. +> - `stripTlsBypassParams()` itself is unchanged — it is still exported and still used unconditionally by +> `vscodeUriHandler.ts`. +> - Updated the docblocks in `tlsException.ts`, `updateConnectionString/ExecuteStep.ts` and +> `newConnection/PromptConnectionStringStep.ts`, which all claimed unconditional stripping. +> - Rewrote the six public/mixed-host cases in `tlsException.test.ts` to assert `connectionString` is returned +> **byte-identical** (previously they asserted the param was stripped). +> +> **Deliberate scope decision: the deep-link path keeps stripping.** `vscodeUriHandler.ts` calls +> `stripTlsBypassParams(parsedCS)` as a separate, explicit step, so it is unaffected by this change and a pasted / +> deep-linked `vscode://` URL still cannot carry a TLS bypass for a public host. That is exactly the gate H2's +> rejected option B would have removed — the asymmetry (trusted wizard input keeps the param, untrusted deep link +> does not) is intentional and now the only place stripping is unconditional. +> +> **L4 — what was done** +> +> - Added `expandIpv6()`: full expansion of an IPv6 literal (compressed `::`, zone index `%eth0`, dotted-quad +> tail), returning 8 hextets or `undefined` for a malformed literal. +> - `isLocalOrPrivateHost()` now classifies IPv6 on the **expanded** form, so every spelling of the same address +> agrees: `::1`, `0:0:0:0:0:0:0:1` and `0000:…:0001` are all loopback; `::ffff:127.0.0.1` and `::ffff:10.0.0.5` +> are classified by their embedded IPv4 (and `::ffff:8.8.8.8` correctly stays public). +> - Extracted the IPv4 range checks into `isLocalOrPrivateIpv4(octets)` so the IPv4 and IPv4-mapped-IPv6 paths +> share one rule set instead of duplicating it. +> - Extended `hostClassification.test.ts` with 15 new true-rows and 4 new false-rows, including the two malformed +> literals (`fe80:::1`, `::1::2`) that must not be mis-classified by the expansion. +> +> **One deliberate deviation (confidence ≫ 80 %):** the review's table lists `::` (unspecified) as a missed +> loopback case. Expanding it yields all-zero hextets, which is the IPv4 `0.0.0.0` — so the classifier now treats +> the unspecified address as local for **both** families (`::` and `0.0.0.0`). Options weighed: (a) special-case +> `::` only — rejected, it would leave `0.0.0.0` public while its IPv6 synonym is local, i.e. exactly the +> spelling-sensitivity L4 is about; (b) leave both public — rejected, the review explicitly lists `::` as a bug; +> (c) treat the unspecified address as local in both families — chosen. Semantically `0.0.0.0`/`::` as a _connect_ +> target is the local machine, so offering the TLS-exception step there is correct. + +#### WP-3 — Provisioning durability & the port model (H3, H4, L3, M5, L1, N5, N6) + +This is the largest package. Do it in the order below — the port change simplifies the rest. + +**3a. Remove the auto-port mechanism (L3, N6, part of M5).** _"No magic after execute."_ + +- The Configure wizard picks a free port up front (start at `QUICK_START_PORT`, walk forward), pre-fills the + field with the **actual** port, and validates availability while the user can still react. +- The port is then **always explicit** on the wire; `provision()` no longer relocates. +- Delete `IContainerRuntime.findAvailablePort`, `QUICK_START_PORT_BAND_END`, + `QUICK_START_PORT_FALLBACK_ATTEMPTS`, the `portFallback` telemetry property, the fallback branch and its + "Port X was busy, using Y" note. Keep `isPortFree` and move its use into the wizard. +- Update the Configure-step copy and `docs/user-manual/local-quick-start.md`. + +**3b. Port conflict messaging (M5/D).** Classify a Docker port-allocation failure at create time and surface +actionable copy instead of the raw Docker string. Do not retry. + +**3c. Persist credentials before the readiness wait (H3/A).** Store the connection string right after +`createAndRunContainer` succeeds. Add the matching cleanup to `provision()`'s failure `finally` so a discarded +attempt does not leave a stale secret. + +**3d. Activate the lease machinery (H3/B).** Write `phase: 'provisioning'` + `leaseAt` before create, renew per +stage, promote to `'ready'` in `finalizeReadyInstance`. The scavenge pass in `reconcile()` and the `freshLease` +branches in `reconcileAlias()` become live — verify against the existing tests in `QuickStartService.test.ts` +(lines ~490–540) which already cover them. + +**3e. Scope the orphan sweep (H4/A).** Stamp a per-run `operationId` into the container labels **before** +`docker run`, and filter the `finally` sweep by it. Do not assume a single managed container anywhere in the +cleanup path — multiple containers are planned. + +**3f. Alias threading cleanup (N5).** Make `resumeReadiness`, `discardTimedOutInstance`, +`willReuseExistingInstance` and `isBusy` take an `alias` consistently instead of hardcoding `DEFAULT_ALIAS`. + +**3g. Provisioning row port (L1).** With 3a done the port is known before provisioning starts — surface it and +replace the hardcoded `localhost:10260` label. + +**Watch out for:** `reconcileAlias`'s `freshLease` branch is currently unreachable; after 3d it is live, so a +slow first image pull must not be mistaken for a crashed host (`PROVISIONING_LEASE_TTL_MS` is 20 min for exactly +this reason — do not shorten it). + +> **IMPLEMENTED (2026-08-06) — commit `feat(quickstart): explicit port model and durable provisioning`.** +> +> | Step | What was done | +> | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +> | **3a** | `IContainerRuntime.findAvailablePort`, `QUICK_START_PORT_BAND_END`, `QUICK_START_PORT_FALLBACK_ATTEMPTS`, the `portFallback` telemetry property and the whole fallback branch (including its "Port X was busy, using Y" note) are gone. `provision()` now binds `options.port ?? QUICK_START_PORT` and a conflict is a hard error. New `QuickStartService.suggestPort()` / `checkPort()` back the wizard: `getDockerStatus` returns `suggestedPort`, a new `checkPort` query validates the field (debounced, 400 ms), and the webview now sends the port **unconditionally**. | +> | **3b** | New `isPortAllocationFailure()` classifies Docker's bind failure at the `creating` stage and rewrites it to the same `portInUseMessage()` copy as the pre-check, so the raw `Bind for 127.0.0.1:10260 failed: port is already allocated` never reaches the UI. No retry, as decided. Telemetry gains `portTaken`. | +> | **3c** | The connection string is stored right after the container is created and its bound port is known — **before** `waitForReadiness`. A discarded attempt restores the previous secret exactly (or deletes it when there was none), so a failed run can't leave credentials that a later `reusing` decision would trust. | +> | **3d** | New `renewProvisioningLease()` / `releaseProvisioningLease()`. The lease is taken before the pull and renewed at `creating` and at the readiness wait, then promoted to `'ready'` by `finalizeReadyInstance`. The scavenge pass and both `freshLease` branches in `reconcileAlias` are now live code. | +> | **3e** | New `QUICK_START_OPERATION_LABEL_KEY`: a per-run 16-hex `operationId` is stamped on the container **before** `docker run` and the id-less cleanup sweep filters `listByLabel` by it, so a run can only ever remove its own container. | +> | **3f** | `provision`, `resumeReadiness` and `discardTimedOutInstance` now take `alias` (defaulting to `DEFAULT_ALIAS`) and thread it through every `stateFor`/`setStatus` call — the `DEFAULT_ALIAS`/`alias` mixture inside those bodies is gone. `willReuseExistingInstance(alias)` likewise, and `isBusy` is now a thin shorthand for `isBusyFor(DEFAULT_ALIAS)`. | +> | **3g** | The tree's Provisioning row uses `status.port`; `QuickStartStatus` gained `port`, and `provision` records the chosen port on the entry before the first stage so the row is correct from the start. | +> | **N6** | Moot — `findAvailablePort` is deleted. | +> | **Docs** | `docs/user-manual/local-quick-start.md` gained a **Port** section describing the pre-filled-and-validated, never-relocated model; the Configure step's Port field hint says the same in one line. | +> +> **Tests:** new `QuickStartProvisionDurability.test.ts` (15 cases). It mocks `mongodb` so a provision can be +> driven end to end — the existing suites deliberately stop at pull/create, which is exactly why none of H3/H4 +> was caught. It covers: the secret exists at the first readiness probe; a failed attempt restores the previous +> secret; the lease is an owned `provisioning` record mid-run and `ready` afterwards; a failed attempt releases +> it; an existing `ready` record is never downgraded; the sweep and the container labels carry the `operationId`; +> an explicit `10260` errors instead of relocating; a custom port is the one bound; and a Docker bind failure is +> reported in the pre-check's words. Plus `suggestPort`/`checkPort` (forward walk, own-port preference, sibling +> reservation, `inUse`). +> +> **Three deliberate deviations (confidence ≫ 80 %):** +> +> 1. **`QUICK_START_PORT_SCAN_LIMIT` replaces the deleted band constant.** A forward walk needs a bound. It is +> semantically different from the old fallback band — it only limits how far the _suggestion_ scans, and the +> port the user ends up with is still always explicit. Alternatives weighed: scan unbounded (rejected — a +> pathological host would spin on `net.listen`); hardcode the limit at the call site (rejected — the tree and +> the wizard would drift). +> 2. **The lease is only taken for a genuinely fresh alias.** Writing a `provisioning` record over an existing +> `ready` one would mean a _failed recreate_ gets its record scavenged at the next activation, so an instance +> whose data volume is still on disk would vanish from the tree — strictly worse than the bug being fixed. A +> recreate keeps its `ready` record, and reconcile's Case 1 / Case 3 already handle it correctly. Covered by +> the "never downgrades an existing ready record" test. +> 3. **`suggestPort` prefers the instance's own recorded port.** This is L2/A's intent, but it does **not** +> depend on the M4 recreate-vs-fresh decision (the same port is correct either way), and without it a recreate +> would be _moved off_ its own port by the sibling-reservation rule. Alternatives weighed: exclude every +> registry port including the alias's own (rejected — actively wrong); ignore registry ports entirely +> (rejected — a stopped sibling's port is baked into its container and `isPortFree` cannot see it). L2's UI +> half (seeding the field from the instance's port) still falls out of this for free, since the field is +> seeded from `suggestedPort`. + +#### WP-4 — Localization (M1, M2, N2) + +1. **M1/A:** convert every module-scope `l10n.t` map in `LocalQuickStart.tsx` (~20 maps, ~120 strings) into + render-time functions memoized with `useMemo`. **Root cause:** `WebviewRegistry` statically imports the + component, so module bodies run before `l10n.config()` in `render()` — module-scope `l10n.t` can never be + translated. +2. **M2/A:** wrap every UI-reachable service message in `QuickStartService.ts` with `l10n.t` and `{0}` + placeholders (template literals are not extractable). Leave channel-only `appendLine` strings unwrapped. +3. **N2:** add a short code comment next to the "fully MongoDB-compatible database" string recording that it is + approved copy from documentdb.io, so future terminology sweeps leave it alone. +4. Run `npm run l10n` and confirm the bundle diff is what you expect. + +**Watch out for:** strings removed by WP-3 (the port-fallback note, "Ports X–Y are all in use") — sequence WP-4 +after WP-3a, or accept a second `npm run l10n` pass. + +> **IMPLEMENTED (2026-08-06) — commit `fix(quickstart): localize the webview lookups and service messages`.** +> +> **M1/A — the 20 module-scope maps are now render-time lookups.** Each `const MAP = {…}` became a +> `function mapName(): T { return {…}; }` (`stageLabels`, `planItems`, `dockerDaemonValues`, +> `dockerFailureLabels`, `dockerProviderLabels`, `dockerOutcomeValues`, `dockerEndpointKindValues`, +> `dockerEndpointSourceNotes`, `dockerProviderEvidenceNotes`, `dockerHostEnvironmentValues`, +> `dockerPermissionDetailValues`, `dockerGuidance`, `dockerGuides`, `dockerStartLabels`, +> `executionTargetValues`, `dockerRecoveryNotes`, `dockerDetailProviderLabels`, `dockerDetailOsLabels`, +> `dockerDetailTargetLabels`, `dockerDetailFailureLabels`). The seven used inside the component are memoized with +> `useMemo`; the rest are called from module-scope helpers (`formatDockerDetailSegment`, +> `buildDockerReviewRows`), which run per render anyway. +> +> **One deliberate deviation from "memoize everything with `useMemo`" (confidence ≫ 80 %):** eight of the maps +> are consumed by module-scope helper functions, not by the component, so `useMemo` cannot reach them without +> threading ~8 extra parameters through the helper chain. Options weighed: (a) thread the maps as parameters — +> rejected, large churn and a worse signature for zero behavioural gain; (b) cache the maps in module-level +> `let`s on first call — rejected, that re-introduces exactly the evaluation-order dependency M1 is about (a +> single pre-`l10n.config()` call would poison the cache permanently); (c) rebuild on call and memoize only where +> the component can — chosen. The maps are a handful of small string literals built during render; the +> allocation is immaterial next to the React tree around it. +> +> **M2/A — every UI-reachable service message is now localized.** Traced which `StageEvent` fields actually +> render first (`onData` uses `message` only for the `done` stage and `error ?? message` for `status === 'error'`), +> so the wrapping is scoped to exactly those: `Setup is already in progress.`, `There is nothing to resume.`, +> `A setup operation is already in progress.`, both `DocumentDB Local is running on localhost:{0}.` success +> subtitles (now `{0}` placeholders, not template literals, so they extract), the two `DockerNotReadyError` +> messages, `Setup was cancelled.`, `Still initializing…`, and the two `exited shortly after` tree-row +> descriptions. The in-flight stage messages (`Checking Docker…`, `Pulling official image…`, …) are deliberately +> **not** wrapped: the webview renders its own `stageLabels()` for those rows and never displays the transport +> string. Channel-only `appendLine` strings stay unwrapped, as decided. `npm run l10n` adds exactly these 10 +> keys. +> +> **N2** — a code comment now marks the "fully MongoDB-compatible database" string as approved verbatim copy +> from documentdb.io, so a future terminology sweep leaves it alone. + +#### WP-5 — Command surface & small fixes (M3, L5, L6, L7, L8, L9) + +- **M3/A:** add `"when": "never"` `commandPalette` entries for the seven lifecycle commands + (`start`, `stop`, `restart`, `delete`, `copyConnectionString`, `copyPassword`, `viewLogs`). Keep + `localQuickStart.open`. +- **L5/A:** cap `MaskingLineBuffer`'s buffer; retain a tail ≥ the longest secret so a forced flush cannot split a + secret past the masker. +- **L6/A:** pass raw **and** `encodeURIComponent`-ed secret forms into the `secrets` arrays at the call sites. +- **L7/A:** handle `pollDockerReadiness`'s `'stopped'` outcome with its own message. +- **L8:** register a disposable that cancels/disposes `activeLogFollow`, next to the existing + `disposeQuickStartOutputChannel` registration in `ClustersExtension`. +- **L9/A:** sweep stale `documentdb-quickstart-*.env` from `os.tmpdir()` at activation (best-effort). Keep + writing to `tmpdir` — the daemon must be able to read the file. + +These are independent; they can land as one PR or be folded into whichever package already touches each file. + +> **IMPLEMENTED (2026-08-05) — commit `fix(quickstart): palette gating, log-follow disposal and masking hardening`.** +> +> | Item | What was done | +> | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +> | **M3/A** | Added seven `"when": "never"` `commandPalette` entries (`start`, `stop`, `restart`, `delete`, `copyConnectionString`, `copyPassword`, `viewLogs`), placed with the other tree-only commands. `localQuickStart.open` stays visible. The palette-reachable **Delete Container…** — the sharp one — is now gone. | +> | **L5/A** | `MaskingLineBuffer` now force-flushes at `MAX_BUFFERED_CHARS = 16 KiB` when no newline arrives, retaining a tail of at least the longest secret's length so a forced cut can never split a secret past the masker. Two tests added: the buffer stays bounded on a 64 KiB newline-less push and emits everything exactly once, and a password landing exactly on the boundary is still masked. | +> | **L6/A** | New `secretVariants(...secrets)` in `quickStartCredentials.ts` returns the raw **and** `encodeURIComponent`-ed forms, de-duplicated. Applied at all four call sites (`provision`'s `secrets`, `seedSampleData`, both `followLogs` — including the one in `viewQuickStartLogs`). `outputMasking.ts` stays dependency-free, as decided. A URL-safe generated password still yields a single-element array. | +> | **L7/A** | `handleStartDocker` gained the missing `'stopped'` branch: "Docker started, but it is not usable yet — see the details below." Previously the spinner just vanished and the assertive `Announcer` said nothing. | +> | **L8/A** | New exported `disposeQuickStartLogFollow()` in `localQuickStartCommands.ts`, registered in `ClustersExtension` right next to `disposeQuickStartOutputChannel` — so the last `docker logs -f` child process no longer outlives deactivation. | +> | **L9/A** | New exported `sweepStaleQuickStartEnvFiles()` in `QuickStartService.ts`, called fire-and-forget after `reconcile()`. The file stays in `os.tmpdir()` (the daemon must read it), as decided. | +> +> **One deliberate addition to L9 (confidence ≫ 80 %): an age threshold.** A bare "delete every +> `documentdb-quickstart-*.env` at activation" would delete the env file of a provision running **in another +> window** — the file lives for the whole `provision()` call, and a cold image pull can take minutes. The sweep +> therefore only removes files whose `mtime` is older than 1 hour (comfortably above `PROVISIONING_LEASE_TTL_MS` +> = 20 min), and matches the exact `documentdb-quickstart-<16 hex>.env` name so it never touches an unrelated +> file. Alternatives weighed: no threshold (rejected — breaks concurrent windows); a lock file (rejected — more +> state for a best-effort cleanup); sweeping on `deactivate` instead (rejected — a killed host is exactly the +> case that has no `deactivate`). + +#### WP-6 — Credential source of truth (H5, M7) — 🛑 **ON HOLD** + +**The approach is decided (§9.1); only the timing is blocked.** Do not implement until §9.2 (M4) is settled, +because the stored record's shape depends on the state model. + +Agreed shape, for reference: + +- Create `StorageService.get('local-quickstart')` with an `instances` workspace — one `StorageItem` per alias, + non-secret metadata in `properties`, the connection string in `secrets`. Mirrors + `service-atlas-mongodb/credentials/atlasCredentialStore.ts` and `service-kubernetes/sources/sourceStore.ts`. +- **Do not** add a `Managed` zone to `ConnectionStorageService` — zones are what the Connections view enumerates. +- `QuickStartClusterItem` overrides `getCredentials()` and `authenticateAndConnect()` to source from + `QuickStartService` (option **D**), so the `CredentialCache` stops being load-bearing. +- Replaces the `documentdb.quickstart..connectionString` secrets **and** the + `documentdb.quickstart.registry` globalState blob. +- Add the regression test: stop → clear cache → start → assert the node can list databases. +- **M7** is expected to resolve implicitly here; re-assess and then reply on the GitHub thread. + +#### WP-7 — Recreate vs. fresh + state model (M4, L2, N1, N3) — 🛑 **ON HOLD** + +Requires the state/collision model in §9.2 to be agreed first. Deliverable includes: an explicit user choice +(recreate onto the existing volume vs. start fresh), honest footer copy, a per-instance state model that does not +assume a single container, and a defined answer for when the Quick Start tree item is visible in each state. + +#### WP-8 — Tree render cost (M6) — 🛑 **ON HOLD** + +Leaning toward rendering from cached state with a `"Refreshing…"` description and updating on result (option B, +with A dropped). Needs the §9.3 confirmation, and must land after WP-1 so it can reuse the same transition guard — +otherwise it recreates H1 in a new shape. + +#### WP-9 — Repository issues (no code) + +Both issues are already filed — no action required: + +- [#864](https://github.com/microsoft/vscode-documentdb/issues/864) — module-scope `l10n.t` guard (from M1/D) +- [#865](https://github.com/microsoft/vscode-documentdb/issues/865) — typed message keys, milestone 0.10.1 (from M2/B) + +### 7.3 Suggested sequencing + +```text +DONE (landed 2026-08-05/06, one commit each): + WP-1 ─┐ + WP-2 ─┤ independent, no design input + WP-5 ─┘ + WP-3 ──► WP-4 (WP-4 ran after WP-3a so the strings settled once) + +NEXT (resume the discussion): + §9.2 M4 decision ──► WP-6 (H5 record shape) ──► WP-7 ──► re-assess M7 ──► reply on the GitHub thread + §9.3 M6 confirmation ──► WP-8 (WP-1 is already in place) +``` + +--- + +## 8. Notes for a fresh-context agent + +- **Diff base is `origin/release/0.10.0`, not `main`.** `git diff main...HEAD` includes unrelated merged work + (Atlas discovery, index dashboard) and will mislead you. +- **The tests mock the world.** `IContainerRuntime`, `ext.secretStorage` and the tree provider are all injected or + mocked, which is why a fully green suite coexists with the findings above. When you fix something here, add the + test that would have caught it — several findings list one explicitly. +- **Two ID concepts.** `treeId` is the TreeView path and changes when a connection moves between folders; + `clusterId` is the stable cache key. `CredentialCache` / `ClustersClient` must always use `clusterId`. +- **The Quick Start instance is not a stored connection.** Its `storageId` (`quickstart-`) does not exist + in any `ConnectionStorageService` zone; the credentials live in raw `SecretStorage` under + `documentdb.quickstart..connectionString` and the instance list in a `globalState` blob. This is the root + of §9.1 — do not assume the inherited storage lookup works. **Decided fix:** a dedicated + `StorageService.get('local-quickstart')` (WP-6, on hold). +- **Two storage layers.** `StorageService.get(name)` is the generic one — each subsystem owns a named storage with + its own workspaces, `properties` (globalState) and `secrets` (SecretStorage). `ConnectionStorageService` is a + facade over the single `StorageNames.Connections` storage whose **zones are workspaces** and are what the + Connections view enumerates. Precedents for owning your own storage: `service-kubernetes/sources/sourceStore.ts` + and `service-atlas-mongodb/credentials/atlasCredentialStore.ts`. **Do not add a zone** for non-Connections-view + data. +- **The registry's lease fields are currently dead code.** `phase: 'provisioning'`, `operationId` and `leaseAt` + are written only by tests. WP-3 makes them live. +- **Terminology:** "DocumentDB" for the service, "MongoDB API" / "DocumentDB API" for the wire protocol. Never + "MongoDB" alone as a product name — except the one approved documentdb.io string covered by N2. +- **`TDD:`-prefixed test suites are behaviour contracts.** If one fails after your change, stop and ask; do not + edit the test. + +--- + +## 9. Open design discussions + +These three were explicitly deferred to a conversation with the maintainer. **All three are now resolved** +(§9.1 on 2026-08-05, §9.2 and §9.3 on 2026-08-06); the resulting work items live in +[§11.1 Iteration 1](#111-iteration-1--opened-2026-08-06). + +> **⚠️ READ [§10](#10-re-assessment-of-the-on-hold-items-after-wp-1--wp-5-2026-08-06) ALONGSIDE THIS SECTION.** +> §9 was written on 2026-08-05, before WP-1…WP-5 landed. §10 re-verifies every item against the current code and +> records the 2026-08-06 decisions (**M4 → option E**, **M6 → option B**). §10 supersedes §9 wherever they differ. + +### 9.1 H5 — where should the managed instance's credentials live? + +**STATUS: RESOLVED (2026-08-05) — option D, backed by a dedicated `StorageService` storage.** +Implementation remains **ON HOLD** pending §9.2, because the record shape depends on the state model. + +**The question raised:** is the Quick Start node still reusing `ClusterItemBase`? That base class has an abstract +method for supplying the connection string — aren't we storing these somewhere already? Pre-populating an +in-memory cache feels wrong; we should store the credentials properly, ideally via the storage services. Is that +what option **D** meant — and should we use the storage service with a **new zone**? + +**Facts established while reviewing (all verified in code):** + +- `QuickStartClusterItem extends DocumentDBClusterItem extends ClusterItemBase`. It inherits **both** abstract + implementations — `getCredentials()` and `authenticateAndConnect()` — unchanged. +- Both inherited implementations are **storage-backed**: they call + `ConnectionStorageService.get(this.storageId, resolveStorageZone(this.cluster))`. +- The Quick Start instance is **not** in any storage zone. Its `storageId` is `quickstart-`; the + credentials live in raw `ext.secretStorage` under `documentdb.quickstart..connectionString`, and the + instance list lives in a separate `globalState` blob (`documentdb.quickstart.registry`). +- Therefore both inherited methods **always fail** for this node. `authenticateAndConnect()` returns `null` at the + `!connectionCredentials` guard — before the auth wizard is ever reached. +- The node only works because `ClusterItemBase.getChildren()` checks + `CredentialCache.hasCredentials(clusterId)` **first** and short-circuits. The cache priming is load-bearing. + +#### Research: how do other subsystems use the storage service? + +There are **two layers**, and that distinction is the whole answer: + +| Layer | What it is | Who uses it | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `StorageService.get(name)` | Generic. Returns a `Storage` keyed by an arbitrary name, partitioned into **workspaces**. Each item carries `properties` (globalState) and `secrets: string[]` (SecretStorage-backed). | Any subsystem that owns its own data | +| `ConnectionStorageService` | A **facade over exactly one** storage name (`StorageNames.Connections`). Its **zones are workspaces** of that storage. | Only the Connections view's saved connections + folders | + +Every subsystem that owns data outside the Connections view creates **its own named storage** — none of them adds +a zone: + +| Subsystem | Storage name | Workspaces | +| ------------------------------------ | --------------------------- | -------------------------------------------------------------------- | +| `service-kubernetes` (`sourceStore`) | `KUBECONFIG_STORAGE_NAME` | `…_STORAGE_WORKSPACE`, `…_ALIASES_WORKSPACE`, `…_SETTINGS_WORKSPACE` | +| `service-atlas-mongodb` | `'atlas-mongodb-discovery'` | `credentials` | +| `ConnectionStorageService` | `StorageNames.Connections` | `clusters`, `emulators` (= the zones) | + +The Atlas store is the freshest precedent — added in **this same release** — and its module header states the +intent explicitly: one `StorageItem` per credential, non-secret metadata in `properties`, secret material in +`secrets`, "mirroring the Kubernetes `sourceStore` shape". + +#### Why not a new `Managed` zone + +1. **Zones are what the Connections view enumerates.** `ConnectionsBranchDataProvider` lists `Clusters`-zone + connections as root items; a zone is a tree partition, not a generic bucket. A managed instance placed in a + zone would have to be filtered back out. +2. **Shared code hardcodes the zone list.** `connectionStorageService.ts` iterates + `[StorageZone.Clusters, StorageZone.Emulators]` in its cleanup pass — a third zone means touching shared, + well-tested cleanup logic on behalf of a consumer that needs none of it. +3. **Wrong semantics inherited.** Zones bring folders, `parentId` hierarchy, orphan cleanup, duplicate-parameter + repair and the `FOLDER_PLACEHOLDER_CONNECTION_STRING` convention. +4. **User mutability.** A zone entry is reachable by rename/move/delete paths that would silently diverge from the + Docker + registry state. + +#### Agreed design + +```text +StorageService.get('local-quickstart') + └── workspace 'instances' + └── one StorageItem per alias + id: + name: + version: '1' + properties: { alias, displayName, port, phase, imageRef, … } → globalState + secrets: [ connectionString ] → SecretStorage +``` + +- `QuickStartService` becomes the single owner: it reads/writes this store and exposes the credentials. +- `QuickStartClusterItem` overrides `getCredentials()` and `authenticateAndConnect()` to source from + `QuickStartService` instead of `ConnectionStorageService` — this is option **D**. +- `CredentialCache` may still be used as a _cache_, but it stops being the source of truth, so the H5 failure mode + disappears by construction rather than by remembering to prime it. +- **Consolidation bonus:** this replaces the ad-hoc `documentdb.quickstart..connectionString` secrets **and** + the `documentdb.quickstart.registry` globalState blob (including its hand-rolled `mutationChain` write lock) + with one coherent store. + +#### Open points to settle before implementing + +1. **Migration.** Existing users have `documentdb.quickstart.*` secrets plus the registry blob, and this PR already + adds `migrateLegacyQuickStartKeys`. Decide whether to extend that migration or add a second one — ordering is a + data-safety property (see the H3 notes: migration must complete before `reconcile()`). +2. **Record shape depends on M4.** If the model becomes per-instance with an explicit recreate/fresh choice + (§9.2), `properties` changes. Design the record once, after M4. +3. **Sequencing with WP-3.** WP-3d activates the lease fields (`phase`, `leaseAt`, `operationId`) in the _current_ + registry. If the store is replaced afterwards, part of that work is redone — decide whether WP-3d should write + into the new store directly instead. + +### 9.2 M4 — recreate vs. fresh, and the instance state model + +**STATUS (updated 2026-08-06, third pass): RESOLVED — all five questions answered. Nothing in §9.2 is blocked.** + +**Scope decision, which frames the rest:** the model is **one managed Quick Start instance**. Multi-instance is +explicitly **out of scope for this iteration**. The service-level seams that already exist (consistent `alias` +threading, `reservedPorts()`, `operationId` labels) are **kept**, but no UI is built on them, and the code should +carry a short note recording the intent: a second instance needs a focused iteration, and at creation time the +existing instance would most likely be offered as a "look at the one you already have" option first. + +| Q | Answer | +| ----------- | ------------------------------------------------------------------------------------------------------------------- | +| **Q1** | ✅ The recreate/fresh choice lives in the **Configure step** (option **E**). | +| **Q2** | ✅ Guard it on the **Introduction step** with a MessageBar + a gated primary action — two variants, detailed below. | +| **Q3** | ✅ **Out of scope.** Single instance by design today; keep the seams, add intent notes, build no UI. | +| **Q4 / N3** | ✅ The tree never shows a passive error **message**; it shows actionable **error nodes**. Detailed below. | +| **Q5** | ✅ The wizard **asks**, so no inferred `willReuse` value can go stale — **N1** is resolved by construction. | + +#### Q2 — the wizard is opened while an instance already exists + +Reaching the wizard in this state should not normally be possible (the tree's entry point does not offer it while +an instance is present), but it stays reachable via the command palette, a stale panel, or a race. So the +**Introduction step** guards it rather than letting the user walk into a destructive recreate. + +The wizard must **verify that the instance is genuinely usable from the Connections view** — "a container is +running" is not the same as "the user has a working connection". Two variants: + +| Variant | MessageBar | Primary action | +| -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| **Healthy** — `state === Running` **and** metadata present, i.e. the browsable cluster row actually renders in the tree | `intent="info"` — DocumentDB Local is already running and is available in the Connections view. | **Disabled.** Offer "Open Connection" / "Close" instead. | +| **Erroneous** — a labelled container exists but the extension cannot open it (no recoverable credentials ⇒ `CredentialsMissing`) | `intent="warning"` — this instance is in an unusable state; continuing **removes it and its data**. | **Enabled**, but forced onto **Start fresh** — "Use existing data" is impossible here. | + +**Policy consequence (deliberate).** Today `provision()` hard-refuses this second case: the +`!reusing && (existing || hasReadyRecord)` gate sets `CredentialsMissing` and returns, forcing the user to hunt +for a separate **Delete Container**. Under this decision that refusal becomes an **explicit, warned Start-fresh +path inside the wizard**. The RR4 / §5.2 invariant is preserved — a volume is still only ever dropped by an +explicit user choice — but the choice is now offered where the user already is. + +#### Q4 / N3 — error states in the tree + +**The rule:** the tree does not render error _messages_; it renders **actionable error nodes**. A failure surfaces +once as a **modal**, and from then on the tree offers recovery affordances — the same pattern the rest of the +codebase already uses (`createRetryNode` → `contextValue: 'error'` + a `/retry` id suffix; companions such as +"open shell" or "update credentials" via the provider's `errorRecoveryActions`). Because +`containsRetryNode()` drives `failedChildrenCache`, classifying the state this way also stops the tree from +re-running the failing operation on every expand. + +Applied to Quick Start, two things are wrong today and must change: + +1. `LocalQuickStartItem` renders the `state_error` row with `status.errorMessage` as its description and **no + command** — a passive dead end. +2. The `NotInstalled` branch pushes a second, message-only `${this.id}/error` child (the one already carrying a + `FOLLOW-UP` comment). **Delete it** — this is **N3**. + +Replace both with the canonical pattern: a retry node whose command **restarts the wizard**, plus companion +`contextValue: 'error'` nodes for **View Logs** and **Delete Container**. This also completes step 3 of WP-1, +which flagged the same `failedChildrenCache` opportunity for the `Missing` / `CredentialsMissing` rows. + +**Everything in §9.2 is now cleared.** The resulting work items are tracked in +[§11.1 Iteration 1](#111-iteration-1--opened-2026-08-06) as **I1-2**, **I1-3**, **I1-4** and **I1-7**. + +**Direction given (2026-08-05):** the user must explicitly choose recreate vs. start fresh; it must not be +inferred from `willReuse`. A state/collision model is required first, and it must not assume a single managed +container — multiple instances are likely. + +**Current (implicit) model, for reference:** + +```mermaid +stateDiagram-v2 + [*] --> NotInstalled + + NotInstalled --> Provisioning: Set up (wizard) + Provisioning --> Running: readiness OK + Provisioning --> Error: pull/create/start failure + Provisioning --> ErrorTimedOut: readiness timeout
(container KEPT) + Provisioning --> NotInstalled: cancel (container removed) + + ErrorTimedOut --> Running: Wait longer → ready + ErrorTimedOut --> ErrorTimedOut: Wait longer → timeout again + ErrorTimedOut --> NotInstalled: Start over (container removed,
volume wiped if fresh attempt) + + Running --> Stopping: Stop + Stopping --> Stopped + Stopped --> Starting: Start + Starting --> Running: stays up (3× confirm) + Starting --> Error: exited shortly after + + Running --> Missing: container removed
outside VS Code + Stopped --> Missing: container removed
outside VS Code + Missing --> Provisioning: click row → wizard → recreate
(volume preserved) + Missing --> NotInstalled: Delete Container + + Running --> CredentialsMissing: secret lost + Stopped --> CredentialsMissing: secret lost + NotInstalled --> CredentialsMissing: reconcile finds labelled
container, no secret + CredentialsMissing --> NotInstalled: Delete Container
(ONLY exit — destroys data) + + Error --> NotInstalled: Delete Container + Error --> Running: Restart + + note right of CredentialsMissing + Reachable today by simply + reloading the window during + provisioning (H3). + end note + + note right of Missing + Unconditional emitter fire + here causes the H1 loop. + end note +``` + +**Tree-item visibility today (verified):** + +| Service state | Row rendered | Click action | +| --------------------------------------------- | ------------------------------------------------------- | -------------------------- | +| `NotInstalled` | rocket "Click here to set up DocumentDB Local" | open wizard | +| `Provisioning` | "Provisioning… · localhost:10260" (hardcoded port — L1) | none | +| `Running` | browsable cluster item, "Running · localhost:{port}" | expand | +| `Stopped` / `Starting` / `Stopping` / `Error` | non-browsable state row | none (context menu only) | +| `Missing` | "Missing · click to recreate" | open wizard | +| `CredentialsMissing` | "Credentials missing · click to delete and start over" | delete (with confirmation) | + +The root "DocumentDB Local - Quick Start" node itself is **always visible** (`Expanded`), including when there are +zero saved connections. + +**Questions to settle:** + +1. **Where does the recreate/fresh choice live** — a wizard step, or a decision on the tree row before the wizard + opens? +2. **What happens when the instance is `Running`** and the user opens the wizard? Today it silently destroys and + recreates. Options: block with "already running, open it?", offer recreate behind a confirmation, or offer + "create another instance" once multiple containers are supported. +3. **Multiple containers.** If the model becomes N instances, "the Quick Start node" is a container list, and + `NotInstalled` stops being a global state. Decide now whether the state model is per-instance (it should be) so + WP-3 and WP-7 do not have to be redone. +4. **Does the `Error` row belong in the tree at all** when the user never opened the wizard from there (N3)? +5. **`willReuse` staleness (N1)** — with an explicit choice, does `willReuse` still exist, or does the wizard just + ask? + +### 9.3 M6 — when does `refreshLiveState()` actually run? + +**STATUS (updated 2026-08-06): RESOLVED — option B confirmed, option A dropped.** WP-8 is cleared for +implementation; its WP-1 dependency has landed. Ship **M6-b** (skip `suggestPort()` on polled `getDockerStatus` +calls) in the same change. + +**Question raised:** does this only happen when DocumentDB Local is expanded? Could we do **B** with a +`"Refreshing…"` description and update once the result arrives? How would **A** help — do we ever update the tree +in a tight loop? + +**Verified answers:** + +- `refreshLiveState()` runs from two places: `LocalQuickStartItem.getChildren()` and the webview's + `getDockerStatus` query (which the Docker-start poller calls on a 1–5 s backoff). +- `getChildren()` is only called when VS Code needs the node's children — i.e. when the node is expanded. **But** + `LocalQuickStartItem.getTreeItem()` returns `collapsibleState: Expanded`, so it is expanded by default, and any + full-tree `refresh()` (connection add/remove/rename, folder ops, discovery refresh, `ext.state` transitions) + re-queries it. +- **The only tight loop is H1's.** Outside that, there is no repeated-in-a-tight-loop update — so once **WP-1** + lands, **A**'s (memoization) value drops to de-duplicating _bursts_ of unrelated refreshes, which is real but + minor. +- **B is therefore the better answer**, and the `"Refreshing…"` description is a genuine improvement: it removes + Docker latency from the render path entirely and makes the async nature visible. The one thing to get right is + that the follow-up update must not re-trigger a fetch — reuse the same transition guard added in WP-1. + +**Recommended decision:** B, with A dropped. Confirm before implementing. + +--- + +## 10. Re-assessment of the on-hold items after WP-1 … WP-5 (2026-08-06) + +> **Why this chapter exists.** WP-1 … WP-5 landed between 2026-08-05 and 2026-08-06. This chapter re-verifies the +> four items that were left **ON HOLD** (H5, M4, M6, M7 — plus the blocked L2) against the code as it stands now, +> and states what — if anything — the implemented work changed about the options. +> Each on-hold finding in §3 links here inline. §10.6 records the decisions taken after this re-assessment. + +**Baseline now:** 203 suites / 3346 tests green (was 202 / 3308), lint clean, `tsc` clean. + +### 10.0 What landed, and why + +| WP | Commit | What it did | +| -------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **WP-1** | `fix(quickstart): fire the Missing status change only on transition (H1)` | Guarded the `!inspected` branch in `refreshLiveState()` with `if (!entry.missing)`. Kills the self-sustaining refresh / `docker inspect` loop. | +| **WP-2** | `fix(tls): keep a deliberate TLS bypass for public hosts (H2, L4)` | `stripTlsBypassParams` no longer strips for public/mixed hosts; `hostClassification` normalizes expanded and IPv4-mapped IPv6. | +| **WP-3** | `feat(quickstart): explicit port model and durable provisioning` | Removed auto-port relocation; added `suggestPort()` / `checkPort()` / `reservedPorts()`; persists the secret **before** the readiness wait; activates the `phase`/`operationId`/`leaseAt` lease; scopes the orphan sweep by `operationId`. | +| **WP-4** | `fix(quickstart): localize the webview lookups and service messages` | Module-scope `l10n.t` maps → render-time; service messages wrapped in `l10n.t`. | +| **WP-5** | `fix(quickstart): palette gating, log-follow disposal and masking hardening` | `"when": "never"` palette entries; `activeLogFollow` disposal; masking buffer cap + percent-encoded secrets; `'stopped'` poll branch; tmp env-file sweep. | + +Three structural consequences matter for the items below: + +1. **The registry became load-bearing in two new ways.** `provision()` now writes and renews a lease + (`renewProvisioningLease`) at four points and releases it in `finally`; and `suggestPort()` / `reservedPorts()` + now _read_ the registry to allocate ports. It is no longer a passive list. +2. **`provision()` now coordinates two stores by hand.** It writes `ext.secretStorage` early, remembers + `previousStoredConnectionString`, and restores it in `finally` if the attempt fails — a manual two-phase commit + across `SecretStorage` + `globalState`. +3. **The service layer is already multi-instance-shaped.** `reservedPorts()` skips sibling instances, `operationId` + labels scope destructive sweeps, and N5 threaded `alias` consistently. The remaining single-instance + assumptions now live in the **tree and webview**, not the service. + +### 10.1 H5 / WP-6 — credential source of truth + +**Still reproduces — verified.** `populateCredentialCache()` has exactly two call sites +(`finalizeReadyInstance`, and `adoptContainer` still gated on `if (running)`), and +`DocumentDBClusterItem.authenticateAndConnect()` still resolves through `ConnectionStorageService`, which has no +record for `quickstart-`. Stop → reload → Start → expand is still a permanent +"connection failed / retry" dead end. **H5 is now the only remaining High-severity finding.** + +**What the implemented work changed:** + +- **The case for option D got stronger.** Consequence (2) above means a single provision now hand-rolls a + two-phase commit across `SecretStorage` and `globalState`. Folding both into one `StorageItem` (one `push()` + per state change) removes that coordination burden rather than just relocating it. +- **The migration surface got larger.** The new store must also carry the now-live lease fields + (`phase`, `operationId`, `leaseAt`) and the `port` that `suggestPort()` depends on — not just the connection + string. Migrating while a lease is held must be handled (or done only at activation, before any provision). +- **The blocking rationale weakened.** H5 was parked behind M4 because "the record shape depends on the state + model". That is still true for the _display_ fields, but the fields WP-3 made load-bearing (`port`, `phase`, + `operationId`, `leaseAt`, `connectionString`) are now settled and M4-independent. + +**Revised recommendation — split WP-6:** + +| Sub-package | Content | Blocked? | Pros | Cons | +| ----------- | ------------------------------------------------------------------------------------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| **WP-6a** | Fix H5 only: prime `CredentialCache` on every transition into `Running` (option **A** from §3), + test | **No** | ~10 lines; closes the last High finding now; independent of M4; no storage migration; unblocks nothing else so it can ship alone. | A temporary shape — WP-6b removes the need for it. Adds a side effect to `setStatus()`. | +| **WP-6b** | The `StorageService.get('local-quickstart')` consolidation + option **D** overrides (§9.1) | Yes | The agreed end state; removes the two-store coordination; resolves M7 implicitly. | Needs a migration for secrets **and** the registry, including live lease fields. | + +Recommend shipping **WP-6a now**. Leaving a High-severity dead end open while WP-6b waits on an unrelated design + +> **⚠ SUPERSEDED 2026-08-06.** This split was overtaken by the implementation. WP-6a was **cancelled** (priming +> entrenches the broken read-through rather than fixing it) and WP-6b's _fix_ half was implemented immediately, +> because the two halves proved separable: the credentials already live durably in `ext.secretStorage`, so the +> override needs no migration. Only the storage **consolidation** remains, as hygiene → **I2-8**. See the +> IMPLEMENTED note under §3 H5. +> discussion is the wrong trade — and 6a is throwaway work of about ten lines. + +### 10.2 M4 / WP-7 — recreate vs. fresh + +**Unchanged in code — verified.** `isRecreate = willReuse` (line 947), the primary button still reads +`"Start DocumentDB Local"`, the footer note still says _"Nothing else on your machine is changed."_, and +`provision()` still unconditionally `removeContainer(existing.id)` on a recreate. **M4 is now the largest +remaining user-visible risk.** + +**What the implemented work changed:** + +- **L2 is effectively resolved.** The Configure "Address" row now renders `suggestedPort` (from + `QuickStartService.suggestPort()`), and `suggestPort()` returns _the instance's own recorded port_ when it is + still free. The "shows 10260 for a recreate that actually lives on 10312" case no longer occurs. + `portTouchedRef` also stops a host-suggested value from clobbering something the user typed. + **Action: close L2 as fixed-by-WP-3** (confirm with the maintainer, as agreed in the L2 decision note). +- **M4 became cheaper to implement.** Configure is now a validated, host-round-tripped decision point + (`checkPort` runs while the user can still react). Adding an explicit _"use existing data" / "start fresh"_ + choice fits that shape directly — it did not exist when the original options were written. +- **The multi-instance question is half-answered.** `reservedPorts()` already allocates ports across siblings and + `operationId` already scopes destructive operations. The state model now only has to settle the **tree and + webview** layers. +- **N1 (`willReuse` staleness) is unchanged**, and stays folded into this discussion. + +**Options, revisited:** + +| Option | Status after WP-1…5 | Pros | Cons | +| -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| **A.** Honest copy ("Recreate DocumentDB Local" + accurate footer note) | Unchanged; still the minimum bar | Two strings; no new flow | Still one click from destroying a running container | +| **B.** A + confirmation when the instance is `Running` | Unchanged | Matches the Delete flow's bar | Needs `status.state` in the webview (already available) | +| **C.** Offer "Open Connection" instead of recreating when already running and healthy | Unchanged | Best outcome for the accidental case | Needs design input on what Configure means then | +| **E.** _(new)_ Explicit Configure-step choice: **"Use existing data"** / **"Start fresh (erases data)"** | **Newly practical** — Configure is now a validated decision point | Matches the stated direction exactly; kills `willReuse`-as-inference; resolves N1 by construction | Largest change; needs copy review and a `provision()` flag to replace the inferred `reusing` | + +**Recommendation:** **E**, with **A** as the copy baseline inside it. ✅ **Chosen 2026-08-06 — see §10.6.** + +### 10.3 M6 / WP-8 — tree render cost + +**What the implemented work changed — this one flipped.** + +- **Option A's stated benefit is gone.** A was justified partly as "caps H1's loop". WP-1 removed the loop + outright (verified: `if (!entry.missing)` guard now present), so A would only de-duplicate _bursts_ of + unrelated refreshes. **Drop A.** +- **The tree path is unchanged** — `LocalQuickStartItem.getChildren()` still awaits `refreshLiveState()`, which + still spawns a `docker inspect` per known alias before the node can render. +- **The webview path got slightly heavier (new).** `getDockerStatus` now also calls + `QuickStartService.suggestPort()` on **every** call, including polled ones. `suggestPort()` binds a probe socket + per candidate port, walking up to `QUICK_START_PORT_SCAN_LIMIT` (100). In the common case it returns after one + probe, but `pollDockerReadiness` re-runs it on a 1–5 s backoff for up to 90 s, and on a machine with a busy + 10260 band each poll re-walks the range. + +**New sub-item (M6-b):** skip `suggestPort()` when `input.polled === true` in `getDockerStatus`. The polled +readiness loop only consumes `readiness`; the port suggestion is only read when the Configure step renders. +One-line guard, no behaviour change. + +**Recommendation:** option **B** (render from cached state with a `"Refreshing…"` description, update when the +probe returns), **A dropped**. ✅ **Chosen 2026-08-06 — see §10.6.** + +### 10.4 M7 — password on the tree model + +**Unchanged in code — verified.** `LocalQuickStartItem` line 112 still assigns +`connectionString: metadata.connectionString`. + +**What the implemented work changed:** nothing directly, but the _reason to defer_ is now clearer. Since WP-6b +would remove the tree model's need for a connection string entirely, acting on M7 first would be work that +WP-6b deletes. The dependency ordering in the M7 decision note still holds: + +- If **WP-6a** ships (cache primed on every `Running` transition), M7 option **A** becomes safe to apply + immediately — stripping the userinfo no longer risks breaking the browse path. +- If **WP-6b** ships, M7 resolves implicitly and the GitHub thread reply changes to "resolved by design". + +**Recommendation:** keep M7 deferred, but re-evaluate it **as soon as WP-6a lands** rather than waiting for +WP-6b — at that point it is a safe four-line change, and the reviewer's thread can be answered. + +### 10.5 Updated status + +| ID | Status now | +| -------- | ------------------------------------------------------------------------------------------------------------------------------- | +| **H5** | ✅ **DONE 2026-08-06** — fixed by option **D** (override). WP-6a (cache priming) **cancelled**; WP-6b demoted to hygiene → I2-8 | +| **M4** | ✅ **DECIDED 2026-08-06 — option E.** WP-7a cleared; WP-7b still blocked on §9.2 Q2–Q4 | +| **M6** | ✅ **DECIDED 2026-08-06 — option B**, A dropped. WP-8 cleared | +| **M7** | 🛑 Deferred — re-evaluate immediately after **WP-6a**, not after WP-6b | +| **L2** | ✅ **Resolved by WP-3** (`suggestedPort` + `portTouchedRef`) — confirm and close | +| **M6-b** | ✅ Cleared, trivial — skip `suggestPort()` on polled `getDockerStatus` calls; ship with WP-8 | + +### 10.6 Decisions taken 2026-08-06 (second pass) + +**M4 → option E.** The Configure step asks the user to choose **"Use existing data"** or +**"Start fresh (erases data)"**. Specifics to implement: + +- `willReuse` stops selecting the outcome. `getDockerStatus` may still report whether a reusable instance + **exists** (to decide whether the choice is offered at all), but it must not silently pick one. This resolves + **N1** by construction — there is no inferred value left to go stale. +- `provision()` takes the choice as an **explicit flag** instead of deriving `reusing` from + `getReusableCredentials()`. +- The RR4 / §5.2 volume-wipe gate is unchanged: **"Start fresh" is the only path allowed to drop a volume**, and a + credential-unavailable instance still requires an explicit Delete. +- Footer copy follows the choice (option **A**'s wording is the baseline). _"Nothing else on your machine is + changed"_ is only true for a genuinely fresh install and must not be shown for either recreate path. + +**M6 → option B.** `LocalQuickStartItem.getChildren()` stops awaiting `refreshLiveState()`: it renders from the +last known state with a `"Refreshing…"` description and lets `onDidChangeStatus` update the row. **Option A is +dropped** — its only justification was capping H1's loop, which WP-1 already removed. Two constraints: + +1. The background update must **reuse WP-1's transition guard**, or it rebuilds H1's refresh loop in a new shape. +2. **M6-b ships with it** — skip `suggestPort()` in `getDockerStatus` when `input.polled === true`. + +**What this unblocks:** **WP-7a** (Configure-step choice + copy) and **WP-8** (tree render cost + M6-b). Together +with **WP-6a**, three packages are now cleared with no further design input. + +**What is still open:** §9.2 **Q2** (wizard behaviour when the instance is currently Running), **Q3** +(per-instance state model now that multiple containers are planned), **Q4 / N3** (whether the `Error` row belongs +in the tree). These gate **WP-7b** and **WP-6b**. + +--- + +## 11. Iterations + +> **Working model (from 2026-08-06).** Everything that is open, pending or not-yet-implemented is collected into +> the **current iteration**. The maintainer picks and fixes a subset; whatever is left, plus anything newly +> discovered, rolls forward into the next iteration chapter. Each iteration keeps its own closing note so the +> history stays readable. +> +> **This is the live worklist.** §3 holds the evidence, §5 the decision log, §9/§10 the design reasoning — but +> **§11 is what is actually outstanding right now.** + +### 11.1 Iteration 1 — opened 2026-08-06 + +Everything below is either **cleared for implementation** (decided, no further input needed) or explicitly +**deferred**. Nothing here is blocked on a maintainer decision any more. + +#### ✅ Cleared — code + +| # | Item | Source (→ details) | Notes | +| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| **I1-1** | **H5 fix — implemented as option D (override), not option A (cache priming).** `QuickStartClusterItem` extends `ClusterItemBase` directly and resolves credentials via `QuickStartService.readStoredConnectionString()`; presentation extracted to `clusterItemPresentation.ts`. **WP-6a cancelled.** | [H5][f-h5] · [§10.1][s-101] | ✅ **DONE** — no storage migration needed; closes the last **High**. Regression test still outstanding → **I2-1**. | +| **I1-2** | **Recreate-vs-fresh choice.** Configure step asks **"Use existing data"** / **"Start fresh (erases data)"**; `provision()` takes an explicit flag instead of deriving `reusing`. | [M4][f-m4] · [§10.2][s-102] | Option **E**. Resolves **N1**. Footer copy follows the choice. | +| **I1-3** | **Wizard guard when an instance already exists.** Introduction step shows a MessageBar and gates the primary action — info + disabled when healthy, warning + enabled (forced _Start fresh_) when the instance is in an unusable state. | [§9.2 Q2][s-92q2] | Replaces `provision()`'s silent hard-refusal. See §9.2 Q2 for the two variants. | +| **I1-4** | **Error-node pattern for the Quick Start rows.** Replace the passive `state_error` row and delete the message-only `${this.id}/error` child; render actionable recovery nodes instead. | [§9.2 Q4 / N3][s-92q4] | Use `createRetryNode` (`/retry` + `contextValue: 'error'`) + companions (View Logs, Delete). | +| **I1-5** | **Tree render cost.** `getChildren()` stops awaiting `refreshLiveState()`: render from cache with a `"Refreshing…"` description, update via `onDidChangeStatus`. Must reuse WP-1's transition guard. | [M6][f-m6] · [§10.3][s-103] | Option **B**. **A dropped.** | +| **I1-6** | **M6-b.** Skip `suggestPort()` in `getDockerStatus` when `input.polled === true`. | [§10.3][s-103] | One-line guard. Ship with I1-5. | +| **I1-7** | **Single-instance intent notes.** Short code comments at the multi-instance seams (`alias` threading, `reservedPorts()`, `operationId` labels) recording that one instance is the deliberate scope today. | [§9.2][s-92] (Q3) | Documentation-in-code only; no behaviour change. | +| **I1-8** | **Credential store consolidation.** `StorageService.get('local-quickstart')`, workspace `instances`; `QuickStartClusterItem` overrides `getCredentials()`/`authenticateAndConnect()`. | [H5][f-h5] · [§9.1][s-91] | **Newly unblocked** — the record shape is settled now that Q3 is out of scope (see §11.2). | + +#### ✅ Cleared — verification / no code + +| # | Item | Source (→ details) | +| --------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------- | +| **I1-9** | **Close L2.** Confirm the Configure "Address" row now shows the instance's real port (`suggestedPort` + `portTouchedRef`). | [L2][f-l2] · [§10.2][s-102] | +| **I1-10** | **Re-evaluate M7** once **I1-1** lands, then post the reply on [`#discussion_r3714252974`][m7thread]. | [M7][f-m7] · [§10.4][s-104] | + +[m7thread]: https://github.com/microsoft/vscode-documentdb/pull/798#discussion_r3714252974 + +#### 🟡 Open questions — raised 2026-08-06, not yet answered + +These shape items above. **Do not guess an answer — ask the maintainer.** Several are small enough that the +surrounding item can start while the question is pending; the "Blocks?" column says which. + +| # | Question | Affects | Blocks? | +| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------- | +| **I1-Q1** | **A `Stopped` instance is not covered by §9.2 Q2.** Its two variants are Healthy and Erroneous. `Stopped` is adoptable, so option **E** would offer "Use existing data" — which _recreates_ a container the user probably just wants to **Start**. Should the Introduction guard also catch `Stopped` (info bar + a **Start** action, primary disabled)? | I1-2, I1-3 | **Yes** — changes the guard's shape | +| **I1-Q2** | **Does "Start fresh" need its own confirmation dialog?** `Delete Container` uses `getConfirmationAsInSettings`. Is the radio choice + footer note enough for the equally destructive Start-fresh path? (The erroneous-state variant is arguably fine without one — that data is unrecoverable anyway.) | I1-2, I1-3 | No — copy/flow detail | +| **I1-Q3** | **What does the retry node retry?** Reopen the wizard (user re-confirms port and the recreate/fresh choice), or silently re-run the last provision? "Restart the wizard" was the stated intent — confirm so it is unambiguous. | I1-4 | **Yes** — determines the command wired | +| **I1-Q4** | **Where does the modal fire?** Provisioning failures are already reported _inside_ the wizard, so a modal would double up. Proposal: modals only for **lifecycle** failures (start/stop/restart/delete, which have no wizard); wizard failures stay in-wizard and the tree row carries only the actionable retry. | I1-4 | **Yes** — determines what is modal | +| **I1-Q5** | **Should `Missing` and `CredentialsMissing` become cached error states** via the provider's `detectErrorState` hook (WP-1 step 3)? If yes, `resetNodeErrorState(nodeId)` must be wired to `QuickStartService.onDidChangeStatus`, or a recreate will not clear the row. | I1-4, I1-5 | No — additive on top | +| **I1-Q6** | **Does I1-8 belong in Iteration 1?** It is the only item needing a storage migration and it would land on top of I1-1…I1-7. Keep it here, or promote it to Iteration 2 to keep this round small and reviewable? | I1-8 | **Yes** — scoping decision | + +#### ⏸️ Deferred out of Iteration 1 (tracked, not scheduled) + +| # | Item | Reason | +| --------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| **I1-11** | **B1** — footer experiment switch + `PREVIEW` badge | User-test still running; removal tracked with the test | +| **I1-12** | **N4** — un-awaited unsubscribe handshake in `runStream` | Papered over by terminal-event buffering; revisit if it resurfaces | +| **I1-13** | **N7** — consolidate the three Quick Start doc folders | Separate work item | +| **I1-14** | **Multi-instance support** (state model, tree, creation flow) | Explicitly out of scope; needs a focused iteration (§9.2 Q3) | +| **I1-15** | Repo issues [#864][i864] (module-scope `l10n.t` guard) and [#865][i865] (typed message keys, 0.10.1) | Filed; not part of this PR | + +[i864]: https://github.com/microsoft/vscode-documentdb/issues/864 +[i865]: https://github.com/microsoft/vscode-documentdb/issues/865 + +#### Suggested order within Iteration 1 + +```text +I1-1 ──► I1-10 (M7 becomes a safe 4-line change once the cache is reliable) +I1-5 + I1-6 (independent; one commit) +I1-2 + I1-3 + I1-4 (one coherent "wizard + tree states" change — they share copy and the recreate flag) +I1-7 (fold into whichever commit touches those files) +I1-8 (largest; needs a migration — do it last so it migrates a settled shape) +I1-9 (verification only) +``` + +**Iteration 1 closing note (closed 2026-08-06).** + +**Shipped — one item, but it closed the last High.** + +- **I1-1 / H5** — `fix(quickstart): resolve managed-instance credentials from QuickStartService (H5)`. + Implemented as option **D**, _not_ the planned option **A**. Working through the mechanism with the maintainer + showed that `CredentialCache` has no read-through: the fill lives in + `DocumentDBClusterItem.authenticateAndConnect()`, which queries `ConnectionStorageService` and bails at + `!connectionCredentials` before the cache is ever written. Priming would have cemented that dead path. + `QuickStartClusterItem` now extends **`ClusterItemBase` directly** and reads its connection string from + `QuickStartService.readStoredConnectionString()` (made public). No `ConnectionStorageService` call remains on + any path for this node. + +**Two structural consequences worth reviewing:** + +1. **New shared module `src/tree/connections-view/clusterItemPresentation.ts`** — `getTreeItem`, tooltip, TLS + badge and host parsing were extracted out of `DocumentDBClusterItem` (they were `private`, so a sibling + subclass could not reuse them). Both classes now consume it, so leaving the `DocumentDBClusterItem` hierarchy + cost no duplicated display logic. Behaviour is unchanged for existing connections. +2. **`QuickStartClusterItem` no longer exposes `storageId`** — it dropped `TreeElementWithStorageId` along with + the base class. This is intentional (the managed instance is deliberately not a stored connection, so the + guard should not classify it as one) and low-risk: `isTreeElementWithStorageId` has no call sites, and + storage-targeting commands are already gated off this node by its `contextValue`. Flagged here so it is a + conscious review point rather than a silent side effect. + +**Not shipped — rolled into Iteration 2:** I1-2 … I1-10 (renumbered **I2-2 … I2-10**). Three of them +(I1-2/3/4) are still gated on unanswered blocking questions. + +**Questions:** **I1-Q6 answered** — I1-8 moves to Iteration 2. Its rationale changed on the way: now that H5 is +fixed without it, the storage consolidation is pure hygiene and no longer holds a High-severity fix hostage. +**I1-Q1, I1-Q3, I1-Q4, I1-Q5 remain unanswered** and are promoted verbatim. + +**Verification:** `npm run lint` clean (pre-existing `eslint-env` warning only); targeted +`npx jest --no-coverage src/tree/connections-view src/tree/documentdb` → 6 suites / 41 tests green. Full +checklist not run — Iteration 1 was not closed as a wrap-up. + +### 11.2 What the 2026-08-06 §9.2 answers unblocked + +The scope decision ("one instance, multi-instance out of scope") did more than answer **Q3**: + +- **WP-6b / I1-8 is no longer blocked.** §9.1 parked it because "the record shape depends on the state model". + With option **E** decided and the model fixed at one instance, the shape is settled: + `properties: { alias, displayName, port, phase, imageRef, operationId, leaseAt }` and + `secrets: [connectionString]`. Nothing further is pending. +- **WP-7b disappears as a separate package.** Its content was the per-instance state model and the multi-instance + tree states — now out of scope. What remains of it (the tree's error states) became **I1-4** under the + error-node decision. +- **The §9.2 state diagram stays valid** as the single-instance model. It only needed re-cutting if the tree had + become a container list. + +### 11.3 How to run and close an iteration + +The procedure below is what keeps this file the single source of truth as work progresses. **An implementation +agent must follow it** — the file is handed to fresh contexts repeatedly, so anything not written down is lost. + +**While working an iteration** + +1. Pick items **only** from the current iteration's ✅ _Cleared_ tables. Never start a ⏸️ _Deferred_ item or one + whose 🟡 _Open question_ is marked `Blocks? = Yes`. +2. If an open question blocks you, **ask the maintainer** and record the answer under the question before coding. +3. As each item lands, mark it in place: change its row to `✅ DONE — ` and, where the finding in + §3 has a decision block, append a one-line `IMPLEMENTED` note there so the evidence and the outcome stay + together. +4. New problems discovered mid-iteration are appended to the **current** iteration as `I-` with the same + columns — do not silently fix them, and do not open a new iteration for them. +5. **Run `npm run lint` only** after each item. The full checklist is a wrap-up activity, not a per-item one — see + the verification-cadence rule in §7.0. + +**Closing an iteration** + +1. Fill in the iteration's **closing note**: what shipped (with commit subjects), what did not, and why. +2. Open the next chapter — `### 11. Iteration — opened ` — and **promote every item that is + still open**: unfinished ✅ items, unanswered 🟡 questions, and any ⏸️ deferred item that has become relevant. + Renumber them `I-…` and keep a `(was I-…)` reference so the history is traceable. +3. Leave the closed iteration chapter intact. It is the record of what was decided and why — never rewrite it. +4. **Ask the operator whether this is a wrap-up.** If yes, run the **full** checklist (§7.0: `l10n` → + `prettier-fix` → `lint` → `jest` → `build`) and update the **baseline** line in §10 if the test/suite counts + moved. If no, `npm run lint` remains the only gate and the baseline is left untouched. + +**Invariants** + +- §11 is the **live worklist**. §3 is evidence, §5 is the decision log, §9/§10 are the design reasoning. If §11 + and an older section disagree, **§11 wins** and the older section should get a pointer to it. +- Nothing is ever deleted from this file. Items move forward; sections get superseded with a note. + +### 11.4 Iteration 2 — opened 2026-08-06, **closed 2026-08-06** + +**✅ Closed.** The live worklist has moved on to [§11.5 Iteration 3][it3] — see the +[closing note](#iteration-2-closing-note-2026-08-06) at the end of this chapter. Kept in full because the item +write-ups below are where the implemented behaviour is explained. Promoted from Iteration 1; numbering carries +a `(was …)` reference so the +history stays traceable. **The Source column links to the detailed write-up** for each item — read it before +implementing. + +#### ✅ Cleared — code + +| # | Item | Source (→ details) | Notes | +| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [**I2-1**][d-1] | **H5 regression test.** Stop → clear `CredentialCache` → start → assert the node still lists databases. The contract changed: assert it resolves through `QuickStartService`, _not_ that the cache was primed. | [H5][f-h5] · [§10.1][s-101] (was I1-1, tail) | The only part of I1-1 left. Also worth covering the "no stored secret" → `undefined` path. | +| [**I2-2**][d-2] | **Recreate-vs-fresh choice** in the Configure step; `provision()` takes an explicit flag. _(was I1-2)_ | [M4][f-m4] · [§10.2][s-102] · [§10.6][s-106] | **Unblocked 2026-08-06** by [I2-Q1][a-q1]. No extra confirmation dialog — [I2-Q4][a-q4]. | +| [**I2-3**][d-3] | **Wizard guard when an instance already exists** — MessageBar + gated primary action on the Introduction step. _(was I1-3)_ | [§9.2 Q2][s-92q2] | **Unblocked 2026-08-06.** **Three** variants now: Healthy, Stopped, Erroneous — see [I2-Q1][a-q1]. | +| [**I2-4**][d-4] | **Error-node pattern for the Quick Start rows** — `createRetryNode` + companions; delete the message-only `${this.id}/error` child. _(was I1-4)_ | [§9.2 Q4 / N3][s-92q4] · [Nits][f-nits] | **Unblocked 2026-08-06** by [I2-Q2][a-q2] + [I2-Q3][a-q3]. Genuine failures only — not `Missing` / `CredentialsMissing` ([I2-Q5][a-q5]). Ship with **I2-17**. | +| [**I2-5**][d-5] | **Tree render cost.** `getChildren()` stops awaiting `refreshLiveState()`: render from cache with a `"Refreshing…"` description, update via `onDidChangeStatus`. Must reuse WP-1's transition guard. | [M6][f-m6] · [§9.3][s-93] · [§10.3][s-103] | Option **B**. **A dropped.** Sole cache for this row — no `failedChildrenCache` on top ([I2-Q5][a-q5]). | +| [**I2-6**][d-6] | **M6-b.** Skip `suggestPort()` in `getDockerStatus` when `input.polled === true`. | [§10.3][s-103] | One-line guard. Ship with I2-5. | +| [**I2-7**][d-7] | **Single-instance intent notes.** Short code comments at the multi-instance seams (`alias` threading, `reservedPorts()`, `operationId` labels). | [§9.2][s-92] (Q3) | Documentation-in-code only; no behaviour change. | +| [**I2-8**][d-8] | **Credential store consolidation.** `StorageService.get('local-quickstart')`, workspace `instances`; retires the ad-hoc `documentdb.quickstart.*` secrets **and** the `documentdb.quickstart.registry` blob. | [H5][f-h5] · [§9.1][s-91] · [§10.1][s-101] | **Demoted to hygiene** — H5 no longer depends on it. Still needs a migration; still the largest item. | +| [**I2-10**][d-10] | **M7.** Strip the password from `connectionString` on the tree model, then post the reply on [`#discussion_r3714252974`][m7thread]. | [M7][f-m7] · [§10.4][s-104] | **Now unblocked and cheaper** — the item reads credentials itself, so the model's string is display-only (`getHosts`, TLS badge). | +| [**I2-17**][d-17] | **Clear the cached tree error state when the failure is resolved elsewhere.** After the user fixes the underlying problem outside the tree (typically in the Quick Start webview), `failedChildrenCache` still holds the error children and the row keeps rendering the error node until a manual collapse/expand. Wire `resetNodeErrorState(nodeId)` + `refresh()`. | _New 2026-08-06_ — raised in [I2-Q3][a-q3] | Precedent: `AtlasDiscoveryProvider.onDidChangeSession` does exactly this before `refresh()`. Ship with **I2-4**. | + +#### ✅ Outcome — what landed, and where + +Updated 2026-08-06 when the iteration was closed. Each row links to the item's write-up, which carries the +full "what was done and why" note; the commit is on `feature/local-quickstart`. + +| # | Commit | Subject | Outcome | +| ----------------- | ---------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| [**I2-1**][d-1] | `ec0a77cb` | test(quickstart): pin the H5 credential-source contract | ✅ Done | +| [**I2-2**][d-2] | `f473b02c` | feat(quickstart): ask before recreating, and take the choice as an explicit flag | ✅ Done — `willReuse` renamed to `canReuseExistingData` | +| [**I2-3**][d-3] | `5e2c9314` | feat(quickstart): guard the wizard when an instance already exists | ✅ Done — **on the Configure step**, not Introduction | +| [**I2-4**][d-4] | `a684ce95` | feat(quickstart): render actionable error nodes for failed Quick Start rows | ✅ Done — closes **N3** | +| [**I2-5**][d-5] | `1a0f3ab9` | perf(quickstart): render the tree row from cache instead of blocking on Docker | ✅ Done — needed a 5 s cooldown as the loop breaker | +| [**I2-6**][d-6] | `bef2128f` | perf(quickstart): skip suggestPort() on polled readiness calls | ✅ Done | +| [**I2-7**][d-7] | `7ae61fd8` | docs(quickstart): record the single-instance scope at the multi-instance seams | ✅ Done | +| [**I2-8**][d-8] | — | — | ⏸ **Deferred to [§11.5][it3]** — `TDD:` gate + migration risk | +| [**I2-9**][d-9] | — | _(verification only)_ | ✅ Verified — **L2 closed** | +| [**I2-10**][d-10] | `81f062f8` | fix(quickstart): strip credentials from the Quick Start tree model | ✅ Done — GitHub thread answered | +| [**I2-17**][d-17] | `bca46b67` | fix(quickstart): clear the cached tree error state when the failure is fixed elsewhere | ✅ Done | + +#### ⛔ Blocked on an unanswered question + +> **Empty as of 2026-08-06.** I2-2, I2-3 and I2-4 were unblocked by the answers below and moved into the +> **Cleared — code** table above. Left in place so the iteration's history reads correctly. + +#### ✅ Cleared — verification / no code + +| # | Item | Source | +| --------------- | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | +| [**I2-9**][d-9] | **Close L2.** Confirm the Configure "Address" row now shows the instance's real port (`suggestedPort` + `portTouchedRef`). | [L2][f-l2] · [§10.2][s-102] (was I1-9) | + +#### � Item detail + +Self-contained write-ups so this chapter can be worked from without paging back through §3/§9/§10. Content is +deliberately repeated from those sections; where they disagree, **this chapter wins**. + +##### I2-1 — H5 regression test + +**➤ IMPLEMENTED 2026-08-06** — `ec0a77cb` _test(quickstart): pin the H5 credential-source contract (I2-1)_. + +New `LocalQuickStartItem.credentials.test.ts` pins option **(c)**, the contract that actually closed H5. +Three cases: (1) with an EMPTY `CredentialCache` — the post-reload state that made H5 reproducible — the +node still lists databases, `readStoredConnectionString(alias)` is what supplies them, and the cache ends up +populated as a _side effect_ rather than a precondition; (2) `getCredentials()` returns the parsed +`nativeAuthConfig`; (3) no stored secret → `getCredentials()` resolves `undefined`, no client is requested, and +`getChildren()` degrades to error-recovery children instead of throwing out of the tree. + +**Problem.** H5 is fixed, but nothing in the suite pins the new contract. The whole reason a fully green suite +coexisted with H5 is that `IContainerRuntime`, `ext.secretStorage` and the tree provider are mocked — so a future +refactor could quietly reintroduce the `ConnectionStorageService` dependency and the suite would stay green. + +**Options.** (a) no test; (b) assert the `CredentialCache` was primed; (c) assert the node resolves credentials +through `QuickStartService`. + +**Decision — (c).** (b) is wrong now: it was option **A**'s contract, and option A was cancelled. Cover both +directions: + +- stop → clear `CredentialCache` → start → expand → the node lists databases; +- no stored secret → `getCredentials()` returns `undefined` and `authenticateAndConnect()` returns `null` + (error-recovery children), rather than throwing out of `getChildren()`. + +##### I2-2 — Recreate-vs-fresh choice + +_Source: [M4][f-m4] · [§10.2][s-102] · [§10.6][s-106]._ + +**➤ IMPLEMENTED 2026-08-06** — `f473b02c` _feat(quickstart): ask before recreating, and take the choice as an +explicit flag (M4, I2-2)_. + +Option **E** shipped as decided. `AdvancedQuickStartOptions.startFresh` is the explicit flag; `provision()` no +longer derives `reusing` from `getReusableCredentials()` when the user asked for a fresh start +(`const reusable = startFresh ? undefined : await this.getReusableCredentials(alias)`). The RR4 / §5.2 gate +became `if (!reusing && !startFresh)`, which is what turns the old hard refusal into an explicit, warned path +and keeps "an explicit user choice" the only way a volume is ever dropped. Four service tests cover the gate in +both directions. + +The Configure step renders the radio pair **above** the settings table (operator's placement call) because it +decides what those settings _mean_; the primary label and the footer note both follow the selection, so +_"Nothing else on your machine is changed"_ can no longer render over a recreate. No modal — [I2-Q4][a-q4]. + +**Rename:** `DockerStatusResult.willReuse` → `canReuseExistingData`, `QuickStartService.willReuseExistingInstance()` +→ `canReuseExistingData()` (operator's request). The old name described an _outcome_ the service no longer +decides; the new one describes the _capability_ that makes the choice available. + +**Problem.** When stored credentials exist (`willReuse === true`) the Configure step relabels the _settings_, but +the primary button still reads **"Start DocumentDB Local"** and the footer still says _"Nothing else on your +machine is changed."_ There is no confirmation, and `provision()` unconditionally removes the existing container +first (`removeContainer(existing.id)`, `force: true`). A user who opens Quick Start out of curiosity while the +instance is happily running force-stops and destroys it. The volume survives, so documents are safe — but +connections drop, container-local state outside `/data` is lost, and the footer note actively told them nothing +would change. + +**Options considered.** **A** honest copy ("Recreate DocumentDB Local" + accurate footer) · **B** A + a +confirmation when `Running` · **C** offer "Open Connection" instead of recreating · **D** leave as-is · +**E** an explicit choice in Configure. + +**Decision — E, with A's copy as the baseline.** Configure asks; nothing is inferred from `willReuse`: + +- **Use existing data** — recreate the container onto the existing volume, reusing its stored credentials and + image (today's implicit `reusing === true` path). +- **Start fresh (erases data)** — remove the container **and** its data volume, then provision new credentials. + +`provision()` takes the choice as an **explicit flag** instead of deriving `reusing` from +`getReusableCredentials()`. The RR4 / §5.2 gate is unchanged — "Start fresh" is the **only** path allowed to drop +a volume. **No modal** ([I2-Q4][a-q4]): the radio option itself states that data will be erased, it is not +pre-selected, and the footer copy follows the selection. Resolves **N1** by construction — there is no inferred +value left to go stale. + +##### I2-3 — Wizard guard when an instance already exists + +_Source: [§9.2 Q2][s-92q2]._ + +**➤ IMPLEMENTED 2026-08-06** — `5e2c9314` _feat(quickstart): guard the wizard when an instance already exists +(I2-3)_. + +**Placement changed from the decision above:** the guard lives on the **Configure step**, not the Introduction +step (operator's call). Rationale that follows from it — Configure is the screen where the destructive decision +is actually made and where the guard sits next to the "Start fresh" radio it forces; a guard on Introduction +would be a speed bump the user clicks past before reaching the choice it is guarding. + +All three variants shipped as tabled: Healthy → `intent="info"`, primary disabled, offers **Open Connection** / +**Close**; Stopped → `intent="info"`, primary disabled, offers **Start** / **Close**; CredentialsMissing → +`intent="warning"`, primary enabled and forced onto Start fresh (`forcedFresh` suppresses the radio pair, since +there is no reusable data left to choose). `Missing` is deliberately NOT guarded — recreating is exactly what +that state asks for. A new `startInstance` router mutation backs the Stopped variant. + +**Problem.** Reaching the wizard while an instance exists should not normally be possible — the tree's entry point +does not offer it — but the command palette, a stale panel and cross-window races all still get there. Today +`provision()` deals with the credential-unavailable case by **hard-refusing**: it sets `CredentialsMissing` and +returns, leaving the user to hunt for a separate **Delete Container**. And a healthy running instance can be +walked straight into a destructive recreate. + +**Decision.** Guard on the **Introduction step** with a MessageBar plus a gated primary action. The guard must +verify the instance is genuinely usable _from the Connections view_ — "a container is running" is not the same as +"the user has a working connection". **Three** variants ([I2-Q1][a-q1] added the middle one): + +| Variant | MessageBar | Primary action | +| ------------------------------------ | ------------------ | ------------------------------------------------ | +| **Healthy** (`Running` + metadata) | `intent="info"` | **Disabled** — offer "Open Connection" / "Close" | +| **Stopped** | `intent="info"` | **Disabled** — offer **Start** | +| **Erroneous** (`CredentialsMissing`) | `intent="warning"` | **Enabled**, forced onto **Start fresh** | + +The hard-refusal becomes an explicit, warned Start-fresh path _inside_ the wizard. The RR4 / §5.2 invariant is +preserved — a volume is still only dropped by an explicit choice — but the choice is offered where the user +already is. + +##### I2-4 — Error-node pattern for the Quick Start rows + +_Source: [§9.2 Q4 / N3][s-92q4] · [Nits][f-nits]._ + +**➤ IMPLEMENTED 2026-08-06** — `a684ce95` _feat(quickstart): render actionable error nodes for failed Quick +Start rows (I2-4, N3)_. + +Both dead ends are gone. The `Error` branch now returns the state row **plus** `createRetryNode` (pointed at +`…localQuickStart.open`, so the retry re-runs the operation that failed — the wizard — per [I2-Q2][a-q2]), a +**View setup log** companion, and, once a container exists, **Delete Container**. The message-only +`${this.id}/error` child is deleted outright, closing **N3**. + +`LocalQuickStartItem` now implements `TreeElementWithRetryChildren` (`hasRetryNode` → `containsRetryNode`). That +is what makes the provider cache the failed children, which is the mechanism behind [I2-Q2][a-q2]'s +passive-vs-real retry distinction — without it `failedChildrenCache` was never populated for this node and every +passive refresh re-ran the fetch. Per [I2-Q5][a-q5] no `detectErrorState` hook was added, so `Missing` and +`CredentialsMissing` keep their own rows. Two tests pin the exact child id lists in both shapes. + +**Problem.** Two things are wrong today: + +1. `LocalQuickStartItem` renders the `state_error` row with `status.errorMessage` as its description and **no + command** — a passive dead end. +2. The `NotInstalled` branch pushes a second, message-only `${this.id}/error` child, the one carrying the + `FOLLOW-UP` comment (**N3**). + +**Decision.** The tree does not render error _messages_; it renders **actionable error nodes**, the same pattern +the rest of the codebase uses: `createRetryNode` (`/retry` id suffix + `contextValue: 'error'`) plus companions +for **View Logs** and **Delete Container**. Delete the message-only child outright — that closes **N3**. + +Three answers shape it: + +- **[I2-Q2][a-q2]** — the retry node performs a **real** retry (`resetNodeErrorState` → `refresh`), which for a + provisioning failure means reopening the wizard. A _passive_ refresh must not re-run anything; it reuses the + cached error children. +- **[I2-Q3][a-q3]** — modals fire wherever the failure surfaces, including from `getChildren()`. The + webview-triggered double-report is accepted for now. +- **[I2-Q5][a-q5]** — this applies to **genuine failures only**. `Missing` and `CredentialsMissing` keep their + own rows and are _not_ classified as cached error states. + +Ship with **I2-17**. + +##### I2-5 — Tree render cost + +_Source: [M6][f-m6] · [§9.3][s-93] · [§10.3][s-103]._ + +**➤ IMPLEMENTED 2026-08-06** — `1a0f3ab9` _perf(quickstart): render the tree row from cache instead of blocking +on Docker (M6, I2-5)_. + +Option **B** shipped. `getChildren()` calls the new `QuickStartService.refreshLiveStateInBackground()` and does +not await it; the row renders from the last known state and `onDidChangeStatus` redraws it. Rows a probe can +actually change (Running / Stopped / Missing) carry a `"… · Refreshing…"` hint while one is in flight. + +**One constraint had to be added beyond the plan.** The plan said to reuse WP-1's transition guard, and +`refreshLiveState()` does still fire only on a real transition — but the `"Refreshing…"` hint has to be cleared +even when _nothing_ changed, so the background probe fires the status event unconditionally when it settles. +That event re-enters `getChildren()`. A **5 s cooldown** (`BACKGROUND_REFRESH_COOLDOWN_MS`) is therefore +load-bearing, not an optimisation: it is what stops the completion event from arming the next probe and +rebuilding **H1** in a new shape. Concurrent callers share the in-flight promise. A regression test asserts one +`docker inspect` for a burst of four calls. + +Note this is a _bounded_ borrowing of option **A**'s memoisation, which the decision dropped as a standalone +fix; it is used here only as the loop breaker that **B** requires. + +**Problem.** `LocalQuickStartItem.getChildren()` starts with `await QuickStartService.refreshLiveState()`, which +spawns a `docker inspect` per known alias and blocks the node's children on it. The Connections view refreshes on +many unrelated events (connection add/remove/rename, folder ops, discovery refresh, `ext.state` transitions), and +the node is `Expanded` by default — so every one of those pays a process spawn plus a Docker round-trip, for +_every_ user who has ever provisioned an instance, including those who never open the feature again. + +**Options considered.** **A** memoize `refreshLiveState()` for a short TTL · **B** render from cached state and +refresh in the background · **C** poll on a timer only while the view is visible · **D** leave as-is. + +**Decision — B; A is dropped.** A's main justification was capping H1's loop, and WP-1 removed that loop outright. +`getChildren()` stops awaiting: render immediately from the last known state with a `"Refreshing…"` description, +kick the probe off in the background, and let `onDidChangeStatus` update the row. Two constraints: + +1. **Reuse WP-1's transition guard** — the background update must fire the emitter only on an actual state + change, or it rebuilds **H1** in a new shape. +2. This stays the **sole cache for the row** — no `failedChildrenCache` layered on top ([I2-Q5][a-q5]). + +Ship **I2-6** with it. + +##### I2-6 — M6-b: skip `suggestPort()` on polled status calls + +_Source: [§10.3][s-103]._ + +**➤ IMPLEMENTED 2026-08-06** — `bef2128f` _perf(quickstart): skip suggestPort() on polled readiness calls +(M6-b, I2-6)_. + +`DockerStatusResult.suggestedPort` became optional and is omitted when `input.polled` is set; the webview keeps +its previous suggestion in that case (`if (result.suggestedPort !== undefined)`), so the Configure field is +never blanked by a poll. Slightly more than the one-line guard the plan predicted, because the field was +required and unconditionally applied on the client. + +**Problem.** `getDockerStatus` calls `QuickStartService.suggestPort()` on **every** call, including polled ones. +`suggestPort()` binds a probe socket per candidate port, walking up to `QUICK_START_PORT_SCAN_LIMIT` (100). +Usually it returns after one probe — but `pollDockerReadiness` re-runs it on a 1–5 s backoff for up to 90 s, and +on a machine with a busy 10260 band each poll re-walks the range. + +**Decision.** Guard it with `input.polled === true`. The polled readiness loop only consumes `readiness`; the port +suggestion is read only when the Configure step renders. One-line change, no behaviour difference. + +##### I2-7 — Single-instance intent notes + +_Source: [§9.2][s-92] Q3._ + +**➤ IMPLEMENTED 2026-08-06** — `7ae61fd8` _docs(quickstart): record the single-instance scope at the +multi-instance seams (I2-7)_. + +Notes added at four seams: the `instances` map (the anchor note — single instance is the deliberate scope, a +second one is a focused iteration, and creation should most likely start by offering the instance that already +exists), `reservedPorts()` (empty set with one instance), `listStatuses()` (the tree reads `getStatus()` +instead), and the `operationId` nonce — flagged as **load-bearing today** for concurrent windows (H3/H4), not +merely aspirational, which was precisely the distinction a reader could not make. + +**Problem.** The service layer is already multi-instance-shaped — `reservedPorts()` allocates around siblings, +`operationId` labels scope destructive sweeps, and N5 threaded `alias` consistently — while the tree and webview +assume exactly one instance. A reader cannot tell whether those seams are load-bearing or aspirational. + +**Decision.** Multi-instance is **explicitly out of scope**. Keep the seams, build no UI on them, and add short +code comments at each recording the intent: one instance is the deliberate scope today, a second needs a focused +iteration, and at creation time the existing instance would most likely be offered as a "look at the one you +already have" option first. Documentation-in-code only — no behaviour change. + +##### I2-8 — Credential store consolidation + +_Source: [H5][f-h5] · [§9.1][s-91] · [§10.1][s-101]._ + +**⏸ NOT IMPLEMENTED in Iteration 2 — carried to Iteration 3 as [I3-1][it3], where it shipped.** + +Deferred at the close of Iteration 2 on two gates. **The first turned out to be false** and is corrected in +[§11.5][it3]: the scoping report claimed `QuickStartProvisionDurability.test.ts` contained a `TDD:`-prefixed +suite, and it does not — there are no `TDD:` suites anywhere under `src/services/localQuickStart/`. The second +gate was real: the migration had to carry the live lease fields ahead of `reconcile()`, where a mistake is a +silent volume wipe. It was resolved by removing the need for a migration at all — see [I3-1][it3]. + +**Problem.** The managed instance's data lives in **two ad-hoc places**: the connection string in raw +`ext.secretStorage` under `documentdb.quickstart..connectionString`, and the instance list in a +`globalState` blob (`documentdb.quickstart.registry`) with a hand-rolled `mutationChain` write lock. Since WP-3 +the registry is load-bearing (leases, port allocation), and `provision()` hand-rolls a two-phase commit across +both stores — writing the secret early, remembering `previousStoredConnectionString`, and restoring it in +`finally` if the attempt fails. + +**Options considered.** Keep both stores · add a `Managed` **zone** to `ConnectionStorageService` (**rejected**: +zones are workspaces of the single `Connections` storage and are exactly what the Connections view enumerates; +they also drag in folders, `parentId`, orphan cleanup and user mutability) · a **dedicated named storage**. + +**Decision.** `StorageService.get('local-quickstart')`, workspace `instances`, one `StorageItem` per alias: + +```text +properties: { alias, displayName, port, phase, imageRef, operationId, leaseAt } → globalState +secrets: [ connectionString ] → SecretStorage +``` + +Mirrors the `service-kubernetes` `sourceStore` and `service-atlas-mongodb` credential-store precedents. One +`push()` per state change replaces the manual two-phase commit. + +**Now hygiene, not a fix.** H5 was closed without it (I1-1), so this no longer blocks anything. It is still the +largest item: it needs a migration for the secrets **and** the registry including the live lease fields, and that +migration must complete **before** `reconcile()` runs. Do it last. + +##### I2-9 — Close L2 + +_Source: [L2][f-l2] · [§10.2][s-102]._ + +**➤ VERIFIED 2026-08-06 — L2 is closed.** No commit; verification only. + +Two halves, both confirmed: + +- **Service.** `suggestPort()` returns the instance's own recorded port when it is still free, before walking + forward from `QUICK_START_PORT`. Already covered by _"prefers the instance own recorded port so a recreate + keeps its address"_ in `QuickStartProvisionDurability.test.ts` (asserts `10333`, not `10260`). +- **Webview.** The Address row renders + `advPort.trim() && advValidation?.field !== 'port' ? advPort.trim() : String(suggestedPort)`, and `advPort` is + seeded from `suggestedPort` only while `portTouchedRef.current` is false — so a typed value is never clobbered + and an untouched field always shows the port that will be bound. + +**One interaction to keep in mind:** I2-6 made `suggestedPort` optional on polled responses. The webview keeps +its last value in that case, and the seeding call is not polled, so the Address row is unaffected. + +**Problem.** `effectivePort` derived only from `advPort`, initialised to `String(QUICK_START_PORT)`, so a +recreate of an instance actually living on e.g. 10312 confidently displayed `localhost:10260`. + +**Decision.** Believed **resolved by WP-3**: the Address row now renders `suggestedPort` from +`QuickStartService.suggestPort()`, which returns the instance's own recorded port when it is still free, and +`portTouchedRef` stops a host suggestion from clobbering a typed value. **Verification only** — confirm on a +recreate that has moved ports, then close. + +##### I2-10 — M7: strip the password from the tree model + +_Source: [M7][f-m7] · [§10.4][s-104]._ + +**➤ IMPLEMENTED 2026-08-06** — `81f062f8` _fix(quickstart): strip credentials from the Quick Start tree model +(M7, I2-10)_. + +Option **A** shipped: the tree model's `connectionString` is built by parsing `metadata.connectionString` and +clearing `username`/`password`, the same pattern used in `copyConnectionString`, `updateCredentials`, +`ruClusterHelpers` and a dozen other call sites. `connectionUser: metadata.username` is kept for display. +`InstanceMetadata.connectionString` is deliberately left password-bearing — it is the credential source of truth +that `copyQuickStartPassword()` parses. + +**GitHub thread [`#discussion_r3714252974`][m7thread] answered** with the revised wording the decision called +for: resolved by design, the tree model no longer carries credentials at all, rather than the drafted "we'll +strip it and keep the cache". + +**Problem.** `LocalQuickStartItem` assigns `connectionString: metadata.connectionString`, a credential-bearing +URI (userinfo carries the generated username/password). Every consumer was traced for this review — `getHosts()`, +`isTlsDisabled()` and `resolveAllowInvalidCertificates()` read hosts/params only, and the generic +copy/rename/move/remove commands are gated off this node by its `contextValue` — so there is **no live leak**. +The cost is defense-in-depth and consistency: the repo's pattern is a password-free base `connectionString` with +the secret carried separately. + +**Options considered.** **A** strip userinfo, keep `connectionUser` · **B** strip only the password · +**C** reuse `buildQuickStartCopyCredentials()` · **D** document the invariant and do nothing. + +**Decision — A.** It was previously blocked on H5 (stripping made the cache the sole source of truth). That +dependency is gone: the item now resolves credentials from `QuickStartService` itself, so the model's string is +display-only. **Do not strip the service-side value** — `InstanceMetadata.connectionString` must keep the +password, since `copyQuickStartPassword()` parses it back out. Then post the reply on +[`#discussion_r3714252974`][m7thread]; the wording changes from the drafted "we'll strip it and keep the cache" +to **"resolved by design — the tree model no longer carries credentials at all"**. + +##### I2-17 — Clear the cached tree error state when the failure is resolved elsewhere + +**➤ IMPLEMENTED 2026-08-06** — `bca46b67` _fix(quickstart): clear the cached tree error state when the failure +is fixed elsewhere (I2-17)_. + +`ConnectionsBranchDataProvider.resetLocalQuickStartErrorState()` drops every `failedChildrenCache` entry under +the Quick Start subtree, and `QuickStartService.onDidChangeStatus` calls it **before** `refresh()` — the ordering +the `AtlasDiscoveryProvider.onDidChangeSession` precedent depends on. The id filter is a substring match on +`/localQuickStart`, so it covers both the root node and the managed-instance cluster row beneath it (the latter +is the one that actually raises connect modals). + +**Problem.** `failedChildrenCache` freezes a node's children once it is classified as failed, and returns them +without re-fetching. If the user then fixes the underlying problem **outside the tree** — typically in the Quick +Start webview — the tree keeps rendering the stale error node until a manual collapse/expand. Raised inside the +[I2-Q3][a-q3] answer as the direct consequence of accepting modals from `getChildren()`. + +**Decision.** Wire `resetNodeErrorState(nodeId)` followed by `refresh()` on the transitions that mean "the +problem is gone". Precedent to copy: `AtlasDiscoveryProvider.onDidChangeSession` does exactly this — reset first, +then refresh, otherwise a successfully authenticated user still sees the "Sign in" node. Ship with **I2-4**. + +#### �🟡 Open questions + +Asked on 2026-08-06. **All five were answered the same day** — the answers are written out below the table. +Nothing in Iteration 2 is blocked on a maintainer decision any more. + +| # | Question | Affects | Blocks? | Status | +| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------- | ------------------------------------------- | +| **I2-Q1** | **Should the Introduction guard also catch a `Stopped` instance?** It is adoptable, so option **E** would offer "Use existing data" — which _recreates_ a container the user probably just wants to **Start**. _(was I1-Q1)_ | I2-2, I2-3 | **Yes** | ✅ [**ANSWERED — yes**][a-q1] | +| **I2-Q2** | **What does the retry node retry?** Reopen the wizard, or silently re-run the last provision? _(was I1-Q3)_ | I2-4 | **Yes** | ✅ [**ANSWERED**][a-q2] | +| **I2-Q3** | **Where does the modal fire?** Proposal: modals only for **lifecycle** failures; wizard failures stay in-wizard. _(was I1-Q4)_ | I2-4 | **Yes** | ✅ [**ANSWERED — proposal rejected**][a-q3] | +| **I2-Q4** | **Does "Start fresh" need its own confirmation dialog?** _(was I1-Q2)_ | I2-2, I2-3 | No | ✅ [**ANSWERED — no**][a-q4] | +| **I2-Q5** | **Should `Missing` and `CredentialsMissing` become cached error states** via `detectErrorState`? If yes, `resetNodeErrorState(nodeId)` must be wired to `onDidChangeStatus`. _(was I1-Q5)_ | I2-4, I2-5 | No | ✅ [**ANSWERED — option A**][a-q5] | + +##### Answer — I2-Q1 + +> _"Yes, it's unlikely the wizard starts as it won't be linked, but let's guard."_ + +The Introduction step gets a **third** variant. Reaching the wizard while an instance exists should not normally +be possible (the tree does not link to it in that state), but the command palette, a stale panel and cross-window +races all remain — and a `Stopped` instance must never be silently recreated when the user meant **Start**. + +| Variant | MessageBar | Primary action | +| ------------------------------------ | ------------------ | ------------------------------------------------ | +| **Healthy** (`Running` + metadata) | `intent="info"` | **Disabled** — offer "Open Connection" / "Close" | +| **Stopped** _(new)_ | `intent="info"` | **Disabled** — offer **Start** | +| **Erroneous** (`CredentialsMissing`) | `intent="warning"` | **Enabled**, forced onto **Start fresh** | + +Supersedes the two-variant table in [§9.2 Q2][s-92q2]. + +##### Answer — I2-Q2 + +> _"Retry on the node? It just refreshes fully, and this will cause another modal dialog to show. Now, refresh on +> the parent / on the view will just reuse the error node from cache — as this is already implemented — but a real +> retry will retry."_ + +The answer is the **distinction between two paths**, not a choice between them: + +| Path | Behaviour | Status | +| -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| **Passive refresh** — parent refresh, whole-view refresh, `ext.state` transition | Must **not** re-run the failing operation. `failedChildrenCache` returns the cached error children and `childrenFetchFunc()` is never called, so **no** second modal. | **Already implemented** in `BaseExtendedTreeDataProvider` — no work | +| **Clicking the retry node** | `resetNodeErrorState(nodeId)` → `refresh(node)` → the operation genuinely re-runs. A second failure legitimately raises another modal. | Precedent: `src/commands/retryAuthentication/retryAuthentication.ts` | + +For a **provisioning** failure the operation being retried is the wizard, so the retry node reopens it — +unchanged from [§9.2 Q4][s-92q4]'s stated intent. + +##### Answer — I2-Q3 + +> _"The idea is that modals fire on user action: a user expands the tree view, `getChildren` is called, then we +> have a modal error. Now the scenario when the user creates something in the webview, and the tree view calls +> `getChildren`, and an error happens there — well, harder to guard, so let's have a modal for now; we can still +> address it in the future."_ + +- The proposal to restrict modals to **lifecycle** failures is **rejected**. Expanding a tree node _is_ a user + action, so a modal raised from `getChildren()` is correct. +- The webview-triggered `getChildren()` case (no direct user action on the tree) will double-report, but guarding + it is disproportionate right now. **Accept the modal**; revisit if it proves noisy in practice. +- `failedChildrenCache` is what stops this becoming modal spam — see [I2-Q2][a-q2]. +- **Consequence raised in the same answer:** it must be possible to clear that cache when the problem is fixed + elsewhere (e.g. in the webview), or the tree keeps showing a stale error node. Tracked as the newly added + **I2-17**. + +##### Answer — I2-Q4 + +> _"Yes, no modal — just a Start fresh option as a radio, and it will tell that data will be erased. No modal +> here."_ + +**No separate confirmation dialog for "Start fresh".** The explicit radio choice in Configure, plus the footer +copy that follows it, _is_ the confirmation — a modal on top would prompt twice for a decision the user has just +made deliberately, and the second prompt would train them to click through it. + +The RR4 / §5.2 invariant is unaffected: a volume is still only ever dropped by an **explicit user choice**. What +changes is where that choice lives — a radio button in the wizard rather than a dialog. `Delete Container` keeps +its `getConfirmationAsInSettings()` prompt, because there the destructive intent is _not_ otherwise stated on +screen; "Start fresh (erases data)" states it in the label itself. + +Two things this decision does require of the implementation: + +1. The destructive option must be **unambiguously labelled at the point of choice** — the data loss is stated on + the radio option itself, not only in the footer — and it must **not** be the pre-selected option, except in + the Erroneous variant of [I2-Q1][a-q1], where it is the only possible outcome and is preceded by a warning + MessageBar. +2. The footer note must follow the selection. _"Nothing else on your machine is changed"_ is true only for a + genuinely fresh install and must not render for either recreate path ([§10.6][s-106]). + +##### Answer — I2-Q5 + +**Option A — do not classify `Missing` / `CredentialsMissing` as cached error states.** No `detectErrorState` +hook is added for them. + +They are **service states with dedicated rows and actions**, not fetch failures. `Missing` already renders an +actionable row ("Missing · click to recreate") and self-heals the moment the container reappears; freezing it +behind `failedChildrenCache` would make a self-healing state require an explicit invalidation to recover. + +The decisive argument is the collision with **I2-5**: that item makes the row render from the _service's_ cached +status and update on `onDidChangeStatus`. Adding `failedChildrenCache` would put a **second, independent cache +over the same row**, each with its own invalidation rule — so the row could show a frozen error node while the +service already reports `Running`. One cache per row. + +Consequences: + +- **I2-4** applies the error-node pattern to _genuine failures only_ (provision error, connect error), not to + these two states. +- **I2-5** is unaffected and needs no design change; the expand-time `docker inspect` cost is addressed there, + not by a second cache. +- **I2-17** is still required — it clears the cache for the failures that _are_ classified. +- This confirms the earlier rejection recorded under §3 M6 / WP-1 step 3 ("an extra cache to keep in sync"). + +#### ⏸️ Deferred (tracked, not scheduled) + +| # | Item | Reason | +| --------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| **I2-11** | [**B1**][f-b1] — footer experiment switch + `PREVIEW` badge _(was I1-11)_ | User-test still running | +| **I2-12** | [**N4**][f-nits] — un-awaited unsubscribe handshake in `runStream` _(was I1-12)_ | Papered over by terminal-event buffering; revisit if it resurfaces | +| **I2-13** | [**N7**][f-nits] — consolidate the three Quick Start doc folders _(was I1-13)_ | Separate work item | +| **I2-14** | **Multi-instance support** _(was I1-14)_ | Explicitly out of scope — [§9.2][s-92] Q3 | +| **I2-15** | Repo issues [#864][i864] and [#865][i865] _(was I1-15)_ | Filed; not part of this PR | +| **I2-16** | Extract the remaining `DocumentDBClusterItem` connect flow behind a shared helper | _New._ Only if a third cluster item needs it; not release work | + +#### Suggested order within Iteration 2 + +```text +I2-10 (M7 — now a small change; unblocks the GitHub thread reply) +I2-5 + I2-6 (independent; one commit) +I2-1 (regression test for the H5 contract) +I2-2 + I2-3 + I2-4 + I2-17 + (one coherent "wizard + tree states" change — all four share + the copy, the recreate flag and the error-node cache lifecycle) +I2-7 (fold into whichever commit touches those files) +I2-8 (largest; needs a migration — do it last) +I2-9 (verification only) +``` + +#### Iteration 2 closing note (2026-08-06) + +**Shipped — 9 items, 9 dedicated commits.** I2-1, I2-2, I2-3, I2-4, I2-5, I2-6, I2-7, I2-10, I2-17 (see the +[Outcome table](#-outcome--what-landed-and-where)); I2-9 closed by verification with no code. Every remaining +review finding routed through this iteration is now resolved except **I2-8**. + +**Where the implementation departed from the plan.** Three places, all recorded in the item write-ups: + +1. **I2-3 moved to the Configure step.** The decision tabled the guard on the Introduction step; the operator + placed it on Configure, next to the "Start fresh" radio it forces. Supersedes the placement in + [§9.2 Q2][s-92q2] and in the [I2-Q1][a-q1] answer — the three variants themselves are unchanged. +2. **I2-5 needed a cooldown.** Option B alone cannot clear its own `"Refreshing…"` hint without firing the + status event unconditionally, and that event re-enters `getChildren()`. A 5 s + `BACKGROUND_REFRESH_COOLDOWN_MS` is the loop breaker — a deliberately bounded borrowing of option **A**, + which the decision had dropped as a standalone fix. +3. **`willReuse` renamed to `canReuseExistingData`** (I2-2, operator's request). The flag now names a + _capability_, not an outcome the service decides — which is the whole point of making the choice explicit. + +**Not shipped.** **I2-8** only. It was approved, then deferred on two gates confirmed while scoping it: it +rewrites the `TDD:` persistence assertions in `QuickStartProvisionDurability.test.ts` (which +`.github/copilot-instructions.md` forbids without a maintainer decision — the operator was asked and was +unavailable), and its migration must carry live lease fields ahead of `reconcile()`, where a mistake is a silent +volume wipe. Nothing depends on it. Promoted to [§11.5][it3] as Iteration 3's first item. + +**Questions.** None outstanding — all five were answered before implementation started, and none needed +revisiting during it. + +**Verification.** Full checklist run at close: `npm run l10n` → `npm run prettier-fix` → `npm run lint` (clean; +only the pre-existing `eslint-env` warning from `webpack.config.views.js`) → `npx jest --no-coverage` +(**204 suites / 3355 tests / 4 snapshots**, all passing — up from the 203 / 3346 baseline in §10, i.e. **+1 +suite and +9 tests**, all added by this iteration) → `npm run build` (clean). Per item, only `npm run lint` was +run, per the §7.0 cadence. + +**Review points for the operator.** + +- **I2-5's unconditional status fire.** `onDidChangeStatus` triggers a whole-view + `connectionsBranchDataProvider.refresh()`, so a background probe costs one extra full refresh per 5 s window + in which the node is rendered. Bounded, but worth a look if the view feels busy. +- **I2-17 resets on every status event**, including the ones I2-5's probe fires. For this subtree that is + cheap — `getChildren()` no longer does I/O — but it does mean the error cache is short-lived here by design. + +### 11.5 Iteration 3 — opened 2026-08-06, **closed 2026-08-06** + +**✅ Closed** — see the [closing note](#iteration-3-closing-note-2026-08-06) at the end. Its one scheduled item +(I3-1) shipped; I3-2 … I3-7 stay deferred and are the pool a future iteration would draw from. Numbering carries +a `(was …)` reference so the history stays traceable. + +#### ✅ Cleared — code + +| # | Item | Source | Commit | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | ---------- | +| **I3-1** | **Credential store consolidation.** `StorageService.get('local-quickstart')`, workspace `instances`; retires the ad-hoc `documentdb.quickstart.*` secrets **and** the `documentdb.quickstart.registry` blob. | [I2-8 write-up][d-8] · [§9.1][s-91] _(was I2-8)_ | `b8e25fc3` | + +##### I3-1 — Credential store consolidation + +**➤ IMPLEMENTED 2026-08-06** — `b8e25fc3` _refactor(quickstart): move the durable instance state into +StorageService (I3-1)_. + +Shipped as designed in the [I2-8 write-up][d-8]: `StorageService.get('local-quickstart')`, workspace +`instances`, one item per alias — the record in `properties`, the connection string in `secrets`. A state change +is a single `push()`, which retires the hand-rolled two-phase commit in `provision()`. New `quickStartStore.ts` +is the whole storage surface; `quickStartRegistry.ts` is deleted. + +**Both gates that deferred it turned out not to apply.** + +1. **The `TDD:` gate was a false alarm.** The scoping report named a `TDD: Persistence — …` suite in + `QuickStartProvisionDurability.test.ts`. There is no such suite — `grep -rl "TDD:" src/` returns only the + query-language and playground files, none of them under `src/services/localQuickStart/`. The affected tests + are plain `it(...)` cases inside `describe('QuickStartService — WP-3 provisioning durability and port +model')`. **Lesson: verify a blocker against the tree before recording it**, not against a summary of the + tree. [I3-Q1][b-q1] was answered anyway, so this cost nothing but a deferral. +2. **The migration gate was removed rather than met.** The operator's call: _"we don't need to migrate existing + keys, this feature has not shipped, just keep it simple, assume this has never been out."_ So there is **no + migration and no legacy read fallbacks**. `migrateLegacyQuickStartKeys`, `secretKey()`, `imageRefKey()`, the + `LEGACY_*` constants and the whole pre-`reconcile()` migration step in `ClustersExtension` are gone. This is + what turned the largest, riskiest item into a **net deletion of ~500 lines**, and it removed the R1 ordering + hazard by construction — there is no migration to order. + +**A real bug surfaced while doing it.** `StorageService.push()` writes a secret when the item has one but +**never clears one** when it does not: the secret key simply isn't touched. The H3 restore path depends on +clearing — a discarded provision must not leave its credentials behind, or the next run decides `reusing` from +credentials no volume was initialized with. `updateInstance` therefore deletes the item before re-pushing it +when the credentials are being cleared (safe inside the lock; a crash between the two leaves no record, which +is the harmless direction). + +The existing coverage could not have caught this: _"restores the previous credential state when the attempt +fails"_ fails at `docker run`, i.e. **before** the early credential write, so the restore never ran. Two new +cases cancel during the readiness wait — the path a user actually takes — and assert both that a fresh +attempt's credentials are cleared and that a failed **recreate** puts the previous ones back. + +**Also dropped:** the registry's `nextSuffix` counter, which had no production reader (multi-instance is out of +scope, [§9.2][s-92] Q3) and has no home in a per-item store. + +#### ⏸️ Deferred (tracked, not scheduled) + +Carried over unchanged from Iteration 2 — none of these were re-examined, and none block anything. + +| # | Item | Reason | +| -------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| **I3-2** | [**B1**][f-b1] — footer experiment switch + `PREVIEW` badge _(was I2-11)_ | User-test still running | +| **I3-3** | [**N4**][f-nits] — un-awaited unsubscribe handshake in `runStream` _(was I2-12)_ | Papered over by terminal-event buffering; revisit if it resurfaces | +| **I3-4** | [**N7**][f-nits] — consolidate the three Quick Start doc folders _(was I2-13)_ | Separate work item | +| **I3-5** | **Multi-instance support** _(was I2-14)_ | Explicitly out of scope — [§9.2][s-92] Q3; intent now recorded in code by I2-7 | +| **I3-6** | Repo issues [#864][i864] and [#865][i865] _(was I2-15)_ | Filed; not part of this PR | +| **I3-7** | Extract the remaining `DocumentDBClusterItem` connect flow behind a shared helper _(was I2-16)_ | Only if a third cluster item needs it; not release work | + +#### 🟡 Open questions + +**Empty as of 2026-08-06.** [I3-Q1][b-q1] was answered and is recorded below; nothing in Iteration 3 is blocked +on a maintainer decision. + +| # | Question | Affects | Blocks? | Status | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | ------- | ------------------------------------- | +| **I3-Q1** | **May I3-1 rewrite the `TDD:` persistence assertions** in `QuickStartProvisionDurability.test.ts` to target the new store, keeping every behavioural assertion and changing only where the data lives? | I3-1 | **Yes** | ✅ [**ANSWERED — yes, scoped**][b-q1] | + +##### Answer — I3-Q1 + +> _"yes, you can update the TDD tests for this, you've got the permission to update these TDD tests, but only +> these, if other TDDs are violated, come back here with a question for a permission."_ + +**Granted, narrowly.** The permission covers `QuickStartProvisionDurability.test.ts` only; any other `TDD:` +suite that a later change breaks needs its own question. + +**In the event it was not needed.** The premise was wrong: there is no `TDD:`-prefixed suite in that file, or +anywhere under `src/services/localQuickStart/`. The tests were rewritten under ordinary rules. The standing +instruction — ask before touching any other `TDD:` suite — carries forward. + +The original question is kept below for the record. + +--- + +Asked during Iteration 2 and left unanswered (the operator was unavailable). + +`QuickStartProvisionDurability.test.ts` pins the persistence contract against the **raw storage keys**: + +- `secretStorage.get(secretKey(DEFAULT_ALIAS))` is written **before** the readiness probe; +- the same key is cleared when the attempt fails; +- `readRegistry(globalState)` shows `provisioning` → `ready` → `missing`. + +I3-1 relocates all three into `StorageService`, so those assertions cannot survive it verbatim. The +**behaviour** they encode would be preserved exactly — write early, restore/clear on failure, same lifecycle +phases — only the read path in the test changes. + +`.github/copilot-instructions.md` says a `TDD:` suite must not be auto-fixed: _"Stop and ask the user whether +the behavior change is intentional."_ Hence this question rather than an assumption. + +| Option | Consequence | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| **A. Yes — rewrite the assertions against the new store** ✅ **chosen** | I3-1 proceeds. The contract is re-expressed, not weakened. | +| **B. Keep the raw keys as a compatibility read path and assert both** | Defeats the point of the item — the ad-hoc keys survive as a second source of truth. | +| **C. Drop I3-1** | Acceptable: it is hygiene, not a fix. H5 was closed without it in I1-1. | + +**Second thing settled with it — the migration.** Asked whether a one-shot migration was acceptable or whether +the store should keep a read-through fallback for one release. The operator chose **neither**: _"we don't need +to migrate existing keys, this feature has not shipped."_ No migration, no fallbacks, no ordering constraint — +which is what took the risk out of this item entirely. + +#### Suggested order within Iteration 3 + +```text +I3-1 ✅ done — b8e25fc3 +I3-2 … I3-7 (unscheduled; promote individually when they become relevant) +``` + +#### Iteration 3 closing note (2026-08-06) + +**Shipped — the whole of the scheduled work.** I3-1 was the only scheduled item and it landed in one commit +(`b8e25fc3`). I3-2 … I3-7 remain deferred and untouched; none of them blocks anything, so the iteration closes +with an empty schedule rather than a rollover. + +**Two corrections to the Iteration 2 record**, both written up under [I3-1](#i3-1--credential-store-consolidation): + +1. The `TDD:` blocker that deferred this item **did not exist** — it came from a scoping summary rather than the + tree. Verify a blocker against the code before recording it. +2. Dropping the migration (the feature has not shipped) turned the largest and riskiest item into a net + deletion. The risk was in the migration, not in the destination. + +**One product bug fixed on the way:** `StorageService.push()` never clears a secret, which broke the H3 restore +path. Worth knowing for any other feature built on this storage — see the I3-1 write-up. + +**Verification.** `npm run prettier-fix` → `npm run lint` (clean) → `npx jest --no-coverage` (**204 suites / +3344 tests**, all passing) → `npm run build` (clean). The test count is 11 lower than at the close of Iteration +2 (3355) because the deleted migration machinery took its suite with it, while the new store suite added cases +of its own. + +### 11.6 Post-iteration fixes from manual testing (2026-08-06) + +Found by running the extension after Iteration 3 closed, not by re-reading the review. Recorded here +because the iterations above all report "closed", and a reader would otherwise conclude the feature +had been exercised as well as reviewed. + +| # | Item | Commit | +| ------- | --------------------------------------------------------------------------------------- | ---------- | +| **P-1** | The Configure guard offered **Start** for a container removed outside VS Code | `4a618d0b` | +| **P-2** | **N1 was never actually fixed.** The panel still read the instance status once per open | `4a618d0b` | +| **P-3** | The guard notice and the data choice read as two competing questions | `2334758b` | + +#### P-1 — "Start" was offered for a container that no longer exists + +`Missing` is not an {@link InstanceState}: the service reports a container removed outside VS Code as +`state: Stopped` **with `missing: true`**. The Configure guard read only the state, classified it as +"stopped", and offered a **Start** button that could not do anything. Reported from a real session: +_"I deleted the container outside of VS Code … I pressed 'start' in that message bar but of course it +does not produce a thing."_ + +Fixed by checking `missing` **before** the state, and by not guarding a missing instance at all — +recreating it is exactly what the user opened the wizard for. The decision was extracted to +`existingInstanceGuard.ts` with tests, because "Missing is Stopped plus a flag" is a distinction that +is easy to get wrong twice. + +#### P-2 — N1 was recorded as resolved when it was not + +[§9.2 Q5][s-92q2], [§10.6][s-106] and the I2-2 write-up all state that the explicit recreate-vs-fresh +choice "resolves **N1** by construction". **It does not.** N1 is about the panel reading the +instance's status **once per open and never refreshing it**; making the choice explicit removed the +_inference_, but the underlying `canReuseExistingData` / status snapshot was still fetched on mount +and never updated. Deleting the instance from the tree with the panel open still left the wizard +describing an instance that was gone — which is precisely how **P-1** was reachable in practice. + +Fixed with the review's own suggested remedy: a new `onInstanceChanged` tRPC subscription pushes +status changes to the panel. It deliberately makes no Docker calls and skips events that change +nothing user-visible, so the tree's background probe cannot turn into a Docker call per open panel. + +**Process note.** This mis-recording survived a full audit because the audit read the document's +claim and repeated it instead of checking the claim against the code. That is the same failure that +produced the phantom `TDD:` blocker in [§11.5][it3] — a summary was trusted over the tree. When a +document says "resolved by construction", the construction is the thing to verify. + +#### P-3 — one decision, one block + +The step showed an info MessageBar and a separate radio group, so the same question was effectively +asked twice and the pair read as competing controls next to a disabled primary button. They are now a +single `MessageBar` (`role="group"`, which is the correct container for a set of related controls): +explanation on top, choice beneath. The radio group is hidden whenever the guard blocks setup, the +alarming _"container is gone"_ title is gone, and the primary button is a fixed **Start DocumentDB +Local** again, with the footer note carrying the consequence of the selection. + + +[it3]: #115-iteration-3--opened-2026-08-06-closed-2026-08-06 +[it-post]: #116-post-iteration-fixes-from-manual-testing-2026-08-06 +[b-q1]: #answer--i3-q1 + + + + +[f-b1]: #b1--prototype-footer-experiment-switch--preview-badge-is-shipped-in-the-ui +[f-h5]: #h5--after-a-reload-starting-a-stopped-instance-leaves-it-unbrowsable-credential-cache-never-repopulated +[f-m4]: #m4--start-documentdb-local-destroys-and-recreates-a-running-container-and-the-footer-note-says-the-opposite +[f-m6]: #m6--refreshlivestate-runs-a-docker-inspect-on-every-connections-view-render +[f-m7]: #m7--credential-bearing-connection-string-is-stored-on-the-tree-model-github-copilot-reviewer +[f-l2]: #l2--the-configure-address-row-shows-10260-for-a-recreate-on-a-fallback-port +[f-nits]: #nits +[s-91]: #91-h5--where-should-the-managed-instances-credentials-live +[s-92]: #92-m4--recreate-vs-fresh-and-the-instance-state-model +[s-92q2]: #q2--the-wizard-is-opened-while-an-instance-already-exists +[s-92q4]: #q4--n3--error-states-in-the-tree +[s-93]: #93-m6--when-does-refreshlivestate-actually-run +[s-101]: #101-h5--wp-6--credential-source-of-truth +[s-102]: #102-m4--wp-7--recreate-vs-fresh +[s-103]: #103-m6--wp-8--tree-render-cost +[s-104]: #104-m7--password-on-the-tree-model +[s-106]: #106-decisions-taken-2026-08-06-second-pass +[a-q1]: #answer--i2-q1 +[a-q2]: #answer--i2-q2 +[a-q3]: #answer--i2-q3 +[a-q4]: #answer--i2-q4 +[a-q5]: #answer--i2-q5 +[d-1]: #i2-1--h5-regression-test +[d-2]: #i2-2--recreate-vs-fresh-choice +[d-3]: #i2-3--wizard-guard-when-an-instance-already-exists +[d-4]: #i2-4--error-node-pattern-for-the-quick-start-rows +[d-5]: #i2-5--tree-render-cost +[d-6]: #i2-6--m6-b-skip-suggestport-on-polled-status-calls +[d-7]: #i2-7--single-instance-intent-notes +[d-8]: #i2-8--credential-store-consolidation +[d-9]: #i2-9--close-l2 +[d-10]: #i2-10--m7-strip-the-password-from-the-tree-model +[d-17]: #i2-17--clear-the-cached-tree-error-state-when-the-failure-is-resolved-elsewhere diff --git a/docs/ai-and-plans/PRs/834-atlas-discovery-review/code-review-2026-07-30.md b/docs/ai-and-plans/PRs/834-atlas-discovery-review/code-review-2026-07-30.md new file mode 100644 index 000000000..f12865fa3 --- /dev/null +++ b/docs/ai-and-plans/PRs/834-atlas-discovery-review/code-review-2026-07-30.md @@ -0,0 +1,2595 @@ +# PR #765 Code Review: MongoDB Atlas Discovery Provider + +Review date: 2026-07-30 (first pass), 2026-07-30 (second pass / independent reassessment), +2026-07-31 (owner decisions applied) + +PR: https://github.com/microsoft/vscode-documentdb/pull/765 + +Base: `release/0.10.0` + +> **For the implementing agent:** start at +> [Recommended Disposition](#recommended-disposition). Findings that carry a `— FINAL DECISION:` +> subsection have been ruled on by the owner; that subsection supersedes any earlier +> "Owner decision" or "Recommendation" paragraph inside the same finding, which is retained only so +> the reasoning trail stays readable. Requested code comments are part of the deliverable. + +## Implementation Iteration — Executive Summary + +Branch: `dev/tnaum/atlas-discovery-review-iteration` (cut from `feature/atlas-discovery`). +Iteration date: 2026-07-31. Each work item is one commit; each finding below carries an inline +`✅ RESOLVED` note at the end of its section pointing at the fix and tests. + +Implementation PR: https://github.com/microsoft/vscode-documentdb/pull/834 (targets +`feature/atlas-discovery`). This document lives under `docs/ai-and-plans/PRs/834-atlas-discovery-review/` +because it now belongs to that implementation PR, not the original review-target PR #765. + +**PR checklist (all green):** `npm run l10n` → `npm run prettier-fix` → `npm run lint` → +`npx jest --no-coverage` (180 suites, **2941 tests pass**, up from 2905) → `npm run build`. + +### Implemented (one commit each) + +| Finding(s) | What was done | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **MEDIUM-1** | `signal?.throwIfAborted()` before `persistCredential()` in both auth methods. | +| **WITHDRAWN-1 + NEW-4** | Sign the full Digest request-target; cache the challenge and reuse with an incrementing `nc`. | +| **NEW-2** | Added `mongoDBAtlas` to the four remaining `treeitem_index` `when` clauses. | +| **MEDIUM-2** | Serialized `listAll()` passes; `invalidate()` keeps `inflight`; per-pass timeout; abort/timeout classifier. | +| **MEDIUM-3 + NEW-3** | Typed `AtlasTokenError`; rethrow transient token failures; classifier-driven tree modal + refresh-vs-expand rule. | +| **NEW-8** | Reverted per-source shell terminal labelling; restored the four original DocumentDB message IDs. | +| **MEDIUM-4** | `updateAtlasCredentialMetadata` no longer rewrites secrets; per-credential generation guard on `storeSession`. | +| **LOW-1 + LOW-2** | `openUrl()` returns the open result; router notifies on failure; credential-neutral `403` fallback. | +| **LOW-3 + LOW-4** | One localized tooltip field list using "Server version"; `requiresInitialCollection` comment terminology. | +| **NEW-5 + NEW-6 + NEW-7 + INFO-1** | Tree/wizard parity for non-connectable clusters; journey correlation ID threaded from root; payload guards (`connectionStrings?`, `UNKNOWN` state, `?? ''` comparators); `AtlasClusterType` union folded in. | + +### Skipped / not implemented (with reasons) + +- **NEW-1** (footer experiment): the experiment itself is kept per the FINAL DECISION (intended for + a preview release), but the experimental adaptive footer position now defaults to **off** + (`adaptiveFooterEnabled = false`) so it is opt-in via the preview switch rather than the default + behaviour. Removal checklist for the preview exit remains recorded in the finding. +- **NEW-9** (`config.ts` module-load `l10n.t()`): accepted per the FINAL DECISION — left consistent + with the two Azure plugins; tracked extension-wide instead of fixing one plugin in isolation. +- **NEW-10 – NEW-13** (dead code, recursive prompt, token-shape check, unrelated-bundling): marked + non-blocking / accepted in the work order. Not addressed to keep scope to the disposition. NEW-13's + `requiresInitialCollection`-once-saved sub-point is left for an explicit product decision. +- **Deferred issues (ISSUE-1 zod boundary, ISSUE-2 platform-aware shell, ISSUE-3 extension-wide + `l10n.t()`):** the disposition says "file these, do not implement". They are **not** implemented. + They were also **not auto-filed as GitHub issues** — creating public issues is a shared-system + action left for the operator; the ready-to-paste bodies remain in "Follow-up Issues to File". + +### Deviations from the plan (confidence-based) + +- **WITHDRAWN-1 Digest URL parsing:** the proposal snippet used `new URL(path, ATLAS_API_BASE_URL)`, + which would drop the base's `/api/atlas/v2` path prefix because request paths start with `/`. The + concatenated string is parsed directly instead — same "one parsed URL" intent, correct for the + path-prefixed base. (Confidence > 80%; documented in the finding.) +- **LOW-1 tests:** the requested router-level notification tests were not added — there is no + existing `appRouter` caller test harness and `appRouter` transitively imports the full webview + router graph, so a bespoke harness was disproportionate for a Low finding. The `openUrl` util + boolean contract is tested; the router branch is trivial and type-checked. (Documented in LOW-1.) +- **INFO-1** (non-blocking) was folded into NEW-7 because that finding already reopened the same + model files, matching the reviewer's "whenever the touched model is next updated" guidance. + +## Severity Summary + +Counts reflect the state **after the owner decisions of 2026-07-31**. Movement is tracked in the +next two tables. + +| Severity | Count | Notes | +| ------------- | ----: | ----------------------------------------------------------------------------------------------------------------- | +| Critical | 0 | No extension-wide, destructive, or secret-disclosure failures found. | +| High | 0 | The only High (NEW-1) was accepted: the footer experiment is intended for a preview release. | +| Medium | 5 | Cancellation persists a secret; discovery passes race; auth errors misclassified; index menus and Digest traffic. | +| Low | 9 | Four confirmed from pass 1, one downgraded from Medium, four new consistency / robustness issues. | +| Informational | 8 | Model typing, six code-health items, plus NEW-1 after acceptance. | + +Net movement in the second pass: + +| Finding | Pass 1 | Pass 2 | Why | +| ------------------------------ | ------------ | ------------------------- | ---------------------------------------------------------------------------------------- | +| MEDIUM-4 (credential writes) | Medium | **Low** | Three of the four claimed scenarios do not reproduce against the actual store/push code. | +| MEDIUM-1 (cancel then persist) | Medium | Medium (impact corrected) | The stated "tree is not refreshed" consequence is wrong; the real defect is narrower. | +| WITHDRAWN-1 (Digest URI) | Withdrawn | Withdrawn, Informational | Confirmed withdrawal; a materially larger Digest problem sits next to it (NEW-4). | +| NEW-1 … NEW-13 | not reported | High/Medium/Low/Info | Second-pass findings, listed under "Second-Pass Findings". | + +Owner decisions, 2026-07-31 — these are the authoritative dispositions: + +| Finding | Decision | Effect | +| ------------ | ------------------------------------------------------------------------ | ----------------------------------------------------------- | +| **MEDIUM-2** | **Proposal B** (serialize passes), not the complete-snapshot redesign. | Much less code; comments required to explain the chaining. | +| **MEDIUM-4** | Second-pass fix (stop rewriting secrets), not the per-credential queue. | Severity stays Low; comments required. | +| **NEW-1** | **Accepted, no change.** This ships as a preview release. | High → Informational; nothing to implement. | +| **NEW-3** | **Proposal A.** Modals on interaction are intended; suppress on Refresh. | Premise of the finding corrected, wording fix still needed. | +| **NEW-7** | Proposal B now; file an issue for the `zod` boundary. | ISSUE-1. | +| **NEW-8** | **Revert the shell labelling entirely**; do not tweak it. | ISSUE-2 covers the real feature. | +| **NEW-9** | Leave as-is; the deferral question is extension-wide. | ISSUE-3. | + +## Review Scope + +The review compares `feature/atlas-discovery` with the PR's actual base, +`release/0.10.0`. It covers the Atlas Admin API and authentication, credential storage, +multi-credential discovery aggregation, tree and connection flows, credential-management webview, +tRPC boundary, database and shell integration, package registration, tests, and workflow changes. + +The design history in `docs/ai-and-plans/PRs/733-atlas-mongodb-discovery/` was consulted to avoid +reporting intentional multi-credential, partial-failure, tree/list, and recovery behavior as bugs. + +### How the second pass was run + +Every pass-1 finding was re-derived from the branch source rather than from the pass-1 text, and +each claimed consequence was traced to the code that would actually produce it. Three claims did +not survive that trace and are corrected below. The second pass then widened the search to areas +pass 1 did not cover: the React credential view, `package.json` menu `when` clauses, the +non-Atlas files bundled into the same commit, and cross-plugin consistency with the four discovery +providers that already exist in this repository. + +## Withdrawn Finding + +### WITHDRAWN-1: Digest authentication signs a different URI than the request sends + +Source: Independent review. + +**Reassessment: Withdrawn. No severity.** Live testing with an API Key on the current branch showed +that Atlas discovery succeeds with the existing path-only Digest value. That test did exercise the +relevant query-string case even with only a few resources: `requestAllPages()` unconditionally adds +`?itemsPerPage=500&pageNum=1` before the first request. A short first page changes only when the loop +stops; it does not bypass pagination parameters or use a different authentication path. + +Files: + +- `src/plugins/service-atlas-mongodb/api/AtlasApiClient.ts` +- `src/plugins/service-atlas-mongodb/api/AtlasApiClient.test.ts` + +There is still a standards discrepancy. RFC 7616 section 3.4.6 says the Digest `uri` must agree with +the request target, and MongoDB's supported Go SDK uses `req.URL.RequestURI()`, which includes the +query string. The current implementation instead signs `new URL(url).pathname` while `fetch()` sends +the query as well. That establishes an RFC-conformance and future-compatibility concern, but not a +current product defect against Atlas. The original review incorrectly promoted the standards-based +hypothesis to a guaranteed Atlas failure without target-specific evidence. + +Multi-page traversal does not add a distinct failure mode here. Page 2 repeats the same challenge +and response path with `pageNum=2`; if Atlas accepts a Digest value that omits the page-1 query, there +is no code or documented server behavior suggesting that it validates the page-2 query differently. +A live page-2 probe would add confidence, but the absence of that probe does not justify a High +finding. + +**Optional hardening proposal: derive the URL and Digest request-target from one parsed URL.** + +```typescript +const parsedUrl = new URL(path, ATLAS_API_BASE_URL); +const url = parsedUrl.toString(); +const digestUri = `${parsedUrl.pathname}${parsedUrl.search}`; + +const authHeader = computeDigestHeader( + 'GET', + digestUri, + this.session.publicKey, + this.session.privateKey, + challenge, + this.digestNonceCount, +); +``` + +Why this helps: `fetch()` and the Digest calculation are derived from the same parsed value, so a +future query parameter cannot be added to the transmitted URL without also appearing in the +signed request-target. + +Pros: + +- Aligns the implementation with RFC 7616 and MongoDB's supported Go SDK. +- Remains correct if the base URL later gains a path prefix. +- Makes the invariant visible beside the authentication code. + +Cons: + +- Slightly broadens the change from one expression to URL construction. +- A future caller must still pass only paths accepted by the client. + +**Owner decision: implement the hardening proposal.** The live test means this is not a confirmed +Atlas failure and does not restore the former High severity, but matching RFC 7616 and MongoDB's +supported Go SDK is preferable to relying on Atlas's current tolerance. Verify the change with an +Authorization-header unit test and a live API Key request. To exercise a second page without +creating 500 resources, use a focused live probe with a temporary page size of 1 and at least two +visible resources. The extension trace line ending in `(digest challenge answered)` can confirm +that the successful request used the Digest branch. + +**Second-pass assessment: withdrawal upheld. Severity: Informational.** `requestOnce()` still signs +`new URL(url).pathname` while `fetch(url, …)` sends `?itemsPerPage=500&pageNum=N`, so the +discrepancy described above is exactly what the code does. Nothing found in the second pass turns +it into a product defect, and the owner-selected hardening is the right disposition. + +Two things should be recorded alongside it: + +- **The Digest path has no test coverage at all.** `AtlasApiClient.test.ts` covers the error + envelope, diagnostic headers, pagination, and the Service Account refresh/`403` behaviour. There + is no test that exercises the challenge/response branch, so `computeDigestHeader`'s output is + never asserted from the client's side. The hardening therefore lands on untested code; the + Authorization-header test the owner asked for is the first test this branch will have. +- **A materially larger Digest problem sits next to this one.** The request-per-call structure of + the Digest branch, not the signed URI, is what will actually be felt by API Key users. See + [NEW-4](#new-4-digest-authentication-repeats-the-unauthenticated-challenge-on-every-request). + +> ✅ **RESOLVED (dev/tnaum/atlas-discovery-review-iteration), together with NEW-4.** `requestOnce()` +> now derives both the transmitted URL and the Digest request-target from one parsed URL, signing +> `${pathname}${search}` so the query string is part of the signed target (RFC 7616 §3.4.6). +> Deviation from the proposal snippet: `new URL(path, ATLAS_API_BASE_URL)` would drop the base's +> `/api/atlas/v2` path prefix (path segments start with `/`), so the concatenated string is parsed +> directly instead — same single-parsed-URL intent, correct for the path-prefixed base. +> Fix: [src/plugins/service-atlas-mongodb/api/AtlasApiClient.ts](../../../../src/plugins/service-atlas-mongodb/api/AtlasApiClient.ts). +> Tests: first Digest-branch coverage added in +> [AtlasApiClient.test.ts](../../../../src/plugins/service-atlas-mongodb/api/AtlasApiClient.test.ts), +> asserting the signed request-target equals `/api/atlas/v2/groups?itemsPerPage=500&pageNum=1`. + +## Active Findings + +### MEDIUM-1: Closing credential setup can still persist the credential + +Source: Independent review. + +**Reassessment: Confirmed. Severity: Medium (unchanged).** Closing the panel is a clear cancellation +signal, yet the operation can still write a secret and report completion to a controller whose +opener has already resolved `false`. The impact is surprising persistent state, but not a privilege +bypass or secret disclosure: the user supplied the credential and explicitly selected Verify. + +Files: + +- `src/webviews/documentdb/atlasCredentials/atlasCredentialsController.ts` +- `src/webviews/documentdb/atlasCredentials/atlasCredentialsRouter.ts` +- `src/plugins/service-atlas-mongodb/auth/AtlasServiceAccountClient.ts` + +Disposing a webview aborts its in-flight tRPC operation through `ctx.signal`, and the controller +immediately resolves the credential flow as `false`. Both submit mutations ignore that signal: +the API Key path does not pass it to `listProjects()`, the Service Account token helper does not +accept one, and neither path checks for cancellation before `persistCredential()`. + +If a user closes the tab while verification is waiting on Atlas, the host operation can finish +later, write the credential, and call `onCredentialPersisted()`. The opener has already settled as +cancelled, so it does not refresh the tree and the user is left with a silently stored credential +from a flow they closed. + +**Second-pass assessment: mechanism confirmed, stated impact corrected. Severity: Medium +(unchanged).** + +The mechanism is exactly as described. In `atlasCredentialsController.ts`, `onDisposed` calls +`finish(state.credentialsStored)`, which is still `false` while verification is in flight, so the +promise settles `false`; the later `onCredentialPersisted()` hits the `settled` guard and is +discarded after the write has already happened. + +One consequence in the pass-1 text is wrong and should not be used to justify the fix: + +> "The opener has already settled as cancelled, so it does not refresh the tree." + +`configureAtlasCredentials()` calls `discoveryService.reset()` and `refreshDiscoveryTree(node)` +**unconditionally**, outside the `changed` check, precisely because Atlas-side state can change +while the QuickPick is open. The tree is therefore refreshed either way; the real problem is +ordering — the refresh runs before the late write lands, so the newly stored credential is invisible +until the next expansion past the 30 s snapshot TTL. The defect that justifies Medium is narrower +and simpler: **a secret is written to SecretStorage after the user closed the flow**, and the +caller (for example `getDiscoveryWizard`, which throws `UserCancelledError` on `false`) proceeds as +if nothing was stored. + +The proposal also needs one correction before it can be implemented as written. `signal` is +declared **optional** on the framework `BaseRouterContext`; `appRouter.ts` documents the intended +usage as `myCtx.signal?.aborted`. So `myCtx.signal.throwIfAborted()` will not compile under the +repository's strict settings. The commit guard must be: + +```typescript +myCtx.signal?.throwIfAborted(); +await persistCredential(myCtx, secrets); +``` + +`AbortSignal.throwIfAborted()` also requires a modern lib target; if that is not available, use the +equivalent explicit check and throw `UserCancelledError`, which the telemetry middleware already +classifies as a cancellation rather than a failure. + +**Proposal A: propagate the operation signal and guard the commit point.** + +```typescript +const projects = await client.listProjects(myCtx.signal); + +const tokenResponse = await fetchServiceAccountToken(clientId, clientSecret, myCtx.signal); + +myCtx.signal.throwIfAborted(); +await persistCredential(myCtx, secrets); +``` + +```typescript +export async function fetchServiceAccountToken( + clientId: string, + clientSecret: string, + signal?: AbortSignal, +): Promise { + const response = await fetch(ATLAS_SERVICE_ACCOUNT_TOKEN_URL, { + method: 'POST', + headers, + body, + signal, + }); + // Keep the existing non-2xx handling here. + return (await response.json()) as AtlasServiceAccountTokenResponse; +} +``` + +Why this helps: disposal aborts the network request where possible, and the final check closes the +important window between successful verification and the persistent write. + +Pros: + +- Follows the repository's tRPC cancellation contract. +- Stops unnecessary token and list requests after disposal. +- Preserves the current behavior that a verified credential is stored before the success screen. + +Cons: + +- The signal must be threaded through token acquisition, list calls, and best-effort error-action + lookups. +- Abort errors must remain classified as cancellation rather than being converted to inline auth + errors. + +**Proposal B: stage verified secrets and persist only from the completion mutation.** + +```typescript +// submitServiceAccount / submitApiKey +myCtx.credentialState.pendingSecrets = secrets; +return { success: true }; + +// complete +myCtx.signal.throwIfAborted(); +await persistCredential(myCtx, nonNullProp(myCtx.credentialState, 'pendingSecrets')); +myCtx.onCredentialsStored(); +``` + +Why this helps: validation and persistence become separate phases; closing the success screen +without completing leaves storage untouched. + +Pros: + +- Creates an explicit user-controlled commit point. +- Removes the verification-versus-disposal write race entirely. + +Cons: + +- Changes the current UX contract: today a verified credential remains stored if the success + screen is closed without pressing Done. +- Holds secret material in controller memory for longer and requires careful clearing on disposal. +- Requires a larger router/controller state change. + +**Owner decision: use a minimal commit-boundary cancellation check.** Do not thread the signal +through token acquisition, project listing, or the best-effort error-action lookups. It is +acceptable for those network calls to finish after the panel closes; the important contract is that +their result must not become persistent state after cancellation. + +```typescript +const projects = await client.listProjects(); +// Existing validation and no-project handling remain unchanged. + +myCtx.signal.throwIfAborted(); +await persistCredential(myCtx, secrets); +``` + +Apply the same check to both auth methods immediately before `persistCredential()`. This uses the +tRPC cancellation signal without plumbing it through every helper, preserves the current validation +flow, and prevents the known post-verification write. The accepted tradeoff is that cancelled work +may still consume an Atlas request. Add a deferred-request test for each auth method: abort while +`listProjects()` is pending, resolve it, and assert that neither store function nor completion +callback runs. + +> ✅ **RESOLVED (dev/tnaum/atlas-discovery-review-iteration).** Added `myCtx.signal?.throwIfAborted()` +> immediately before `persistCredential()` in both `submitApiKey` and `submitServiceAccount` +> (optional chaining, per the FINAL DECISION work order). The signal is not threaded through the +> verification helpers, matching the minimal commit-boundary decision. +> Fix: [src/webviews/documentdb/atlasCredentials/atlasCredentialsRouter.ts](../../../../src/webviews/documentdb/atlasCredentials/atlasCredentialsRouter.ts). +> Tests: two deferred-abort tests (one per auth method) assert neither the store function nor +> `onCredentialPersisted` runs, in +> [atlasCredentialsRouter.test.ts](../../../../src/webviews/documentdb/atlasCredentials/atlasCredentialsRouter.test.ts). + +### MEDIUM-2: Incompatible discovery passes can coalesce or overwrite each other + +Source: Independent review. + +**Reassessment: Confirmed. Severity: Medium (unchanged).** This can produce an empty List view or +replace a user-requested refresh with older credential/error data for the 30-second cache TTL. It is +visible and repeatable under slow requests, but it neither loses persisted data nor blocks all +discovery permanently. + +File: `src/plugins/service-atlas-mongodb/discovery/AtlasDiscoveryService.ts` + +`listAll()` stores one untyped `inflight` promise. A call with `includeClusters: true` joins an +existing projects-only pass and receives `clustersIncluded: false`, so switching to List mode +during an expansion can render no clusters until another refresh. Forced refreshes have the +opposite race: they start beside the old pass, but both passes commit to the same snapshot and each +`finally` clears `this.inflight` unconditionally. If the old pass finishes last, it overwrites the +fresh result and can reintroduce removed credentials or pre-refresh errors for the 30-second TTL. + +The tests cover sequential cache upgrades and refreshes, but not overlapping deferred calls. + +**Second-pass assessment: confirmed and reproducible from the UI. Severity: Medium (unchanged).** + +The second pass adds the concrete user gesture that reaches it and two aggravating factors pass 1 +did not record. + +_The reachable gesture._ `AtlasServiceRootItem.getChildren()` calls +`listAll({ includeClusters: getAtlasViewMode() === 'list' })`, and `switchAtlasViewMode` persists the +mode and then calls `ext.discoveryBranchDataProvider.refresh()`. Toggling **View as List** while a +tree-mode expansion is still fetching therefore joins a `includeClusters: false` pass and renders a +List view with no clusters, from a single click on the view title bar. This is a one-gesture +reproduction, not a theoretical interleaving. + +_`invalidate()` cannot stop a running pass._ `invalidate()` clears `snapshot`, `snapshotTakenAt`, +`lastResults`, and `inflight`, but the promise it dropped keeps running and still executes the +`this.snapshot = snapshot` assignment at the end of `buildSnapshot()`. `refreshAll()` is +`invalidate()` followed by `listAll({ forceRefresh: true })`, so the documented "explicit refresh" +path is itself a two-writer situation — this is not limited to two competing user actions. + +_An aborted pass still becomes the cached snapshot._ `buildSnapshot()` never consults +`options.signal` before committing, and `classifyAtlasError()` has no branch for an abort: a +`DOMException` named `AbortError` is not an `AtlasApiError`, is not a `TypeError`, and its message +does not match the network regex, so it lands in `kind: 'other'`. A cancelled pass therefore commits +a snapshot in which every credential carries an `other` error, `snapshotHasFailures()` returns +`true`, and `classifyRecoveryAction()` renders a "Click here to retry" row — for work the extension +itself cancelled. Whatever shape the fix takes, `classifyAtlasError` needs an abort branch and +`buildSnapshot` needs to refuse to commit an aborted pass. + +The owner's chosen direction is **Proposal B** (see the FINAL DECISION subsection below), which +resolves the coalescing dimension and the last-writer dimension by queuing rather than by +restructuring the snapshot; the abort handling above is additional, independent, and becomes +mandatory once the serialized path gains a timeout. + +**Proposal A: describe each pass and protect commits with a generation.** + +```typescript +interface InflightDiscovery { + readonly generation: number; + readonly includesClusters: boolean; + readonly promise: Promise; +} + +if (this.inflight && !options.forceRefresh && (!needsClusters || this.inflight.includesClusters)) { + return this.inflight.promise; +} + +const generation = ++this.generation; +const promise = this.buildSnapshot(needsClusters, options.signal, forceFreshSessions).then((result) => { + if (generation === this.generation) { + this.commit(result.results, needsClusters); + } + return result.snapshot; +}); +``` + +The `finally` block should clear the slot only when `this.inflight?.promise === promise`. Why this +helps: a caller joins only work that satisfies its requested shape, and a superseded pass can no +longer commit or clear the current pass's state. + +Pros: + +- Cluster-inclusive work can satisfy both callers while projects-only work cannot satisfy List + mode. +- A forced refresh has explicit last-generation-wins semantics. +- Keeps fast concurrent refresh behavior; a service-owned controller may also abort superseded work. + +Cons: + +- `buildSnapshot()` must stop committing unconditionally or return an uncommitted result. +- Superseded work may still consume requests unless it is also aborted. + +**Proposal B: serialize incompatible discovery passes.** + +```typescript +const previous = this.inflight?.promise; +const promise = (previous ? previous.catch(() => undefined) : Promise.resolve()).then(() => + this.buildSnapshot(needsClusters, options.signal, forceFreshSessions), +); +``` + +Why this helps: only one pass can write the snapshot at a time. A cluster-inclusive call waits for +the projects-only pass and then performs the richer query, so completion order cannot overwrite a +newer result. + +Pros: + +- Simple commit ordering with no concurrent snapshot writers. +- Avoids duplicate fleet-wide requests running at the same time. + +Cons: + +- A manual refresh waits behind the stale or slow request it was meant to supersede. +- A hung request can block every later discovery call unless all requests have a timeout. +- Does not make request compatibility explicit. + +**Owner decision (superseded): use complete internal snapshots plus Proposal A's generation guard.** +Dynamic cluster loading is not required. Discovery should first collect the complete internal data +set for each credential - organizations, projects, clusters, and typed failures - and merge it into +one immutable snapshot. Tree and List modes should then be presentation projections over that same +snapshot rather than requesting different snapshot shapes. + +Concretely, `listAll()` no longer needs `includeClusters` as a cache or in-flight compatibility +dimension: every pass fetches clusters with the existing bounded project concurrency. Tree mode +groups the resulting snapshot as organization to project to cluster; List mode flattens the same +cluster entries. `AtlasProjectItem` should read its cluster children from the snapshot instead of +making a separate `listClusters()` call on expansion. + +This makes the architecture easier to reason about and removes the projects-only versus +cluster-inclusive coalescing bug entirely. The accepted cost is a slower and more request-heavy +initial Tree-mode load, especially for credentials with many projects; bounded concurrency, +per-project errors, and the snapshot TTL remain important. Proposal A's generation/identity guard +is still required because a forced refresh can overlap an older complete pass, and only the newest +generation may commit or clear the active in-flight slot. + +Add deferred tests that prove: + +- Tree and List modes render different projections of the same complete snapshot without a new API pass. +- Expanding a project performs no direct cluster request. +- A slow old pass finishing after a fast forced refresh cannot replace the newer snapshot. + +--- + +### MEDIUM-2 — FINAL DECISION: Proposal B (serialize discovery passes) + +**Decision: implement Proposal B. This supersedes the complete-snapshot / generation-guard decision +above.** Rationale: the bug is a genuine edge case (it needs two overlapping passes), and Proposal B +is materially less code than restructuring `listAll` into complete snapshots plus a generation +counter plus tree/list projections. Ship the cheap correct fix; do not redesign the snapshot shape +in this PR. + +**Implement exactly this in `AtlasDiscoveryService.listAll()`.** + +Extract the existing cache lookup into a helper so it can be evaluated twice — once before queuing, +once after the predecessor has committed — then chain instead of racing: + +```typescript +/** + * Returns the cached snapshot when it is still fresh and rich enough for this caller. + * Split out of `listAll()` because the serialized path has to ask twice: once before queuing, + * and again after the pass ahead of it committed, which may have already produced the answer. + */ +private readUsableSnapshot(needsClusters: boolean, forceRefresh: boolean): AtlasDiscoverySnapshot | undefined { + if (forceRefresh || !this.snapshot) { + return undefined; + } + if (needsClusters && !this.snapshot.clustersIncluded) { + return undefined; + } + return monotonicNow() - this.snapshotTakenAt < SNAPSHOT_TTL_MS ? this.snapshot : undefined; +} + +public async listAll(options: ListAllOptions = {}): Promise { + const needsClusters = options.includeClusters === true; + const forceRefresh = options.forceRefresh === true; + + const cached = this.readUsableSnapshot(needsClusters, forceRefresh); + if (cached) { + return cached; + } + + // Discovery passes are queued, never raced. Joining an arbitrary in-flight pass was wrong in + // both directions: a projects-only pass would answer a clusters-inclusive caller (List mode + // rendered empty when the view was toggled mid-fetch), and two overlapping passes both wrote + // `this.snapshot`, so a slow old pass could replace a newer forced refresh for the whole TTL. + // Waiting and then re-checking the cache gives the fast path back for free: a caller whose + // needs the predecessor already satisfied returns that snapshot without a second API pass. + const previous = this.inflight; + const work = (previous ?? Promise.resolve()) + // The predecessor's failure is its own caller's problem; it must not fail this pass. + .catch(() => undefined) + .then( + () => + this.readUsableSnapshot(needsClusters, forceRefresh) ?? + this.buildSnapshot(needsClusters, options.signal, options.forceFreshSessions === true), + ) + .finally(() => { + // Only the tail of the queue may clear the slot; an earlier pass finishing late must + // not detach a successor that other callers are already chained behind. + if (this.inflight === work) { + this.inflight = undefined; + } + }); + + this.inflight = work; + return work; +} +``` + +**Three companion changes are required, not optional.** Proposal B is only safe with all three. + +1. **`invalidate()` must stop clearing `inflight`.** Under the old code `inflight` was a + join-target; under Proposal B it is the tail of a queue. `refreshAll()` calls `invalidate()` and + then `listAll({ forceRefresh: true })`, so leaving the clear in place would detach the forced + pass from the running one and reintroduce exactly the two-writer race this change removes. + + ```typescript + public invalidate(): void { + this.snapshot = undefined; + this.snapshotTakenAt = 0; + this.lastResults = undefined; + // `inflight` is deliberately NOT cleared: it is the tail of the serialized pass queue, + // not cached data. Clearing it would let the next pass start beside the running one. + } + ``` + +2. **Every pass needs a timeout.** Serializing makes one hung request block _all_ later discovery, + including a forced refresh — the old code at least let `forceRefresh` bypass a stuck join. No + Atlas request currently has a deadline: `AtlasServiceRootItem.getChildren()` calls `listAll()` + with no signal, and `AtlasApiClient` passes `signal` straight through to `fetch`, which has no + default timeout. Give `buildSnapshot` a deadline of its own: + + ```typescript + /** A discovery pass may not outlive this. Serialized passes queue behind each other, so a + * request with no deadline would stall every later expansion for the rest of the session. */ + const DISCOVERY_TIMEOUT_MS = 30_000; + + const deadline = AbortSignal.timeout(DISCOVERY_TIMEOUT_MS); + const signal = options.signal ? AbortSignal.any([options.signal, deadline]) : deadline; + ``` + + Both `AbortSignal.timeout` and `AbortSignal.any` are available (`target: ES2023`, `lib` includes + `dom`, Node `>=22.18.0`), and `AbortSignal.timeout` already has in-repo precedent in + `SelectAtlasDatabaseUserStep`. + +3. **`classifyAtlasError()` needs an abort/timeout branch.** This was already required (see the + second-pass assessment above) and becomes unavoidable once (2) lands, because + `AbortSignal.timeout` rejects with a `TimeoutError` `DOMException` that is neither an + `AtlasApiError` nor a `TypeError` and does not match the network regex, so it would be reported + as `kind: 'other'` on every credential: + + ```typescript + // `AbortSignal.timeout()` rejects with TimeoutError and a disposed webview/tree with AbortError. + // Neither is an Atlas response, so neither may be reported as a credential problem. + if (error instanceof DOMException && (error.name === 'TimeoutError' || error.name === 'AbortError')) { + return { kind: 'network', message: error.message }; + } + ``` + + `buildSnapshot()` must additionally refuse to commit when the pass was aborted rather than timed + out, so a cancelled pass never becomes the cached snapshot. + +**Optional hardening, not required for this PR:** `retryCredential()` calls `this.commit()` directly +and is therefore a fourth writer outside the queue. A retry launched from the credential manager +while a tree expansion is fetching can still be overwritten. Routing that commit through the same +chain closes it; the window is narrow enough that it can wait. + +**Two implementation notes.** + +- Referencing `work` inside its own `.finally()` is fine here and is not a circular-inference error, + because every function in the chain has an explicit return type. + `AtlasCredentialSessionRegistry.getSession()` already uses exactly this shape and compiles. +- Keep the existing `atlasTrace` output. The current `listAll()` logs a cache hit with the snapshot's + age and contents, and logs separately when the snapshot has expired. Moving the cache check into + `readUsableSnapshot()` must not drop those lines — they are the only way to tell a served cache + from a fresh pass in a bug report. Add one more for the queued case + (`listAll: waiting for the discovery pass ahead of this one`) so a serialized wait is visible + rather than looking like a hang. + +**Comments are part of the deliverable.** The reason for the chaining, the reason `invalidate()` +leaves `inflight` alone, the reason `finally` compares identity, and the reason the timeout exists +are all non-obvious from the code. Keep each of the comments above (or equivalents); a future +maintainer removing any one of them silently reintroduces one of the three bugs. + +Tests to add: + +- Two deferred `listAll()` calls, the first `includeClusters: false` and the second `true`: assert + the second returns a snapshot with `clustersIncluded: true` and that it ran a second pass. +- A slow first pass and a `forceRefresh` second pass: assert the final `this.snapshot` is the + second pass's result regardless of completion order. +- A `refreshAll()` issued while a pass is in flight: assert only one pass runs at a time and the + forced result wins. +- A timed-out pass: assert every credential is reported as `kind: 'network'`, not `'other'`. + +> ✅ **RESOLVED (dev/tnaum/atlas-discovery-review-iteration) — Proposal B.** `listAll()` now queues +> passes instead of racing them: a `readUsableSnapshot()` helper is evaluated before queuing and +> again after the predecessor commits, and the pass chains off `this.inflight` with an +> identity-checked `finally`. All four required companion changes landed: `invalidate()` no longer +> clears `inflight`; `buildSnapshot()` wraps the caller's signal with `AbortSignal.any([signal, +AbortSignal.timeout(DISCOVERY_TIMEOUT_MS)])`; `classifyAtlasError()` maps `DOMException` +> `TimeoutError`/`AbortError` to `network`; and `buildSnapshot()` returns without committing when the +> caller's `signal` aborted (a timeout still commits as network errors). All required explanatory +> comments were kept. +> Fix: [src/plugins/service-atlas-mongodb/discovery/AtlasDiscoveryService.ts](../../../../src/plugins/service-atlas-mongodb/discovery/AtlasDiscoveryService.ts). +> Tests in [AtlasDiscoveryService.test.ts](../../../../src/plugins/service-atlas-mongodb/discovery/AtlasDiscoveryService.test.ts): +> clusters-inclusive caller not answered by a projects-only pass; forced refresh commits last; +> `maxActive === 1` under overlap; timed-out pass reported as `network`; a caller-cancelled pass is +> not cached; plus a direct `classifyAtlasError` abort/timeout assertion. The optional +> `retryCredential()` fourth-writer hardening was intentionally left out per the FINAL DECISION. + +### MEDIUM-3: Transient Service Account token failures are reported as rejected credentials + +Source: Independent follow-up review. + +**Assessment: New finding. Severity: Medium.** + +Files: + +- `src/plugins/service-atlas-mongodb/auth/AtlasServiceAccountClient.ts` +- `src/plugins/service-atlas-mongodb/auth/AtlasCredentialSessionRegistry.ts` +- `src/plugins/service-atlas-mongodb/discovery/AtlasDiscoveryService.ts` +- `src/webviews/documentdb/atlasCredentials/atlasCredentialsRouter.ts` + +`fetchServiceAccountToken()` throws a plain `Error` for every non-2xx response. The session registry +then catches every token failure and returns `undefined`; `queryCredential()` interprets that only +as `kind: 'auth'` with "Stored credentials were rejected." A DNS failure, offline machine, Atlas +`429`, or token-service `5xx` therefore sends an existing user to update a valid secret. The add +flow preserves network `TypeError`, but still converts token-service `429` and `5xx` responses into +"MongoDB Atlas did not accept the Client ID and secret." + +This is more than generic wording: the original status and retry category are discarded before the +shared error classifier can select the correct recovery action. Existing tests cover only a generic +`invalid_client` rejection and explicitly expect `undefined`, so they encode the collapse rather +than distinguish transient failure. + +**Second-pass assessment: confirmed, with one clarification and one extension. Severity: Medium +(unchanged).** + +_Clarification._ The raw failure is not entirely lost today. `mintServiceAccountToken()` already +does `atlasWarn(… service account token request failed: ${message})` before returning `undefined`, +so the output channel does carry the underlying text. The defect is therefore squarely about the +**typed** outcome: `queryCredential()` turns every `undefined` session into a hardcoded +`kind: 'auth'` with `'Stored credentials were rejected. Update them to continue.'`, and that string +is what drives `classifyRecoveryAction()` to `revisitCredentials`. The owner's decision to keep full +diagnostics in the output channel and classify only at the UI's required level therefore matches +what the code already does for logging; the work is on the return type. + +_Extension._ The same collapse has a second, worse-behaved instance in the tree, reported separately +as [NEW-3](#new-3-project-expansion-raises-a-blocking-modal-that-blames-credentials-for-every-failure-kind). +Fixing only the session registry leaves that path telling a user with a flaky network to revisit +their credentials, in a modal. The two should be fixed together, because they share the same root +cause: a failure kind is discarded and replaced with a credential-blaming default. + +**Proposal A: introduce a typed token error and rethrow transient failures.** + +```typescript +export class AtlasTokenError extends Error { + constructor( + message: string, + public readonly statusCode: number, + public readonly code?: string, + ) { + super(message); + } +} + +if (!response.ok) { + throw new AtlasTokenError(errorDetail, response.status, errorCode); +} +``` + +```typescript +try { + return await this.mintServiceAccountToken(credentialId, secrets); +} catch (error) { + if (error instanceof AtlasTokenError && (error.statusCode === 400 || error.statusCode === 401)) { + return undefined; + } + throw error; +} +``` + +`classifyAtlasError()` and `describeAtlasError()` can then map `429` to rate limiting, `5xx` to a +retryable service failure, and an unchanged `TypeError` to network failure. + +Pros: + +- Small change to the existing `Promise` API. +- Preserves HTTP status and OAuth error code for both discovery and the webview. +- Lets actual invalid-client responses keep the current rejected-credential behavior. + +Cons: + +- Both existing classifiers must learn the token error type. +- The token client's status-to-category policy must be maintained explicitly. + +**Proposal B: return a discriminated session-resolution result.** + +```typescript +type SessionResolution = + | { readonly ok: true; readonly session: AtlasSession } + | { readonly ok: false; readonly error: unknown; readonly retryable: boolean }; +``` + +Why this helps: "missing secret," "invalid secret," and "could not contact token service" can no +longer share the ambiguous `undefined` value. + +Pros: + +- Makes every failure path explicit at the type boundary. +- Scales if the UI later needs token-expiry or consent-specific recovery. + +Cons: + +- Changes all session-registry consumers and the refresher interface. +- Adds branching to API-client retry code for a problem that currently needs only status fidelity. + +**Owner decision: preserve full diagnostics in the output channel and classify only at the UI's +required level.** The existing Show details action is the escape hatch for status, OAuth code, and +raw backend detail; user-facing state does not need to reproduce that envelope. Preserve enough +structured information from token acquisition to distinguish these broad outcomes: + +- invalid client/secret responses become `authentication`; +- fetch/DNS/offline failures become `network`; +- `429` becomes `rateLimit`; +- token-service `5xx` and unrecognized responses become `unknown` with retry-oriented wording. + +Log the complete original failure through `ext.outputChannel.error` before returning the concise +classification. A small typed token error carrying status and OAuth code remains a suitable way to +avoid parsing message text, but the types should serve this high-level mapping rather than expand +the webview contract. + +Do not regress the existing Atlas Admin API classification. In particular, project-list `403` +responses must continue through `AtlasApiError` and `isAtlasIpAccessListError()`, retaining the +rejected-IP message, access-settings action, and current distinction between IP-access and missing +permissions. Token-endpoint classification must not replace or intercept that path. Add focused +tests for `invalid_client`, `TypeError`, token `429`, and token `503`, plus the existing Admin API IP +access-list cases as regression coverage. + +> ✅ **RESOLVED (dev/tnaum/atlas-discovery-review-iteration), with NEW-3.** Added a typed +> `AtlasTokenError` (status + OAuth code) thrown by `fetchServiceAccountToken`. +> `mintServiceAccountToken` now logs the full failure via a new `atlasError` helper, returns +> `undefined` only for a genuinely rejected client/secret (`400`/`401`), and **rethrows** transient +> failures (`429`, `5xx`, network `TypeError`) so the discovery pass classifies them. +> `classifyAtlasError` gained an `AtlasTokenError` branch (`429`→`rateLimited`, `5xx`→`other`), and the +> webview `describeAtlasError` classifies token failures directly, removing the hardcoded +> authentication override in `submitServiceAccount`. The Admin API `403` / `isAtlasIpAccessListError` +> path is untouched. The wizard's `getClusterItems` swallows the now-throwing `getSession` to keep +> its neutral "manage credentials" fallback. +> Fix: [AtlasServiceAccountClient.ts](../../../../src/plugins/service-atlas-mongodb/auth/AtlasServiceAccountClient.ts), +> [AtlasCredentialSessionRegistry.ts](../../../../src/plugins/service-atlas-mongodb/auth/AtlasCredentialSessionRegistry.ts), +> [AtlasDiscoveryService.ts](../../../../src/plugins/service-atlas-mongodb/discovery/AtlasDiscoveryService.ts), +> [atlasCredentialsRouter.ts](../../../../src/webviews/documentdb/atlasCredentials/atlasCredentialsRouter.ts), +> [SelectAtlasSteps.ts](../../../../src/plugins/service-atlas-mongodb/discovery-wizard/SelectAtlasSteps.ts), +> [atlasTrace.ts](../../../../src/plugins/service-atlas-mongodb/atlasTrace.ts). +> Tests: `invalid_client`, `429`, `503`, and `TypeError` cases in both the session-registry and +> router suites. + +### MEDIUM-4: Concurrent credential writes can restore stale secrets after rotation or sign-out + +Source: Independent follow-up review. + +**Assessment: New finding. Severity: Medium.** The race can undo a validated secret rotation or +recreate an item after sign-out, which is a data-integrity failure. It requires overlapping storage +operations, so its likelihood is lower than the deterministic cancellation and error-classification +paths above. + +**Second-pass assessment: partially confirmed. Severity: downgraded to Low.** + +The unserialized read-modify-write pattern is real, but three of the four scenarios pass 1 built on +top of it do not reproduce against the actual store and `StorageService` code. Downgrading matters +here because the pass-1 severity is what justified the heaviest proposed change in the whole review +(a per-credential write queue across four call sites). + +_Scenario 1 — token caching restores an old client secret. Does not reproduce._ +`cacheServiceAccountToken()` calls `readAtlasCredentialSecrets(id)` **after** the token round-trip, +not before it, and spreads that fresh read: `pushItem(record, { ...secrets, accessToken, expiresAt })`. +A rotation that lands during a token mint is therefore preserved; the only stale value written is an +access token minted from the previous secret, which self-heals on its next `401` through +`AtlasApiClient`'s existing refresh-and-retry. + +_Scenario 2 — a stale write recreates a removed credential. Does not reproduce in the token path._ +`cacheServiceAccountToken()` returns early twice for a deleted credential: `readAtlasCredentialSecrets` +resolves `undefined` because the storage item is gone, and `ensureCache()` re-reads from storage +because `removeAtlasCredential()` called `invalidateCache()`. Resurrection needs both reads to land +before the delete **and** the `pushItem` to land after it. + +_Scenario 3 — metadata writes clobber secrets when the secret read fails. Does not reproduce._ +`StorageService.push()` only touches SecretStorage under `if (item.secrets && item.secrets.length > 0)`. +A `pushItem(record, undefined)` leaves the stored secret untouched, so a failed or empty secret read +during a metadata update is already safe. + +_Scenario 4 — the one that does hold._ `updateAtlasCredentialMetadata()` is the only path that reads +the full secret array and writes it straight back: + +```typescript +const secrets = await readAtlasCredentialSecrets(id); +await pushItem(updated, secrets); +``` + +It is called on every discovery pass through `cacheOrganizationMetadata()`. A rotation completing +between those two lines is written back as the old secret. The window is one `await` boundary wide, +so this is a Low-likelihood, high-consequence race rather than the Medium-likelihood one pass 1 +described. + +**Second-pass recommendation: prefer a one-line elimination over the write queue.** The +`updateAtlasCredentialMetadata` path does not need to write secrets at all, because `push()` already +treats `undefined` as "leave the stored secrets alone": + +```typescript +// Metadata is non-secret by definition; `push()` preserves the existing secret array +// when none is supplied, so this path never needs to read or rewrite it. +await pushItem(updated, undefined); +``` + +Why this is better than Proposal A here: it removes the only reachable window instead of narrowing +it, it deletes a SecretStorage read from the hot discovery path, and it does not add a queue whose +own cleanup and rejection handling then need tests to avoid permanently blocking a credential ID. +Proposal A remains the right answer **if** cross-window credential management is in scope, but that +is Proposal B territory (independent storage keys) and is out of scope for this release. Keep the +deferred storage tests pass 1 asked for; they are cheap and they pin the corrected behaviour. + +Files: + +- `src/plugins/service-atlas-mongodb/credentials/atlasCredentialStore.ts` +- `src/plugins/service-atlas-mongodb/auth/AtlasCredentialSessionRegistry.ts` +- `src/webviews/documentdb/atlasCredentials/atlasCredentialsRouter.ts` +- `src/services/storageService.ts` (for the `push()` secret-preservation behaviour above) + +`replaceAtlasCredentialSecrets()`, `updateAtlasCredentialMetadata()`, and +`cacheServiceAccountToken()` each perform an asynchronous read-modify-write of the entire item, +including its secret array, with no per-credential serialization. A discovery pass can read the old +secret for metadata or token caching, the webview can persist a validated replacement, and the old +pass can then call `pushItem()` last and restore the stale secret. The same pattern can recreate an +item when a write has already captured the record and `removeAtlasCredential()` wins only before +that stale `pushItem()`. + +Session invalidation does not make the storage write atomic. In-flight session promises are removed +from the registry map, but the already-running promise can still finish and call +`cacheServiceAccountToken()`. + +**Second-pass note on the session registry.** There _is_ a related in-memory race in +`AtlasCredentialSessionRegistry` that pass 1 gestured at but did not isolate, and it is more likely +than the storage race: `invalidate(credentialId)` deletes the cached session and the in-flight map +entries, but a `resolveSession()` promise that is already running still finishes with +`this.storeSession(credentialId, session)` and repopulates the cache with the pre-rotation session. +The same applies to `refreshSession()` racing an older `getSession()`: both call `storeSession`, and +the later completion wins regardless of which is newer. Today this is masked because +`configureAtlasCredentials()` ends with `discoveryService.reset()` (a full `invalidateAll()`), but +`AtlasCredentialActionStep.update()` relies on the narrow `sessionRegistry.invalidate(credentialId)` +alone. A generation counter on the registry, or having `storeSession` refuse to write when its +originating request has been invalidated, closes it without a queue. + +**Proposal A: serialize every operation for one credential.** + +```typescript +return withCredentialWrite(id, async () => { + const current = await readAtlasCredentialSecrets(id); + const record = (await ensureCache()).find((candidate) => candidate.id === id); + if (!current || !record) { + return; + } + + await pushItem(record, mergeWithLatestSecrets(current, update)); + invalidateCache(); +}); +``` + +Rotation, metadata updates, token caching, and removal must all use the same per-ID queue. Why this +helps: whichever operation runs second re-reads the first operation's result instead of writing a +snapshot captured before it. + +Pros: + +- Focused fix for all same-extension-host call sites. +- Preserves the current storage schema. +- Gives removal deterministic ordering with token and metadata writes. + +Cons: + +- Every credential write must consistently use the queue. +- An in-process queue does not coordinate two VS Code windows sharing the same profile. +- Queue cleanup and rejected-operation handling need tests to avoid permanently blocking an ID. + +**Proposal B: separate independently changing values instead of rewriting the whole secret array.** + +```typescript +// Primary credential item +secrets: [clientId, clientSecret]; + +// Separate cache item keyed by credential ID +tokenSecrets: [accessToken, expiresAt]; + +// Metadata update: push properties without rewriting either secret value +await pushCredentialMetadata(record); +``` + +Why this helps: organization-name caching cannot overwrite credentials, and token caching cannot +restore an old client secret. Rotation becomes the only writer of the primary secret. + +Pros: + +- Removes the structural cause of stale whole-record writes. +- Reduces the amount of sensitive data rewritten by unrelated operations. +- Is safer across multiple extension hosts because independent values use independent keys. + +Cons: + +- Requires a storage migration and cleanup of orphaned token-cache entries. +- Removal becomes a multi-key operation. +- Larger implementation and compatibility surface for this release. + +**Recommendation:** choose Proposal A for this PR, covering removal and all three read-modify-write +paths in one per-credential queue. It is the smallest complete correction for the reachable UI +race. Proposal B is the stronger long-term storage design if cross-window concurrent credential +management is a supported scenario. Add deferred storage tests that force metadata/token writes to +finish after rotation and after removal, then assert that the new secret remains and a removed item +stays absent. + +--- + +### MEDIUM-4 — FINAL DECISION: second-pass fix, with the reasoning captured in comments + +**Decision: implement the second-pass recommendation. Do not build the per-credential write queue.** +Only `updateAtlasCredentialMetadata()` is actually exposed, and it does not need to write secrets at +all, so the window is removed rather than narrowed. + +**Change 1 — `atlasCredentialStore.ts`, `updateAtlasCredentialMetadata()`.** + +```typescript +export async function updateAtlasCredentialMetadata( + id: string, + metadata: AtlasCredentialMetadataUpdate, +): Promise { + const records = await ensureCache(); + const existing = records.find((record) => record.id === id); + if (!existing) { + return undefined; + } + + const updated: AtlasCredentialRecord = { + ...existing, + label: metadata.label ?? existing.label, + orgId: metadata.orgId ?? existing.orgId, + orgName: metadata.orgName ?? existing.orgName, + }; + + // Deliberately no secret read here. `StorageService.push()` only writes SecretStorage when + // `item.secrets` is a non-empty array, so passing `undefined` leaves the stored secret exactly + // as it is. Reading the secret and writing it back was a real hazard: this runs on every + // discovery pass (via `cacheOrganizationMetadata`), and a credential rotation completing + // between the read and the push would have been silently overwritten with the old secret. + await pushItem(updated, undefined); + invalidateCache(); + return updated; +} +``` + +The comment is required. Without it the next maintainer sees a metadata writer that "forgets" to +carry the secrets forward and re-adds the read. + +**Change 2 — `AtlasCredentialSessionRegistry`, the in-memory half.** `invalidate(credentialId)` +drops the cached session and the in-flight map entries, but a `resolveSession()` promise that is +already running still ends in `this.storeSession(...)` and repopulates the cache with the +pre-rotation session. Add a per-credential generation so a superseded resolve cannot write: + +```typescript +/** + * Bumped by `invalidate()` / `invalidateAll()`. A session resolve that started before the bump is + * stale by definition - the secret it read may already have been replaced - so it is allowed to + * finish, but not to become the cached session. Without this, rotating a credential and then + * losing the race against an in-flight discovery pass leaves the old key in memory until the next + * full `reset()`. + */ +private readonly generations = new Map(); + +private storeSession(credentialId: string, session: AtlasSession, generation: number): AtlasSession { + if (generation === (this.generations.get(credentialId) ?? 0)) { + this.sessions.set(credentialId, session); + } + return session; +} +``` + +Today this is masked in the common path because `configureAtlasCredentials()` ends with +`discoveryService.reset()` (a full `invalidateAll()`), but `AtlasCredentialActionStep.update()` +relies on the narrow `sessionRegistry.invalidate(credentialId)` alone. Note that in the comment so +the dependency on `reset()` is not re-established by accident. + +**Explicitly out of scope for this PR:** the per-credential write queue (Proposal A) and the +split-storage schema (Proposal B). File neither as blocking work; both only matter if concurrent +credential management across two VS Code windows becomes a supported scenario. + +Tests to add (both cheap, both pin the corrected behaviour): + +- Force `updateAtlasCredentialMetadata()` to complete after `replaceAtlasCredentialSecrets()` and + assert the new secret survives. +- Invalidate a credential while `getSession()` is deferred, resolve it, and assert the registry + cache stays empty rather than being repopulated with the stale session. + +> ✅ **RESOLVED (dev/tnaum/atlas-discovery-review-iteration) — second-pass fix, no write queue.** +> Change 1: `updateAtlasCredentialMetadata()` now calls `pushItem(updated, undefined)` and no longer +> reads or writes the secret (`StorageService.push()` leaves SecretStorage untouched for empty +> `secrets`), with the required explanatory comment. Change 2: `AtlasCredentialSessionRegistry` gained +> a per-credential generation map; `resolveSession`/`performRefresh` snapshot the generation before +> their first `await` and pass it to `storeSession`, which only writes the in-memory cache when the +> generation still matches; `invalidate()`/`invalidateAll()` bump it. The per-credential write queue +> (Proposal A) and split-storage schema (Proposal B) were deliberately left out of scope. +> Fix: [atlasCredentialStore.ts](../../../../src/plugins/service-atlas-mongodb/credentials/atlasCredentialStore.ts), +> [AtlasCredentialSessionRegistry.ts](../../../../src/plugins/service-atlas-mongodb/auth/AtlasCredentialSessionRegistry.ts). +> Tests: rotated-secret-survives-metadata-update in the store suite, and the invalidate-mid-flight +> generation-guard test in the registry suite. + +### LOW-1: External-link failures are silently discarded in the credential webview + +Source: Independent review. + +**Reassessment: Confirmed. Severity: Low (unchanged).** The failed action is part of the recovery +path for IP-access and permission errors, so silence is actionable usability debt. The user can +still open Atlas manually, and no local state is damaged. + +Files: + +- `src/webviews/documentdb/atlasCredentials/AtlasCredentialsView.tsx` +- `src/webviews/_integration/appRouter.ts` +- `src/utils/openUrl.ts` + +`openLink()` fires `common.openUrl.mutate()` with `void` and no rejection handler. If the host +mutation rejects, the access-settings and setup-guide buttons do nothing and provide no feedback. +`openUrl()` also ignores the boolean returned by `vscode.env.openExternal()`, so a refused open is +reported as success. Existing tests cover URL parsing and log redaction, not the mutation or client +failure path. + +**Proposal A: return the open result and handle both `false` and rejection in the webview.** + +```typescript +export async function openUrl(url: string): Promise { + return vscode.env.openExternal(vscode.Uri.parse(url)); +} +``` + +```typescript +try { + const opened = await trpcClient.common.openUrl.mutate({ url }); + if (!opened) { + setLinkError(l10n.t("We couldn't open this link.")); + } +} catch { + setLinkError(l10n.t("We couldn't open this link.")); +} +``` + +Why this helps: `false` is an expected API outcome rather than an exception, while transport and +host failures still take the rejection path. Both become visible in the existing MessageBar. + +Pros: + +- Preserves the distinction between refusal and an actual error. +- Keeps feedback in the surface where the user clicked the action. +- The common router remains reusable and does not choose UI on behalf of callers. + +Cons: + +- Webview call sites that need feedback must handle the result. +- Adds one small state transition to the credential component. + +**Proposal B: make the extension host own failure feedback.** + +```typescript +const opened = await vscode.env.openExternal(vscode.Uri.parse(url)); +if (!opened) { + void vscode.window.showErrorMessage(vscode.l10n.t("We couldn't open this link.")); +} +``` + +Why this helps: every caller receives a consistent VS Code notification without adding client-side +state. + +Pros: + +- Covers command and webview callers from one utility. +- Smallest component change. + +Cons: + +- Couples a generic URL utility to presentation. +- A modal/toast is less contextual than the existing credential error MessageBar. +- Promise rejection before the utility handles the result still needs router-level treatment. + +**Owner decision: handle failure in the extension host/router; no webview changes.** Change +`openUrl()` to return the `boolean` from `vscode.env.openExternal()`. The common router should catch +exceptions, show one localized VS Code error notification when the result is `false` or the call +throws, and return `true`/`false` to the caller. + +```typescript +export async function openUrl(url: string): Promise { + return vscode.env.openExternal(vscode.Uri.parse(url)); +} + +// common.openUrl mutation +try { + const opened = await openUrl(input.url); + if (!opened) { + void vscode.window.showErrorMessage(vscode.l10n.t("We couldn't open this link.")); + } + return opened; +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + ext.outputChannel.error(`[openUrl] Failed to open ${formatUrlForLogging(input.url)}: ${message}`); + void vscode.window.showErrorMessage(vscode.l10n.t("We couldn't open this link.")); + return false; +} +``` + +Yes, the `boolean` is optional for current webviews to consume. Existing fire-and-forget mutation +calls can ignore the result because the extension host supplies the user feedback. Returning it +still keeps the router contract truthful and allows future callers or tests to react without +changing the host behavior. Add router tests for `true`, `false`, and rejection; a component test is +not needed for this chosen design. + +**Second-pass assessment: confirmed. Severity: Low (unchanged).** `openUrl()` is still +`Promise` discarding `openExternal`'s boolean, the router still does a bare `await openUrl(…)`, +and `AtlasCredentialsView.openLink` is still `void trpcClient.common.openUrl.mutate({ url })` with no +rejection handler. One implementation detail worth pinning in the router: keep +`formatUrlForLogging(input.url)` **after** the zod `isSupportedExternalUrl` refine, because +`formatUrlForLogging` constructs `new URL(value)` unguarded and would throw on an unparseable input. +The current ordering is correct; the new `try`/`catch` must not move the trace line above the +validation. + +> ✅ **RESOLVED (dev/tnaum/atlas-discovery-review-iteration) — host-owned handling.** `openUrl()` now +> returns the `boolean` from `vscode.env.openExternal()`. The `common.openUrl` mutation catches +> exceptions, shows one localized "We couldn't open this link." notification when the result is +> `false` or the call throws, and returns the boolean; the trace/`formatUrlForLogging` line stays +> after the zod refine. No webview changes. +> Fix: [openUrl.ts](../../../../src/utils/openUrl.ts), +> [appRouter.ts](../../../../src/webviews/_integration/appRouter.ts). +> Tests: `openUrl` true/false in [openUrl.test.ts](../../../../src/utils/openUrl.test.ts). +> **Deviation:** the requested router-level notification tests were not added — there is no existing +> `appRouter` caller test harness and `appRouter` transitively imports the full webview router graph, +> so a bespoke harness was disproportionate for a Low finding. The util boolean contract plus type +> checking cover the substantive change; the router branch is trivial (try/catch + notification). + +### LOW-2: The generic 403 fallback tells Service Account users to fix an API key + +Source: Copilot reviewer, verified. + +**Reassessment: Confirmed. Severity: Low (unchanged).** The fallback is factually wrong for one +supported auth method, but only when Atlas omits every useful detail from a `403`; the status and +recovery flow remain intact. + +Discussion: https://github.com/microsoft/vscode-documentdb/pull/765#discussion_r3498997624 + +File: `src/plugins/service-atlas-mongodb/api/AtlasApiClient.ts` + +When Atlas returns `403` without a detail body, the shared client says, "Verify your API key has +the required permissions." The same client serves Service Accounts, so that fallback can direct +those users to the wrong credential type. + +**Proposal A: use a credential-neutral fallback.** + +```typescript +vscode.l10n.t('Access denied. Verify this credential has the required permissions.'); +``` + +Pros: + +- Correct for API Keys and Service Accounts. +- One localized string and one test expectation. +- Avoids exposing authentication branching in generic error handling. + +Cons: + +- Less specific than naming the active credential type. + +**Proposal B: branch on the session type.** + +```typescript +const fallback = + this.session.type === 'serviceaccount' + ? vscode.l10n.t('Access denied. Verify this Service Account has the required permissions.') + : vscode.l10n.t('Access denied. Verify this API Key has the required permissions.'); +``` + +Pros: + +- Gives the user the exact Atlas object to inspect. + +Cons: + +- Adds branches and translation combinations to a fallback used only when Atlas supplies no detail. + +**Recommendation:** choose Proposal A. The recovery instruction is identical for both methods, so +neutral wording is accurate and simpler. Add one no-detail `403` test per session type. + +**Second-pass assessment: confirmed, scope narrower than it reads. Severity: Low (unchanged).** +`handleErrorResponse` computes `detail = body.detail ?? body.reason ?? body.raw ?? ''`, so the +API-key wording only surfaces when Atlas returns a `403` with no `detail`, no `reason`, **and** no +non-JSON body — every populated response takes the `Access denied: {0}` branch instead. That is a +narrow trigger, which is why Low is right, but it is also why Proposal A is clearly the correct +choice: branching on session type to improve a message almost nobody sees is not worth the extra +translation combinations. + +> ✅ **RESOLVED (dev/tnaum/atlas-discovery-review-iteration) — Proposal A.** The no-detail `403` +> fallback in `handleErrorResponse` now reads "Access denied. Verify this credential has the required +> permissions." (credential-neutral). +> Fix: [AtlasApiClient.ts](../../../../src/plugins/service-atlas-mongodb/api/AtlasApiClient.ts). +> Test: no-detail `403` neutral-message assertion in +> [AtlasApiClient.test.ts](../../../../src/plugins/service-atlas-mongodb/api/AtlasApiClient.test.ts). + +### LOW-3: The cluster tooltip's labels and successful-connection sentence bypass localization + +Source: Copilot reviewer, verified. + +**Reassessment: Confirmed and broader than originally reported. Severity: Low (unchanged).** + +Discussion: https://github.com/microsoft/vscode-documentdb/pull/765#discussion_r3498997649 + +File: `src/plugins/service-atlas-mongodb/discovery-tree/AtlasClusterItem.ts` + +`buildTooltip()` appends the raw English sentence "Connection string available - expand to connect +and browse databases." It also hardcodes every field label: `State`, `Type`, `MongoDB`, `Tier`, +`Provider`, `Region`, and `Project`. The earlier review caught only the final sentence. State +explanations use `l10n.t()`, but the normal IDLE tooltip remains substantially English in localized +builds. + +**Proposal A: localize every literal in place.** + +```typescript +md.appendMarkdown(`- **${l10n.t('State')}:** ${escapeMarkdown(this.cluster.stateName)}\n`); +md.appendMarkdown(`- **${l10n.t('Type')}:** ${escapeMarkdown(this.cluster.clusterType)}\n`); +md.appendMarkdown(l10n.t('Connection string available - expand to connect and browse databases.')); +``` + +Apply the same label pattern to Tier, Provider, Region, Project, and the version label. + +Pros: + +- Direct, low-risk correction. +- Easy to compare against the current tooltip. + +Cons: + +- Repeats formatting and makes it easy for the next field to omit localization again. +- Translators control the label but not the surrounding Markdown punctuation. + +**Proposal B: render a localized field list through one helper.** + +```typescript +const fields: Array<[string, string | undefined]> = [ + [l10n.t('State'), this.cluster.stateName], + [l10n.t('Type'), this.cluster.clusterType], + [l10n.t('Server version'), this.cluster.mongoDBVersion], + [l10n.t('Tier'), this.cluster.instanceSizeName], + [l10n.t('Provider'), this.cluster.providerName], + [l10n.t('Region'), this.cluster.regionName && this.formatRegion(this.cluster.regionName)], + [l10n.t('Project'), this.cluster.projectName], +]; + +for (const [label, value] of fields) { + if (value) md.appendMarkdown(`- **${label}:** ${escapeMarkdown(value)}\n`); +} +``` + +Pros: + +- Makes localization the default for every tooltip field. +- Removes repeated conditionals and aligns the version terminology fix. + +Cons: + +- Slightly restructures a small method. +- Optional empty values need deliberate handling so required fields do not disappear accidentally. + +**Recommendation:** choose Proposal B. The original review's narrow miss demonstrates why a single +localized field list is safer than correcting literals one by one. Localize the final sentence, +run `npm run l10n`, and test with a mocked translator that visibly transforms every label. + +**Second-pass assessment: confirmed, and it is an internal inconsistency rather than an omission. +Severity: Low (unchanged).** The two sibling tree items added by the _same PR_ do it correctly: +`AtlasProjectItem.buildTooltip()` writes `` `- **${vscode.l10n.t('Organization')}:** …` `` and +`AtlasOrganizationItem.buildTooltip()` writes `` `- **${vscode.l10n.t('Organization ID')}:** …` ``. +Only `AtlasClusterItem` hardcodes its labels. That strengthens the case for Proposal B: the pattern +the reviewer would have to remember already exists two files away and was still not applied here, so +the fix should be structural rather than another round of literal-by-literal edits. + +### LOW-4: The cluster tooltip uses "MongoDB" as a standalone product label + +Source: Copilot reviewer, verified. + +**Reassessment: Confirmed. Severity: Low (unchanged).** This violates the repository's explicit +terminology policy on every cluster tooltip, but it does not change behavior. + +Discussion: https://github.com/microsoft/vscode-documentdb/pull/765#discussion_r3498997670 + +File: `src/plugins/service-atlas-mongodb/discovery-tree/AtlasClusterItem.ts` + +The tooltip renders `MongoDB: v...`. Repository terminology requires "MongoDB API" or another +explicit compatibility/server-version description rather than "MongoDB" alone. + +**Proposal A: label the value as the server version.** + +```typescript +md.appendMarkdown(`- **${l10n.t('Server version')}:** ${escapeMarkdown(this.cluster.mongoDBVersion)}\n`); +``` + +Why this helps: Atlas's `mongoDBVersion` is the database server version reported for the cluster; +"Server version" describes the value without using "MongoDB" as a standalone product label. + +Pros: + +- Precise, localized, and minimal. +- Avoids implying that this is an API compatibility level. + +Cons: + +- The TypeScript property still mirrors Atlas's `mongoDBVersion` payload name. + +**Proposal B: rename the internal model property at the API boundary.** + +```typescript +return { + // ... + serverVersion: cluster.mongoDBVersion, +}; +``` + +Pros: + +- Internal code consistently uses domain-neutral terminology. +- Future UI labels are less likely to repeat the standalone product name. + +Cons: + +- Touches the model, factory, tooltip, and tests for no runtime benefit. +- Diverges from the Atlas response field name, making payload mapping less direct. + +**Recommendation:** choose Proposal A. Keep the API-shaped property name at the boundary and fix +the user-facing terminology where it is rendered. Fold this into LOW-3's localized field list. + +**Second-pass assessment: confirmed, and the scope should be widened. Severity: Low (unchanged).** +The tooltip is not the only terminology violation this PR introduces. `CreateDatabaseWizardContext.ts` +documents the new flag as: + +```typescript +/** + * When true, the wizard prompts for an initial collection name. + * Required for standard MongoDB (Atlas) where dropping the last collection deletes the database. + * Azure DocumentDB vCore does not need this. + */ +requiresInitialCollection?: boolean; +``` + +"standard MongoDB (Atlas)" uses MongoDB as a standalone product name, which the repository +instructions prohibit in code comments as well as user-facing strings. Fix both under this finding +so the terminology sweep is complete; the comment can say "the MongoDB API wire protocol as +implemented by Atlas" or simply describe the behaviour ("where dropping the last collection also +removes the database"). + +> ✅ **RESOLVED (dev/tnaum/atlas-discovery-review-iteration) — LOW-3 Proposal B + LOW-4 Proposal A.** +> `AtlasClusterItem.buildTooltip()` now renders one localized field list (`State`, `Type`, +> `Server version`, `Tier`, `Provider`, `Region`, `Project`), and the "Connection string available…" +> sentence is wrapped in `l10n.t()`. The `MongoDB: v…` line became `Server version: v…`, removing the +> standalone "MongoDB" product label while keeping the API-shaped `mongoDBVersion` property. +> `CreateDatabaseWizardContext.requiresInitialCollection`'s comment no longer says "standard MongoDB +> (Atlas)". `npm run l10n` runs at the final checklist step. +> Fix: [AtlasClusterItem.ts](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasClusterItem.ts), +> [CreateDatabaseWizardContext.ts](../../../../src/commands/createDatabase/CreateDatabaseWizardContext.ts). +> Tests: "Server version" / no-"MongoDB:" and localized-label assertions in +> [AtlasClusterItem.test.ts](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasClusterItem.test.ts). + +### INFO-1: The cluster model drops the existing cluster-type union + +Source: Copilot reviewer, partially fixed and still applicable. + +**Reassessment: Confirmed. Severity: Informational (unchanged).** `AtlasCluster` already constrains +the payload, so current production call sites pass the right type. The wider factory input weakens +compile-time protection but does not create a runtime defect by itself. + +Discussion: https://github.com/microsoft/vscode-documentdb/pull/765#discussion_r3498997729 + +Files: + +- `src/plugins/service-atlas-mongodb/models/AtlasClusterModel.ts` +- `src/plugins/service-atlas-mongodb/models/AtlasProjectModel.ts` + +`stateName` now uses `AtlasClusterState`, addressing half of the original comment, but +`clusterType` remains `string` in both `AtlasClusterModel` and the factory input even though +`AtlasClusterType` already describes the API contract. + +**Proposal A: use the existing named union in both declarations.** + +```typescript +import { type AtlasClusterState, type AtlasClusterType } from './AtlasProjectModel'; + +readonly clusterType: AtlasClusterType; +// factory input +clusterType: AtlasClusterType; +``` + +Pros: + +- Small and explicit. +- Produces readable compiler errors and documentation. + +Cons: + +- The factory continues to repeat a hand-written subset of `AtlasCluster`. + +**Proposal B: derive the field type from the API model.** + +```typescript +import { type AtlasCluster } from './AtlasProjectModel'; + +readonly clusterType: AtlasCluster['clusterType']; +``` + +The factory can accept `AtlasCluster` or a `Pick` rather than restating each +field type. + +Pros: + +- Prevents the API and tree-model declarations from drifting. +- Can remove several duplicated field declarations if applied to the whole factory input. + +Cons: + +- Indexed-access and large `Pick` types are less readable than the named domain union. +- Accepting the whole API object couples the factory more tightly than necessary. + +**Recommendation:** choose Proposal A. `AtlasClusterType` exists specifically as the readable +contract for this field; using it is clearer than deriving the same union indirectly. A build is +sufficient validation because this is compile-time hardening. + +**Second-pass assessment: confirmed. Severity: Informational (unchanged).** `AtlasClusterModel` +still declares `readonly clusterType: string` and `createAtlasClusterModel`'s inline parameter +shape still declares `clusterType: string`, while `AtlasProjectModel.ts` exports +`AtlasClusterType = 'REPLICASET' | 'SHARDED' | 'GEOSHARDED'`. Worth noting that the _runtime_ +version of this concern is a separate, larger issue: the union is never enforced against the actual +payload either, which is covered by +[NEW-7](#new-7-atlas-api-payloads-are-cast-never-validated). + +> ✅ **RESOLVED (dev/tnaum/atlas-discovery-review-iteration) — Proposal A, folded into NEW-7.** Since +> NEW-7 already reopened these model files, `AtlasClusterModel.clusterType` and the +> `createAtlasClusterModel` factory input now use the `AtlasClusterType` union instead of `string`, +> as the reviewer suggested doing "whenever the touched model is next updated". +> Fix: [AtlasClusterModel.ts](../../../../src/plugins/service-atlas-mongodb/models/AtlasClusterModel.ts). + +## Second-Pass Findings + +Everything below was found in the second pass and was not reported in pass 1. Each item names the +exact code that produces it. + +### NEW-1: A user-test prototype switch ships inside the credential-entry webview + +**Severity as reported: High. Final severity after the owner decision: Informational — accepted, see +the FINAL DECISION subsection at the end of this finding.** The analysis below is retained because +it is the removal checklist for whoever exits preview. + +Not a correctness or security failure, but it is user-visible on every add and update of an Atlas +credential, and its strings have already been exported for translation. + +File: `src/webviews/documentdb/atlasCredentials/AtlasCredentialsView.tsx` + +The component renders an experiment toggle above the entire flow: + +```tsx +{/* USER-TEST PROTOTYPE: Remove this switch and badge with the footer experiment logic above. */} +
+ + PREVIEW +
+``` + +The file carries four `USER-TEST PROTOTYPE` markers, the first of which says the state, refs, +measurement callback, and `scrollAreaInlineFooter` class are all to be removed after user testing. +Both strings are already committed to the shipped bundle: + +```jsonc +// l10n/bundle.l10n.json +"Footer experiment": "Footer experiment", +"Footer experiment is in preview": "Footer experiment is in preview", +``` + +Consequences: users adding an Atlas credential see an unexplained "Footer experiment / PREVIEW" +control on a security-sensitive screen; translators are asked to localise a throwaway A/B label; +and the strings must then be removed again, churning every language file. + +**Proposal A: pick the winning layout and delete the experiment.** Remove the `Switch`, the `Badge`, +`adaptiveFooterEnabled`, `styles.prototypeToggle`, the unused `Switch`/`Badge` imports, and the +conditional in `updateFooterLayout`, keeping whichever branch the user testing selected. Then run +`npm run l10n` so the two strings leave the bundle. + +- Pros: the shipped surface matches the intended design; no dead prototype state; the bundle is + clean before translation. +- Cons: requires the user-testing result to be known now. + +**Proposal B: keep both layouts but remove the user-facing control.** Retain +`adaptiveFooterEnabled` as a module constant and delete only the `Switch`, `Badge`, and their +strings. + +- Pros: unblocks the release without waiting for the testing outcome; the comparison code stays + available for a follow-up. +- Cons: leaves a dead branch and a `ResizeObserver` measurement path that nothing can reach, which + is exactly the debt the prototype markers were meant to prevent. + +**Recommendation: Proposal A.** A prototype toggle that survives into a release branch is how it +survives into a release. If the result genuinely is not known yet, take Proposal B **now** and open +a tracked follow-up, but do not merge with the control visible. + +--- + +### NEW-1 — FINAL DECISION: accepted, no change + +**Decision: keep the footer experiment as-is. Severity reduced from High to Informational.** This +ships as a **preview release**, which is exactly the audience a labelled `PREVIEW` A/B control is +for. The finding stood on "a release branch should not carry a user-test toggle"; that premise does +not apply here, so the finding does not. + +Nothing to implement. Two notes for whoever exits preview: + +- The four `USER-TEST PROTOTYPE` markers in `AtlasCredentialsView.tsx` are the complete removal + checklist (state, root/footer refs, measurement callback, `scrollAreaInlineFooter`, the `Switch`, + the `Badge`, and the `Switch`/`Badge` imports). Removing the control without them leaves an + unreachable `ResizeObserver` branch. +- `'Footer experiment'` and `'Footer experiment is in preview'` are in `l10n/bundle.l10n.json` and + must be removed with `npm run l10n` at the same time, so translators are not left maintaining a + string that no longer renders. + +### NEW-2: Four index context-menu commands were not extended for the Atlas experience + +**Severity: Medium.** A whole feature area silently disappears for Atlas-discovered clusters, with +no error and no log line — precisely the failure mode the repository's dual-ID guidance calls out as +"silent bugs". + +File: `package.json` + +The PR adds `mongoDBAtlas` to twenty-one `experience_(…)` `when` clauses. Four were missed, and all +four are the index-level ones: + +| Command | `when` clause | +| ------------------------------------------------------- | ---------------------------------- | +| `vscode-documentdb.command.hideIndex` | `experience_(documentDB\|mongoRU)` | +| `vscode-documentdb.command.unhideIndex` | `experience_(documentDB\|mongoRU)` | +| `vscode-documentdb.command.dropIndex` | `experience_(documentDB\|mongoRU)` | +| `vscode-documentdb.command.copyReference` (index scope) | `experience_(documentDB\|mongoRU)` | + +Verification: + +```console +$ grep -o 'experience_([^)]*)' package.json | sort | uniq -c + 21 experience_(documentDB|mongoRU|mongoDBAtlas) + 4 experience_(documentDB|mongoRU) +``` + +All four are scoped to `treeitem_index`, and `IndexItem` builds its context value as +`` `experience_${this.experience.api}` `` from the inherited cluster experience, which for an Atlas +cluster is `mongoDBAtlas`. So Hide Index, Unhide Index, Delete Index, and Copy Reference are absent +from the context menu on every Atlas cluster, while the twenty-one database/collection/document +actions all work. + +**Proposal A: add `mongoDBAtlas` to the four remaining clauses.** + +- Pros: one-line-per-entry, restores parity, matches what the other twenty-one already do. +- Cons: none; this is the same edit the PR already made everywhere else. + +**Proposal B: assert the invariant in a test.** Add a small test over `package.json` that fails when +any `experience_(…)` clause omits an API that `experiencesArray` declares. + +- Pros: the next experience added cannot repeat this; the mistake is invisible to `lint`, `build`, + and the current suite, which is why it reached review. +- Cons: a test that reads `package.json` is unusual in this repository. + +**Recommendation: Proposal A now, Proposal B as a small follow-up.** The omission is trivially +fixable, but nothing in CI would have caught it, and a fifth experience will eventually be added. + +> ✅ **RESOLVED (dev/tnaum/atlas-discovery-review-iteration) — Proposal A.** Added `mongoDBAtlas` to the +> four remaining `treeitem_index` `when` clauses (hideIndex, unhideIndex, dropIndex, copyReference). +> All `experience_(…)` clauses now read `experience_(documentDB|mongoRU|mongoDBAtlas)` (25/25). +> Proposal B (a `package.json` invariant test) is left as the noted follow-up. +> Fix: [package.json](../../../../package.json). + +### NEW-3: Project expansion raises a blocking modal that blames credentials for every failure kind + +**Severity: Medium.** The reported premise was partly wrong — the modal itself is intended — but the +wording, the missing error text, and the `await` are all real. See the FINAL DECISION subsection at +the end of this finding for what to build. + +Files: + +- `src/plugins/service-atlas-mongodb/discovery-tree/AtlasProjectItem.ts` +- `src/plugins/service-atlas-mongodb/discovery-tree/showAtlasLoadFailure.ts` + +`AtlasServiceRootItem` documents the intent as: "Whatever goes wrong across the credential fleet +collapses into a single recovery row, so one broken credential never blanks the healthy data and +never produces a storm of nodes or modals", and `classifyRecoveryAction()` exists so a network +failure says _retry_ rather than _revisit credentials_. Project expansion does the opposite: + +```typescript +} catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + await showAtlasLoadFailure(vscode.l10n.t('Failed to load MongoDB Atlas clusters.'), errorMessage); + return [this.createRetryNode()]; +} +``` + +```typescript +export async function showAtlasLoadFailure(failureMessage: string, errorMessage: string): Promise { + ext.outputChannel.appendLine(l10n.t('Failed to load MongoDB Atlas discovery: {0}', errorMessage)); + await window.showErrorMessage(failureMessage, { + modal: true, + detail: l10n.t('Revisit credentials and try again.'), + }); +} +``` + +Three problems compound: + +1. It is **modal**, and it is `await`ed inside `getChildren()`, so the expansion does not resolve + until it is dismissed. Expanding several projects queues several modals. +2. The `detail` is a fixed credential-blaming string for _every_ failure kind. A `429`, a dropped + Wi-Fi connection, and a genuinely revoked key all produce "Revisit credentials and try again." +3. `errorMessage` is only written to the output channel; the modal never shows what actually + happened, so the one place the user is looking carries the least information. + +This is the tree-side twin of MEDIUM-3 and shares its root cause. + +**Proposal A: classify, then choose the wording; keep the modal only for credential failures.** +Reuse `classifyAtlasError()` and pass the kind into `showAtlasLoadFailure`, so `network` and +`rateLimited` say "retry" and only `auth`/`forbidden` mention credentials. + +- Pros: one shared classifier drives both the root row and the project row; the recovery verb + becomes correct. +- Cons: still modal for some cases. + +**Proposal B: drop the modal and let the retry node carry the message.** `createRetryNode()` already +returns a row; give it a tooltip built the same way `buildRecoveryTooltip()` builds the root's, and +keep the raw text in the output channel. + +- Pros: matches the stated "no storm of modals" design exactly; non-blocking; consistent with how + the root already reports fleet failures; nothing is lost because the details were never in the + modal anyway. +- Cons: a quieter failure, which the author may have deliberately avoided for a scoped failure. + +**Recommendation: Proposal B, with Proposal A's classification applied to the row label.** The root +already proves this pattern works, and a blocking modal per expanded project is the one behaviour +the surrounding design explicitly set out to avoid. + +--- + +### NEW-3 — FINAL DECISION: Proposal A, with an explicit modal rule + +**Decision: implement Proposal A. Proposal B is rejected — the modal is intended.** The finding was +partly built on a wrong premise: it read the root's "no storm of modals" comment as banning modals +outright. The intended rule is narrower, and it is about _who asked_: + +| Trigger | Failure surface | +| ---------------------------------------------- | ---------------------------------- | +| User expands a project node | Modal **plus** the retry node | +| User clicks the "Click here to retry" node | Modal **plus** the retry node | +| User runs **Refresh** on the node or the tree | Retry node only, **no modal** | +| Atlas answered successfully with an empty list | `empty` placeholder, never a modal | + +The parts of the finding that survive are real and must still be fixed: the modal's `detail` is a +fixed credential-blaming string for every failure kind, the actual error text never reaches the +user, and the call is `await`ed inside `getChildren()`. + +**Change 1 — classify, and stop blaming credentials for transient failures.** Replace +`showAtlasLoadFailure`'s fixed detail with wording chosen from `classifyAtlasError()`, and include +the real error, matching what `KubernetesContextItem.createConnectionErrorChildren()` already does +in this repository: + +```typescript +export function showAtlasLoadFailure(title: string, error: unknown, hint: string): void { + const message = error instanceof Error ? error.message : String(error); + ext.outputChannel.error(l10n.t('Failed to load MongoDB Atlas discovery: {0}', message)); + + // `void`, not `await`: the Kubernetes plugin does the same. Awaiting a modal inside + // `getChildren()` keeps the tree node spinning until the dialog is dismissed, and queues one + // dialog per expanded project. + void window.showErrorMessage(title, { + modal: true, + detail: `${hint}\n\n${l10n.t('Error: {0}', message)}`, + }); +} +``` + +The `hint` comes from the taxonomy the plugin already owns, so a dropped connection no longer tells +the user to re-enter a working key: + +```typescript +function recoveryHintFor(kind: AtlasErrorKind): string { + switch (kind) { + case 'auth': + return l10n.t('The stored credential was rejected. Update it, then try again.'); + case 'forbidden': + return l10n.t( + 'The credential is signed in but lacks access to this project. Review its roles and IP access list in MongoDB Atlas.', + ); + case 'rateLimited': + return l10n.t('MongoDB Atlas asked us to slow down. Wait briefly, then try again.'); + case 'network': + return l10n.t('MongoDB Atlas could not be reached. Check your connection or proxy settings, then try again.'); + default: + return l10n.t('Try again. If this persists, check the output channel for details.'); + } +} +``` + +**Change 2 — suppress the modal on Refresh only.** `refreshTreeElement` calls `node.refresh(context)` +when the element defines it, while `retryAuthentication` (the "Click here to retry" handler) goes +straight to `resetNodeErrorState()` + provider `refresh(node)` and never touches the element. That +asymmetry is exactly the signal needed, so no new command or context plumbing is required — set a +one-shot flag in the element's own `refresh()`: + +```typescript +/** + * Set by {@link refresh} and consumed by the next {@link getChildren}. + * + * Refresh is a passive, whole-subtree action: the user is not asking about this project in + * particular, so a failure belongs in the retry node, not in a dialog. Expanding the node, or + * clicking "Click here to retry", *is* a question about this project and still answers with a + * modal. `retryAuthentication` deliberately does not call this method, which is what keeps the + * two paths distinguishable without extra plumbing. + */ +private suppressNextLoadModal = false; + +public async refresh(_context: IActionContext): Promise { + this.suppressNextLoadModal = true; + atlasTrace(`project "${this.project.name}": explicit refresh requested`); + await this.discoveryService.sessionRegistry.refreshSession(this.ownerCredentialId); + ext.discoveryBranchDataProvider.resetNodeErrorState(this.id); + ext.discoveryBranchDataProvider.refresh(this); +} + +async getChildren(): Promise { + const quiet = this.suppressNextLoadModal; + this.suppressNextLoadModal = false; // one-shot; a later expand must show the modal again + // … +} +``` + +Reset the flag at the top of `getChildren()` (not in a `finally` further down) so an early return or +a throw cannot leave a stale `true` that silences the next genuine expansion. + +**Change 3 — same treatment for the no-session branch.** `getChildren()`'s +`if (!session)` path currently hardcodes "The credential for this project was rejected", which is +the same MEDIUM-3 collapse. Once MEDIUM-3 gives the session registry a typed failure, route that +branch through `recoveryHintFor()` as well; the two findings should land in one change. + +Tests to add: + +- `getChildren()` after `refresh()` → no `showErrorMessage` call, retry node still returned. +- `getChildren()` without a preceding `refresh()` → `showErrorMessage` called once. +- Two consecutive `getChildren()` calls after one `refresh()` → the second one shows the modal. +- A `network`-classified failure → the detail contains the retry wording, not the credential wording. + +> ✅ **RESOLVED (dev/tnaum/atlas-discovery-review-iteration) — Proposal A, with MEDIUM-3.** +> `showAtlasLoadFailure(title, error, hint)` is now `void` (not awaited), logs the real error to the +> output channel, and renders the hint plus the error text; `recoveryHintFor(kind)` supplies the +> per-kind wording. `AtlasProjectItem.getChildren()` classifies the failure through +> `classifyAtlasError` and only shows the modal when the one-shot `suppressNextLoadModal` flag is +> clear; `refresh()` sets that flag (a passive Refresh), while `retryAuthentication` does not (an +> explicit retry still shows the modal). The flag is read and reset at the top of `getChildren()`. +> The no-session branch routes through `recoveryHintFor('auth')`, and `getSession`/`refreshSession` +> can now throw (MEDIUM-3), handled by the same catch/`.catch(() => undefined)`. +> Fix: [AtlasProjectItem.ts](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasProjectItem.ts), +> [showAtlasLoadFailure.ts](../../../../src/plugins/service-atlas-mongodb/discovery-tree/showAtlasLoadFailure.ts). +> Tests: modal-once on expand, no-modal-after-refresh, modal-again on the second expand, and +> network-wording assertion in +> [AtlasProjectItem.test.ts](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasProjectItem.test.ts). + +### NEW-4: Digest authentication repeats the unauthenticated challenge on every request + +**Severity: Medium.** It doubles Atlas Admin API traffic for every API Key credential, in a design +whose whole point is fan-out across credentials and projects, and it doubles it against the very +rate limit the code already has a taxonomy for. + +File: `src/plugins/service-atlas-mongodb/api/AtlasApiClient.ts` + +`requestOnce()` sends an unauthenticated `GET`, waits for the `401` challenge, then sends the real +request — **for every call**, including every page of every paginated list: + +```typescript +const initialResponse = await fetch(url, { method: 'GET', headers, signal }); +if (initialResponse.status === 401) { + const challenge = parseDigestChallenge(wwwAuth); + this.digestNonceCount++; + … + const authedResponse = await fetch(url, { method: 'GET', headers, signal }); +``` + +The nonce-count field is the tell. `digestNonceCount` is an instance field incremented per request, +which is exactly the RFC 7616 mechanism for **reusing** a server nonce across subsequent requests — +but the challenge is discarded after each call, so a fresh nonce is fetched every time and the +counter never serves its purpose. + +Cost: List mode with `N` projects issues roughly `2 × (2 + N)` requests per API Key credential +instead of `2 + N`. The owner's chosen MEDIUM-2 direction (always fetch clusters, for both view +modes) multiplies `N` by the credential count, so this lands on the more expensive design, not the +cheaper one. + +**Proposal A: cache the challenge per client and reuse it with an incrementing `nc`.** Store the +parsed challenge, send the Digest header pre-emptively, and fall back to the challenge round-trip +only when the server answers `401` (stale nonce or first request). + +```typescript +if (this.digestChallenge) { + headers['Authorization'] = computeDigestHeader('GET', digestUri, …, this.digestChallenge, ++this.digestNonceCount); +} +const response = await fetch(url, { method: 'GET', headers, signal }); +if (response.status === 401) { + // Re-challenge: parse, reset the counter, and retry once. +} +``` + +- Pros: halves request volume on the steady-state path; uses `digestNonceCount` as intended; + matches how conventional HTTP Digest clients behave; keeps a correct fallback for `stale=true`. +- Cons: adds one retry branch and a nonce-lifetime concern; needs the counter reset on a new nonce. + +**Proposal B: leave the round-trip and reduce request count elsewhere.** Accept two requests per +call and instead avoid duplicate cluster listings (the snapshot already holds clusters that +`AtlasProjectItem` and `SelectAtlasClusterStep` re-fetch). + +- Pros: no change to authentication code, which is the part with zero test coverage. +- Cons: does not address the multiplier; Service Account credentials would keep paying one request + where API Key credentials pay two, so throttling behaviour stays auth-method dependent. + +**Recommendation: Proposal A, implemented together with the WITHDRAWN-1 hardening.** Both touch the +same eight lines and both need the same first Digest unit test, so doing them separately means +writing that test twice. Assert: a pre-emptive `Authorization` header on the second request, a +correctly incremented `nc`, and a re-challenge on a `401` with `stale=true`. + +> ✅ **RESOLVED (dev/tnaum/atlas-discovery-review-iteration), together with WITHDRAWN-1.** Added a +> `digestChallenge` field cached per client. `requestOnce()` now answers pre-emptively with the +> cached challenge and an incrementing `nc` (`++this.digestNonceCount`), only fetching a fresh +> unauthenticated challenge on the first request or on a `401` re-challenge (the counter resets to 0 +> when a new nonce is adopted). This drops steady-state API Key traffic from two requests per call to +> one. The `(digest challenge answered)` trace line became `(digest)` because the challenge round-trip +> is no longer the common path. +> Fix: [src/plugins/service-atlas-mongodb/api/AtlasApiClient.ts](../../../../src/plugins/service-atlas-mongodb/api/AtlasApiClient.ts) +> (`DigestChallenge` exported from +> [AtlasDigestAuth.ts](../../../../src/plugins/service-atlas-mongodb/api/AtlasDigestAuth.ts)). +> Tests in [AtlasApiClient.test.ts](../../../../src/plugins/service-atlas-mongodb/api/AtlasApiClient.test.ts): +> pre-emptive reuse with `nc` advancing 1→2, and a stale-nonce re-challenge resetting `nc` (1,2,1). + +### NEW-5: Non-connectable clusters are guarded in the wizard but not in the tree + +**Severity: Low.** The user reaches an internal assertion message instead of an explanation; nothing +is corrupted and the tree recovers. + +Files: + +- `src/plugins/service-atlas-mongodb/discovery-tree/AtlasClusterItem.ts` +- `src/plugins/service-atlas-mongodb/models/AtlasClusterModel.ts` +- `src/plugins/service-atlas-mongodb/discovery-wizard/SelectAtlasSteps.ts` + +`SelectAtlasClusterStep` handles this carefully: non-`IDLE` clusters become `unavailableCluster` +items with a per-state explanation, and a missing connection string raises a worded +`UserCancelledError`. The tree does none of it. `getTreeItem()` returns +`collapsibleState: Collapsed` unconditionally, and `authenticateAndConnect()` then reaches: + +```typescript +nonNullValue(this.cluster.connectionString, 'cluster.connectionString', 'AtlasClusterItem.ts'); +``` + +`buildTooltip()` already proves the value is known to be optional (`if (this.cluster.connectionString)`). +The same applies to `getCredentials()`, which backs "Save to Connections". The failure is contained — +`callWithTelemetryAndErrorHandling` catches it and the item falls back to its error-recovery +children — but the message the user sees is an internal invariant string, for a state the wizard +explains properly two files away. + +Related: `createAtlasClusterModel()` dereferences `cluster.connectionStrings.standardSrv` and +`SelectAtlasSteps` dereferences `c.connectionStrings.standardSrv`, both without a guard, even though +`connectionStrings` is populated by the API and is not validated (see NEW-7). + +**Proposal A: mirror the wizard's guard in the tree.** Render a non-`IDLE` or connection-string-less +cluster as `TreeItemCollapsibleState.None` and reuse `getStateExplanation()` in the tooltip. + +- Pros: the two surfaces agree; the user gets the explanation that already exists; no new strings. +- Cons: a cluster that becomes `IDLE` needs a refresh before it is expandable, which is already true + of everything else in the snapshot. + +**Proposal B: keep it expandable and return an informative child row.** Detect the condition in +`authenticateAndConnect()` and surface `getStateExplanation()` instead of the assertion. + +- Pros: no change to collapsible state; the affordance stays uniform. +- Cons: invites a click that can never succeed, which is what the wizard deliberately prevents. + +**Recommendation: Proposal A.** The wizard already made this decision for the same data; the tree +should not disagree with it. + +> ✅ **RESOLVED (dev/tnaum/atlas-discovery-review-iteration) — Proposal A.** `AtlasClusterItem` now +> renders a non-IDLE or connection-string-less cluster as `TreeItemCollapsibleState.None` via an +> `isConnectable()` helper, and the tooltip explains why (`getStateExplanation()` for a non-IDLE +> state, or a "does not expose a connection string yet" message for the connection-string-less case). +> `authenticateAndConnect()` and `getCredentials()` guard on `isConnectable()` and show a localized +> warning instead of tripping the internal `nonNullValue` assertion. The related unguarded +> `connectionStrings` dereferences are covered by NEW-7. +> Fix: [AtlasClusterItem.ts](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasClusterItem.ts). +> Tests: collapsibleState `Collapsed`/`None` cases in +> [AtlasClusterItem.test.ts](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasClusterItem.test.ts). + +### NEW-6: The Atlas plugin is the only discovery provider without a journey correlation ID + +**Severity: Low.** A telemetry blind spot rather than a user-visible defect, but it silently breaks +the one funnel metric the plugin's own code comments say they exist for. + +Files: + +- `src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts` +- `src/plugins/service-atlas-mongodb/discovery-tree/AtlasProjectItem.ts` + +Every other provider mints one at its root and threads it down: +`AzureServiceRootItem`, `AzureMongoRUServiceRootItem`, `AzureVMServiceRootItem`, and +`KubernetesKubeconfigSourceItem` all do `const journeyCorrelationId = randomUUID();`. Atlas passes an +empty string from both construction sites: + +```typescript +return new AtlasClusterItem('', treeCluster, context, { … }); // AtlasServiceRootItem +return new AtlasClusterItem('', treeCluster, undefined, { … }); // AtlasProjectItem +``` + +`AtlasClusterItem` then guards with `if (this.journeyCorrelationId)`, so nothing is emitted, and +`addConnectionFromRegistry`'s `if (node.journeyCorrelationId)` and `trackJourneyCorrelationId()` +both find nothing. Atlas connections cannot be correlated across the discovery funnel while every +other source can. + +**Proposal A: mint at the root and thread it through, matching the four existing providers.** + +- Pros: identical to prior art; makes Atlas comparable with the other sources in dashboards. +- Cons: two constructor arguments to plumb, one of which passes through `AtlasOrganizationItem`. + +**Proposal B: mint per `AtlasClusterItem`.** + +- Pros: no plumbing. +- Cons: defeats the purpose — the ID is meant to correlate a _journey_ from root expansion to + connection, not to label one node. + +**Recommendation: Proposal A.** This is prior art that already exists four times in the repository; +diverging from it is not a design choice here, it is an omission. + +> ✅ **RESOLVED (dev/tnaum/atlas-discovery-review-iteration) — Proposal A.** `AtlasServiceRootItem` +> mints a `journeyCorrelationId` (`randomUUID()`) and threads it through `AtlasOrganizationItem` and +> `AtlasProjectItem` to every `AtlasClusterItem`, plus the List-mode direct construction. Both the +> Tree and List paths now pass the ID instead of `''`, so Atlas connections correlate across the +> discovery funnel like the other providers. +> Fix: [AtlasServiceRootItem.ts](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts), +> [AtlasOrganizationItem.ts](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasOrganizationItem.ts), +> [AtlasProjectItem.ts](../../../../src/plugins/service-atlas-mongodb/discovery-tree/AtlasProjectItem.ts). + +### NEW-7: Atlas API payloads are cast, never validated + +**Severity: Low.** No confirmed reproduction, but the surface is wide and the mitigation is already +a dependency of this file's own router. + +Files: `src/plugins/service-atlas-mongodb/api/AtlasApiClient.ts`, `models/AtlasClusterModel.ts`, +`discovery/AtlasDiscoveryService.ts` + +Every Admin API response is `(await response.json()) as T`. Nothing checks that the payload matches +the declared interfaces, and several consumers dereference or sort on fields that would be +`undefined` if Atlas ever omitted them: + +- `createAtlasClusterModel` → `cluster.connectionStrings.standardSrv` (throws if `connectionStrings` + is absent, which is plausible for a cluster still being created); +- `mergeResults` → `a.organization.name.localeCompare(…)` and `a.cluster.name.localeCompare(…)`; +- `AtlasClusterItem.buildTooltip` → `escapeMarkdown(this.cluster.stateName)`; +- `AtlasClusterModel.stateName` is typed as the `AtlasClusterState` union but is only ever cast into + it, so an unrecognised Atlas state silently becomes a value outside the union. + +`zod` is already used at the tRPC boundary in `atlasCredentialsRouter.ts`, so the tool is present. + +**Proposal A: validate at the API boundary with narrow schemas.** Parse list responses in +`requestAllPages`/`request` with `zod`, keeping schemas permissive (`.passthrough()`, optional +everything Atlas marks optional) so a new Atlas field never breaks discovery. + +- Pros: one place to enforce the contract; unknown cluster states can be normalised to `UNKNOWN`, + which the UI already renders; makes the declared interfaces true. +- Cons: schema drift becomes a maintenance item; over-strict schemas would turn an additive Atlas + change into a discovery outage, so `.passthrough()` is not optional. + +**Proposal B: guard only the dereferences that can throw.** Make `connectionStrings` optional in the +model, default `stateName` to `'UNKNOWN'`, and use `?? ''` in the sort comparators. + +- Pros: much smaller; targets the three places that would actually throw. +- Cons: leaves the type declarations lying about what was verified. + +**Recommendation: Proposal B for this PR, Proposal A tracked.** The full-boundary schema is the +right long-term shape but is a large addition to an already large PR; the three concrete +dereferences should be fixed now, and `connectionStrings` should be declared optional so the +compiler enforces the guard. + +--- + +### NEW-7 — FINAL DECISION: Proposal B now, Proposal A tracked as a GitHub issue + +**Decision: implement Proposal B in this PR, then file an issue for Proposal A.** + +Implement now, in `AtlasProjectModel.ts` / `AtlasClusterModel.ts` / `AtlasDiscoveryService.ts`: + +1. Declare `AtlasCluster.connectionStrings` as optional (`readonly connectionStrings?: AtlasConnectionStrings;`) + so the compiler forces every dereference to be guarded. This turns the two unguarded accesses in + `createAtlasClusterModel()` and `SelectAtlasSteps.getClusterItems()` into build errors rather + than runtime hazards. +2. Normalise an unrecognised `stateName` to `'UNKNOWN'` at the model boundary. The UI already + renders `UNKNOWN` with a label and an explanation, so this costs nothing and makes + `AtlasClusterState` true instead of merely asserted. +3. Use `?? ''` in the three `localeCompare` comparators in `mergeResults()`. + +```typescript +// Atlas is a live API and this model is built from a cast, not a validated payload. These three +// guards cover the fields a missing value would actually throw on; see the tracked issue for +// validating the whole boundary. +connectionString: cluster.connectionStrings?.standardSrv ?? cluster.connectionStrings?.standard, +stateName: ATLAS_CLUSTER_STATES.includes(cluster.stateName) ? cluster.stateName : 'UNKNOWN', +``` + +**Then file the issue** — see [ISSUE-1](#issue-1-validate-atlas-admin-api-payloads-at-the-boundary) +in "Follow-up Issues to File". Do not attempt the `zod` boundary in this PR. + +> ✅ **RESOLVED (dev/tnaum/atlas-discovery-review-iteration) — Proposal B; INFO-1 folded in.** +> `AtlasCluster.connectionStrings` is now optional, turning the two unguarded dereferences +> (`createAtlasClusterModel`, `SelectAtlasSteps.getClusterItems`) into guarded `?.` accesses. A new +> exported `ATLAS_CLUSTER_STATES` array normalizes an unrecognized `stateName` to `'UNKNOWN'` at the +> model boundary, and the three `mergeResults` `localeCompare` comparators use `?? ''`. INFO-1 was +> folded in while the model was open: `AtlasClusterModel.clusterType` and the factory input now use +> the `AtlasClusterType` union instead of `string`. The `zod` boundary (Proposal A / ISSUE-1) was +> not attempted; it is noted in the executive summary as a follow-up. +> Fix: [AtlasProjectModel.ts](../../../../src/plugins/service-atlas-mongodb/models/AtlasProjectModel.ts), +> [AtlasClusterModel.ts](../../../../src/plugins/service-atlas-mongodb/models/AtlasClusterModel.ts), +> [AtlasDiscoveryService.ts](../../../../src/plugins/service-atlas-mongodb/discovery/AtlasDiscoveryService.ts), +> [SelectAtlasSteps.ts](../../../../src/plugins/service-atlas-mongodb/discovery-wizard/SelectAtlasSteps.ts). +> Tests: missing-connectionStrings and unrecognized-state cases in +> [AtlasClusterModel.test.ts](../../../../src/plugins/service-atlas-mongodb/models/AtlasClusterModel.test.ts). + +### NEW-8: Terminal and shell titles lose their existing translations + +**Severity: Low.** A localization-quality regression that no CI step detects. The owner's decision is +to remove the change rather than repair it; see the FINAL DECISION subsection at the end. + +Files: `src/commands/openInteractiveShell/openInteractiveShell.ts`, +`src/documentdb/shell/DocumentDBShellPty.ts` + +To make the shell say "MongoDB Atlas" for Atlas clusters, three message IDs were replaced by +placeholder-only ones: + +```diff +- l10n.t('DocumentDB: {0}/{1}', connectionInfo.clusterDisplayName, connectionInfo.databaseName) ++ l10n.t('{0}: {1}/{2}', label, connectionInfo.clusterDisplayName, connectionInfo.databaseName) + +- l10n.t('DocumentDB Shell: {0}', this._connectionInfo.clusterDisplayName) ++ l10n.t('{0} Shell: {1}', label, this._connectionInfo.clusterDisplayName) + +- l10n.t('DocumentDB: {0}@{1}/{2}', …) ++ l10n.t('{0}: {1}@{2}/{3}', label, …) +``` + +Two effects: existing translations for the old IDs are orphaned, and `'{0}: {1}/{2}'` is an ID with +no translatable content and no context whatsoever — a translator cannot tell what it labels or +whether word order matters in their language. + +**Proposal A: keep one message per brand.** Select the localized string by label rather than +interpolating the brand into the ID. + +```typescript +const name = isAtlas + ? l10n.t('MongoDB Atlas: {0}/{1}', clusterDisplayName, databaseName) + : l10n.t('DocumentDB: {0}/{1}', clusterDisplayName, databaseName); +``` + +- Pros: the existing `DocumentDB: {0}/{1}` translations keep working; each ID is meaningful; word + order is translatable per brand. +- Cons: two strings per site instead of one, and a third if another source is added. + +**Proposal B: keep the interpolated ID but add a comment.** `vscode.l10n.t` supports a `comment` +field; use it to tell translators that `{0}` is a product name. + +- Pros: one string; minimal diff. +- Cons: still discards the existing translations and still produces an ID with no content. + +**Recommendation: Proposal A.** Brand names are exactly the case where interpolating into the +message ID costs more than it saves, and this PR is also the moment the old translations are lost. + +--- + +### NEW-8 — FINAL DECISION: revert the shell labelling entirely + +**Decision: neither proposal. Remove the change instead.** The shell is the DocumentDB shell; it is +the same shell whether the server behind it is Atlas or anything else, so renaming the terminal per +discovery source was scope the feature did not need. Reverting is also the only option that costs +nothing in translation: the four original message IDs come back and their existing translations +start working again. + +Restore the base-branch text at all eight `shellLabel` touchpoints: + +| File | Action | +| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/documentdb/shell/ShellSessionManager.ts` | Delete the `shellLabel?: string` field from `ShellConnectionInfo`. | +| `src/commands/openInteractiveShell/openInteractiveShell.ts` | Delete both `const label = …` lines, the `shellLabel` computation in `extractConnectionInfo`, both `shellLabel,` properties, and the now-unused `API` import. | +| `src/documentdb/shell/DocumentDBShellPty.ts` | Delete both `const label = …` lines. | + +The exact strings to restore: + +```typescript +// openInteractiveShell.ts, both call sites +l10n.t('DocumentDB: {0}/{1}', connectionInfo.clusterDisplayName, connectionInfo.databaseName); + +// DocumentDBShellPty.ts, welcome banner +l10n.t('DocumentDB Shell: {0}', this._connectionInfo.clusterDisplayName); + +// DocumentDBShellPty.ts, updateTerminalTitle(), with a username +l10n.t('DocumentDB: {0}@{1}/{2}', this._username, this._connectionInfo.clusterDisplayName, this._currentDatabase); + +// DocumentDBShellPty.ts, updateTerminalTitle(), without a username +l10n.t('DocumentDB: {0}/{1}', this._connectionInfo.clusterDisplayName, this._currentDatabase); +``` + +Run `npm run l10n` afterwards so `'{0}: {1}/{2}'`, `'{0} Shell: {1}'`, and `'{0}: {1}@{2}/{3}'` +leave the bundle. + +**Then file the issue** — see [ISSUE-2](#issue-2-make-the-interactive-shell-aware-of-its-target-platform). +The underlying idea (telling the user what they are connected to) is worth doing properly, as a +session summary such as "connected to `` on ``", rather than as a terminal-title +prefix. Not in this PR. + +> ✅ **RESOLVED (dev/tnaum/atlas-discovery-review-iteration) — reverted.** Removed the `shellLabel` +> field from `ShellConnectionInfo`, deleted the `shellLabel` computation and both properties in +> `openInteractiveShell.ts` (plus the now-unused `API` import), and deleted the two `const label = …` +> lines in `DocumentDBShellPty.ts`. All four original message IDs are restored: `DocumentDB: {0}/{1}`, +> `DocumentDB Shell: {0}`, `DocumentDB: {0}@{1}/{2}`. `npm run l10n` will remove the placeholder-only +> IDs at the final checklist step. ISSUE-2 is noted in the executive summary as a follow-up (not +> auto-filed). +> Fix: [openInteractiveShell.ts](../../../../src/commands/openInteractiveShell/openInteractiveShell.ts), +> [DocumentDBShellPty.ts](../../../../src/documentdb/shell/DocumentDBShellPty.ts), +> [ShellSessionManager.ts](../../../../src/documentdb/shell/ShellSessionManager.ts). + +### NEW-9: `config.ts` evaluates `l10n.t()` at module load, against in-repo precedent + +**Severity: Low.** Deferred to an extension-wide issue rather than fixed here; see the FINAL DECISION +subsection at the end. + +File: `src/plugins/service-atlas-mongodb/config.ts` + +```typescript +export const LABEL = l10n.t('MongoDB Atlas'); +export const DESCRIPTION = l10n.t('Service Discovery for MongoDB Atlas'); +export const WIZARD_TITLE = l10n.t('MongoDB Atlas Service Discovery'); +``` + +The two Azure plugins do the same, so this is not novel — but the newest plugin in the repository +deliberately does not, and says why: + +```typescript +// src/plugins/service-kubernetes/config.ts +/** + * Display strings use getter functions to defer l10n.t() evaluation + * until first access, avoiding module-load-time crashes if the + * l10n subsystem isn't fully initialized during extension activation. + */ +export function getLabel(): string { + return l10n.t('Kubernetes Clusters'); +} +``` + +Atlas is more exposed than the Azure plugins because `ClustersExtension` constructs +`private readonly atlasDiscoveryProvider = new AtlasDiscoveryProvider();` as a **class field**, so +the module graph including `config.ts` is pulled in when `ClustersExtension` is constructed, whereas +the Azure providers are instantiated inside `activate()`. + +**Proposal A: follow the Kubernetes pattern (`getLabel()`, `getDescription()`, `getWizardTitle()`).** + +- Pros: matches the most recent, explicitly reasoned precedent; removes an activation-order + dependency; a new plugin copying Atlas would inherit the safe pattern. +- Cons: touches every consumer of the three constants. + +**Proposal B: leave it, consistent with the Azure plugins.** + +- Pros: zero change; the pattern is already shipping twice. +- Cons: entrenches the pattern the repository has already decided against, and Atlas has the + earliest instantiation of the four. + +**Recommendation: Proposal A.** When a repository contains both an old pattern and a documented +replacement, new code should be written against the replacement. + +--- + +### NEW-9 — FINAL DECISION: leave as-is, track extension-wide + +**Decision: no change in this PR.** Atlas matches two of the four existing plugins; fixing one +plugin in isolation produces three inconsistent patterns instead of two, and the deferral question +is not Atlas-specific — it applies to every module-level `l10n.t()` in the extension. + +**File the issue** — see [ISSUE-3](#issue-3-revisit-module-load-time-l10nt-evaluation-extension-wide). +That issue owns the sweep, including whether `getLabel()`-style deferral becomes the documented +convention or the Azure/Atlas constant form is confirmed as safe. + +### NEW-10: Dead code and contract fields the implementation never produces + +**Severity: Informational.** + +- `AtlasClusterItem.getAtlasConsoleUrl()` is `public`, builds an unencoded URL, and has **no call + sites** anywhere in `src/`. It is the remnant the Copilot review's URL-encoding comment referred + to. Delete it; leaving an unencoded URL builder around invites its reuse. +- `AtlasCredentialError.retryable` is documented as "`false` only when retrying cannot possibly + help (for example the credential was removed)", but every construction site in + `AtlasDiscoveryService.ts` passes `retryable: true`. Either produce `false` for the removed-record + case in `retryCredential()`, or drop the field and the comment. +- `persistCredential()`'s fallthrough comment — "The record disappeared while the webview was open; + fall through and add it back" — describes an unreachable branch, because + `validateUpdateIdentity()` already returns an `identity` error ("This credential no longer + exists") before either submit mutation reaches `persistCredential`. + +### NEW-11: Fleet-level credential actions recurse through `prompt()` + +**Severity: Informational.** + +File: `src/plugins/service-atlas-mongodb/credentialsManagement/SelectAtlasCredentialStep.ts` + +Both "Retry all" and a cancelled "Add a credential…" re-enter the step with +`await this.prompt(context)`, so each round adds a frame instead of returning to the wizard loop. In +practice the depth is bounded by user patience, but a `GoBackError` raised from the QuickPick inside +a nested frame propagates through every outer frame to the wizard, so pressing Back after several +"Retry all" rounds leaves the list entirely rather than returning to it. A `for (;;)` loop inside +`prompt()` — the pattern `AtlasCredentialActionStep` and `SelectAtlasDatabaseUserStep` already use — +would give the same behaviour with a flat stack and predictable back navigation. + +### NEW-12: The Service Account token response is used without checking its shape + +**Severity: Informational.** + +File: `src/plugins/service-atlas-mongodb/auth/AtlasServiceAccountClient.ts` + +`return (await response.json()) as AtlasServiceAccountTokenResponse;` is unchecked. A `200` without +`expires_in` yields `Date.now() + undefined * 1000` → `NaN` → `expiresAt: "NaN"`, which +`isExpired()` treats as expired, so every request re-mints a token. A `200` without `access_token` +produces `Authorization: Bearer undefined`. Both degrade rather than crash, which is why this is +informational, but a two-field check would turn a confusing symptom into a clear error — and it is +the natural place to attach MEDIUM-3's typed token error. + +### NEW-13: Unrelated behaviour changes are bundled into the Atlas commit + +**Severity: Informational**, with one substantive sub-point worth resolving before merge. + +The single `feat: add MongoDB Atlas discovery provider plugin` commit also changes core +database-creation behaviour and shell labelling: + +- `ClustersClient.createDatabase(databaseName, collectionName?)` — a public signature change; +- `InitialCollectionNameStep` and `CreateDatabaseWizardContext.requiresInitialCollection` — a new + prompt in the shared Create Database wizard; +- shell/terminal title changes (see NEW-8). + +None of these are wrong on their own, but they are invisible from the PR title, they are not +described in the Atlas design docs, and a reviewer looking at an Atlas discovery PR has no reason to +scrutinise `createDatabase`. + +The substantive sub-point: `requiresInitialCollection` is gated on +`node.experience.api === AtlasExperience.api`. That experience only exists on nodes in the +**Discovery** tree. Once the same Atlas cluster is saved into the Connections view, its experience is +no longer `mongoDBAtlas`, so Create Database silently reverts to the +`_dummy_collection_creation_forces_db_creation` path — the exact behaviour the new step was added to +avoid, on the exact same server. Either the gate needs to be a property of the connection rather +than of the discovery node, or the initial-collection prompt should be unconditional (it is harmless +for vCore, which simply gets a real first collection instead of a dummy one). + +Also worth noting: `InitialCollectionNameStep` instantiates a whole `CollectionNameStep` purely to +borrow `validateInput`, and skips its async `validateNameAvailable` check. That is defensible for a +database that does not exist yet, but extracting the validator into a plain function would say so +more clearly than holding a wizard step as a field. + +## Recommended Disposition + +**Request changes before merge.** Every item below carries an owner decision; the sections above hold +the reasoning and the code. This section is the work order. + +Read this first if you are the implementing agent: + +- Where a finding has a `— FINAL DECISION:` subsection, **that subsection wins**. Earlier + "Owner decision" / "Recommendation" paragraphs in the same finding are kept for context and are + explicitly superseded. This applies to MEDIUM-2, MEDIUM-4, NEW-1, NEW-3, NEW-7, NEW-8, NEW-9. +- Comments requested in the decisions are part of the deliverable, not optional polish. Several of + these fixes look like mistakes without them and will be "corrected" back into bugs. +- Do not widen scope. Items marked _deferred_ have GitHub issues instead; file them, do not build them. + +### Blocking + +| # | Finding | Work | +| --- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | **MEDIUM-1** | `myCtx.signal?.throwIfAborted()` immediately before `persistCredential()`, both auth methods. Note the optional chaining. | +| 2 | **MEDIUM-2** (Proposal B) | Serialize `listAll()` passes; `invalidate()` stops clearing `inflight`; add `DISCOVERY_TIMEOUT_MS`; add the abort/timeout branch to `classifyAtlasError()`. Keep all four comments. | +| 3 | **MEDIUM-3 + NEW-3** together | One root cause. Give token failures a typed outcome, then drive both the tree modal's wording and the no-session branch from `classifyAtlasError()`. Add the refresh-vs-expand modal rule. | +| 4 | **NEW-2** | Add `mongoDBAtlas` to the four remaining `treeitem_index` `when` clauses in `package.json`. | +| 5 | **NEW-4 + WITHDRAWN-1** | Cache the Digest challenge and reuse it with an incrementing `nc`; sign the full request-target. Same code, one new test file. | +| 6 | **NEW-8** | Revert the shell labelling at all eight `shellLabel` touchpoints; restore the four original message IDs; `npm run l10n`. | + +### Should land with the above + +| # | Finding | Work | +| --- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| 7 | **MEDIUM-4** (now Low) | `pushItem(updated, undefined)` in `updateAtlasCredentialMetadata`; generation guard on `storeSession`. No write queue. | +| 8 | **LOW-1**, **LOW-2** | Host-owned link-failure handling; credential-neutral `403` fallback. | +| 9 | **LOW-3 + LOW-4** | One localized tooltip field list using "Server version", plus the `requiresInitialCollection` comment terminology. | +| 10 | **NEW-5**, **NEW-6**, **NEW-7** | Tree/wizard parity on non-connectable clusters; journey correlation ID; the three payload guards. | + +### Accepted / no action + +- **NEW-1** — the footer experiment ships; this is a preview release. Removal checklist recorded for + the preview exit. +- **NEW-9** — `config.ts` stays consistent with the Azure plugins; tracked extension-wide instead. +- **INFO-1**, **NEW-10**–**NEW-13** — non-blocking. NEW-13's sub-point (`requiresInitialCollection` + stops applying once an Atlas cluster is saved into Connections) still deserves an explicit + yes/no, even if the answer is "accepted for now". + +### Deferred to issues — file these, do not implement + +1. [ISSUE-1](#issue-1-validate-atlas-admin-api-payloads-at-the-boundary) — `zod` validation of Atlas + Admin API payloads (NEW-7 Proposal A). +2. [ISSUE-2](#issue-2-make-the-interactive-shell-aware-of-its-target-platform) — platform-aware + interactive shell (replaces NEW-8's original intent). +3. [ISSUE-3](#issue-3-revisit-module-load-time-l10nt-evaluation-extension-wide) — extension-wide + `l10n.t()` deferral review (NEW-9). + +Also explicitly out of scope: the per-credential write queue (MEDIUM-4 Proposal A) and the +split-storage schema (MEDIUM-4 Proposal B). Neither is needed unless concurrent credential +management across two VS Code windows becomes supported. + +### Before you finish + +Per the repository checklist, run in order and do not stop until all five pass: +`npm run l10n` (strings changed in items 3, 5, 6, 8, 9) → `npm run prettier-fix` → `npm run lint` → +`npx jest --no-coverage` → `npm run build`. + +## Follow-up Issues to File + +Three issues, to be created on `microsoft/vscode-documentdb` after the implementation lands. Titles +and bodies are ready to paste. + +### ISSUE-1: Validate Atlas Admin API payloads at the boundary + +**Labels:** `enhancement`, `tech-debt`, `atlas` + +> Every Atlas Admin API response in `src/plugins/service-atlas-mongodb/api/AtlasApiClient.ts` is +> returned as `(await response.json()) as T`. Nothing verifies that the payload matches the declared +> interfaces in `models/AtlasProjectModel.ts`, so the types are assertions rather than facts. +> +> PR #765 shipped targeted guards for the three places a missing field would actually throw +> (`connectionStrings` made optional, `stateName` normalised to `UNKNOWN`, `?? ''` in the +> `localeCompare` comparators). This issue covers the general fix. +> +> Proposal: parse list and single-resource responses with `zod` in `request()` / +> `requestAllPages()`. `zod` is already a dependency and is already used at the tRPC boundary in +> `atlasCredentialsRouter.ts`. +> +> **Hard requirement:** schemas must be permissive (`.passthrough()`, optional for everything Atlas +> marks optional). An over-strict schema would turn an additive Atlas API change into a total +> discovery outage, which is strictly worse than the current casts. +> +> Acceptance: unknown extra fields are preserved and ignored; a missing required field produces one +> classified, credential-scoped error instead of a thrown `TypeError`; existing +> `AtlasApiClient.test.ts` fixtures still pass unmodified. +> +> Origin: PR #765 review, finding NEW-7 Proposal A. + +### ISSUE-2: Make the interactive shell aware of its target platform + +**Labels:** `enhancement`, `shell` + +> PR #765 briefly prefixed the terminal title with the discovery source ("MongoDB Atlas: …" instead +> of "DocumentDB: …"). That was reverted: the shell _is_ the DocumentDB shell regardless of which +> server it reaches, and encoding a brand into the terminal title also broke the existing +> localization of four message IDs. +> +> The underlying idea is still worth doing, but as session context rather than a title prefix. When +> a shell session starts, the welcome banner could state what it connected to and where it is +> hosted — for example "Connected to `` on ``" — derived from the connection's +> origin rather than from the tree node that happened to launch it. +> +> Points to settle: +> +> - Where does the platform fact live? The launching tree node knows it; a saved connection in the +> Connections view currently does not, so the same cluster would report differently depending on +> how it was opened. +> - Banner only, or also `db.hello()`-style output in the session? +> - Keep the terminal title untouched — that is what made the first attempt costly. +> +> Origin: PR #765 review, finding NEW-8. + +### ISSUE-3: Revisit module-load-time `l10n.t()` evaluation extension-wide + +**Labels:** `tech-debt`, `localization` + +> `src/plugins/service-kubernetes/config.ts` deliberately wraps its display strings in getter +> functions, documenting the reason: _"Display strings use getter functions to defer `l10n.t()` +> evaluation until first access, avoiding module-load-time crashes if the l10n subsystem isn't fully +> initialized during extension activation."_ +> +> Three other discovery plugins (`service-azure-mongo-ru`, `service-azure-mongo-vcore`, +> `service-azure-vm`) and the new `service-atlas-mongodb` use module-level `export const LABEL = +l10n.t(…)` instead. PR #765 deliberately left Atlas consistent with the majority rather than +> creating a third pattern. +> +> This issue is the sweep, not a single-plugin fix: +> +> 1. Determine whether the Kubernetes comment describes a real, reproducible hazard on the currently +> supported VS Code versions, or a defensive measure that is no longer needed. +> 2. If real: convert the remaining module-level `l10n.t()` call sites and add a lint rule or a test +> so the pattern cannot come back. Note that `service-atlas-mongodb` has the earliest evaluation +> of the four, because `ClustersExtension` instantiates `AtlasDiscoveryProvider` as a class field +> rather than inside `activate()`. +> 3. If not real: simplify `service-kubernetes/config.ts` to constants and delete the comment, so +> the repository stops carrying two contradictory conventions. +> +> Either outcome is fine; carrying both is not. +> +> Origin: PR #765 review, finding NEW-9. + +## Copilot Reviewer Consolidation + +Copilot submitted one review on 2026-06-30: +https://github.com/microsoft/vscode-documentdb/pull/765#pullrequestreview-4600653270 + +All six inline comments were re-read against the 2026-07-30 branch state. + +| Discussion | Current assessment | Severity / action | +| ------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | +| [Wizard can continue with an undefined session](https://github.com/microsoft/vscode-documentdb/pull/765#discussion_r3498997591) | Obsolete. `getDiscoveryWizard()` now awaits credential management and throws `UserCancelledError` when it returns `false`. | Replied and resolved on GitHub. | +| [403 fallback mentions API key for Service Accounts](https://github.com/microsoft/vscode-documentdb/pull/765#discussion_r3498997624) | Still present; merged into LOW-2. | Low, open. | +| [Tooltip sentence is not localized](https://github.com/microsoft/vscode-documentdb/pull/765#discussion_r3498997649) | Still present; merged into LOW-3. | Low, open. | +| [Standalone MongoDB terminology](https://github.com/microsoft/vscode-documentdb/pull/765#discussion_r3498997670) | Still present; merged into LOW-4. | Low, open. | +| [Cluster console URL does not encode path values](https://github.com/microsoft/vscode-documentdb/pull/765#discussion_r3498997702) | Obsolete after the deep-link refactor. `getAtlasConsoleUrl()` has no call sites; active links use `atlasDeepLinks` helpers. | Replied and resolved on GitHub. | +| [Cluster model should reuse unions](https://github.com/microsoft/vscode-documentdb/pull/765#discussion_r3498997729) | Partially fixed: state is typed, cluster type is not; merged into INFO-1. | Informational, open. | + +No duplicate Copilot comments were found beyond these six. Related descriptions were merged into +the findings above rather than repeated as separate issues. + +**Second-pass note on the resolved URL-encoding comment.** The reply that closed +[discussion_r3498997702](https://github.com/microsoft/vscode-documentdb/pull/765#discussion_r3498997702) +is accurate — `getAtlasConsoleUrl()` genuinely has no call sites and the active deep links use +`encodeURIComponent`. But the unencoded builder is still in the file. "No call sites" is a reason to +delete it, not a reason to keep it; see NEW-10. + +## Verified Design Decisions + +Re-verified in the second pass against the branch source; all still hold. + +- Multi-credential fan-out uses bounded concurrency and isolates failures with `Promise.allSettled()`. +- Organizations, projects, and clusters are deduplicated by Atlas identity while retaining every + credential that can reach them. +- Secrets remain in SecretStorage-backed records and are not included in webview configuration or + normal trace output. +- Atlas list endpoints now paginate to the documented 500-item maximum with a defensive page cap. +- Project-level cluster failures preserve healthy projects and credentials. + +Additionally confirmed in the second pass: + +- **Cluster ID discipline is correct.** `AtlasClusterItem` uses `this.cluster.clusterId` for + `CredentialCache`, `ClustersClient`, and cleanup, and `treeId` only for tree identity, matching the + repository's dual-ID rule. `createAtlasClusterModel` sanitises `/` out of both the project ID and + the cluster name. +- **Deep links encode their path segments.** `buildAtlasAccessUrlFor` and `buildAtlasNetworkAccessUrl` + both use `encodeURIComponent`. The unencoded builder the Copilot review flagged survives only as + dead code (NEW-10). +- **Tooltips are untrusted.** Every `MarkdownString` sets `isTrusted = false` and passes values + through `escapeMarkdown`. +- **Trace output cannot carry secrets.** `atlasTrace`/`atlasWarn` log paths, statuses, counts, and + durations; credentials appear only as a label plus an eight-character record-ID prefix, and the + `identityHint` is derived from the public key / client ID, never the secret half. +- **`SelectAtlasDatabaseUserStep` is well-behaved.** Bounded by `AbortSignal.timeout`, degrades to a + plain username prompt on any failure, and never blocks sign-in. + +## Validation + +GitHub reports all five PR checks successful, including build/package, code quality/tests, +integration tests, API extraction, and CLA. + +The repository-required local validation also passed after this review update: + +```text +npm run prettier-fix passed +npm run lint passed + +Test Suites: 179 passed, 179 total +Tests: 2905 passed, 2905 total +Snapshots: 4 passed, 4 total + +npm run build passed +``` + +Localization generation was not run because this review changed no extension-facing strings. + +The full suite includes the focused Atlas API, discovery, and credential-router suites: + +- `src/plugins/service-atlas-mongodb/api/AtlasApiClient.test.ts` +- `src/plugins/service-atlas-mongodb/discovery/AtlasDiscoveryService.test.ts` +- `src/webviews/documentdb/atlasCredentials/atlasCredentialsRouter.test.ts` + +Green tests do not invalidate the active findings: there is no aborted deferred submission test, +no overlapping `listAll()`/forced-refresh test, no transient token-status classification test, and +no deferred credential-write interleaving test. + +The second pass adds four more coverage gaps that explain why the findings above reached review with +a green suite: + +- **No Digest test at all.** `AtlasApiClient.test.ts` covers the error envelope, diagnostic headers, + pagination, and the Service Account refresh/`403` path. The API Key branch — challenge parsing, + header construction, the signed request-target, and the request-per-call structure — has zero + assertions (WITHDRAWN-1, NEW-4). +- **No `package.json` contribution test.** `lint`, `build`, and the Jest suite are all blind to a + `when` clause that omits an experience, so NEW-2 could not have been caught by CI. +- **No component test for `AtlasCredentialsView.tsx`.** Nothing asserts what the credential screen + renders, so a shipped `PREVIEW` control is invisible to CI (NEW-1). That is acceptable while it is + intentional; it will not be once the preview exit removes it and nothing verifies it is gone. +- **No test asserts tree/wizard parity.** `SelectAtlasSteps` guards non-`IDLE` clusters and + `AtlasClusterItem` does not; nothing compares the two surfaces (NEW-5). diff --git a/docs/ai-and-plans/PRs/local-quickstart-multi-instance/implementation-plan.md b/docs/ai-and-plans/PRs/local-quickstart-multi-instance/implementation-plan.md new file mode 100644 index 000000000..d2bce6f36 --- /dev/null +++ b/docs/ai-and-plans/PRs/local-quickstart-multi-instance/implementation-plan.md @@ -0,0 +1,424 @@ +# Local Quick Start — Multiple Managed Instances: Implementation Plan + +> Full design: [`local-quickstart-v2.md`](../../local-quickstart/local-quickstart-v2.md). +> Reverses: [`decision-instance-model.md`](../../local-quickstart/decision-instance-model.md) +> (single-instance v1 → **multi-instance in v1**, per owner decision 2026-07-06). +> Running gap log: [`v1-readiness-gaps.md`](../../local-quickstart/v1-readiness-gaps.md). +> Review history & resolutions: `review-and-resolutions.md` (created after the 5-agent review). +> +> **Audience:** an implementation agent (Opus/Sonnet-class) or a developer. +> **Status:** **PLAN v3 — round-1 + round-2 5-agent reviews folded in (see +> [`review-and-resolutions.md`](./review-and-resolutions.md)); owner decisions resolved (§10). +> Round-2: WI-0 unanimously READY; remaining items are WI-2-scoped and specified. Ready to start WI-0.** +> **Goal:** let a user run **N** independent managed local DocumentDB instances from Quick +> Start, each with its own container, volume, port, and credentials — while keeping the +> first instance a one-click, zero-decision experience. + +--- + +## 0. How the implementing agent must work (process contract) + +1. **Work item by work item.** Numbered **WI-n**. Do one at a time; commit per WI. +2. **This plan is the source of truth.** After each WI, tick its checkbox + append a one-line outcome. +3. **Data-safety gate.** This refactor touches the volume/credential/teardown paths. Every WI that + changes the service MUST preserve the invariants in §7 and add/extend the tests in §8. If + confidence in any non-obvious data-safety decision is **< 80%**, stop and ask. +4. **5-agent review** (GPT-5.4/5.5 xhigh, Opus 4.6/4.7/4.8 max) on WI-1, WI-2, and the final + integration — all must agree before commit (owner workflow). +5. **PR checklist before declaring a WI done:** `npm run l10n` (if user-facing strings changed) → + `npm run prettier-fix` → `npm run lint` → `npx jest --no-coverage` → `npm run build`. (`npm run + build`, never `compile`.) +6. **Terminology:** "DocumentDB" for the service; "MongoDB API"/"DocumentDB API" for the wire + protocol. Never "MongoDB" alone. All user-facing strings via `vscode.l10n.t()`. +7. **No `any`.** `unknown` + type guards. Explicit return types. `instanceof Error` in catch. +8. **Cluster ID rule (repo convention):** cache lookups (CredentialCache/ClustersClient) use the + stable per-instance `clusterId`, never the tree `treeId`. + +--- + +## 1. Goal, UX narrative, and non-goals + +### Goal +From the **DocumentDB Local - Quick Start** node the user can create, browse, and manage **several** +independent local DocumentDB instances side by side — the concrete use cases German raised +(compare image vX vs vY; isolate project A from project B) without leaving the one-click flow. + +### UX narrative +- **First instance stays one click.** Empty state → rocket → provisioning panel → Start → Running + row. No naming step required (a sensible default name is pre-filled). +- **Add another:** a persistent **“+ New instance”** row under the Quick Start node opens a fresh + provisioning panel for a new instance (its own port auto-picked, its own credentials). +- **The node lists all instances**, each an expandable Running row (browse inline) or a state row + (Stopped/Starting/Missing/Error) carrying the existing lifecycle menus. Delete/Stop/Start act on + **that** instance only. + +### Non-goals (unchanged from the design) +- **Adopting unlabelled / hand-run containers** — still out. Recognition stays **label-only** + (`vscode.documentdb.quickstart=1`); those connect via the regular wizard (design §13.10). +- **Auto-discovery** of non-managed DocumentDB containers — belongs to the generic connections + experience, not Quick Start. +- **Cross-instance orchestration** (compose, dependency graphs) — out. + +--- + +## 2. Decision reversal (record for reviewers) + +`decision-instance-model.md` locked v1 to a single managed instance (raised by German Eichberger, +re-affirmed 2026-06-30). On **2026-07-06** the owner directed building **full multi-instance in +v1**. This plan supersedes that decision; WI-6 updates `decision-instance-model.md` with the +reversal + rationale so the record stays coherent. The label model that decision preserved is +exactly what makes this additive — **there is no data migration for the existing single instance** +beyond re-keying two flat storage keys (§6). + +--- + +## 3. UX design decisions (proposed — reviewers/owner confirm) + +| # | Decision | Proposal | Rationale | +| - | -------- | -------- | --------- | +| U1 | **Identity** | Each instance has an immutable **alias** (slug; also the Docker container name + `vscode.documentdb.alias` label) and an editable **display name**. | Alias is the stable key for names/creds/cache; display name is the human label. | +| U2 | **First instance = zero decisions** | The provisioning panel pre-fills a default name; the user can just click **Start**. No mandatory naming prompt. | Preserves the “one click” value prop; naming is opt-in. | +| U3 | **Default alias** | First instance keeps alias/container `vscode-documentdb-local` and volume `vscode-documentdb-local-data` (today’s names). | **Backward compatible** — the existing instance is adopted unchanged. | +| U4 | **New-instance aliases** | Auto-generated, **monotonic** suffix from `registry.nextSuffix` (`vscode-documentdb-local-2`, `-3`, … — never reused after delete, since suffixes leak into container names/logs). Allocation also avoids names held by unlabelled containers (§7 preflight). | Predictable, DNS/label-safe, no user typing; monotonic avoids confusing reuse. | +| U5 | **Creation entry points** | (a) rocket empty-state (instance #1); (b) a persistent **“+ New instance”** action row when ≥1 instance exists. Both open a provisioning panel bound to a new alias. | Matches the design’s “rocket hides after setup” but replaces it with an explicit add affordance. | +| U6 | **Managing an instance** | Lifecycle via the existing tree context menus (per-row). Reopening the webview for an existing instance (resume on-timeout, next-steps) targets that alias. | Reuses shipped lifecycle UI; only adds alias-scoping. | +| U7 | **Cap** | No hard cap; add a **soft warning** in the panel when many (~5+) are running (owner decision: in scope). | Docker/OS already bounds this; a cap adds a decision. | +| U8 | **Persistence model** | Each instance stays **ephemeral/CredentialCache-based** (not saved into a storage zone), exactly like today’s single instance. | Keeps the ownership boundary; no zone/tree-shape churn. | + +**Plan default (per review R15):** U2 **auto-fills** the display name (no prompt, no schema change) so +the first instance stays one click and later ones don't derail into a naming step. Renaming an +instance is deferred (v-next). **OPEN for owner:** if you prefer an explicit **name prompt** on +“+ New instance”, only the provisioning panel gains a name field — the architecture below is identical. + +--- + +## 4. Architecture: from singleton to per-alias + +### 4.1 Identity + keying (the core change) +Introduce pure derivation helpers keyed on `alias` (in `quickStartTypes.ts`), replacing the flat +constants. `DEFAULT_ALIAS = 'vscode-documentdb-local'`. + +| Concept | Today (flat) | Per-alias derivation | +| ------- | ------------ | -------------------- | +| Container name | `vscode-documentdb-local` | `containerName(alias) = alias` | +| Volume name | `vscode-documentdb-local-data` | `volumeName(alias) = alias + '-data'` | +| Cache key (clusterId) | `quickstart-local-documentdb` | `clusterId(alias) = 'quickstart-' + alias` *(ephemeral — no migration)* | +| SecretStorage key | `documentdb.quickstart.connectionString` | `secretKey(alias) = 'documentdb.quickstart.' + alias + '.connectionString'` | +| imageRef globalState key | `documentdb.quickstart.imageRef` | `imageRefKey(alias) = 'documentdb.quickstart.' + alias + '.imageRef'` | +| Label(s) | `quickstart=1` (+ `alias` already stamped) | unchanged; the `alias` label is the reconcile join key | +| Port | `10260` default | per-instance, auto-allocated from `[10260,10360)` (existing `findAvailablePort`) | + +With `containerName(DEFAULT_ALIAS)==='vscode-documentdb-local'` and +`volumeName(DEFAULT_ALIAS)==='vscode-documentdb-local-data'`, the existing container/volume need +**no rename**. Only `secretKey`/`imageRefKey` differ from the legacy flat keys → one-time migration +(§6). + +### 4.2 Instance registry (new, persisted) + reservation & cross-window model +A `globalState` object records known instances + the monotonic suffix + a provisioning lease: + +``` +documentdb.quickstart.registry = { + nextSuffix: number, // monotonic; HEALED in reconcile (see below) + instances: Array<{ + alias, displayName, + port, // AUTHORITATIVE for stopped instances (R3/Major-2) + phase: 'provisioning' | 'ready', + operationId?: string, // owner nonce of the in-flight provision + leaseAt?: number, // provisioning lease timestamp (crash/cross-window) + }>, +} +``` + +**Allocation happens at provision START, inside ONE locked critical section (Minor-1):** pick the next +`nextSuffix`; `findAvailablePort` **excluding every registry `port` (running OR stopped) + in-flight +reservations**; then write `{alias, port, phase:'provisioning', operationId, leaseAt:now}` — the whole +*pick-port → reserve → write* sequence under a single lock acquisition. **“+ New instance” opens a +draft panel** (draft id, no alias yet); `startQuickStart` performs the allocation — this fixes the +allocate-at-open contradiction (a closed/abandoned draft panel reserves nothing). Fresh registry: +`nextSuffix = 2` (DEFAULT_ALIAS carries no suffix). + +**Cross-window safety is best-effort + self-healing, NOT lock-based (Major-1).** A per-process async +lock cannot serialize two VS Code windows over `globalState` (separate hosts; last-writer-wins, no +cross-process CAS). So the model is: +- **Races degrade safely.** Docker **container-name + host-port uniqueness** make a genuine + double-allocate fail at `docker run` — the loser errors cleanly. Established instances are **never + lost** because their **persisted per-alias secret ⇒ `reusing=true` ⇒ volume kept**. +- **Pre-clean is `operationId`-guarded (opus47-M2):** the provision pre-clean / `findManagedContainer` + destructive path removes only a container whose owner nonce matches **this** provision — it **never** + removes another window's same-alias container. Cleanup on abort removes only this `operationId`'s + reservation. +- **`nextSuffix` self-heals:** `reconcile()` sets `nextSuffix = max(existing suffixes) + 1`, so a + clobbered counter corrects itself. +- **Provisioning lease (`leaseAt`):** any window renders a `phase:'provisioning'` entry with a **fresh** + lease as **Provisioning** (not Missing, not touched); a **stale** lease (> readiness timeout ⇒ crashed + host) is a recoverable **Missing** and its pre-create reservation is **scavenged** at reconcile + (closes the crash-orphan hole, §7.8). +- The tree **re-reads the registry on every `refreshLiveState()`**. Multi-window *state sync* stays + **best-effort** (consistent with the shipped single-instance model, design §12) — but *allocation* and + *destructive pre-clean* are race-safe. Do **not** claim the lock prevents cross-window clobber. + +### 4.3 Service state: `Map` +`QuickStartServiceImpl` today holds single fields (`metadata`, `pendingReadiness`, `provisioning`, +`lifecycleBusy`, `missing`, `state`, `errorMessage`). Replace with a per-alias map: + +``` +private instances = new Map(); +interface InstanceRuntimeState { + alias: string; displayName: string; port?: number; + metadata?: InstanceMetadata; state: InstanceState; + provisioning: boolean; lifecycleBusy: boolean; missing: boolean; // R4: missing is per-alias + pendingReadiness?: PendingReadiness; errorMessage?: string; +} +``` + +Every public method gains an `alias` parameter (or returns a keyed collection). **WI-2 keeps +default-alias wrapper overloads** (old signatures delegate to `alias = DEFAULT_ALIAS`) so each WI +stays committable until callers migrate (R13): +- `provision(alias, opts)` / `resumeReadiness(alias)` / `discardTimedOut(alias)` +- `start/stop/restart/deleteContainer/viewLogs(alias)` +- `getStatus(alias)` **and** `listStatuses(): QuickStartStatus[]` (tree uses the list) +- `isBusy(alias)` and `willReuseExistingInstance(alias)` are **alias-scoped** (R4); any aggregate + status is named explicitly. +- `refreshLiveState()` issues **one** `listByLabel({quickstart:1})`, indexes results by container id, + and updates every known alias from that single response (R15 — not N `docker inspect`s); + `liveStateGuard(alias)` scopes to one. **Port caveat (Major-2):** `docker ps -a` omits host-port + bindings for **stopped** containers, so `refreshLiveState` updates `port` **only for running** + containers and **never clears** a stored `registry.port`. `registry.port` is **authoritative** for + stopped instances (populated once via `inspect` at adoption/migration while discoverable). + +**Singleton destructive call sites that MUST become per-alias (R7 — exact sites):** +- `findManagedContainer()` returns `listByLabel(...)[0]` (`QuickStartService.ts:825-828`) → **must be + `findManagedContainer(alias)`** filtering on the `alias` label. Used in provision pre-clean + (`:310-314`) and the post-failure orphan sweep (`:469`) — otherwise provisioning/failing **B** + removes **A**. +- Every volume/container op takes the alias: fresh-wipe (`:316`), `discardTimedOutInstance` (`:682`), + `deleteContainer` (`:972`) → `volumeName(alias)`/`containerName(alias)`. +- `isManaged(id)` (`:850-852`) → `isManaged(id, alias)` requires `quickstart=1` **AND** matching alias + (legacy no-alias allowed **only** for `DEFAULT_ALIAS`). + +**`reconcile()` rules (R2, R14 + round-2):** enumerate `listByLabel({quickstart:1})`, then per container +`const alias = labels[ALIAS_LABEL] || DEFAULT_ALIAS`: +- Adopt into that alias's `InstanceRuntimeState`; populate `registry.port` from `inspect` bindings if + absent. A container whose alias isn't in the registry is added (adopted) — **never merged**. +- **Heal `nextSuffix = max(existing suffixes) + 1`** so a cross-window-clobbered counter self-corrects. +- **The legacy “no stored secret → remove the container” branch is REMOVED** (R2): a labelled + container with no recoverable secret is **surfaced** in a **distinct `InstanceState`** (not `Missing` + — its volume data is unreachable; e.g. `Error` with a “credentials unavailable” message or a + `NeedsRecreate` token, Minor-2), whose row offers **Delete** (behind the data-loss confirmation) but + **no silent recreate**, and its **volume is never touched**. +- Two containers sharing one alias → deterministic winner (most recently created), **log** the + collision, **leave the other untouched**. +- **Lease-based Provisioning vs Missing (Major-1):** a registered alias with **no container** and a + **fresh** `phase:'provisioning'` lease → render **Provisioning** (don't touch); with a **stale** lease + (> readiness timeout) or `phase:'ready'` → **Missing** (and scavenge a stale pre-create reservation). + +### 4.4 Tree +`LocalQuickStartItem.getChildren()` becomes: `refreshLiveState()` → `listStatuses()` → render **one +row per instance** (per-alias `id = ${this.id}/instance/${alias}`; Running → `QuickStartClusterItem` +expandable, else a state row) → append the persistent **“+ New instance”** action row. Zero +instances → today’s rocket empty-state (creates instance #1). Row labels use the **display name** ++ `· localhost:`. The per-state switch gains a **per-instance Provisioning** case (R9) with +**no hardcoded port** — today `LocalQuickStartItem.ts:159-168` renders a single global +`Provisioning… · localhost:10260` row that can't represent #2 provisioning while #1 runs. + +### 4.5 Webview + router + commands +- The provisioning panel carries a **target alias** (a controller/panel param). Its tRPC + subscription/mutations (`startQuickStart`, `waitLonger`, `discardTimedOut`, `getDockerStatus`, + `openConnection`, …) all take/thread the alias. +- **Create-or-reveal per alias (R10):** the controller keeps a `Map`; + on open, if a non-disposed panel exists for that key, `revealToForeground()` and return; evict on + dispose. **“+ New instance” opens a DRAFT panel** keyed by a draft id (no alias yet — a + closed/abandoned draft reserves nothing); `startQuickStart` allocates+registers the alias at Start + (§4.2). The rocket/`.open` targets `DEFAULT_ALIAS` when none exists; an existing instance's panel is + keyed by its alias. +- Lifecycle commands (`localQuickStartCommands.ts`) resolve the alias from the **invoking tree node**. + The alias is **stamped on both node kinds (R11)** — the `QuickStartClusterItem` model row **and** + the `createGenericElementWithContext` generic rows — so `start/stop/restart/delete/copy*/viewLogs` + read it uniformly. **All Quick-Start runtime output is alias-scoped** — a **per-alias output channel** + (or alias-prefixed lines) for `viewQuickStartLogs` **and** the pull/create/start streams — not just + `viewQuickStartLogs`; `viewQuickStartLogs` also keeps a **`Map`** (R15). + +--- + +## 5. Work items (sequenced, each committable + reviewable) + +- [x] **WI-0 — Testability seam (R12).** Extract a `ContainerRuntime` **interface** and inject it into + the service (today it's a module singleton called at **~38 sites**, with **no** service test). The + **pure inspectors `isRunning(item)` / `getBoundHostPort(item)` become standalone exported functions** + (no IO → keep the interface = IO surface only). Enables Docker-free tests for every later WI. No + behavior change. **Unanimously green in round-2 — safe to start immediately, decoupled from WI-1/2.** + - _Done:_ `IContainerRuntime` (13 IO methods) extracted; `ContainerRuntimeImpl implements + IContainerRuntime`; `isRunning`/`getBoundHostPort` now standalone exports; `QuickStartServiceImpl` + gains `constructor(runtime: IContainerRuntime = ContainerRuntime)` + `this.runtime.*` (31 sites) and + is `export`ed for test injection. Behavior-preserving. Gates: build · lint · jest **2768/2768**. +- [x] **WI-1 — Identity & keying foundation.** Add `DEFAULT_ALIAS` + derivation helpers + (`containerName/volumeName/clusterId/secretKey/imageRefKey`), the registry (§4.2) + locked + `globalState` accessors, and the **legacy-key migration** (§6). **Also repoint the still-singleton + service to the alias-keyed (`DEFAULT_ALIAS`) keys** so WI-1 is independently safe (R1/gpt55): the + service must not read a flat key the migration just deleted. Pure + storage; fully unit-testable. + *(5-agent review.)* + - _Done:_ `DEFAULT_ALIAS` + `containerName/volumeName/clusterId/secretKey/imageRefKey` helpers + (backward-compat: default maps to the legacy container/volume names) + `LEGACY_*` keys; + `quickStartRegistry.ts` (registry schema with lease/`operationId`, per-process-locked + `updateRegistry`, **step-wise resumable** `migrateLegacyQuickStartKeys` — `await`ed before + reconcile, copy→ensure→delete-legacy-last, port derived from conn-string/inspect); service + repointed to alias-keyed keys with a **legacy fallback** on the volume-wipe-gating reads + the + imageRef reuse chain; Delete purges legacy keys. **2 review rounds (initial + fix confirmation), + 5-agent — round-2 unanimous APPROVE.** Note: `QUICK_START_CLUSTER_ID` value changed + (`quickstart-local-documentdb` → `quickstart-vscode-documentdb-local`) — ephemeral cache key only. + Deferred to WI-2 (registry becomes read there): `deleteContainer`/`finalizeReadyInstance` must + remove/upsert the alias's registry record (currently write-only, so inert). Gates: build · lint · + jest **2787** (+21 tests). +- [ ] **WI-2 — Service → multi-instance state machine.** `Map` (incl. + `missing`); alias-parameterize provision/lifecycle/reconcile/getStatus/listStatuses/ + refreshLiveState(**one `listByLabel`**, R15)/liveStateGuard/`isBusy(alias)`/ + `willReuseExistingInstance(alias)`; **keep default-alias wrapper overloads** so the build stays + green (R13). Convert the **exact** destructive call sites in §4.3 (`findManagedContainer(alias)`, + `volumeName/containerName(alias)`, `isManaged(id, alias)`). **Port allocation reserves every + registry port (running + stopped) + in-flight reservations** (R3), and a **collision preflight** + rejects an unlabelled container holding `containerName(alias)` before pull/create (R6). Remove the + reconcile no-secret-remove branch (R2). Preserve §7 invariants; add the §8 tests. **The big one.** + *(5-agent review.)* + - _In progress — **part 1 done** (commit `0cf022cd`): the registry is now authoritative + (`upsertInstanceRecord`/`removeInstanceRecord`; `finalizeReadyInstance` upserts the default record + as `'ready'`; `deleteContainer` removes it). **RESUME HERE →** the core `Map` field migration, the alias-parameterized methods, the cross-window + concurrency model (§4.2 lease/`operationId`/`nextSuffix`-heal, §4.3), the port reservation + + collision preflight, removing the reconcile no-secret branch (R2), and the 5-agent review all + remain. The service is `QuickStartService.ts` (~1,120 lines); state fields at `:171-182`; provision + generator `:250-536`; finalize `:546`; resume `:588`; discard `:690`; reconcile `:1066`._ +- [ ] **WI-3 — Tree: N instances + “+ New instance.”** `listStatuses()`-driven rows, per-alias ids, + per-instance **Provisioning** row (no hardcoded port), add-instance action + its **command id + + `package.json` `view/item/context` contribution** (R15); display-name labels. +- [ ] **WI-4 — Webview + router alias-scoping.** Target-alias panel param; alias on every procedure; + controller **`Map` create-or-reveal** (R10); `.open` alias selection + (rocket → `DEFAULT_ALIAS`, +New → next-free); resume/next-steps target the right instance. +- [ ] **WI-5 — Commands alias resolution.** Lifecycle/copy/logs commands extract alias from **both** + tree-node kinds (R11); `viewQuickStartLogs` → `Map` + **per-alias output channel** (R15). +- [ ] **WI-6 — Migration hardening + docs.** Verify upgrade path (incl. absent-alias-label container → + `DEFAULT_ALIAS`); update `decision-instance-model.md` (reversal), `v1-readiness-gaps.md`, release notes. +- [ ] **WI-7 — Live Docker E2E.** Two instances; independent stop/start/delete; **delete-isolation**; + reconcile after reload with 2 containers; **stopped-sibling port reservation**; upgrade-migration + from a pre-existing single instance (legacy keys + adopt with no rename). + +--- + +## 6. Backward-compat migration (one-time, on activation — ORDERING IS DATA-SAFETY) + +For `DEFAULT_ALIAS` only: if the **legacy flat** `documentdb.quickstart.connectionString` secret +exists and the alias-keyed secret does not, copy flat → `secretKey(DEFAULT_ALIAS)` (and +`documentdb.quickstart.imageRef` → `imageRefKey(DEFAULT_ALIAS)`), add `{DEFAULT_ALIAS, "DocumentDB +Local", port, phase:'ready'}` to the registry — deriving **port** from the legacy connection string, +else the container's `inspect` HostConfig binding, else `QUICK_START_PORT` (**never** a blind +`10260`, since an upgrading user may run on a fallback/custom port; gpt55-#4) — then delete the flat +keys. Idempotent; guarded so it runs once. + +**Ordering (R1 — prevents data loss):** the migration is **`await`ed at activation BEFORE +`QuickStartService.reconcile()` and before any command that can call `provision()`** — wire it as +`await migrate(); void reconcile();` in `ClustersExtension.ts:264-273`. Otherwise reconcile reads a +missing alias-keyed secret → removes the container, and the user's re-provision reads no creds → +`reusing=false` → **wipes the default volume**. Belt-and-suspenders: destructive paths (volume wipe, +orphan removal) **fall back to the legacy flat key** if the alias-keyed value is absent. + +The existing container/volume are already correctly named (§4.1), so reconcile adopts them with no +Docker changes. **Net upgrade experience:** the user’s existing instance reappears as instance #1. +**Downgrade note:** removing the updated extension after migration leaves the instance unmanageable +until reinstall (one-way migration; standard practice). + +--- + +## 7. Data-safety invariants (MUST hold — the reason this is gated) + +1. **Isolation:** Delete/Stop/Restart/**discard**/orphan-sweep on instance A never touches B’s + container, volume, creds, or cache. All destructive ops resolve names/keys via the **alias’s** + derivation only (`findManagedContainer(alias)`, `volumeName(alias)` — R7). +2. **Volume-wipe stays per-alias + fresh-only:** the `!reusing ⇒ removeVolume(volumeName(alias))` + guard; a reusing/recreate path never wipes. +3. **Port allocation reserves EVERY known instance's port (running OR stopped) + in-flight + reservations** (R3), plus non-managed processes via loopback `isPortFree`. The *pick-port → reserve + → write* sequence runs inside **one lock acquisition** (Minor-1); `registry.port` is + **authoritative for stopped** instances and `refreshLiveState` **never clears** it (Major-2). + Explicit Advanced ports also reject sibling-reserved ports. +4. **Reconcile never cross-adopts and never auto-removes (R2/R14):** `alias = labels[ALIAS_LABEL] || + DEFAULT_ALIAS`; unknown alias → its own instance; a labelled container with **no recoverable + secret is surfaced, never removed**, volume never touched; same-alias duplicates → deterministic + winner + log + leave the other. +5. **Ownership preflight (R6):** before pull/create, if `containerName(alias)` exists and is **not** + quickstart-labelled → fail inline, never touch it. `isManaged(id, alias)` requires `quickstart=1` + **and** matching alias (legacy no-alias only for `DEFAULT_ALIAS`). +6. **Migration ordering + copy-then-delete** (§6, R1): `await`ed before reconcile/provision; copies, + never moves-destructively; destructive paths fall back to the legacy key. +7. **`missing` is per-alias** — setting one instance Missing never affects another’s badge or guards. +8. **Reservation lifecycle (R5 + Major-1):** a cancelled/errored provision that never created a + container removes **its own** (`operationId`-matched) reservation; a **crashed** host's stale-lease + reservation is **scavenged at reconcile**; the pre-clean / destructive path is **`operationId`- + guarded** so it never removes another window's same-alias container. Cross-window allocation races + **degrade safely** via Docker name/port uniqueness — established data is never lost. +9. **Loopback bind (shipped) applies per instance** — every instance publishes on `127.0.0.1`. +10. **Credential-unavailable instance** (labelled, no recoverable secret) renders in a **distinct + state** (not `Missing`); its row offers **Delete behind the data-loss confirmation** and **no + silent recreate** — a recreate that finds no creds must not take the `reusing=false ⇒ wipe` path + (Minor-2/opus47-M1). + +--- + +## 8. Testing strategy (also closes the reviewers’ “no state-machine tests” gap) + +WI-0 makes `ContainerRuntime` injectable; mock `secretStorage`/`globalState` (jest-mock-vscode is +wired). New `QuickStartService.multiInstance.test.ts`: +- Provision two instances → two containers/volumes/ports/secret keys; no overlap. +- **Provision(B) leaves A’s container AND volume intact** (not just “both exist at the end”). +- **Delete A leaves B** intact (container/volume/creds/registry) — headline isolation test. +- **discardTimedOut(A) / orphan-sweep(A) never touch B**; a cancelled provision(B) doesn’t remove A + and leaves **no** stale registry/reservation entry. +- Reuse/recreate on A never wipes A’s volume and never touches B. +- `reconcile()` with two labelled containers → two `Running` by alias; a registered alias with no + container (and no in-flight provision) → `Missing`; **idempotent** on a second run. +- **Absent alias label** container → `DEFAULT_ALIAS` (no phantom); two same-alias containers → + deterministic winner, other left alone. +- **A labelled container with no recoverable secret is surfaced, NOT removed** (R2). +- Legacy flat-key **migration → default-alias keys, idempotent**; **legacy-only at activation → + reconcile adopts (doesn’t remove), `getReusableCredentials` recovers via the legacy fallback, + volume preserved** (R1). +- Port allocation for #2 **skips #1’s port even when #1 is Stopped** (R3); explicit Advanced port + colliding with a stopped sibling’s baked port → error. +- **Collision preflight:** an unlabelled container holding `containerName(alias)` → provision fails + inline, container untouched (R6). +- **Labelled same-alias collision (Major-1):** a pre-clean for alias `-N` does **not** remove a + labelled container it didn't create (`operationId` mismatch) — simulates two windows racing the same + suffix; both instances survive, the loser fails cleanly. +- **Cross-window heal:** `reconcile()` sets `nextSuffix = max(existing suffix)+1`; a stale-lease + pre-create reservation is scavenged; a fresh-lease no-container entry renders `Provisioning`. +- **Stopped-port persistence (Major-2):** after a `refreshLiveState()` whose `listByLabel` result has + no host-port for a stopped instance, `registry.port` is **preserved** (not cleared); #2 still skips it. +- **Credential-unavailable (Minor-2):** an alias with a labelled container but no recoverable secret + → distinct state; a recreate does **not** wipe the volume without explicit confirmation. +- **Migration port derivation:** a legacy instance on a **non-10260** port → registry `port` picks up + the real port (from the connection string / inspect), not `10260`. +- On-timeout `pendingReadiness` is per-alias (a timeout on A doesn’t affect B). + +--- + +## 9. Effort, risk, and sequencing note + +- **Risk concentration:** WI-2 (service). It is the data-safety-critical core; it gets the tests in + §8 and a full 5-agent review before commit. +- **Everything else is mechanical** once identity (WI-1) and the service (WI-2) land: tree, webview, + commands are alias-plumbing. +- **Ship options:** this can land as its own PR on top of the Quick Start feature branch, or fold + into it. Recommended: a dedicated PR (`feat(local-quickstart): multiple managed instances`) so the + data-safety refactor is reviewed in isolation. + +--- + +## 10. Owner decisions (resolved 2026-07-06) + +1. **Naming:** **auto-fill** a default display name, **no prompt** (one click preserved). Rename UI + deferred. → no router-schema change (U2/U4 as written). +2. **Ship line:** **multi-instance stays in v1**; the later ship date + larger test bar (WI-0 seam + + §8 state-machine tests) are accepted. +3. **PR packaging:** **dedicated PR** — `feat(local-quickstart): multiple managed instances` — so the + data-safety refactor is reviewed in isolation. +4. **Cap:** **no hard cap**; add a **soft warning** in the provisioning panel when many (~5+) instances + are running (WI-3/WI-4). +5. **Rename an instance:** **deferred** to a later release. diff --git a/docs/ai-and-plans/PRs/local-quickstart-multi-instance/review-and-resolutions.md b/docs/ai-and-plans/PRs/local-quickstart-multi-instance/review-and-resolutions.md new file mode 100644 index 000000000..15550c9ef --- /dev/null +++ b/docs/ai-and-plans/PRs/local-quickstart-multi-instance/review-and-resolutions.md @@ -0,0 +1,89 @@ +# Multi-Instance Plan — Review & Resolutions + +Companion to [`implementation-plan.md`](./implementation-plan.md). Records the 5-agent plan review +and how each finding was resolved into the plan. + +## Round 1 — plan review (2026-07-06) + +**Reviewers:** GPT-5.4 (xhigh), GPT-5.5 (xhigh), Opus 4.6 (max), Opus 4.7 (max), Opus 4.8 (max), +each reading the plan + the actual Quick Start code. + +**Verdict:** 4× NEEDS CHANGES, 1× "sound with clarifications" (Opus 4.6). **No re-architecture +required** — the identity/keying model, backward compat, and migration completeness were +independently **verified correct** by all five. All findings are targeted edits to close +data-safety and lifecycle gaps. Every reviewer confirmed: + +- `containerName(DEFAULT_ALIAS)` / `volumeName(DEFAULT_ALIAS)` equal today's constants → existing + container/volume adopted with **no rename** (`quickStartTypes.ts:51,64`). +- Only two persisted keys exist (`SECRET_KEY`, `IMAGE_REF_STATE_KEY`) → migration is complete. +- `clusterId` is ephemeral (never persisted) → safe to re-derive with no migration. +- Ownership boundary / non-goals preserved (label-only; no adopt-unmanaged; ephemeral instances). + +### Findings & resolutions (consensus) + +| # | Severity | Finding (raised by) | Resolution in plan | +| - | -------- | ------------------- | ------------------ | +| R1 | **Blocker (data loss)** | Migration must run **before** `reconcile()` and any `provision()`; else reconcile finds no alias-keyed secret → removes the container, and a re-provision reads no creds → `reusing=false` → **wipes the volume** (opus47, gpt55) | §6 rewritten: migration is `await`ed at activation **before** `reconcile()`/any command; wiring becomes `await migrate(); void reconcile();`. §7 adds invariant: destructive paths (volume wipe, orphan removal) **fall back to the legacy flat key** if the alias-keyed value is absent. §8 adds the legacy-only-at-activation test. | +| R2 | **Blocker (data safety)** | reconcile's "labelled container with **no stored secret** → remove container" branch (`QuickStartService.ts:1042-1054`) contradicts §7.4 and can tear down a sibling / adopted instance — German's "stray delete" (opus48) | §4.3/§7.4: the legacy orphan-removal-on-no-secret branch is **removed** for multi-instance. A credential-less/unknown-alias labelled container is **surfaced** as a state row (“needs recreate / credentials unavailable”), **never auto-removed**, volume never touched. | +| R3 | **Blocker** | Port allocation must reserve **stopped** siblings' ports (baked into container config; `docker start` later fails), not just `isPortFree` live binds; registry `{alias,displayName}` can't express this (all 5) | §4.1/§4.2: registry record gains **`port`**. Allocation (and explicit-Advanced-port validation) reserves **every** registry port (running/stopped) **plus** in-flight reservations, then `isPortFree`. §8: “#2 skips a **stopped** #1's port”. | +| R4 | **Blocker/Major** | `InstanceRuntimeState` omits `missing`; `isBusy`/`willReuseExistingInstance` must be per-alias or one instance corrupts another's badge/guards/UI (all 5) | §4.3: add `missing: boolean`; `isBusy(alias)`, `willReuseExistingInstance(alias)` alias-scoped; any aggregate status named explicitly. | +| R5 | **Blocker/Major** | Alias reservation + registry lifecycle race: allocate-on-open vs register-on-provision; parallel provisions + cross-window globalState RMW clobber; tree only reads in-memory aliases (gpt54, opus47, gpt55, opus48) | §4.2 rewritten: **register the alias at provision START** (so a per-instance Provisioning row + panel stay consistent); reconcile **tolerates** a registry alias with no container mid-provision (does not flip to Missing); **cleanup on abort/failure before container creation** (no stale entry). Every registry mutation is `read→mutate→write` under a **per-process async lock**; the tree **re-reads the registry on every refresh**. Alias suffix is **monotonic** (`nextAliasSuffix` in globalState); allocation considers registry + live labels + unlabelled container names. | +| R6 | **Major (ownership boundary)** | Generated aliases need the decision-doc §10.2 **collision preflight** vs unlabelled containers (gpt55) | §7 + WI-2: before pull/create, **inspect `containerName(alias)`**; if it exists and is **not** quickstart-labelled → fail with a clear inline error, **never touch it**. Alias generation skips names held by unlabelled containers. | +| R7 | **Major** | Enumerate the singleton destructive call sites: `findManagedContainer()→list[0]` (used in provision pre-clean + orphan sweep) and hardcoded volume wipes (`:316,:682,:972`); `isManaged` must require **both** labels (opus48, gpt55) | WI-2 lists exact sites: `findManagedContainer(alias)` (filter alias label) at `:310-314`/`:469`; `volumeName(alias)`/`containerName(alias)` at `:316,:682,:972`; `isManaged(id, alias)` requires `quickstart=1` **and** matching alias (legacy no-alias only for `DEFAULT_ALIAS`). §8: `provision(B)` leaves A's container **and** volume. | +| R8 | **Major** | Non-Delete destructive paths uncovered: `discardTimedOut(A)`, orphan reconcile cleanup, failed/cancelled provision (gpt54, opus47, gpt55, opus48) | §7/§8: invariants + tests for discard(A)!→B, orphan-sweep(A)!→B, cancelled provision(B)!→A and leaves **no** stale registry/reservation. | +| R9 | **Major** | Tree “Provisioning…” row is global + hardcoded `localhost:10260`; §4.4 omits a Provisioning state (opus48, opus47) | §4.4: add a **per-instance** Provisioning row (no hardcoded port); registry entry at provision start makes it consistent. | +| R10 | **Major** | Panel create-or-reveal per alias — framework docs say consumer-owned; double-open spawns duplicate panels driving the same alias (opus47, gpt55, opus48) | WI-4: controller keeps `Map`; on open, reveal an existing non-disposed panel; evict on dispose. Applies to recreate-Missing **and** +New (its key = the newly allocated alias). | +| R11 | **Major** | Command→alias resolution spans **two** node shapes (`QuickStartClusterItem` model row vs generic element); alias must be stamped on **both** (opus48) | WI-5: stamp the alias uniformly on both node kinds; confirm handlers accept the node arg; `start/stop/restart/delete/copy*/viewLogs` read it consistently. | +| R12 | **Major** | Injectable `ContainerRuntime` seam is a real ~20-site refactor and a prerequisite for **every** test — not a §8 aside (opus48) | New **WI-0**: extract the `ContainerRuntime` interface + inject into the service, first (Docker-free tests become possible). | +| R13 | **Major** | WI-2 breaks the build (contract requires each WI committable) unless callers keep compat (gpt55) | WI-2 keeps **default-alias wrapper overloads** (old signatures delegate to `alias = DEFAULT_ALIAS`) until WI-3/4/5 migrate call sites. | +| R14 | **Minor** | reconcile: a `quickstart=1` container with **absent/empty** alias label must default to `DEFAULT_ALIAS`, not a phantom `undefined` instance (all 5) | §7.4: `const alias = labels[ALIAS_LABEL] || DEFAULT_ALIAS`; two containers sharing an alias → deterministic winner, log, leave the other untouched. §8 covers both. | +| R15 | **Suggestion** | Perf: `refreshLiveState()` per-alias = N sequential `docker inspect`; missing WIs (+New command + `package.json` contribution, `.open` alias selection); shared OutputChannel interleaves N logs; migration downgrade note; display-name schema/UI vs auto-fill | WI-2: `refreshLiveState()` = **one** `listByLabel` indexed by id. WI-3/5: add +New command id + `view/item/context` contribution + `.open` alias selection. WI-5: **per-alias output channels** (or alias-prefixed lines). §6: downgrade note. §3/U2: **auto-fill** display name (no prompt), rename deferred → no schema change needed now. | + +### Net (round 1) +Foundation validated by all five; ~15 targeted edits folded in (data-safety ordering, sibling +isolation, port/alias/registry lifecycle, concurrency, collision preflight, testability seam). + +## Round 2 — confirmation review (2026-07-06) + +Same five reviewers, on plan v2. **opus-4.8 verified every R1–R15 resolution against the code** +(line numbers accurate; the R7 destructive-site enumeration is **complete**). **Verdict: WI-0 is +unanimously READY to start** (2 explicit READY, 3 "READY for WI-0, fix the below before WI-2"); **no +established-data-loss blocker** (persisted per-alias secret ⇒ `reusing=true` ⇒ volume kept). The +residual items are WI-2-scoped plan edits, now folded into **plan v3**: + +| # | Severity | Finding (raised by) | Resolution in plan v3 | +| - | -------- | ------------------- | --------------------- | +| RR1 | Blocker→Major | Per-process lock can't serialize two windows; `nextSuffix`/alias/port can clobber; a sibling window can flip a mid-provision alias to Missing; crash leaves a phantom (gpt54, gpt55, opus47, opus48) | §4.2 rewritten: cross-window races **degrade safely** (Docker name/port uniqueness); **`operationId`-guarded pre-clean** (never removes another window's container); **`nextSuffix` self-heals** in reconcile; **provisioning lease** (fresh→Provisioning, stale→scavenge). Don't claim the lock prevents clobber. | +| RR2 | Major | R15's one-`listByLabel` **erases stopped-instance ports** (`docker ps -a` omits host-port for stopped) → reintroduces R3 collision (opus48) | §4.3: `refreshLiveState` updates `port` **only for running**, **never clears** it; `registry.port` **authoritative** for stopped; adoption/migration populate it via `inspect`. §7.3, §8 test. | + +## Code review — WI-2b/2c/2d foundation (3-agent data-safety sanity check, 2026-07) + +**Reviewers:** Opus 4.8 (max), GPT-5.5 (xhigh), Opus 4.7 (max) — rubber-duck, code-reading only, focused +on the plan §5 data-safety invariants. **Verdict (consensus): the WI-2b/2c/2d foundation has NO +currently-triggerable data-safety bug.** All destructive ops are alias-scoped; reconcile never removes +a container or wipes a volume; `aliasMatches` cannot cross-adopt (suffixed containers always carry their +own label; the empty/absent-label→DEFAULT fallback only ever matches genuine legacy = DEFAULT); +`stateFor` returns a stable per-alias object; migration-before-reconcile ordering intact; the tree does +not regress (a reloaded ready-record-no-container default falls through to the rocket, recreate reuses +the volume). Findings are landmines that activate in WI-2e or cheap hardening: + +| # | Severity | Finding (raised by) | Resolution | +| - | -------- | ------------------- | ---------- | +| C1 | **HIGH (data loss)** | `provision(!reusing)` runs `removeVolume` on a **credential-unavailable** instance (container+volume, no secret). WI-2d made it worse: reconcile now *surfaces* it with a "recoverable — use Delete" message, but the only 1-click tree action (rocket → webview → provision) silently wipes (gpt55, opus47) | **FIXED (`3dcd4d0f`), 5-agent APPROVE (5/5):** `provision` RR4 wipe-gate — when `!reusing` AND (a managed container exists OR a durable `'ready'` record exists), surface `CREDENTIAL_UNAVAILABLE_MESSAGE` and abort instead of removing/wiping. Only a truly-fresh alias may wipe. All 5 confirmed a `volumeExists` check would be *worse* (over-blocks the dead-orphan retry). **Known residual (accepted):** the sole remaining wipe of a real volume needs globalState `'ready'` record AND secret AND container all gone but the volume surviving — indistinguishable from a dead orphan and unopenable without the secret, so wiping is the only sensible action. Lease/`operationId` refinement lands in WI-2e-2. | +| C2 | MEDIUM (concurrency) | reconcile scavenge filters **by alias only**; a concurrent adopt/finalize upsert between the stale-decision and the locked write is dropped (all 3, unanimous) | **FIXED now (WI-2e-1):** phase-guard the delete at write time — remove only if still `phase:'provisioning'` AND still stale inside the locked mutator. Dormant until WI-2e writes leases, but closed now. | +| C3 | MEDIUM (concurrency) | `refreshLiveState` captures `entry`, awaits `inspect`, and a concurrent `deleteContainer` clearing `entry.metadata` makes it write `missing=true` onto cleared metadata (opus47) | **FIXED now (WI-2e-1):** capture `containerId` before the await; skip the mutation if `entry.metadata?.containerId` changed (delete/re-adopt raced). | +| C4 | MEDIUM (WI-2e landmine) | `provision`'s orphan-sweep `findManagedContainer()` (`:572`) + ~24 `stateFor(DEFAULT_ALIAS)` refs in `provision`/`resumeReadiness`/`discardTimedOutInstance` are DEFAULT-hardwired — correct today, cross-instance leak if WI-2e's "allocate at Start" only changes the `const alias` (opus48 N1, opus47) | **WI-2e-2 checklist:** thread the owning `alias` through *every* `stateFor(...)`/`findManagedContainer(...)`/`setStatus(...)` in those 3 methods (not just the `const alias =`); the `pendingReadiness` write especially must use the owning alias or DEFAULT's leaks. Enumerated for the WI-2e PR. | +| C5 | LOW (hardening) | Empty-`alias`-label container buckets to DEFAULT — a hypothetical foreign/malformed empty-label container could be adopted as DEFAULT (gpt55); `deleteContainer` skips `isManaged` when `missing` (gpt55) | **Deferred (WI-2e-2 / optional):** opus48+opus47 rate non-triggerable (we always stamp the alias label; ids aren't recycled). Optional: scope the no-label fallback to `containerName(DEFAULT_ALIAS)`, and always `isManaged`-verify before remove. | +| C6 | LOW (UX) | Adopted-but-unregistered container ⇒ `displayName = raw alias`; credential-unavailable has no 1-click tree Delete (rocket only) (opus47) | **Deferred (WI-3 tree):** derive `DocumentDB Local N`; render credential-unavailable as an instance row with Delete. Safe (no data loss) after C1; purely UX. | +| RR3 | Major | Contradiction: register-at-provision-start (§4.2) vs "+New allocates alias at panel open" (§4.5/WI-4) reintroduces stale reservations (gpt54, gpt55) | §4.5/§4.2: **+New opens a DRAFT panel** (draft id, no alias); `startQuickStart` allocates at Start. | +| RR4 | Major | R2 fixed reconcile, but a user-clicked **recreate** on a credential-less instance still hits `reusing=false ⇒ removeVolume` (opus47, opus48) | §4.3/§7.10: credential-unavailable renders a **distinct state** (not Missing); **Delete behind confirmation, no silent recreate-wipe**. §8 test. | +| RR5 | Major | Migration `port` source unspecified — upgrading user may run on a non-10260 port (gpt55) | §6: derive port from legacy conn-string → `inspect` binding → `QUICK_START_PORT`. §8 test. | +| RR6 | Major | Log isolation only scoped `viewQuickStartLogs`; pull/create/start still share one channel (gpt54) | §4.5: **all** Quick-Start runtime output is per-alias (channel or prefixed). | +| RR7 | Minor | Port must be picked **inside** the lock, not just the write (opus47, opus48) | §4.2/§7.3: the whole pick→reserve→write is one lock acquisition. | +| RR8 | Minor | `nextSuffix` initial value unstated (gpt54, opus47) | §4.2: fresh registry `nextSuffix = 2`. | +| RR9 | Minor | WI-0 is ~38 call sites (not ~20); pure inspectors `isRunning`/`getBoundHostPort` should be standalone functions, not interface methods (opus46, opus48) | §5 WI-0 updated. | + +### Net (round 2) +No re-architecture; the identity/keying/migration/isolation core is code-verified. **WI-0 can start +immediately** (decoupled, behavior-preserving). The WI-2 concurrency spec is now explicit; a light +round-3 can confirm before the WI-2 PR, but WI-0/WI-1 don't depend on it. diff --git a/docs/ai-and-plans/PRs/local-quickstart-multi-instance/wi2-core-plan.md b/docs/ai-and-plans/PRs/local-quickstart-multi-instance/wi2-core-plan.md new file mode 100644 index 000000000..7d2cabcdb --- /dev/null +++ b/docs/ai-and-plans/PRs/local-quickstart-multi-instance/wi2-core-plan.md @@ -0,0 +1,299 @@ +# WI-2 core — execution plan (the multi-instance service state machine) + +> Elaborates [`implementation-plan.md`](./implementation-plan.md) §4.2/§4.3/§5 WI-2 + §7/§8 into a +> committable, verifiable sub-step sequence. Round-1/2 findings: [`review-and-resolutions.md`](./review-and-resolutions.md). +> **Status:** IMPLEMENTING — plan v2 (round-1 5-agent review folded in, see §8). +> **Done:** WI-2b (`365dcd72`), WI-2c (`b8af43cc`), WI-2d (`d9f2a133`, registry-driven reconcile + R2 +> inversion), **WI-2e-1** (`3dcd4d0f`, RR4 volume-wipe gate + scavenge phase-guard + refreshLiveState +> stale-entry guard — from a 3-agent foundation review, then **5-agent confirmation: 5/5 APPROVE**). +> Full jest 2801/2801, build/lint/prettier green. **Next: WI-2e-2 (allocation core).** +> **WI-2e-2 checklist (folds in review finding C4 — the DEFAULT-hardwired provision path):** allocate a +> fresh alias + port at Start for `+New` (async `updateRegistry` mutator; reserve every registry port +> running+stopped + in-flight; bind the reserved port, never re-pick); write the `provisioning` lease +> AFTER Docker/port checks; `operationId` pre-clean decision table (own-id for recreate, op-guarded for +> `+New`, skip no-op-label); collision preflight (never touch an unlabelled name holder); `nextSuffix` +> self-heal (incl. unlabelled name holders). **Thread the OWNING alias through EVERY `stateFor(...)`/ +> `findManagedContainer(...)`/`setStatus(...)` in `provision` (~15 refs incl. the `:572` orphan-sweep + +> `:580` reset), `resumeReadiness` (~7), `discardTimedOutInstance` (~2)** — NOT just the `const alias =`; +> the `pendingReadiness` write especially must use the owning alias or DEFAULT's leaks (opus47/opus48 C4). +> Also skip in-flight aliases in `reconcileAlias` (opus48 S2). Then WI-2f: full matrix + mandatory 5-agent review. +> **Deviation note (WI-2c):** `liveStateGuard(id)` / `confirmStaysRunning(id)` act purely on a container +> id — the per-instance message is a WI-4 l10n change, so no `alias` param was added. +> **Prereqs done:** WI-0 (injectable `ContainerRuntime`), WI-1 (identity + keying + migration), +> WI-2 part 1 (registry authoritative: `upsert/removeInstanceRecord`, finalize upsert, delete remove). + +--- + +## 1. Goal (definition of done) + +The **service layer** (`src/services/localQuickStart/QuickStartService.ts`) manages **N independent +instances**, each keyed by an immutable `alias`, each with its own container / volume / port / +credentials / cache key — with the specified cross-window concurrency and **every data-safety +invariant preserved**, and **fully unit-tested**. The existing single-instance UI keeps working +unchanged (via `DEFAULT_ALIAS`) until WI-3 exposes N. + +### Non-goals for WI-2 (they CONSUME WI-2's API) +- Tree N rows + "+ New instance" (WI-3), webview alias-scoping + `Map` (WI-4), + command alias resolution + per-alias output channel (WI-5), live Docker E2E (WI-7). + +### API WI-2 must expose for WI-3/4/5 (revised per round-1 review) +- `getStatus(alias = DEFAULT_ALIAS): QuickStartStatus` (unchanged; the router keeps calling it no-arg). +- `listStatuses(): InstanceStatus[]` where **`InstanceStatus = { alias; displayName; state: + InstanceState; missing: boolean; port?: number; errorMessage?; canResumeReadiness: boolean; + metadata?: InstanceMetadata }`** — a richer per-instance snapshot (m3/gpt54/opus46/gpt55). Rationale: + `QuickStartStatus.metadata` is absent for Provisioning / Missing / credential-unavailable rows, so the + tree needs top-level `alias`/`displayName`/`port` to render + key every row. `Missing` has no enum + value — it's the `missing` boolean overlaid on `state` (as today). Ordering: `DEFAULT_ALIAS` first, + then registry insertion order (suffix `-2, -3, …`). +- `provision(signal, options?, alias?)`, `resumeReadiness(signal, alias = DEFAULT_ALIAS)`, + `discardTimedOutInstance(alias = DEFAULT_ALIAS)`. **Allocation is at Start, inside `provision`** + (NOT a separate `allocateNewInstance` — that contradicted the round-2 "+New opens a draft panel, + allocate at Start" decision, impl-plan §4.5, and had no access to `options.port`). When `alias` is + omitted → `DEFAULT_ALIAS`; a distinct **`draft` marker** (or `alias === undefined && isNewInstance` + flag) tells provision to **allocate a fresh alias + port from `options`**, then **yield an early + `StageEvent` carrying the allocated alias** so the draft panel can bind its subscription. +- `start/stop/restart/deleteContainer/viewLogs(alias = DEFAULT_ALIAS)`, + `willReuseExistingInstance(alias = DEFAULT_ALIAS)`. +- **`isBusy` stays a getter** (`get isBusy(): boolean` = `stateFor(DEFAULT_ALIAS).provisioning || + lifecycleBusy`) so `localQuickStartRouter.ts:110`'s property access still compiles; add + **`isBusyFor(alias): boolean`** for WI-3/4/5 (all 5 reviewers — `isBusy` is a getter, not overloadable). + +Optional trailing `alias` params keep every current caller (router/tree/commands) compiling (R13) +**except `isBusy`** (kept as a getter); WI-4/5 pass the real alias. + +--- + +## 2. Starting shape (current single-instance code) + +- **State fields** (`:171-182`): `state / metadata / errorMessage / missing / provisioning / + pendingReadiness / lifecycleBusy` — all single-valued. +- **Methods**: `provision` (`:250`), `finalizeReadyInstance` (`:546`), `resumeReadiness` (`:588`), + `discardTimedOutInstance` (`:690`), `waitForReadiness` (`:713`), `start/stop/restart` (`:884-980`), + `deleteContainer` (`:991`), `refreshLiveState`, `reconcile` (`:1066`), `runLifecycle`, + `liveStateGuard`, `findManagedContainer` (`list[0]`, `:856`), `isManaged` (`:850`), + `getReusableCredentials`/`readStoredConnectionString`/`willReuseExistingInstance`, + `populateCredentialCache`. +- **Registry** (WI-1/2a): schema `{alias, displayName, port, phase, operationId?, leaseAt?}` + + `readRegistry`/`updateRegistry`/`upsertInstanceRecord`/`removeInstanceRecord`/migration — + **written but not yet READ for logic**. +- Container/volume/keys hardwired to the `DEFAULT_ALIAS`-derived constants. + +--- + +## 3. Sub-steps (each a green, committable commit) + +### WI-2b — State model: fields → `Map` (behavior-preserving) +- Add `interface InstanceRuntimeState { alias; displayName; port?; metadata?; state; provisioning; + lifecycleBusy; missing; pendingReadiness?; errorMessage? }` + `private instances = Map()` + `private stateFor(alias): InstanceRuntimeState` (lazy default). +- Route every `this.` through `stateFor(alias)`. `setStatus(alias, state, metadata?, error?)`; + `getStatus(alias = DEFAULT_ALIAS)`; add `listStatuses()` (a per-alias `InstanceStatus` — for 2b, just + `DEFAULT_ALIAS`). **Keep `get isBusy()`** (DEFAULT); add `isBusyFor(alias)` (m1 — `isBusy` is a getter). +- **PendingReadiness gains `alias` + `displayName`** (opus47-B2) so `finalizeReadyInstance` / + `resumeReadiness` / `discardTimedOutInstance` operate on the owning alias (finalize today hardcodes + `alias: DEFAULT_ALIAS`, `clusterId: QUICK_START_CLUSTER_ID` at `:565/:578` — these must derive from + the alias). Assert `pending.alias === alias` in discard/resume (defensive, opus46). +- **Preserved invariants (do NOT let the mechanical port break these):** + - `stateFor(alias).provisioning = false` stays **inside `finally`**, and the buffered `terminalEvent` + is yielded **after** `finally` — the race-avoidance ordering is load-bearing (opus47-B3). Same for + `resumeReadiness`'s `pendingReadiness` clear + `provisioning=false`. + - The `statusEmitter` stays a **single shared** emitter (the tree re-reads `listStatuses()` on any + change) — not per-alias (opus47-s2). + - `refreshLiveState()` stays **`DEFAULT_ALIAS`-only** in 2b (iterate in 2d). +- **Explicit semantic note (opus47-M5):** routing the `provisioning`/`lifecycleBusy` guards through + `stateFor(alias)` **narrows** them from *any-instance-busy* to *this-instance-busy*. In single-alias + use (all callers pass `DEFAULT_ALIAS`) behavior is unchanged; multi-alias concurrency is enabled by + construction here and exercised in 2d/2e. +- *Verify:* build + full jest green, no observable behavior change. + +### WI-2c — Alias-thread the methods + alias-derived names/keys (behavior-preserving) +- Optional trailing `alias = DEFAULT_ALIAS` on the §1 methods **and the private helpers that carry + `lifecycleBusy`/inspect**: `runLifecycle(alias, …)`, `liveStateGuard(alias)`, `confirmStaysRunning(…, alias)` + (opus48-Q3), plus `getReusableCredentials(alias)` / `readStoredConnectionString(alias)`. +- Replace hardwired constants with `containerName(alias)/volumeName(alias)/secretKey(alias)/ + imageRefKey(alias)/clusterId(alias)`. +- **`findManagedContainer(alias)` AND `isManaged(id, alias)` both take the legacy fallback (m3/gpt55/ + opus47/opus48):** match a container whose `alias` label equals `alias`, **OR (when `alias === + DEFAULT_ALIAS`) whose alias label is absent/empty** — so a pre-alias-label legacy container is still + found/adopted for `DEFAULT_ALIAS` (2c stays behavior-preserving). `readStoredConnectionString(alias)` + reads `secretKey(alias)`, legacy fallback **only** for `DEFAULT_ALIAS`. +- *Verify:* build + full jest green (behavior identical at `DEFAULT_ALIAS`). + +### WI-2d — Registry-driven reconcile + `listStatuses` + lease Missing (multi-instance becomes real) +- `reconcile()`: **one** `listByLabel({quickstart:1})` → group by `labels[ALIAS_LABEL] || DEFAULT_ALIAS`; + union with `readRegistry()`; build per-alias `InstanceRuntimeState` by this **precedence** (gpt54/opus48): + 1. **Container present** → adopt (running→Running, exited→Stopped); write **`imageRefKey(alias)`** + per-alias (opus47-m1). A stale lease with a container present is still **adopted, NOT scavenged** + (a slow/on-timeout provision keeps its container — opus48-Q4). Backfill `registry.port` from inspect. + 2. **No container + fresh `phase:'provisioning'` lease** → **Provisioning** (regardless of a + half-created container that isn't listed yet — a fresh in-flight container with no secret is + Provisioning, **never** credential-unavailable). + 3. **No container + (stale lease OR `phase:'ready'`)** → **Missing** + scavenge the stale pre-create + reservation. + 4. **Container present + no fresh lease + no recoverable secret** → **credential-unavailable** + (`InstanceState.Error` + message) — surfaced, **never removed**, volume never touched (R2). + - Same-alias duplicate containers → deterministic winner (most-recently-created), log, leave the other. + - **`nextSuffix` self-heal** = `max(existing suffixes across registry + live container names, incl. + unlabelled name holders)+1` (gpt55-minor9). +- `listStatuses(): InstanceStatus[]` for **every** known alias (registry ∪ adopted), ordered + `DEFAULT_ALIAS` first then suffix. +- `refreshLiveState()`: single `listByLabel`, index by id, update all known aliases — **but SKIP any + alias where `stateFor(alias).provisioning || lifecycleBusy`** (never clobber an in-flight alias; a + sibling being busy does NOT skip others — opus47-M6/opus48-M4). `registry.port` authoritative for + stopped (never cleared; update port only for running). **Scavenge is NOT done here** — reconcile + (activation) only (opus48-m5). +- **Update the existing R2 test** `QuickStartService.test.ts:142-162` (`removeContainer` was asserted + called) → the no-secret orphan is now **surfaced** (Error/credential-unavailable), `removeContainer` + **NOT** called, volume never touched (opus46/opus47/opus48). +- *Verify:* new multi-instance tests (§6) + all existing tests green. + +### WI-2e — Allocation-at-Start + provision concurrency +- **Async registry lock (gpt54/gpt55/opus48-M2 — a required WI-1-code touch):** change + `updateRegistry` to accept an **async** mutator (`(registry) => T | Promise`, `await` before + `globalState.update`) so the whole *pick-port → reserve → write* runs under one acquisition. +- **Port reservation (opus48-M2):** allocation must exclude **every registry port (running+stopped) + + in-flight** — either give `findAvailablePort` an **exclusion set** (interface + impl + mock) or run + the exclusion loop service-side via `isPortFree`. An explicit `options.port` (Advanced) is honored + and **rejected if sibling-reserved**. **`provision(alias)` BINDS the reserved `registry.port`** — it + must NOT re-pick (else bound ≠ reserved, defeating R3). +- **Allocation-at-Start:** for a NEW instance, `provision` (draft path) allocates `nextSuffix` + port + + writes `{alias, displayName, port, phase:'provisioning', operationId, leaseAt}` **after the Docker/port + checks pass** (so the Docker-not-ready / port-busy early returns at `:325/:351/:362` never leave a + phantom reservation — opus48-m4), then yields the alias to the panel. For `DEFAULT_ALIAS`/recreate, + the lease is written similarly. +- **Pre-clean DECISION TABLE (gpt55-B2/opus47-M7/opus48-M1):** + | Case | Pre-clean action | + | --- | --- | + | Recreate/reuse of an existing instance (alias has a `ready` record/metadata) | Remove the **known owned container id** (from metadata/registry) — no nonce guard; the reused volume is kept | + | Fresh `+New` allocation, same-alias container with **matching** `op` label | Remove (ours, in-flight) | + | Same-alias container with a **different/absent** `op` label | **Skip** — another window's in-flight or a legacy container; let `docker run` fail the loser cleanly | + | `containerName(alias)` held by an **unlabelled** container | Collision preflight → **fail inline**, never touch (R6) | +- **Recreate-of-`ready` registry safety (opus47-M1):** before overwriting a `'ready'` record with a + `'provisioning'` lease, **snapshot** it; on abort/failure **restore** the prior `'ready'` record + (don't drop the creds+volume signal). Fully remove the reservation only for a **newly allocated** alias. +- **Lease TTL (opus48-m5):** the provisioning-lease staleness threshold must **exceed the worst-case + first image pull** (renew `leaseAt` per stage, or a generous TTL) — the pull precedes any container. +- `finalizeReadyInstance(alias)` upserts `'ready'` (clears `op`/`lease`), binds the metadata alias/clusterId. +- **Provision outcome table** (what happens to container + reservation on each exit): + | Exit | Container | Reservation | + | --- | --- | --- | + | success | kept, Running | upsert `ready` (clear op/lease) | + | readiness timeout | **kept** (+ `pendingReadiness`) | keep `provisioning` lease | + | resume success | kept | `ready` | + | discard (Start over) | remove own; wipe volume only if `!reusing` | remove own reservation | + | cancel/error/unsubscribe before finalize | remove own `op`-container if created | remove own reservation (restore prior `ready` if recreate) | + | cleanup can't confirm removal | leave as-is | leave lease → reconcile scavenges | +- *Verify:* allocation / port-reservation-incl-stopped / collision-preflight / operationId-recreate / + early-failure-no-phantom / lease-TTL tests + all green. + +### WI-2f — Tests + 5-agent review (the whole WI-2 core) +- The §6 matrix; then the mandated 5-agent review before merge of the WI-2 core. *(Optional: a lighter + 1–3 agent sanity check after 2c, before 2d/2e depend on the alias-threading — opus47-s3.)* + +--- + +## 4. Concurrency model (concrete) +Registry mutations run under a **per-process async lock** (WI-2e upgrades `updateRegistry` to an +**async mutator** so the *pick-port → reserve → write* is one acquisition — the current sync mutator +can't hold the lock across an async port probe). Cross-window safety = **Docker container-name + +host-port uniqueness** (a genuine double-allocate fails at `docker run`; loser errors cleanly) + +**pre-clean per the §3 WI-2e table** (own-id for recreate; op-guarded for `+New`; never touch a +container you didn't create) + **`nextSuffix` self-heal** in reconcile + a **provisioning lease** +(fresh → Provisioning; stale → recoverable Missing + scavenge). The lease TTL must **exceed the +worst-case first image pull** (renew per stage, or a generous TTL) so a slow pull isn't seen as stale; +**scavenge fires only in reconcile (activation)**, never in the per-render `refreshLiveState`. +Established data is never lost: persisted per-alias secret ⇒ `reusing=true` ⇒ volume kept; and the +volume-wipe gate additionally requires a truly-fresh alias / explicit confirmation (§5.2). + +## 5. Data-safety invariants (must hold at EVERY sub-step) +1. Delete/Stop/Restart/discard/pre-clean on A never touch B's container/volume/creds/cache. +2. **Volume-wipe requires `!reusing` AND a truly-fresh alias** (no existing managed container/volume + for this alias) **or an explicit caller confirmation flag** — NOT just `reusing=false`. In + particular a **credential-unavailable** instance (labelled container + on-disk volume, no readable + secret) yields `reusing=false`; a `provision(alias)`/recreate on it must **NOT wipe** — it must fail + or require explicit Delete (RR4 / impl-plan §7.10; gpt55/opus48). Always `volumeName(alias)`. +3. Reconcile never cross-adopts and **never auto-removes** a credential-less labelled container. +4. Port allocation reserves every registry port (running+stopped) + in-flight; explicit Advanced port + rejects sibling-reserved; `provision` binds the **reserved** port (never re-picks). +5. Collision preflight: never touch an unlabelled container holding `containerName(alias)`. +6. Pre-clean per the §3 WI-2e decision table (own-id for recreate; op-guarded for `+New`; no-op-label + ⇒ skip); cleanup by owner only. +7. **`refreshLiveState` never clobbers an in-flight alias** (skips `provisioning`/`lifecycleBusy` + aliases) and **never scavenges** — scavenge is reconcile-only (activation). +8. Migration ordering + legacy fallback (WI-1) intact; loopback bind per instance. + +## 6. Test plan (WI-0 injectable runtime + mocked `ext`) +**Isolation & reconcile:** +- Provision two instances → two containers/volumes/ports/secrets, no overlap. +- **Delete A leaves B**; `provision(B)` leaves A's container **and** volume; `discardTimedOut(A)` / + orphan-sweep use `volumeName(A)` only and never touch B. +- reconcile with two labelled containers → two states by alias; **credential-unavailable → surfaced + (Error, not removed)**; absent-label → `DEFAULT_ALIAS`; idempotent; `nextSuffix` heal (incl. from + live container names). +- **Container present + stale lease → adopt, do NOT scavenge** (Q4 invariant, opus48). + +**Ports & allocation:** +- Port allocation for #2 skips #1 even when **Stopped**; explicit Advanced port colliding a stopped + sibling → error; `provision` binds the reserved port. +- Explicit-Advanced-port allocation on a **new** alias reserves + binds that port. + +**Concurrency (the per-alias-flag payoff):** +- `provision(A)` ∥ `provision(B)` → both succeed, distinct ports/volumes/secrets; `provisioning(A)` and + `provisioning(B)` transition independently; no cross-writes to secretStorage/globalState/registry. +- `start(A)` ∥ `stop(B)` → both complete (per-alias `lifecycleBusy`, not global serialization). +- `resumeReadiness(A)` ∥ `provision(B)` → A's `pendingReadiness` untouched; B's finalize doesn't clear A's. +- `provision(B)` racing `deleteContainer(A)`'s `removeInstanceRecord` → registry mutations serialize + under the (async) per-process lock; both records converge. +- `refreshLiveState` during a `provision(A)` does **not** clobber A's in-flight state. + +**Recreate / credential-safety / cross-window:** +- Same-window recreate removes the instance's **own prior container** (created under a *different* + original nonce) → recreate succeeds (M1). +- **Recreate on a credential-unavailable alias does NOT wipe** the volume (RR4). +- Legacy no-op-label container present for `DEFAULT_ALIAS` → adoption takes over (`reusing=true`), no + removal, no wipe. +- Lease-scavenge + `nextSuffix`-heal in one reconcile (window A allocated `-3` `provisioning` then + crashed; window B reconcile scavenges the stale reservation AND heals `nextSuffix`; next allocate + picks a valid free suffix). +- Recreate-of-`ready` DEFAULT aborts → the prior `'ready'` registry record is restored (creds+volume + signal intact); volume never touched. +- Migration (WI-1) + a second instance coexist. + +## 7. Risks & open decisions +- **Biggest risk:** WI-2b touches every field access in a ~1,120-line stateful file. Mitigation: + mechanical, method-by-method, existing tests as guardrail, build after each method. +- **Open (minor):** `operationId` label key (propose `vscode.documentdb.op`); keep `getStatus()` + no-arg for the router until WI-4 (yes); does `deleteContainer(alias)` also `removeInstanceRecord` + (yes — generalize WI-2a) and `finalize` upsert per alias (yes). +- **Sequencing:** 2b→2c behavior-preserving (green throughout); 2d→2e add multi-instance behavior + (tested); 2f reviews the whole. Each sub-step is its own commit. + +--- + +## 8. Round-1 plan-review resolutions (2026-07-06) + +5 reviewers (GPT-5.4/5.5 xhigh, Opus 4.6/4.7/4.8 max): **4 NEEDS-CHANGES, 1 SOUND-with-NBs.** All +confirmed the 2b→2c→2d→2e sequencing is sound and the round-2 concurrency decisions are carried; the +findings are targeted plan edits (no re-architecture), all folded into v2 above: + +| Finding (reviewers) | Resolution | +| --- | --- | +| `operationId` guard breaks single-window recreate (gpt55/opus47/opus48) | §3 WI-2e **pre-clean decision table**: recreate removes the own container id; only `+New` is op-guarded; no-op-label ⇒ skip. | +| Atomic pick-port-under-lock + stopped-exclusion not realizable (gpt54/gpt55/opus48) | §3 WI-2e/§4: **async `updateRegistry` mutator**; `findAvailablePort` **exclusion set**; provision **binds the reserved port**. | +| RR4 credential-unavailable recreate wipe dropped (gpt55/opus48) | §5.2 invariant + §6 test — wipe requires truly-fresh alias / explicit confirmation. | +| `refreshLiveState` clobbers in-flight aliases; scavenge on per-render (opus47/opus48) | §3 WI-2d/§5.7 — skip busy aliases; **scavenge reconcile-only**. | +| `isBusy` is a getter (all 5) | §1 — keep `get isBusy()`, add `isBusyFor(alias)`. | +| `listStatuses(): QuickStartStatus[]` too weak (gpt54/opus46/gpt55) | §1 — `InstanceStatus { alias, displayName, state, missing, port?, … }`. | +| `allocateNewInstance()` vs round-2 draft-panel + explicit port (gpt54/gpt55) | §1/§3 WI-2e — allocate **inside provision at Start** from `options`; yield the alias. | +| `PendingReadiness` lacks `alias`; finalize hardcodes DEFAULT (opus46/opus47) | §3 WI-2b — add `alias`/`displayName`; metadata alias/clusterId derive from alias. | +| buffered-terminalEvent + `provisioning=false`-in-`finally` ordering (opus47) | §3 WI-2b — listed as a **preserved invariant**. | +| WI-2b silently narrows global→per-alias guard (opus47) | §3 WI-2b — stated explicitly. | +| `findManagedContainer` legacy no-label→DEFAULT fallback in 2c (gpt55/opus47/opus48) | §3 WI-2c — fallback on **both** helpers. | +| R2 existing test inverts at 2d (opus46/opus47/opus48) | §3 WI-2d — rewrite `QuickStartService.test.ts:142-162`. | +| lease TTL vs slow first pull; recreate-of-ready registry restore (opus48/opus47) | §3 WI-2e/§4 — TTL > worst-case pull; snapshot/restore the prior `ready` record. | +| early-failure returns leave phantom reservation (opus48) | §3 WI-2e — register lease **after** Docker/port checks pass. | +| per-alias `imageRef` in reconcile; nextSuffix from live names; concurrency tests (opus47/gpt55/opus46) | §3 WI-2d + §6 test matrix. | + +**Not blocking / accepted:** `statusEmitter` stays shared; `getStatus()` stays no-arg for the router; +a lighter sanity check after 2c is optional (opus47-s3). + diff --git a/docs/ai-and-plans/PRs/local-quickstart-poc/description.md b/docs/ai-and-plans/PRs/local-quickstart-poc/description.md new file mode 100644 index 000000000..20fa25251 --- /dev/null +++ b/docs/ai-and-plans/PRs/local-quickstart-poc/description.md @@ -0,0 +1,162 @@ +# Local Quick Start — POC: Design Decisions & Scope + +**Status:** Planning — **reviewed by 5 agents over two rounds; revised to consensus (rev. 3), all five APPROVE, no blocking issues**. No implementation yet. +**Branch:** `feature/local-quickstart/POC` +**Base:** `guanzhou/local-quickstart-design` +**Date:** 2026-06-22 +**Companions:** [`poc-implementation-plan.md`](./poc-implementation-plan.md) (the *how*) · +[`review-and-resolutions.md`](./review-and-resolutions.md) (the 5-agent review + every resolution). +**Folder note:** rename to `-local-quickstart-poc` once a PR is opened (matches the +existing `653-local-quickstart-design` convention). + +## Why this note + +This document records the **decisions and rationale** behind a **proof-of-concept (POC)** +for the Local Quick Start feature. The POC exists to be **demoed**, not shipped. Its job is +to prove the end-to-end value of the full design ([`local-quickstart-v2.md`](../../local-quickstart/local-quickstart-v2.md)) +with the **smallest credible vertical slice**, and to de-risk the parts the design leaves as +"architecture, not an implementation plan." + +It is the companion "why" to the step-by-step "how" in +[`poc-implementation-plan.md`](./poc-implementation-plan.md). Read this first. + +## One-sentence goal (unchanged from the design) + +> From an empty machine-with-Docker to an **open, browsable** local DocumentDB connection, +> in one click, without leaving VS Code. + +## What the POC proves (the demo narrative) + +1. User opens the **DocumentDB Local – Quick Start** entry (tree rocket or command). +2. A **card-based webview** (same design language as the Query Insights tab) shows Docker + readiness and a "what we'll do" summary. +3. User clicks **Start DocumentDB Local**. The real container is pulled, created, started. +4. The webview shows **lightweight staged progress** (Checking → Pulling → Creating → + Starting → Waiting for readiness → Done), driven by a tRPC subscription. +5. A **wire-protocol readiness probe** confirms the DB accepts connections. +6. The connection is **saved and revealed in the Connections view**; the webview auto-closes. +7. User **expands the connection and browses real databases/collections** — using the + extension's existing tree/browse code, end to end. + +If steps 1–7 work live, the POC has proven the design. + +## Scope: what the POC focuses on vs. leaves out + +The split is driven by one question: **does it move the demo?** + +| Area | In POC (focus) | Deferred (left out) | Why | +| ---- | :---: | :---: | ---- | +| Quick Start webview (Review → Progress → Success) | ✅ | | The visual centerpiece of the demo | +| Real container provisioning via `@microsoft/vscode-container-client` | ✅ | | The design-sanctioned runtime; proves it works | +| **Lightweight in-webview staged progress** | ✅ | | See "Key deviation 1" — it's what the manager singled out | +| Wire-protocol readiness probe (180 s, POC) | ✅ | | "Running ≠ ready"; the design's core correctness contract | +| **Inline managed instance** under the Quick Start node + **browse** | ✅ | | The payoff + the design's signature "webview closes, tree takes over" handoff; cheap via `DocumentDBClusterItem` | +| Quick Start tree node + rocket empty state (incl. **fresh-machine** empty state) | ✅ | | The design's entry point | +| Lifecycle actions (Stop / Start / Delete) | ▲ Stretch | | Not needed to prove the provisioning flow | +| Storage persistence across reload + named data volume | ▲ Stretch | | Demo is single-session; volume needs the image data path (OPEN-2) | +| Auto-generated credentials in SecretStorage (masked in all logs) | ✅ | | Zero-friction is the whole point; never leak secrets | +| Legacy emulator migration (§4) | | ✅ | Invisible in a fresh-machine demo | +| TLS-exception wizard step (§7) | | ✅ | Separate slice; POC hardcodes `emulatorConfiguration` | +| Full 7-state machine + complete action matrix (§6) | | ✅ | POC uses a reduced state set | +| Port fallback band (§8.3) | | ✅ | POC pre-checks 10260 and errors clearly if busy (no random band) | +| Container adoption / label-conflict resolution (§10) | | ✅ | POC uses a fixed name + simple message | +| Multi-window coordination / Docker events (§12) | | ✅ | Single-window demo | +| Advanced panel (custom creds/image/seed data) (§5.2, §8.4) | | ✅ | Happy path only | +| Categorized Docker diagnosis (§9, v1.2) | | ✅ | Basic "Docker not ready" message only | +| Telemetry (§14) | | ✅ | Not demo-visible | +| `10255 → 10260` manual-wizard fix (§13.5) | | ✅ | Quick Start uses its own port; unrelated to the POC | + +## Key deliberate deviations (POC vs. the v1.0 shipping design) + +These are intentional. They make the POC a better demo while staying true to the design's +intent. They are **not** proposals to change the shipping plan. + +1. **Include lightweight staged progress (the design's v1.1 "prefer to ship").** + The shipping design makes v1.0 *terminal-first / spinner-only* and pushes lightweight + in-webview progress to v1.1. The POC pulls v1.1's lightweight progress **forward**, because: + (a) a demo must *show* the value, and a silent spinner shows nothing; and (b) this is + precisely the slice the manager (Tomaz) carved out and singled out as worth shipping + (commit `ce0224f8`). We still honor the hard constraint: **no `docker pull` percentage + streaming** — stage-level transitions only. + +2. **OutputChannel transparency is a deliberate POC *compromise*, not parity.** The design's + "terminal-first transparency" runs `docker` as VS Code *terminal tasks*. The repo has **no + VS Code terminal-task integration** (`vscode.Task`/`ShellExecution`) — though it *does* have a + general `Task` service framework (`src/services/taskService/`, which the POC reuses for + lifecycle/cancellation). For the POC we stream the runtime's stdout/stderr to a dedicated + **Output channel**. This is **not equivalent** to the integrated-terminal experience the design + anchors on; it is a cheaper stand-in that still lets a viewer see the **real docker commands and + live output**. The generated password is **masked** in everything written to the channel. Full + terminal-task transparency is a shipping-time follow-up. + +3. **Service-owned instance, rendered inline (no double-appearance).** `QuickStartService` owns the + managed instance; the **DocumentDB Local - Quick Start** tree node renders it **inline** as a + read-only `DocumentDBClusterItem` built from the instance's connection string (with + `emulatorConfiguration = { isEmulator: true, disableEmulatorSecurity: true }`). This buys + TLS-allow-invalid and **full browse for free**, and — because nothing is written to the shared + Emulators storage zone in the Core path — the instance shows up **only** under the Quick Start + node, matching the iteration-2 tree shape (§2/§3.2) and avoiding the duplicated/legacy-zone + appearance the design was redesigned to remove. **Storage persistence is Stretch (WI-8).** + +4. **Ephemeral data, honestly labeled.** The documented `docker run` mounts no volume, and the + image's internal data path is unverified (OPEN-2). The POC therefore runs **ephemeral**, and the + webview's **Data card reads "Ephemeral (POC)"** (never "Persistent"/"Persisted") so the demo UI + does not claim a property the build lacks. A named volume is Stretch (WI-8). + +## Codebase reuse strategy (the leverage points) + +The POC is small **because** it stands on existing infrastructure (all verified in source): + +| Need | Reuse | Path | +| ---- | ----- | ---- | +| Webview panel + tRPC + React | `WebviewControllerBase`, `appRouter`, `WebviewRegistry` | `src/webviews/_integration/*` | +| Webview UI vocabulary | `MetricsRow` / `MetricBase` / `SummaryCard` / `Card`,`Badge`,`Button` | `.../queryInsightsTab/components/*` | +| Streaming progress pattern | subscription generator + `AbortSignal` | `.../queryInsights/queryInsightsEventsRouter.ts` (`streamStage3`) | +| Inline instance + browse (the payoff) | `DocumentDBClusterItem` from a `TreeCluster`; primes `CredentialCache` from the connection string on expand | `src/tree/connections-view/LocalEmulators/LocalEmulatorsItem.ts` (template) | +| Lifecycle / cancellation / state | the `Task` base class (state machine + `AbortSignal` + `updateProgress`) | `src/services/taskService/` | +| Persist a connection (Stretch only) | `ConnectionStorageService.save(ConnectionType.Emulators, …)` + reveal helpers | `src/commands/newLocalConnection/ExecuteStep.ts:177-201` | +| TLS-allow-invalid behavior | `emulatorConfiguration.disableEmulatorSecurity` → `tlsAllowInvalidCertificates` | `src/documentdb/connectToClient.ts` | +| Conn-string composition (already percent-encodes) | `DocumentDBConnectionString` | `src/documentdb/utils/DocumentDBConnectionString.ts` | +| New tree node + refresh | `ConnectionsBranchDataProvider`, `ext.state.notifyChildrenChanged` | `src/tree/connections-view/*` | +| Command + menu registration | `ClustersExtension.activateClustersSupport()` + `package.json` contributes | `src/documentdb/ClustersExtension.ts` | + +## Real-world findings that shape implementation + +From the **official DocumentDB image** (`github.com/microsoft/documentdb` README): + +- **Image:** `ghcr.io/documentdb/documentdb/documentdb-local:latest` +- **Run:** `docker run -dt -p 10260:10260 --name --username --password

` +- **Port `10260`** is the documented default — the design's canonical port is already correct. +- **Connection string:** `mongodb://:

@localhost:10260/?tls=true&tlsAllowInvalidCertificates=true` + → maps cleanly onto `emulatorConfiguration`/TLS-allow-invalid. + +**Tension to flag:** the image takes credentials as **container CLI args** (`--username` / +`--password` after the image name), **not** as environment variables. This means the password +lands on the host `docker run` command line — re-introducing exactly the `ps -ef` / process-audit +exposure the design's §8.2 `--env-file` was meant to avoid (separate from the `docker inspect` +exposure the design already accepts). **POC decision:** use the documented `--username/--password` +args **and mask the password in all Output-channel writes**; record for the shipping design a +two-part open question — *does the gateway accept env-var credentials*, and if not, *how do we +avoid CLI-arg exposure?* + +## Open questions / risks + +1. **Credential transport** — does the image accept env-var credentials, or only the + documented CLI args? (Affects §8.2 hardening. POC uses CLI args.) +2. **Data persistence** — the documented `docker run` mounts no volume; the in-container data + path for a persistent named volume is unknown. POC treats persistence as a **stretch**; + ephemeral data is acceptable for a demo. +3. **Readiness probe reuse** — confirm we can drive a one-shot `ping`/`hello` over the wire + protocol through the existing connect path (`connectToClient.ts` / `ClustersClient`) with + `tlsAllowInvalidCertificates`, in a 60 s retry loop. +4. **Container client ergonomics** — `@microsoft/vscode-container-client@0.5.4` is installable; + confirm the `DockerClient` + command-runner API for pull/create/start/inspect/stop and for + appending post-image args (`--username/--password`). +5. **Double-appearance cosmetic** — the saved Emulators connection may show under both the new + Quick Start node and the existing **DocumentDB Local** node. Acceptable for the POC. + +## Status + +Planning only. The detailed work breakdown, acceptance checks, and demo script live in +[`poc-implementation-plan.md`](./poc-implementation-plan.md). The multi-agent review of this +plan and its resolutions will be recorded in `review-and-resolutions.md`. diff --git a/docs/ai-and-plans/PRs/local-quickstart-poc/poc-implementation-plan.md b/docs/ai-and-plans/PRs/local-quickstart-poc/poc-implementation-plan.md new file mode 100644 index 000000000..feeb6527b --- /dev/null +++ b/docs/ai-and-plans/PRs/local-quickstart-poc/poc-implementation-plan.md @@ -0,0 +1,353 @@ +# Local Quick Start — POC: Implementation Plan + +> Companion to [`description.md`](./description.md) (read that first for the *why*). +> Full design: [`local-quickstart-v2.md`](../../local-quickstart/local-quickstart-v2.md). +> Review history & resolutions: [`review-and-resolutions.md`](./review-and-resolutions.md). +> +> **Audience:** an implementation agent (Opus/Sonnet-class) or a developer. +> **Status:** **Implemented (WI-0…WI-6) and reviewed by 5 agents against the running code — all APPROVE, 12 findings fixed.** See review-and-resolutions.md "Implementation review". +> **Goal of this plan:** a demoable POC, built as small focused work items, grounded in +> the existing codebase so it is *easy to implement*. + +--- + +## 0. How the implementing agent must work (process contract) + +1. **Work item by work item.** Numbered **Work Items (WI-n)**. Do one at a time. +2. **Commit per work item** (e.g. `feat(quickstart): add container runtime wrapper (WI-0)`). +3. **Report status** before/after each WI with what changed and check results. +4. **This plan is the source of truth.** After each WI, tick its checkbox + append a one-line outcome. +5. **Confidence gate.** If confidence in any non-obvious decision is **< 80%**, stop and ask. +6. **Phase gating.** A→D ordered. **Phase A+B+C = the demo.** Phase D is stretch. +7. **PR checklist before declaring a phase done:** `npm run l10n` (if user-facing strings + changed) → `npm run prettier-fix` → `npm run lint` → `npx jest --no-coverage` → + `npm run build`. All must pass. (`npm run build`, never `npm run compile`.) +8. **Two kinds of acceptance checks.** Each WI marks checks as **[unit]** (must pass under + `npx jest`, Docker-free — e.g. credential generation, connection-string composition, + stage-event ordering, password-masking) or **[manual/integration]** (requires a live Docker + daemon — e.g. pull/create/start/inspect). The §0.7 gate runs the **[unit]** checks; + **[manual/integration]** checks are run by hand and reported, never assumed. +9. **Terminology:** "DocumentDB" for the service; "MongoDB API"/"DocumentDB API" for the wire + protocol. Never "MongoDB" alone. All user-facing strings via `vscode.l10n.t()`. +10. **No `any`.** `unknown` + type guards. Explicit return types. `instanceof Error` in catch. + +--- + +## 1. Goal, demo narrative, and non-goals + +### Goal +Prove the Local Quick Start vertical slice end-to-end, live: one click → provisioned local +DocumentDB container → **browsable connection rendered inline under the Quick Start node**. + +### Demo narrative +Rocket/command → webview (readiness + summary) → **Start** → lightweight staged progress → +wire-protocol readiness → webview **auto-closes** → the **DocumentDB Local - Quick Start** node +shows a **Running** instance **inline**; expand it to browse real databases/collections. (Full +script: §6.) + +### Non-goals (explicitly out — see `description.md` scope table) +Legacy migration; TLS-exception wizard; full 7-state machine + complete action matrix; port +**fallback band** (we still detect a busy port and error cleanly); container adoption/label +**conflict** resolution; multi-window coordination/Docker events; Advanced panel; categorized +Docker diagnosis; telemetry; the `10255→10260` manual-wizard fix; init-script seed feature. +**Storage persistence across reloads and a named data volume are Stretch (Phase D).** + +--- + +## 2. Architecture map (where it plugs in — all paths verified against source) + +**New code (POC owns these):** + +``` +src/services/localQuickStart/ + ContainerRuntime.ts # wrapper over @microsoft/vscode-container-client (Docker) + QuickStartService.ts # singleton: orchestration, state, credentials, readiness + quickStartTypes.ts # InstanceState, StageEvent union, InstanceMetadata +src/commands/localQuickStart/ + openLocalQuickStart.ts # command → opens the webview +src/webviews/documentdb/localQuickStart/ + localQuickStartController.ts # extends WebviewControllerBase + localQuickStartRouter.ts # tRPC: getDockerStatus / startQuickStart (sub) / cancel + LocalQuickStart.tsx # React entry (Review → Progress → Success) + components/... # cards reusing query-insights vocabulary +src/tree/connections-view/LocalQuickStart/ + LocalQuickStartItem.ts # "DocumentDB Local - Quick Start" node + rocket empty state + inline instance + QuickStartActionItem.ts # empty-state "Quick Start..." row (opens webview) +``` + +**Existing code to modify (small, surgical):** + +| File | Change | +| ---- | ------ | +| `package.json` | add `@microsoft/vscode-container-client`; command + menu contributions; (empty-state) viewsWelcome option | +| `src/webviews/_integration/appRouter.ts` | mount `localQuickStart` router (import primitives from `./trpc`, not here) | +| `src/webviews/_integration/WebviewRegistry.ts` | register the React component (auto-bundles — no esbuild change) | +| `src/documentdb/ClustersExtension.ts` | register command(s) + create/attach `QuickStartService` | +| `src/tree/connections-view/ConnectionsBranchDataProvider.ts` | render `LocalQuickStartItem` **including the zero-connections empty state** (§WI-6); fix `savedConnections` count | + +**Reuse verbatim (verified reusable):** `WebviewControllerBase`, `useTrpcClient`, +**`MetricsRow`/`MetricBase`/`SummaryCard` (confirmed pure-presentational, NOT coupled to +CollectionView context)**, the subscription-generator pattern from +`queryInsightsEventsRouter.streamStage3` (with its rethrow-in-`catch` / `finally`-cleanup / +abort-listener gotchas), `DocumentDBClusterItem` + the `TreeCluster` +recipe in `LocalEmulatorsItem.ts` (browse works because `DocumentDBClusterItem` primes +`CredentialCache` from the model's `connectionString` on expand), `DocumentDBConnectionString` +(already percent-encodes), `connectToClient.ts` (TLS-allow-invalid), and the **`Task` base +class in `src/services/taskService/`** (see D13). + +> **Correction (from review):** the repo **does** have a task framework +> (`src/services/taskService/`: a `Task` state machine with `AbortSignal`, `updateProgress`, +> telemetry, and a `TaskService` singleton). What it lacks is **VS Code *terminal-task*** +> integration (`vscode.Task`/`ShellExecution`). D2/D13 are worded accordingly. + +--- + +## 3. Confirmed design decisions + +| # | Decision | +| - | -------- | +| D1 | **Runtime = `@microsoft/vscode-container-client` `DockerClient`** (v0.5.4, confirmed installable, not yet a dep). No hand-rolled `docker` strings *as the primary path*. Podman/OCI later = a client swap (§13.8). | +| D2 | **Transparency = a dedicated VS Code OutputChannel** streaming runtime stdout/stderr. **This is a deliberate POC compromise, *not* parity with the design's terminal-task transparency** (the repo has no VS Code terminal-task integration). The demo must still let a viewer see the **real docker commands + live output**. Full terminal-task transparency is a shipping follow-up. | +| D3 | **Progress = lightweight in-webview staged checklist** via a tRPC **subscription** fed by the service-level `StageEvent` sink (D13; template: `streamStage3`). Stage-level only — **no pull-% streaming**. (Pulls design v1.1 forward for the demo; manager-emphasized.) | +| D4 | **Image** = `ghcr.io/documentdb/documentdb/documentdb-local:latest`; **port 10260**; credentials passed as **container args** `--username/--password`; conn-string `mongodb://U:P@localhost:10260/?tls=true&tlsAllowInvalidCertificates=true`. Run detached+tty (`-dt`). | +| D5 | **Service-owned instance, rendered inline.** `QuickStartService` owns the managed instance (state + credentials + connection string). `LocalQuickStartItem` renders it **inline** as a read-only `DocumentDBClusterItem` built from the instance's `connectionString` → browse works for free and **only under the Quick Start node** (no Emulators-zone save in Core → **no double-appearance**, matches §2/§3.2). Storage persistence is **Stretch (WI-8)**. | +| D6 | **Credentials auto-generated** from a URL-safe alphabet `[A-Za-z0-9]`; held in SecretStorage. `DocumentDBConnectionString` already `encodeURIComponent`s them (belt-and-suspenders, §8.1). | +| D7 | **Readiness = wire-protocol `ping`** through the existing connect path with TLS-allow-invalid, retry loop with backoff, **timeout 180 s for the POC** (first cold start generates TLS certs + initializes Postgres; 60 s is too tight). On timeout: keep-waiting / logs / cancel. "Running" only on probe success (§9.1). | +| D8 | **Reduced state set for the POC:** `NotInstalled → Provisioning → Running` (+ `Error`). Stretch adds `Stopped`/`Starting`/`Stopping`. | +| D9 | **Fixed container name** `vscode-documentdb-local` + Docker labels `vscode.documentdb.quickstart=1`, `vscode.documentdb.alias=` (§10.1). **All destructive/inspect ops act on the stored `containerId`, never the name; any name-collision branch verifies the `vscode.documentdb.quickstart` label before touching a container** (§10.1/§13.1). If an unlabeled container owns the name: show a simple message (no adopt flow in POC). | +| D10 | **Entry point = a tree node + a command.** The node `DocumentDB Local - Quick Start` shows a rocket "Quick Start…" empty-state row that opens the webview; **the node renders even with zero saved connections** (WI-6). Command `vscode-documentdb.command.localQuickStart.open` is the palette/fallback launch. | +| D11 | **Bound port from `docker inspect`** (`NetworkSettings.Ports`) is the source of truth for the saved connection string (§8.3), even though the POC requests a fixed 10260. | +| D12 | **Cancel via `AbortSignal`.** `provision()` accepts an `AbortSignal` threaded into every runtime call. **Pull-phase cancel** aborts the pull — **no container exists to remove**. **Create/Start-phase cancel** removes the container *by `containerId`* and releases the port (§5.6 provisioning rows). Lifecycle-transition cancel is out of POC scope. | +| D13 | **`QuickStartService` (singleton) uses the `Task` framework by *composition*, not inheritance** (`src/services/taskService/`). `Task` is single-use (`start()` throws if state ≠ Pending; `delete()` disposes its emitters), so the singleton owns **a fresh internal `Task` per provisioning attempt** — this is what makes **Retry** (WI-4) and re-provision after Delete (WI-7) work. Reuse `Task` for `doWork(signal)` / `stop()` / `AbortSignal`. **Do not** use `TaskProgressReportingService` (numeric 0-100 → a VS Code *notification*, conflicting with D3's in-webview stage model). Stage progress flows through a **service-level `EventEmitter` `StageEvent` sink** that `doWork` pushes into; the tRPC subscription drains it into an async-iterable (the `streamStage3` pattern). The **tree change-event lives on the service**, not the per-attempt `Task` (whose emitters are disposed on `delete()`). | +| D14 | **Never write secrets to the OutputChannel.** All runtime stdout/stderr is **line-buffered** (split on newlines *before* masking, so a chunk boundary can't split the secret) and passed through a `writeMasked()` helper that redacts the generated password (and any connection string containing it) to `***` in every command echo, stdout, and stderr line. Unit-tested. | + +### Still open (resolve at the relevant WI; ask if confidence < 80%) +- **OPEN-1:** credential transport for shipping. The image takes credentials as **CLI args**, + which puts the password on the host `docker run` command line (`ps -ef`) — re-introducing the + exact exposure §8.2's `--env-file` avoids. Shipping question is two-fold: *does the image + accept env-var credentials*, and if not, *how do we avoid CLI-arg exposure*? POC uses CLI args + + D14 masking. **Note:** D14 masks only the *OutputChannel*; the host process-table (`ps -ef`) + exposure genuinely **remains** in the POC and is part of the shipping question, not solved by it. +- **OPEN-2:** persistent volume data path inside the image (needed for WI-8 / honest "persisted"). +- **OPEN-3:** the `DockerClient` call surface — **WI-0 must validate**: pull-with-streaming, + create-with-**post-image args** (`--username/--password` after the image), inspect bound port, + start/stop/remove, list-by-label. **If the client cannot append post-image args, fall back to + a raw `docker` spawn via `src/utils/cp.ts`** (still centralized, still masked per D14). + +--- + +## 4. Work items + +### Phase A — Runtime foundations (no UI) + +- [ ] **WI-0 — Container runtime wrapper + API validation.** + Add `@microsoft/vscode-container-client` to `package.json`. **First, validate OPEN-3** against + the installed package (a throwaway spike is fine); if post-image args aren't supported, switch + `ContainerRuntime` to a raw `docker` spawn via `src/utils/cp.ts`. Then implement + `ContainerRuntime.ts`: `isDockerReady()` (CLI on PATH + daemon reachable), + `isPortFree(10260)` (pre-check → friendly "Port 10260 is in use" instead of raw Docker stderr), + `pullImage(ref, onLine)`, `createContainer(opts)` (name, labels, `10260:10260`, post-image args + `--username/--password`, detached+tty), `startContainer(id)`, **`followLogs(id, onLine)`** + (because `-dt` detaches, the readiness wait must stream container logs explicitly or the + channel goes silent), `inspectContainer(id)` (state + bound host port), `stopContainer(id)`, + `removeContainer(id)`, `listByLabel(label)`. Stream everything to an OutputChannel + **through a single `writeMasked()` helper that redacts the password (D14)**. + - *Acceptance:* **[unit]** `writeMasked()` never emits the password; port-busy maps to the + friendly message. **[manual/integration]** version logs through the wrapper; a hand-created + container is `inspect`ed for its bound port; `followLogs` shows live output. + - *Files:* `package.json`, `ContainerRuntime.ts`, `quickStartTypes.ts`. + +- [ ] **WI-1 — QuickStartService (orchestration + state + readiness + reconciliation).** + `QuickStartService.ts` singleton using the `Task` framework **by composition (D13)** — it owns a + **fresh internal `Task` per provisioning attempt** (so Retry/re-provision work cleanly). Generate + credentials (D6). The `Task`'s `doWork(signal)` runs the steps and **pushes `StageEvent`s into a + service-level `EventEmitter` sink** (`checking → pulling → creating → starting → waiting → + done|error`); the tRPC subscription drains that sink into an async-iterable for the webview (D3). + Hold `InstanceState` + `InstanceMetadata` (`containerId`, alias, boundPort, clusterId, + connectionString). Readiness probe per D7 (reuse the connect path; 180 s; backoff). Cancel per + D12 via the `Task`'s `AbortSignal` (pull-cancel = abort, no removal; create/start-cancel = remove + by `containerId`). Emit a **service-level** tree change event (not on the per-attempt `Task`). + **Activation reconciliation:** on init, `listByLabel('vscode.documentdb.quickstart=1')`; if a + labeled container exists with no in-memory state (e.g. after a window reload), **adopt it** + (rehydrate `containerId`/port/state from `inspect` + SecretStorage so the inline node reappears), + or if its credentials can't be recovered, offer a one-click **Reset** (remove). This prevents an + orphaned container from silently blocking the next `isPortFree(10260)`. + - *Acceptance:* **[unit]** stage-event ordering; credential alphabet; connection-string + composition; cancel-during-pull performs **no** `removeContainer`; a second attempt after a + failed one starts cleanly (fresh `Task`). **[manual/integration]** `provision()` brings the + container up and resolves `Running`; reload → reconciliation re-shows the running instance; + cancel during create removes the container by id. + - *Files:* `QuickStartService.ts`, `quickStartTypes.ts`, `ClustersExtension.ts` (attach). + +### Phase B — The Quick Start webview (demo centerpiece) + +- [ ] **WI-2 — Webview scaffold (controller + router + React + wiring).** + `localQuickStartController.ts` (extends `WebviewControllerBase`, mirrors + `documentsViewController.ts`); **pass `closePanel: () => this.panel.dispose()` into the trpc + context** (use `this.panel.dispose()`, **not** `this.dispose()` — the framework deliberately does + not close the panel from `dispose()` to avoid a circular chain; disposing the panel fires + `onDidDispose → dispose()`, so cleanup still runs). `localQuickStartRouter.ts` + with `getDockerStatus` (query), `startQuickStart` (subscription → yields `StageEvent`s), + `cancelQuickStart` (mutation) — **import `publicProcedure*`/`router` from `./trpc`, not + `appRouter.ts` (circular-import trap)**. Mount under `appRouter`; register the React entry in + `WebviewRegistry`; `openLocalQuickStart.ts` opens it; register the command in `ClustersExtension` + + `package.json`. + - *Acceptance:* **[manual]** the command opens a webview that calls `getDockerStatus` and renders it. + +- [ ] **WI-3 — Review & Start view.** + `LocalQuickStart.tsx` Review state: 4 metric cards (**Docker / Port / Data / Security**) reusing + `MetricsRow`+`MetricBase`; a "What we'll do" `SummaryCard` (image = the official + `ghcr.io/documentdb/...` ref, host, credentials, lifetime). **The Data card reads + "Ephemeral (POC)"** (the POC has no volume — do not claim "Persistent"). **Start** + **Cancel**. + Basic **Docker-not-ready** variant (single message + Retry; no categorized diagnosis; honors + opt-in — never auto-starts Docker). + - *Acceptance:* **[manual]** cards reflect real `getDockerStatus`; Start disabled when not ready; + Data card says Ephemeral. + +- [ ] **WI-4 — Progress + Success (staged progress, D3).** + On **Start**, subscribe to `startQuickStart`; render the **lightweight staged checklist** + (done/active/pending) + elapsed timer; **Start** shows a spinner while running. **Inherit the + subscription gotchas:** rethrow in the router `catch` so `onError` reaches the webview, clean up + in `finally`, manage the abort listener. On failure: inline error + **Retry** (detail in the + Output channel via a **"View Docker output"** link). On success: brief Success card (if it shows + a Data card, it also reads **"Ephemeral (POC)"**) → **auto-close by calling `closePanel` + (which disposes the panel, per WI-2)** → hand off to the tree. + - *Acceptance:* **[manual]** Start runs the real flow with live stage transitions; failure shows + Retry; success auto-closes and the instance appears inline in the tree (WI-5). + +### Phase C — Inline instance + entry (the payoff, design-faithful) + +- [ ] **WI-5 — Inline managed instance + browse.** + After readiness success, compose the connection string (D4) from the **inspected bound port** + (D11) with name **"DocumentDB Local"** (§8 default). `LocalQuickStartItem.getChildren()` returns + a read-only `DocumentDBClusterItem` built from a `TreeCluster` (reuse the + `LocalEmulatorsItem.ts:60-80` recipe) carrying `emulatorConfiguration { isEmulator:true, + disableEmulatorSecurity:true }` and the `connectionString`. Browse works via + `CredentialCache`-from-connection-string (verified). Give the inline row a **static + `description = 'Running · localhost:'`** so the demo's "Running" row exists in Core (the + full state-aware/colored-dot description is WI-7). **Sample data:** programmatically insert one + sample doc (1 db / 1 collection / 1 doc) — a single driver call, *not* the init-script seed + feature. Optional polish **unless** the fresh image exposes no browsable database/collection, in + which case it becomes a **demo-prep requirement** (so the final beat isn't an empty tree). + - *Acceptance:* **[manual]** after success the instance shows **inline** under the Quick Start + node (only there), with a `Running · localhost:10260` description, and **expands to real + databases/collections**; the sample doc is visible. + +- [ ] **WI-6 — "DocumentDB Local - Quick Start" node + rocket + empty state.** + `LocalQuickStartItem.ts` (mirror `LocalEmulatorsItem.ts`): with no managed instance, + `getChildren()` returns a rocket **"Quick Start — Install & try DocumentDB locally"** row (opens + the webview). Wire into `ConnectionsBranchDataProvider`. **Critical: handle the zero-connections + empty state** — `getRootItems()` currently `return null` when there are 0 clusters **and** 0 + emulators (`:111-116`), which renders the *welcome screen* and hides all root nodes. + **Mandate: `LocalQuickStartItem` must render as a root tree item *unconditionally* — independent of + the stored-connection count *and* of whether the instance is persisted** (return it before/instead + of the `null` early-return). **Do not** rely on a `viewsWelcome` button as the fix: because the + Core instance is in-memory (unsaved, D5), a fresh machine stays at 0 *stored* connections **even + after a successful provision**, so a `viewsWelcome` view would render empty and the running inline + instance (WI-5) would be hidden behind the welcome screen — breaking demo §6 step 4. (A welcome + button may be added *in addition*, for the pre-provision state only.) **Also fix** the + `savedConnections = rootItems.length - 2` telemetry (`:61`) so the extra always-present node + doesn't skew the count. + - *Acceptance:* **[manual]** on a **fresh machine (0 connections)** the node + rocket appear and + open the webview; telemetry count is correct. + +### Phase D — Stretch (only if time before the demo) + +- [ ] **WI-7 (stretch) — Minimal lifecycle actions.** + Inline **Stop / Start / Delete Container** actions wired to `QuickStartService`, **all acting on + the stored `containerId` and verifying the quickstart label first (D9)**; state-aware row + description (`Running · localhost:10260`); refresh via `ext.state.notifyChildrenChanged(this.id)`. +- [ ] **WI-8 (stretch) — Persistence + named volume.** + Persist the instance/connection to storage so it survives reload (mirror + `src/commands/newLocalConnection/ExecuteStep.ts:177-201` — note ~15 files share the name + `ExecuteStep.ts`; this is the **`newLocalConnection`** one: build a `ConnectionItem`, call + `ConnectionStorageService.save(...)`, reveal helpers) and mount a named volume + `vscode-documentdb-local-data` (resolve OPEN-2) so the Data card can honestly say "Persistent." + If persisting into the Emulators zone, **filter the managed instance out of `LocalEmulatorsItem` + rendering** to preserve the single-location UX (D5). + +--- + +## 5. Risks & mitigations + +| Risk | Mitigation (in-plan) | +| ---- | -------- | +| **Secret leak to OutputChannel** | D14 `writeMasked()`, unit-tested (WI-0) | +| **`-dt` detach → silent channel during wait** | `followLogs()` streams container logs explicitly (WI-0) | +| **Cold-start readiness > 60 s** | D7: 180 s + backoff + keep-waiting | +| **Cancel orphans a container / crashes on pull-cancel** | D12: `AbortSignal`; pull-cancel removes nothing; create/start-cancel removes by id | +| **`DockerClient` lacks post-image args** | OPEN-3 validated in WI-0; raw-`cp.ts` fallback | +| **Port 10260 busy (no fallback band)** | `isPortFree` pre-check → friendly message (WI-0) | +| **Fresh-machine node hidden by welcome screen** | WI-6 empty-state handling | +| **Image pull slow/blocked on demo network** | Pre-pull in demo prep (§6); already-present image skips the pull stage | +| **Destructive op hits a user's container** | D9: act on `containerId` + verify label | +| **Orphaned container after a VS Code reload blocks the next run** | WI-1 activation reconciliation (adopt-or-reset by label) | +| **`Task` is single-use → Retry / re-provision break; generator-vs-`Task` impedance** | D13 composition: a fresh `Task` per attempt + `EventEmitter`→subscription bridge | +| **Masking misses a secret split across a stream chunk** | D14: line-buffer before `writeMasked()` | + +## 6. Demo script + +**Prep (do before the demo):** Docker running; **run in a VS Code profile *without* the Docker VS +Code extension** (so "no Docker-extension dependency" is *shown*, not just claimed); **pre-pull** +the image; **verify no stale `vscode-documentdb-local` container** (`docker rm -f` if present); +**verify port 10260 is free**; if the fresh image exposes **no** default browsable database/ +collection, ensure the **WI-5 sample-doc seed** runs (so step 5 isn't an empty tree); keep the +command palette (`DocumentDB: Local Quick Start`) ready as a fallback launch. + +1. Fresh VS Code, 0 connections. Connections view shows **DocumentDB Local - Quick Start** with + the **rocket** row (proves the empty-state entry, WI-6). +2. Click the rocket → webview. Point out: **Docker ✅**, **Port 10260**, the **official + `ghcr.io/documentdb/...` image** in "What we'll do", **Data = Ephemeral (POC)**. Mention it + needs **no Docker VS Code extension**. +3. **Start DocumentDB Local** → watch staged progress: Checking ✅ → Pulling → Creating → + Starting → Waiting → Done. (Click **View Docker output** to show the real commands/output.) +4. Webview **auto-closes**; the **Running** instance appears **inline** under the Quick Start node + (the "webview closes, tree takes over" handoff). +5. Expand it → **admin** → a database → a collection (and, if the optional seed ran, open it to + show a document). +6. (Stretch) **Stop** / **Start** the instance live. + +**Three manager-checkpoints to call out:** official ghcr image; **works without the Docker +extension (running in a profile that doesn't have it — shown, not just claimed)**; the auto-close → +inline-tree handoff. + +## 7. Deviation log + +- **WI-1 — `QuickStartService` is a standalone service, not built on the `Task` base class + (deviates from D13).** Rationale: the `Task` base class is single-use (`start()` throws once + it has run; terminal states don't reset) which conflicts with the **Retry**/re-provision + requirement, and its progress model is numeric `0-100` driving a VS Code *notification* — at + odds with D3's in-webview stage checklist. A standalone singleton with a per-attempt + `AbortSignal` + a `vscode.EventEmitter` status sink satisfies every functional requirement the + reviewers raised (cancellation threaded to docker, fresh-per-attempt, no single-use breakage) + with less ceremony. D13 explicitly permits a standalone service; provisioning is an async + generator consumed directly by the tRPC subscription. +- **Windows arg-quoting fix (manual-testing finding).** The container-client's + `ShellStreamCommandRunnerFactory` must be given a **`shellProvider`** (`Cmd` on Windows, `Bash` + elsewhere); without one it drops each argument's quoting and sets `windowsVerbatimArguments` on + Windows, splitting Go-template `--format {{json .}}` args and breaking `info`/`inspect`/`list`. + Added the `@microsoft/vscode-processutils` dependency. `-dt` is achieved with `detached: true` + alone (the client adds `--tty` automatically) — no `customOptions` needed. +- **Sample data uses the image's native `--init-data true`** (decision from reviewing the + DocumentDB source), not a bespoke driver-side seed. This matches §8.4 ("use the image's standard + init-script convention") and loads the rich `sampledb` (users/products/orders/analytics). A + capped, non-fatal `waitForSampleData()` waits for `sampledb` to appear so the browse step + reliably shows data. The old single-document driver seed was removed. + +--- + +## Appendix — equivalent raw Docker (reference; we call this via the client, not as strings) + +``` +docker pull ghcr.io/documentdb/documentdb/documentdb-local:latest +docker run -dt -p 10260:10260 \ + --name vscode-documentdb-local \ + --label vscode.documentdb.quickstart=1 \ + --label vscode.documentdb.alias=vscode-documentdb-local \ + ghcr.io/documentdb/documentdb/documentdb-local:latest \ + --username --password # masked to *** in all logs (D14) +docker logs -f vscode-documentdb-local # stream during readiness wait (D2/-dt) +docker inspect vscode-documentdb-local # NetworkSettings.Ports → bound host port +# connection string (creds percent-encoded by DocumentDBConnectionString): +# mongodb://:

@localhost:10260/?tls=true&tlsAllowInvalidCertificates=true +``` diff --git a/docs/ai-and-plans/PRs/local-quickstart-poc/review-and-resolutions.md b/docs/ai-and-plans/PRs/local-quickstart-poc/review-and-resolutions.md new file mode 100644 index 000000000..35687c1d2 --- /dev/null +++ b/docs/ai-and-plans/PRs/local-quickstart-poc/review-and-resolutions.md @@ -0,0 +1,369 @@ +# Local Quick Start POC — Plan Review & Resolutions + +**Artifact under review:** [`poc-implementation-plan.md`](./poc-implementation-plan.md) + +[`description.md`](./description.md) (rev. 1 → rev. 2). +**Branch:** `feature/local-quickstart/POC` +**Date:** 2026-06-22 + +## What this document is + +The POC plan was reviewed by **5 independent agents**, each on a different model and a distinct +lens, before any code was written. This file records each lens's verdict, every finding, and how +the plan was changed in response (the `> ✅ RESOLVED` notes). It exists so the manager, a +co-worker, or a future agent can see *why the plan is shaped the way it is* and continue without +re-deriving the rationale. + +## Reviewers and verdicts + +| Lens | Agent / model | Initial verdict | +| ---- | ------------- | --------------- | +| Design fidelity vs `local-quickstart-v2.md` | rubber-duck / Opus | APPROVE-WITH-CHANGES | +| Manager (Tomaz) perspective & expectations | rubber-duck / GPT-5.4 | APPROVE-WITH-CHANGES | +| Implementability against the real codebase | rubber-duck / Opus | APPROVE-WITH-CHANGES | +| POC scope & demo effectiveness | rubber-duck / GPT-5.4 | APPROVE-WITH-CHANGES | +| Technical risk & live-demo correctness | rubber-duck / Gemini 3.1 Pro | **NEEDS-WORK** | + +**Convergent signal:** 3 of 5 lenses independently flagged the same top issue — the design's +signature **inline managed instance under the Quick Start node** was wrongly relegated to Stretch. +The risk lens (the lone NEEDS-WORK) surfaced two P0 demo-breakers the others didn't. + +--- + +## Findings & resolutions (severity-sorted) + +### P0 — Credential leak to the OutputChannel *(risk)* +The plan streams runtime stdout/stderr to an OutputChannel **and** passes `--password` as a CLI +arg, which an entrypoint can echo → the plaintext password could persist in the Output tab. +> ✅ **RESOLVED.** Added **D14** ("never write secrets to the OutputChannel") and a `writeMasked()` +> helper in **WI-0** that redacts the password in every command echo / stdout / stderr line, with +> a **[unit]** acceptance check. `description.md` deviation 2 now states the password is masked. + +### P0 — Detached `-dt` makes the OutputChannel silent during the wait *(risk)* +A detached container returns immediately; the stream closes after the container ID, so the channel +shows nothing during the readiness wait → the demo looks frozen. +> ✅ **RESOLVED.** Added `followLogs(id, onLine)` to **WI-0** (stream `docker logs -f` after start); +> noted in **D2** and the §-appendix. Risk table row added. + +### P0 — Fresh-machine empty state hides the entry node *(scope + impl; verified first-hand)* +`ConnectionsBranchDataProvider.getRootItems()` returns `null` when there are 0 clusters **and** 0 +emulators (`:111-116`), which renders the VS Code *welcome screen* and hides **all** root nodes — +so a naively-inserted Quick Start node never appears in the demo's "fresh machine" scenario. +> ✅ **RESOLVED.** **WI-6** now explicitly handles the zero-connections state (render +> `[LocalQuickStartItem]` instead of `null`, or add a `viewsWelcome` button) and its acceptance +> check is "on a fresh machine (0 connections) the node + rocket appear." Demo §6 step 1 calls it out. + +### P1 — Signature inline instance was Stretch *(design + manager + scope — 3-way consensus)* +Iteration 2's headline is "the managed cluster is **inline**… no separate entry" (§2/§3.2). The +plan saved a **separate** connection in the legacy Emulators zone and pushed the inline view to +Stretch — showcasing the exact shape the redesign removed, with possible double-appearance under +the legacy "DocumentDB Local" node. +> ✅ **RESOLVED.** **Promoted to Core.** **D5** rewritten: `QuickStartService` **owns** the +> instance; `LocalQuickStartItem` renders it **inline** as a read-only `DocumentDBClusterItem` +> (browse via `CredentialCache`-from-connection-string, verified by the impl lens). **Nothing is +> written to the Emulators zone in Core → no double-appearance.** Only Stop/Start/Delete stay +> Stretch (WI-7); storage persistence is Stretch (WI-8, which filters the instance out of +> `LocalEmulatorsItem` if it persists there). Scope table + WI-5 updated. + +### P1 — "No Tasks infrastructure" is factually wrong; WI-1 over-scoped *(impl)* +`src/services/taskService/` is a full `Task` framework (state machine, `AbortSignal`, +`updateProgress`, telemetry, `TaskService` singleton). The repo only lacks **VS Code +*terminal-task*** integration. WI-1 rebuilt orchestration/state/cancel from scratch. +> ✅ **RESOLVED.** Added **D13**: build `QuickStartService` on the `Task` base class for +> lifecycle/state/cancellation; do **not** use `TaskProgressReportingService` (its numeric +> notification progress conflicts with the in-webview stage model). Corrected the wording in +> `description.md` deviation 2 ("no VS Code *terminal-task* integration"). WI-1 + §2 updated. + +### P1 — Readiness 60 s too short for a cold start *(risk)* +First run generates TLS certs + initializes a Postgres-backed gateway; 60 s can time out. +> ✅ **RESOLVED.** **D7** bumped to **180 s + backoff + keep-waiting**; risk table updated. + +### P1 — Cancellation lacked `AbortSignal`; pull-cancel "removes a container" is wrong *(risk)* +Without a threaded signal a cancelled provision keeps running and orphans a container; and a pull +creates no container to remove. +> ✅ **RESOLVED.** **D12** rewritten: `provision()` takes an `AbortSignal` threaded into every +> runtime call; **pull-cancel removes nothing**; **create/start-cancel removes by `containerId`**. +> WI-1 acceptance adds a unit check that pull-cancel performs no `removeContainer`. + +### P1 — Webview "Data" card claims Persistent, but the POC is ephemeral *(design + manager)* +WI-3/WI-4 mirrored §5.1/§5.5 cards verbatim ("Persistent volume" / "Persisted") while persistence +is deferred — a UI claim the build can't back. +> ✅ **RESOLVED.** Added **deviation 4** (ephemeral, honestly labeled); **WI-3** now specifies the +> Data card reads **"Ephemeral (POC)"**; volume + "Persistent" is Stretch (WI-8). + +### P1 — Demo script ends at documents, but Core seeds none *(scope)* +> ✅ **RESOLVED.** Demo §6 now ends at **databases/collections** by default; **WI-5** adds an +> **optional** one-document programmatic seed (a single driver call, *not* the init-script feature) +> for a richer ending if time allows. + +### P1 — OutputChannel framed as equivalent to terminal-first *(manager)* +> ✅ **RESOLVED.** **D2** + deviation 2 reworded as a **deliberate compromise, not parity**; the +> demo must still expose the real docker commands/output ("View Docker output", §6 step 3). + +### P2 — Destructive ops keyed on name, not id/label *(design)* +> ✅ **RESOLVED.** **D9**/**D12**/**WI-7** state all stop/remove/inspect act on the stored +> `containerId` and verify the `vscode.documentdb.quickstart` label first (§10.1/§13.1). + +### P2 — `savedConnections` telemetry off-by-one *(impl)* +`getRootItems` computes `rootItems.length - 2`; a third always-present node skews it. +> ✅ **RESOLVED.** **WI-6** includes fixing the count/comment. + +### P2 — Circular-import trap mounting the new router *(impl)* +> ✅ **RESOLVED.** **WI-2** states the router imports tRPC primitives from `./trpc`, not `appRouter`. + +### P2 — Webview auto-close mechanism unspecified *(impl)* +> ✅ **RESOLVED (corrected in rev. 3).** **WI-2** passes `closePanel: () => this.panel.dispose()` +> into the trpc context; **WI-4** calls it on success. *Note:* an earlier draft used +> `this.dispose()`, which the impl re-review correctly flagged as wrong — the framework +> deliberately does **not** close the panel from `dispose()` (circular-chain guard); disposing the +> **panel** fires `onDidDispose → dispose()`, so cleanup still runs. + +### P2 — Saved connection label would be `user@host` *(design)* +> ✅ **RESOLVED.** **WI-5** sets the name to **"DocumentDB Local"** (§8 default). + +### P2 — `DockerClient` post-image-arg support unproven *(risk + impl)* +> ✅ **RESOLVED.** **OPEN-3** + **WI-0** front-load API validation; **fallback to a raw `docker` +> spawn via `src/utils/cp.ts`** (still masked) if the client can't append post-image args. + +### P2 — Port 10260 busy → raw Docker error *(risk)* +> ✅ **RESOLVED.** **WI-0** adds an `isPortFree()` pre-check → friendly "Port 10260 is in use". + +### P2 — Acceptance checks are Docker-dependent, not CI-runnable *(impl)* +> ✅ **RESOLVED.** §0.8 splits checks into **[unit]** (jest gate) vs **[manual/integration]**; +> each WI tags its checks. + +### P2 — Credential-transport open question understated *(design)* +> ✅ **RESOLVED.** **OPEN-1** + the "Tension to flag" note now capture both halves (env support +> *and* avoiding CLI-arg `ps -ef` exposure). + +### P2 — Demo resilience too network-only; dirty-machine failures more likely *(scope + risk)* +> ✅ **RESOLVED.** §6 "Prep" now also: verify no stale `vscode-documentdb-local` container, verify +> 10260 free, keep the command-palette launch as a fallback. + +--- + +## Confirmed-accurate (verified by the implementability lens against source — no change needed) + +- New-webview file set is correct; the `WebviewRegistry` key **auto-bundles** the component (no + esbuild/entry wiring). `revealToForeground()` public, `setupTrpc` protected. +- The `streamStage3` subscription generator is a sound template (with rethrow-in-`catch`, + `finally` cleanup, abort-listener add/remove). +- `MetricsRow` / `MetricBase` / `SummaryCard` are **pure presentational** — not coupled to + CollectionView context — so the "reuse the query-insights vocabulary" claim holds. +- The TLS / encoding / browse chain is exactly as claimed; **browse works after a plain model + build** because `DocumentDBClusterItem` primes `CredentialCache` from the connection string. +- `@microsoft/vscode-container-client` is genuinely absent (dep + lockfile + `node_modules`), so + WI-0 must add it and the API is genuinely unproven — making WI-0's front-loaded validation the + correct first move. + +## Status + +Plan revised to **rev. 3** after **two rounds** of 5-agent review. **Consensus reached: all five +lenses approve, no blocking issues.** The round-2 outcomes and the full rev.-3 change list are +recorded immediately above. Ready to implement starting at WI-0. + +### Re-review outcomes (rev. 2 → rev. 3) + +All five lenses re-reviewed rev. 2. **Result: unanimous approval, no blocking issues.** The prior +NEEDS-WORK (risk) flipped after confirming its P0/P1 fixes. The re-reviews surfaced a focused set of +**non-blocking** refinements, all folded into **rev. 3**: + +| Lens | Round 1 | Round 2 (on rev. 2) | +| ---- | ------- | ------------------- | +| POC scope & demo | APPROVE-WITH-CHANGES | **APPROVE** | +| Manager perspective | APPROVE-WITH-CHANGES | **APPROVE-WITH-CHANGES** — P0/P1 resolved | +| Design fidelity | APPROVE-WITH-CHANGES | **APPROVE-WITH-CHANGES** — all resolved, no blocking | +| Implementability | APPROVE-WITH-CHANGES | **APPROVE-WITH-CHANGES** — `Task` API validated | +| Technical risk | **NEEDS-WORK** | **APPROVE-WITH-CHANGES** — prior blockers resolved | + +**Validated, not just claimed:** the impl lens verified against source that **every `Task`-API +assumption in D13 is real** (`doWork(signal)`, `stop()→abort`, threaded `AbortSignal`, +`updateProgress`, `onDidChangeState/Status`), that the empty-state fix point and telemetry line are +correct, and that the circular-import / registry-auto-bundle facts hold. + +**Rev. 3 changes (from the re-reviews):** + +1. **`closePanel` correction (impl, important).** `this.dispose()` does **not** close the panel + (framework circular-chain guard). → `closePanel: () => this.panel.dispose()` (WI-2, WI-4). +2. **`Task` by composition, not inheritance (impl + risk).** `Task` is single-use (`start()` throws + if state ≠ Pending; `delete()` disposes emitters), so a singleton that inherits it breaks **Retry** + and re-provision. → **D13/WI-1** rewritten: the singleton owns a **fresh `Task` per attempt**; a + service-level `EventEmitter` `StageEvent` sink feeds the tRPC subscription; the tree change-event + lives on the service. This also resolves the async-generator-vs-`doWork` impedance. +3. **Activation reconciliation (risk).** Persistence is Stretch, so a VS Code reload would orphan the + running container and block the next `isPortFree(10260)`. → **WI-1** adds reconcile-on-init + (`listByLabel` → adopt/rehydrate or Reset). +4. **Line-buffered masking (risk).** A secret split across a stdout chunk could evade redaction. → + **D14** masks **after** line-buffering. +5. **WI-6 must mandate the always-render root node (design).** The `viewsWelcome`-button alternative + is incompatible with the in-memory Core instance (a fresh machine stays at 0 *stored* connections + even after provision, hiding the running inline instance behind the welcome screen). → WI-6 now + **mandates** `LocalQuickStartItem` renders unconditionally; the welcome button is at most an + addition for the pre-provision state. +6. **Static "Running" description in Core (design).** The demo promises a `Running · localhost:10260` + row, but the state-aware description was Stretch. → **WI-5** adds a static description in Core. +7. **Honest "Ephemeral" on the Success card (design).** → **WI-4** Data card (if shown) reads + "Ephemeral (POC)". +8. **OPEN-1 crispness (design).** Clarified that D14 masks only the OutputChannel — the `ps -ef` + process-table exposure genuinely remains in the POC. +9. **Explicit "no Docker-extension" demo proof (manager).** → §6 prep runs in a VS Code profile + without the Docker extension, so the checkpoint is *shown*, not just spoken. +10. **Seed fallback for a non-empty final beat (scope).** → §6 prep + WI-5: if the fresh image + exposes no browsable database/collection, the 1-doc seed becomes a prep requirement. +11. **`ExecuteStep.ts` path disambiguation (impl).** → WI-8 names the `newLocalConnection` file + (15 files share the name). + +**Residual (intentionally deferred, logged for shipping):** OPEN-1 (credential transport), +OPEN-2 (volume data path), OPEN-3 (validated in WI-0). No reviewer considers any of these a blocker +for a POC demo. + +**Consensus reached.** The plan is consistent with the design, aligned with the manager's +perspective, and judged implementable. Ready to start at WI-0. + +--- + +## Implementation review (POC code, 2026-06-22) + +After implementing WI-0…WI-6, the working POC was reviewed by **5 more agents** against the +running code (functional correctness · design fidelity · webview/tRPC · tree+browse · secret +masking/robustness). **All five returned APPROVE-WITH-CHANGES — no P0, no NEEDS-WORK.** The +security agent **ran the real image** and confirmed no actual password leak today. The core demo +path (provision → masked output → wire-protocol readiness → inline browse) was verified sound. + +**12 findings, all fixed** (commit `fix(quickstart): address 5-agent POC review`): + +| Sev | Finding | Resolution | +| --- | ------- | ---------- | +| P1 | `followLogs` masked per-chunk, not line-buffered (D14 split-secret gap; design+security) | Route container logs through `MaskingLineBuffer` | +| P1 | `followLogs` leaked on success — `cts` disposed but never cancelled, so `docker logs -f` ran forever (functional) | `cts.cancel()` in `finally` (all outcomes) | +| P1 | Container orphaned if cancelled in the create window (id not yet captured) (functional) | `createAttempted` flag + label-based sweep in `finally` | +| P1 | Re-provision reused a stale `ClustersClient` cached by id (tree) | `ClustersClient.deleteClient(clusterId)` before publishing new creds | +| P1 | Webview could hang in `provisioning` on a busy/empty stream (webview) | `provision()` emits a terminal error when busy + `onComplete` handler recovers to review | +| P2 | Subscription leak on double-click Start (webview) | Unsubscribe-before-resubscribe + null the ref on terminal callbacks | +| P2 | Cancel deferred up to ~30s during an in-flight readiness connect (functional) | Direct `MongoClient` with `serverSelectionTimeoutMS: 3000` | +| P2 | Redundant `-t` in `customOptions` — `detached` already adds `--tty` (functional) | Removed `customOptions` | +| P2 | `savedConnections` telemetry undercounted (tree) | Count real connections/folders by contextValue, excluding synthetic nodes | +| P2 | "Learn more…" row missing from the empty state (design) | Added (opens the DocumentDB repo) | +| P2 | Review screen lacked a Cancel button (design) | Added (closes the panel) | +| P2 | WI-5 sample seed not implemented (design) | Best-effort 1-doc seed after readiness so the tree isn't empty to browse | + +**Verified correct by the reviewers (no change needed):** the cancellation plumbing +(`ctx.signal` → mirror → `provision` → `cts` → tree-kill) and `return()` propagation through the +nested generator; the browse/cache-key path (`CredentialCache.setAuthCredentials` under the same +`clusterId` the tree item uses); no double-appearance (nothing written to the Emulators zone); +webview mount/typing/auto-close/bundle-purity; and split-safe masking on the primary paths. + +**Post-fix gates:** `npm run lint` ✅ · `npx jest --no-coverage` (2055/2055) ✅ · `npm run build` ✅ +· webview webpack bundle ✅. + +--- + +## Manual testing (live on Windows, 2026-06-23) + +Running the POC end-to-end surfaced two issues — one launch-recipe gotcha (not a code bug) and one +genuine Windows bug — both resolved. + +### 1. Blank webview (launch recipe, not a code bug) + +A `webpack-dev` build bakes `DEVSERVER='true'` (via `webpack.config.ext.js` `EnvironmentPlugin`), +so the extension fetches the webview script from the dev server at `http://localhost:18080`. A +one-shot `code --extensionDevelopmentPath=dist` launch does **not** start that dev server → the +webview HTML had no script to load → blank page. + +**How to run a standalone manual test (no dev server, no `Watch` task, no problem-matcher +extension):** + +```powershell +npm run webpack-prod # bakes DEVSERVER='' + IS_BUNDLE='true' → loads dist/views.js from disk +code --extensionDevelopmentPath="\dist" --profile=noExtensionsProfile +``` + +(Or press **F5**, which starts the dev server via the `Watch` task — but that task references the +`amodio.tsl-problem-matcher` extension, absent in `--profile=noExtensionsProfile`, so install it +first: `code --install-extension amodio.tsl-problem-matcher`.) + +### 2. P1 (real bug) — "Docker daemon not reachable" on Windows even when Docker is running + +**Symptom:** `isDockerReady()` reported the daemon unreachable; `docker info` worked fine from a +shell. + +**Root cause:** `ShellStreamCommandRunnerFactory` **without a `shellProvider`** discards each +argument's quoting metadata (`args.map(a => a.value)`) and sets `windowsVerbatimArguments` on +Windows. Go-template arguments like `--format {{json .}}` were therefore split on the space, so +`docker info` (and `inspect`/`list`) failed — breaking readiness and, latently, the whole flow. + +**Fix (commit `fix(quickstart): pass shell provider …`):** provide a platform shell provider — +`Cmd` on Windows, `Bash` elsewhere — to every runner; switch `makeRunner` to `strict:false` +(non-zero exit still rejects, harmless stderr warnings don't). Added the +`@microsoft/vscode-processutils` dependency. + +**Live verification (real Docker on Windows):** a full end-to-end run of the exact provision +sequence passed — Docker readiness (the previously-broken `info`), `runContainer` with credential +args + labels, `inspect` bound port, wire-protocol `ping`, sample seed, browse +(`dbs=[quickstart]`), and cleanup. The official image also ships a default `sampledb`, so the demo's +final browse step is never an empty tree. + +--- + +## v1.0 management layer (2026-06-23) + +After a tier-by-tier gap analysis against the design's §15, the biggest v1.0 gap was the +**managed-instance management surface** (§6.2 action matrix / §11 lifecycle vocabulary) — the POC +could provision and browse but not manage the instance. Implemented: + +- **Lifecycle actions:** Start · Stop · Restart · Delete Container · Copy Connection String · + Copy Password · View Logs (inline icons + context menu, gated by state). +- **State machine completed:** added `Starting` / `Stopping`; `Stopped` is now reachable; each + state renders a distinct row (icon + `Running · localhost:` style description). +- **`Missing` badge (§6.1):** when the extension holds metadata but Docker has no matching + container, the row shows `Missing · click to recreate` (primary action re-opens Quick Start; + Delete clears the stale metadata). +- **Live freshness (§12, partial):** the tree re-checks live Docker state on expand + (`refreshLiveState`), so external/other-window changes (and the Missing case) are reflected + without a full polling subsystem. +- **Safety (§9/§13.1):** every destructive op (`stop`/`start`/`remove`) verifies the + `vscode.documentdb.quickstart` **label** on the container before acting; Delete uses a one-line + modal confirm (§11); View Logs masks the password (D14). + +The instance row carries a Quick-Start-specific `contextValue` +(`treeItem_quickStartInstance` + a `state_*` token) so it shows Quick Start actions instead of the +generic cluster menus, while still browsing via the pre-populated `CredentialCache`. + +**Live verification (real Docker on Windows):** run → stop (`exited`) → start (`running`) → remove, +with the label-ownership check confirmed. Gates: `lint` ✅ · `jest` (2055/2055) ✅ · `build` ✅ · +`webpack-prod` ✅. + +**Still open in v1.0** (readiness polish, not yet implemented): platform-supported check (§9), +port-fallback random band (§8.3), "Start Docker Desktop" action (§5.3/§9), and success-card action +buttons (§5.5, lower value since the webview auto-closes and the tree is now the control surface). +Full multi-window *polling* (§12) remains beyond the on-expand refresh. + +--- + +## Restart-safe sample data — `docker start` bug fix (2026-06-23) + +**Symptom (found in manual test action 5 "Start"):** after a Stop, clicking **Start** showed the +row flip to *Running* in the tree, but the Docker backend showed the container had **exited**. + +**Root cause (confirmed via live container logs):** provisioning baked `--init-data true` into the +container's *run args*. That flag is **not restart-safe** — the image's entrypoint re-runs the +sample-data init on **every** `docker start`, the second run hits +`Duplicate key violation Index '_id_'` on `01-users.js`, the init script exits non-zero, and +`set -e` tears the whole entrypoint (and container) down. A secondary bug: `start()` inspected the +container *immediately* after `docker start`, catching the ~3 s window where it still reports +"running" before it dies — hence the false *Running* badge. + +**Fix:** +- **Drop the baked flag.** `provision()` now runs the container with only + `--username/--password` (restart-safe), and seeds the built-in sample data **once**, after + readiness, by running the image's *native* init script via `docker exec`: + `/home/documentdb/gateway/scripts/init_documentdb_data.sh -H localhost -P 10260 -u … -p … -d /home/documentdb/gateway/sample-data` + (paths verified against `Dockerfile_documentdb_local`). New `ContainerRuntime.execInContainer` + streams + masks the exec output (D14); seeding is best-effort/non-fatal. +- **Confirm-stays-running.** `start()`/`restart()` now poll `confirmStaysRunning` (3 × 1.5 s) and + only declare *Running* if the container is still up after the settle window; otherwise they set + *Error* with a "exited shortly after — check the logs" message instead of a false *Running*. + +**Live verification (real Docker on Windows):** run (no `--init-data`) → `exec` native init → +`sampledb` loaded (users 5 · products 5 · orders 4 · analytics 2) → **stop → start stays +`running`** at +1/5/10/15 s and the **sample data persists** (identical counts). Gates: `build` ✅ · +`lint` ✅ · `jest` (2055/2055) ✅ · `webpack-prod` ✅. diff --git a/docs/ai-and-plans/live-preview-playwright-future-work.md b/docs/ai-and-plans/live-preview-playwright-future-work.md new file mode 100644 index 000000000..2766c99d7 --- /dev/null +++ b/docs/ai-and-plans/live-preview-playwright-future-work.md @@ -0,0 +1,260 @@ +# Live webview preview + Playwright checks (future work) + +> How to render a **production** webview in a plain browser, drive it with Playwright, and assert +> layout / accessibility / overflow without launching an extension host. Written up after the Local +> Quick Start redesign (PR branch `dev/tnaum/quickstart-ui-redesign`), where the technique caught +> real defects — a misaligned info icon, a grey code block punching through an error tint, and a +> footer note that was capped 48 px narrower than the content column it was supposed to align with. + +**Status:** working technique, not yet a skill. This document is the recipe. Iterate here. + +--- + +## Why this exists + +The webview bundle served by `watch:views` is the same bundle the extension loads. Point a browser +at it with a small HTML shim and you get the real component tree — real Fluent styling, real +`makeStyles` output, real DOM — in a few seconds, with a full DevTools/Playwright surface and no F5 +cycle. + +That makes it cheap to answer questions that are otherwise guesswork: + +- Does anything overflow horizontally at 312 px of content width? +- Does the breadcrumb collapse into its overflow menu while keeping the current step visible? +- Does focus land on the new step's `h2` after navigation? +- Is that icon _actually_ aligned with its text, or does it just look close? + +--- + +## Prerequisites + +The `watch:views` task must be running. Probe it: + +```bash +curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:18080/views.js # expect 200 +``` + +The dev server emits the **bundled** asset name, so `/views.js` is 200 and `/index.js` is 404 — +requesting the wrong one is the usual cause of a blank preview. Note also that `webpack serve` reads +`webpack.config.views.js` only at startup: editing it (or a `git checkout` that reverts it) requires +a full restart of `watch:views`. + +Anything dropped in `src/webviews/static/` is served at `/static/.html`. + +--- + +## The harness + +A minimal page that mounts one registered webview. The view name is the key from +[`src/webviews/_integration/WebviewRegistry.ts`](../../src/webviews/_integration/WebviewRegistry.ts) — +`localQuickStart`, `collectionView`, `atlasCredentials`, and so on. + +```html + + + + + + + +

+ + + +``` + +### The theme variables are not decoration + +Omit the `--vscode-*` block and the app dies before it renders: + +``` +TypeError: Cannot read properties of undefined (reading '0') + at snappingPointsForKeyColor + at paletteShadesFromCurvePoints + at getBrandTokensFromPalette + at generateAdaptiveLightTheme + at WithTheme +``` + +The adaptive theme generator derives a brand palette from the VS Code button colour. No variables, +no palette, blank page. If you see that stack, it is the harness, not the component. + +--- + +## Faking the extension host + +The stub above is enough for pages that do not need data, but any view that issues a tRPC call will +hang on it. To drive real states — a failed Docker check, a populated grid — answer the wire +protocol directly. The shapes are in +[`packages/vscode-ext-webview/src/shared/wireProtocol.ts`](../../packages/vscode-ext-webview/src/shared/wireProtocol.ts); +the host side that produces them is +[`packages/vscode-ext-webview/src/host/attachTrpc.ts`](../../packages/vscode-ext-webview/src/host/attachTrpc.ts). + +The webview sends `{ id, op }` where `op` carries `type`, `path` and `input`. Reply on the `window` +message bus: + +- query / mutation result: `{ id, result }` then `{ id, complete: true }` +- subscription: one `{ id, result: }` per emission, then `{ id, complete: true }` +- ignore `op.type === 'subscription.stop'` and `'abort'` + +```js +const reply = (id, result) => window.postMessage({ id, result }, '*'); +const complete = (id) => window.postMessage({ id, complete: true }, '*'); + +const fakeHost = { + postMessage(message) { + const { id, op } = message ?? {}; + if (!id || !op) return; + if (op.type === 'subscription.stop' || op.type === 'abort') return; + setTimeout(() => { + if (op.path === 'localQuickStart.getDockerStatus') { + reply(id, { readiness, status: { state: 'NotProvisioned' }, busy: false, willReuse: false }); + complete(id); + } else if (op.path === 'localQuickStart.startQuickStart') { + reply(id, { stage: 'checking', status: 'active' }); + setTimeout(() => { + reply(id, { stage: 'checking', status: 'error', error: 'Daemon not reachable.' }); + complete(id); + }, 400); + } else { + reply(id, null); + complete(id); + } + }, 60); + }, + getState: () => state, + setState: (n) => Object.assign(state, n), +}; +``` + +Build the payloads from the real types (here `DockerStatusResult` / `StageEvent`) so the fake cannot +drift into shapes the component never actually receives. + +--- + +## The check loop + +1. `open_browser_page` on a cache-busted URL. +2. `read_page` for the accessibility tree — this is usually more informative than a screenshot, + because it shows roles, names and disabled state. +3. `run_playwright_code` to resize, click through states, and measure. +4. `screenshot_page` only for questions the tree cannot answer: colour, spacing, weight. + +### Assertions worth running + +```js +// No horizontal overflow at any width. +document.documentElement.scrollWidth <= window.innerWidth; + +// Focus moved to the new step heading, not . +document.activeElement.tagName === 'H2'; + +// Breadcrumb collapsed but kept the current step. +Array.from(document.querySelectorAll('nav[aria-label] button')).map((b) => b.textContent); + +// Optical alignment, measured rather than eyeballed. +const ir = icon.getBoundingClientRect(); +const tr = text.getBoundingClientRect(); +// aligned when ir.top === tr.top and ir.bottom === tr.bottom + +// What is that background actually painting? +getComputedStyle(code).backgroundColor; +``` + +Run at a normal width and at roughly 312 px of content width. + +--- + +## Gotchas + +**The integrated browser's default viewport is ~548 px.** Wide enough to trip `max-width: 560px` +media queries, so a "desktop" screenshot may silently be the narrow layout. Call `setViewportSize` +explicitly and assert `window.innerWidth` before trusting a screenshot. + +**`box-sizing` is `content-box`.** A `maxWidth: '760px'` element with `padding: '24px'` measures +808 px. Match a footer or banner to the content column by giving it the same `maxWidth`, not the +measured width. + +**HMR will lie to you after a hook change.** Adding a `useRef` and letting the page hot-patch +produces `Should have a queue. You are likely calling Hooks conditionally` with a hook-order diff. +It is a stale-module artefact, not a bug in the change. Hard-navigate to a fresh URL. + +**`page.goto(..., { waitUntil: 'load' })` can time out** when a stubbed tRPC request never settles. +The page renders fine; only the load event is pending. Catch the timeout and continue, or wait on a +selector instead. + +--- + +## What this does _not_ prove + +Be explicit about this when reporting results — the renders are real enough to be persuasive well +beyond what they actually verify. + +- **Light theme only.** Dark and high-contrast are untested. The harness hardcodes one palette. +- **Not the VS Code webview host.** No real theme variables, no CSP, no host messaging, no panel + chrome or sizing behaviour. +- **Fake backend.** Nothing about service behaviour, cancellation, timeouts or telemetry is + exercised. +- **No real user input devices.** Screen-reader behaviour is inferred from the accessibility tree, + not observed. + +Layout, overflow, focus order, roles and names are genuinely verified. Everything else is not. + +--- + +## Future work + +**Ship a committed harness instead of a throwaway.** Each round so far has created and deleted a +temporary page. A permanent `src/webviews/static/preview.html` taking the view name from a query +string (`?view=localQuickStart`) would remove the recreate-and-delete cycle. Needs a decision on +whether it is dev-only or excluded from the packaged extension. + +**Theme switching.** Drive `data-vscode-theme-kind` and the `--vscode-*` block from a query string +so dark and high-contrast get the same coverage. This is the largest current gap. + +**A fixture library for host responses.** Fake payloads are hand-written per session and typed only +by eye. Exported fixtures built from the real types — one per interesting state — would make the +states reproducible and keep them honest as the types evolve. + +**Promote to a skill.** Once the harness is committed and theme switching works, this document is a +`SKILL.md` with a reference harness. It is deliberately not one yet: the recipe should stabilise +across a few more features first. + +**Consider screenshot regression.** Tempting, and probably premature — Fluent version bumps would +churn baselines constantly. Revisit only if visual regressions actually start slipping through. + +--- + +## History + +The technique was first written down in the Local Quick Start design lab handoff, which was deleted +along with the lab once the redesign shipped. Those files were never committed, so this document is +the only surviving copy. Do not delete it without moving the recipe somewhere else first. diff --git a/docs/ai-and-plans/local-quickstart/decision-instance-model.md b/docs/ai-and-plans/local-quickstart/decision-instance-model.md new file mode 100644 index 000000000..7ffee8fcc --- /dev/null +++ b/docs/ai-and-plans/local-quickstart/decision-instance-model.md @@ -0,0 +1,125 @@ +# Decision: Instance model — single managed instance, ownership-bounded (v1) + +**Status:** Accepted (production v1) · **Date:** 2026-06-25 +**Scope:** Production v1 of Local Quick Start (not the POC). +**Design doc:** [`local-quickstart-v2.md`](./local-quickstart-v2.md) (§10.1 labels, §10.2 +existing-container conflict, §13.10 attach, §15 roadmap, decision log). +**Raised by:** German Eichberger (xgerman) in design review — "manage multiple +containers / versions" and "connect to a retained test container." + +## Questions + +1. Should Quick Start manage **multiple** local DocumentDB containers (e.g. several + instances, or multiple image versions side by side)? +2. If the user already created DocumentDB containers **another way** (CLI, `docker run`, + a test harness), should Quick Start **list / adopt / manage** them? + +## Decision + +For **v1**, Quick Start manages **exactly one** instance and only ever touches +containers **it created**, recognized by the Docker label `vscode.documentdb.quickstart=1` +(§10.1). Concretely: + +| Topic | v1 decision | Deferred to | +| ----- | ----------- | ----------- | +| Multiple **managed** instances | **No.** One managed instance; the rocket entry hides after setup. | v1.2 (§15) | +| Multiple **image versions** side by side | **No.** | v1.2 (§15) | +| Listing the user's **own** (unlabelled) containers inside Quick Start | **No.** They connect via the **regular new-connection wizard** at `localhost:` — "Attach stays first-class" (§13.10). Quick Start does not own them. | — | +| **Adopt-existing-container** flow | **No** as a general feature. The *only* adoption v1 performs is re-recognizing **its own labelled** container after a reload (reconcile). | v1.2 (§15) | +| **Auto-discovery** of unmanaged DocumentDB containers | **No** — and when built, it belongs to the **generic connections** experience, not Quick Start. | v1.2 (§15) | +| **Name / port collision safety** | **Yes — required in v1.** See "What v1 must do" below (sharpens §10.2). | — | + +This ratifies the design doc's existing position (§15: "Single managed instance"; +decision log: "Single instance in v1; labels keep the model forward-compatible; +multi-instance + multi-version are v1.2") and records the reasoning below. + +### Re-affirmed 2026-06-30 (manual-testing review) + +Revisited during hands-on manual testing, framed by user personas, and **held**: + +- **Newbie / trial** and **typical app dev** want *one* decision-free instance; a second + instance only re-introduces the "which one / alias / port" choices Quick Start removes. +- The **advanced "validate before deploying to k8s / on-prem"** persona is the strongest case + *for* multi-version — but their genuine need is met more cheaply and correctly by + **(a) image-tag / version selection on the single managed instance (the Advanced panel, P1-4)** + and **(b) attaching their own side-by-side `docker run` containers via the regular wizard** — + not by Quick Start managing N containers. +- Known papercut accepted for v1: switching versions today = **Delete (loses data) → re-provision**. + P1-4 (pick image tag → recreate) smooths this *without* going multi-instance. + +Net: single-instance stays the v1 model; multi-instance / multi-version remain the additive +v1.2 features the label model already makes free to add. + +## Rationale + +1. **The value proposition is "zero decisions."** Quick Start exists to go from an empty + machine to an open, browsable local DB in one click. Supporting N instances + re-introduces exactly the decisions it removes (which one? alias? port?) and multiplies + port allocation, credential storage, tree shape, reconciliation, and multi-window + coordination by N. Users who genuinely need N custom containers are already well served + by their own `docker run` + the regular wizard. + +2. **Ownership boundary = trust + safety.** The clean, defensible mental model is *Quick + Start only manages containers it created (label-gated).* The moment it lists or acts on + containers it did not create, a stray Stop/Delete can destroy something the user cares + about, and it must guess "is this even DocumentDB? what port? what TLS?" That ambiguity + is a support and trust liability. Recognition is therefore **label-based, never** + name/image/port-based (§10.1). + +3. **Credentials make adoption hollow anyway.** Quick Start auto-generates and stores the + container's credentials. For a hand-run container it cannot know the `--username` / + `--password` the user chose, so it could never populate a working connection. "Listing" + such a container degrades to "here's a thing, go type your own creds" — which **is** the + regular new-connection wizard. So discovery rightly lives in the generic connections + experience, not here. + +4. **Deferring is cheap because the model is already forward-compatible.** Because + recognition is by label (not by the fixed name/port), adding multi-instance or + adopt-existing in v1.2 needs **no data migration** — it is purely additive. That is the + whole reason the design chose labels. + +## What v1 must do (the one concrete work item) + +Even with a single instance, v1 must handle a pre-existing container that holds the planned +name **or** the planned port, without clobbering it (§10.2): + +- **Labelled as ours** (`vscode.documentdb.quickstart=1`) → re-adopt / reconcile it (the + managed instance reappears in the tree). This is *not* general adoption — only our own + container. +- **Unlabelled** (someone else's container holds the name, or the port is taken) → **never + recreate over it.** Validate **both** identifiers up front (the connection/cluster name in + the Connections view **and** the Docker container name), reject with a **clear inline + error**, and point the user to the regular wizard / a port change. Matches the PostgreSQL + reference, which refuses on a duplicate of either identifier. + +This is the only part of this topic that is in-scope for v1 implementation. + +## v1.2 extension shape (recorded so deferral is provably safe) + +- **Discovered (unmanaged) containers:** a **read-only** section populated by `docker ps` + filtered on the DocumentDB image; each row shows name / port / status; the only action is + **Connect**, which opens the regular wizard pre-filled with `localhost:` (user + supplies credentials). No Stop/Delete — these are not owned. +- **Multiple managed instances:** the Quick Start node becomes a parent of N label-tagged + rows, each carrying a unique `vscode.documentdb.alias`; the provision flow gains an + alias + port step; credentials are keyed per-alias in SecretStorage; tree / lifecycle / + reconcile iterate the labelled set instead of taking the first match. + +Both are additive on top of today's label model. + +## Current implementation note (starting point) + +Today's code (POC) is strictly single-instance and assumes the first labelled match: +fixed container name/alias `vscode-documentdb-local`, a singleton cache key +`QUICK_START_CLUSTER_ID = 'quickstart-local-documentdb'`, and +`findManagedContainer()` returns `list[0]`. The unlabelled-collision safety above is **not +yet implemented** and is the concrete v1 hardening item this decision identifies. + +## Consequences + +- **Users:** one-click path stays decision-free; power users attach their own containers via + the regular wizard; nobody's hand-run container is ever modified by Quick Start. +- **Engineering:** v1 surface stays small; the label model makes multi-instance / adopt / + discovery clean v1.2 additions with no migration. +- **Review:** answers German's points with a documented rationale and a forward path + (scheduled to v1.2; labels make it free to add) — see the design-doc decision log. diff --git a/docs/ai-and-plans/local-quickstart/docker-readiness-implementation-plan.md b/docs/ai-and-plans/local-quickstart/docker-readiness-implementation-plan.md new file mode 100644 index 000000000..f7e9a28bf --- /dev/null +++ b/docs/ai-and-plans/local-quickstart/docker-readiness-implementation-plan.md @@ -0,0 +1,1059 @@ +# Local Quick Start Docker Readiness - Implementation Plan + +**Date:** 2026-08-02 +**Status:** Slice A and Slice B implementation complete; cross-platform manual verification handoff remains +**Related design:** [local-quickstart-v2.md](local-quickstart-v2.md) + +> **User-facing language:** Use **Docker** as the default term in cards, summaries, and general status messages. This keeps the primary experience simple and avoids exposing implementation details that most users do not need. Use **Docker CLI**, **Docker daemon**, **Docker Engine**, or **Docker Desktop** only when the distinction explains a specific failure or names the exact action being offered, such as `Start Docker Desktop`. The implementation must still detect and model these components separately; this simplification applies only to presentation. + +> **User-facing punctuation:** No em dashes (U+2014) and no en dashes (U+2013) in any user-facing string, message, notification, card value, button label, tooltip, or accessible announcement. Use a comma, a colon, a semicolon, parentheses, or two sentences instead. A hyphen inside a compound word such as `Docker-not-ready` is fine; only those two characters are banned. This applies to every string passed to `vscode.l10n.t()`, to the generated `l10n/bundle.l10n.json`, and to any literal rendered in the webview. Before review, search the diff for U+2014 and U+2013. +> +> | Written with a banned dash | Write instead | +> | ----------------------------------------------------------- | --------------------------------------------------- | +> | `Docker is starting [U+2014] this can take a minute.` | `Docker is starting. This can take a minute.` | +> | `Access denied [U+2013] your user cannot reach the socket.` | `Access denied. Your user cannot reach the socket.` | +> | `Last checked 5 minutes ago [U+2014] Refresh` | `Last checked 5 minutes ago. Refresh` | + +## Objective + +Make Local Quick Start describe and recover from Docker readiness failures accurately across Windows, macOS, Linux, WSL, and remote VS Code environments. The Review screen must also state where Docker and DocumentDB Local will actually run. + +Docker Desktop is not a prerequisite. The actual prerequisite is a Docker CLI that the extension host can use to reach a Linux-container Docker daemon. The UI must mention Docker Desktop only when the extension has positive evidence that Docker Desktop is the relevant provider. + +The implementation must have a direct, easy-to-follow execution flow. Platform checks, error classification, launch behavior, and presentation decisions must each have one clear owner. Do not solve this with scattered string checks, nested conditional expressions, dynamic dispatch, or implicit JavaScript coercion. + +## Current State + +The current implementation is concentrated in [ContainerRuntime.ts](../../../src/services/localQuickStart/ContainerRuntime.ts): + +1. `docker -v` checks whether the CLI is available. +2. `docker info` checks whether a daemon is reachable. +3. `process.arch` checks whether the host CPU is `x64` or `arm64`. +4. Any `docker info` failure is returned as `daemonReachable: false` with a raw error string. +5. The webview displays every daemon failure as `Stopped`. +6. The recovery text and button always say `Docker Desktop`. +7. The launcher uses `process.platform`: Windows launches `Docker Desktop.exe`, macOS opens the Docker application, and every Linux environment attempts the `docker-desktop` user service. +8. The Platform card reports `process.arch`, which is the VS Code extension-host architecture, not necessarily the Docker daemon architecture or image platform. +9. The Review screen always says `This machine (Docker)`, even when the extension host and Docker run in WSL, SSH, a dev container, or Codespaces. +10. Docker prerequisite commands have no explicit timeout or cancellation path, so a hung `docker info` can leave the webview on `Checking Docker...` indefinitely. +11. The Docker error text is never captured. The command runner rejects with a generic `Process exited with code 1`, and Docker's own stderr goes only to the masked OutputChannel, so `DockerReadiness.error` carries no diagnosable content. +12. Readiness is invoked from the panel, from Retry, and from the provisioning `checking` stage with no deduplication, memoization, or in-flight guard. +13. A daemon failure that occurs _during_ provisioning is surfaced as a raw error string in the failure card, disconnected from the readiness recovery UI. + +This creates several correctness problems: + +- A permission failure, invalid context, missing socket, and stopped daemon all look identical. +- WSL, SSH, dev containers, native Linux Docker Engine, and Linux Docker Desktop are treated as the same environment. +- Remote users are not told that `local` refers to the remote extension host. +- The Platform card can describe the wrong CPU when the active Docker endpoint is remote. +- The evidence needed to tell these cases apart is discarded before anything can classify it. + +There are currently no focused unit tests for readiness classification or provider launch selection. + +## Verified Execution-Path Constraints + +These facts were confirmed by reading the current code and its dependencies. They are prerequisites for everything below; ignoring any one of them silently defeats the classifier. + +| Fact | Where | Consequence for this plan | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A non-zero exit rejects with `ChildProcessError('Process exited with code ')`. The Docker error text is **not** part of the rejection. | `spawnStreamAsync` in `@microsoft/vscode-processutils` | A classifier that reads the rejected error's message returns `unknown` for every real failure, including the reported Ubuntu case. Probe evidence must be captured separately (WI-0). | +| stderr is piped only into the masked OutputChannel writable, and the stdout accumulator is destroyed in `finally` when the command rejects, so `parse` never runs. | `ShellStreamCommandRunnerFactory` in `vscode-container-client` | Both the stderr text and the `docker info` JSON body are discarded on failure. Probes must tee stdout and stderr into their own accumulators. | +| `docker info --format {{json .}}` prints a valid JSON body containing `ServerErrors` **and still exits non-zero** when the daemon is unreachable. | Docker CLI behavior | The most stable failure signal is structured, and today it is thrown away. Read it before falling back to stderr text. | +| Cancellation calls `treeKill(pid)` and rejects with `CancellationError`. | `spawnStreamAsync` | A `CancellationTokenSource` plus a timer is a correct, process-killing timeout. Timeout and user cancellation raise the **same** error type, so they must be distinguished by which source fired, never by inspecting the error. | +| `DockerInfoRecordSchema` keeps only `OperatingSystem` and `OSType` and strips every other field. `InfoItem.raw` still carries the full JSON. | `DockerClientBase` | Daemon `Architecture`, `ServerVersion`, and `ServerErrors` must be parsed from `raw` with a local schema. They are not reachable through the typed `InfoItem`. | +| `docker info` reports `x86_64` and `aarch64`, not `amd64` and `arm64`. | Docker CLI behavior | Daemon architecture needs a tested normalization function before it reaches the Platform card or the test matrix. | +| `listContexts()` already exists, runs `docker context ls --format {{json .}}`, returns `name`/`current`/`containerEndpoint`, and does not require a reachable daemon. | `DockerClient` | Use it for endpoint resolution instead of adding a `docker context inspect` command. | +| Probes already spawn with `stdio[0] = 'ignore'` because no `stdInPipe` is supplied. | `spawnStreamAsync` | This is the only reason an `ssh://` endpoint's passphrase prompt cannot hang a probe forever. It is an invisible property of the current wiring, so it needs a regression test rather than a comment. | + +## Recovered Design Requirements + +The earlier design documents contain useful requirements that were intentionally deferred from v1 or simplified during implementation. This plan records an explicit decision for each one so they are not lost again. + +| Earlier requirement or observation | Source | Current implementation | Decision for this plan | +| --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Linux user not in the `docker` group receives platform-specific guidance | `local-quickstart.md` sections 7.1 and 10 | Every `docker info` failure is shown as `Stopped` | **Include.** This is the reported Ubuntu/WSL failure and the first classifier acceptance test. | +| WSL2, SSH, and dev-container sessions explain where `local` runs | `local-quickstart.md` section 4.3; `local-quickstart-v2.md` v1.2 scope | Review always says `This machine (Docker)` | **Include.** Environment detection must drive both failure guidance and a happy-path execution-target notice. | +| Daemon socket, Windows-container mode, WSL setup, and remote daemon failures have distinct guidance | `local-quickstart.md` section 7.1 | All daemon failures share Desktop wording | **Include.** Implement typed failure categories, with conservative fallback for uncertain cases. | +| Apple Silicon/image architecture is evaluated, with explicit consent before x86 emulation | `local-quickstart.md` sections 7.1 and 10; `local-quickstart-v2.md` section 9 | `process.arch` alone marks `x64` and `arm64` supported | **Correct the model now.** Report Docker daemon architecture when available; do not claim image compatibility from extension-host architecture. Handle missing image manifests or emulation consent during pull as a follow-up. | +| Docker CLI missing offers install help and an `Already installed?` path | `local-quickstart.md` section 7.1 | Only Docker Desktop install/troubleshooting links are shown | **Include provider-neutral help.** Offer details/restart guidance for PATH mismatches. Do not add an `Open settings` button unless a real extension setting exists. | +| Detailed Docker output is available from readiness and progress failures | `local-quickstart.md` sections 7.2 and 17.4 | OutputChannel exists, but Docker-not-ready UI does not expose it | **Include.** Reuse the masked OutputChannel and expose `View Docker output` for every readiness failure. | +| Docker probes cannot leave the readiness UI spinning forever | Later readiness review in `v1-readiness-gaps.md` | `docker info` has no explicit timeout | **Include.** Every prerequisite probe must be cancelable and bounded. | +| Registry/proxy reachability has its own diagnosis | `local-quickstart.md` section 7.1; `local-quickstart-v2.md` section 9 | UI shows proxy advice without performing a registry check | **Do not run it as a Docker prerequisite.** Remove speculative advice here and classify actual pull/registry failures in a separate provisioning follow-up. | +| Disk below 2 GB is a non-blocking warning | `local-quickstart.md` sections 7.1 and 10 | No disk check | **Follow-up.** Add only after defining which filesystem to measure and validating a supported threshold for the image and data volume. | +| Docker Desktop resource limits too low link to Desktop resources | `local-quickstart.md` section 7.1 | No resource check | **Follow-up.** Surface only from a concrete memory/resource failure and only when Desktop is positively identified. | +| Windows Home/WSL2 missing links to WSL setup | `local-quickstart.md` section 7.1 | No Windows/WSL prerequisite classification | **Conditional follow-up.** Use only when Desktop is identified and there is positive evidence of a missing WSL2 prerequisite; otherwise show generic Desktop diagnostics. | +| Docker commands run as terminal tasks | `local-quickstart-v2.md` sections 5.4 and 16; POC deviation notes | Commands stream to a masked OutputChannel | **Separate product decision.** Keep the existing masked OutputChannel in this work; do not mix a terminal-execution rewrite into readiness classification. | +| `Start Docker Desktop` or generic `Start Docker` may be offered without privilege escalation | `local-quickstart.md` sections 1 and 13 | Linux always attempts the Desktop user service | **Include narrowly.** A positively identified rootless Docker Engine user service may get `Start Docker`; root-managed Engine remains documentation-only and never invokes `sudo`. | +| Unsupported extension-host OS is rejected explicitly | `local-quickstart.md` section 10 | Non-Windows/non-macOS hosts fall through to Linux launch behavior | **Include.** Return an unsupported-host result instead of assuming every other platform is Linux. | +| A permission failure names the exact fix instead of only linking to documentation | This review | No guidance at all; the failure reads `Stopped` | **Include as a copyable command.** Show the documented fix as read-only text with a `Copy command` button. The extension never runs it. See [Copyable Recovery Commands](#copyable-recovery-commands). | +| A wrong or inconclusive diagnosis must not block a user whose Docker actually works | This review | Any non-ready readiness result hard-gates the Set up button | **Include.** Indeterminate results keep a `Continue anyway` path that lets the real `docker pull`/`run` produce the authoritative error. | +| Provider facts learned while Docker worked are reused when Docker is down | This review | Nothing is remembered between sessions | **Include, with an expiry and an exit.** Persist a small last-known-good provider record and treat it as positive evidence, but label it with its check time and keep a `Refresh` that discards it, so a changed setup cannot strand the user. | + +The original documents moved categorized Docker readiness and the remote-session banner to v1.1/v1.2 to protect the initial delivery. This plan intentionally takes on that deferred slice; it does not treat the v1 simplification as evidence that those requirements were invalid. + +## Scope + +### In scope + +- Capture the evidence a classification can actually be built on: spawn errno, exit code, `docker info` JSON body, and stderr text. +- Classify common Docker CLI and daemon failures. +- Detect the extension-host environment. +- Explain the execution target in both ready and not-ready states. +- Detect Docker Desktop only from positive provider evidence, including a remembered last-known-good record. +- Offer only recovery actions that are appropriate for the detected environment. +- Offer copyable, never-executed recovery commands for the documented Linux and WSL fixes. +- Correct the daemon card, guidance, links, and buttons. +- Replace the extension-host CPU guess with Docker daemon architecture facts when available. +- Bound and cancel Docker prerequisite probes under a single deadline so readiness cannot spin forever. +- Deduplicate readiness so concurrent callers and polling cannot stack Docker processes. +- Keep a forward path when the diagnosis is indeterminate. +- Route daemon-class failures raised during provisioning through the same classifier and the same recovery UI. +- Make masked Docker output available from every readiness failure. +- Preserve provider-neutral behavior when detection is inconclusive. +- Add focused tests for classification, orchestration, launch selection, and presentation, driven by captured real-world fixtures. +- Update telemetry categories without recording paths, context names, hostnames, or raw errors, and add a redacted fingerprint for unclassified failures. + +### Out of scope + +- Installing Docker. +- Running `sudo`, changing group membership, changing socket permissions, or enabling services. Offering a command as copyable text is not running it. +- Silently starting any Docker provider. +- Switching the user's Docker context. +- Supporting Podman or other OCI runtimes. +- Diagnosing registry or proxy failures before an image operation is attempted. +- Checking free disk space or Docker Desktop memory limits without a validated requirement and target filesystem. +- Automatically enabling x86 emulation or forcing an image platform without explicit user consent. +- Replacing the masked OutputChannel with VS Code terminal tasks. +- Guaranteeing that every third-party Docker-compatible daemon can be identified by product name. + +## Design Principles + +| Principle | Requirement | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Provider-neutral prerequisite | Treat Docker daemon access as the requirement. Docker Desktop is one possible provider. | +| Facts before presentation | Detection returns typed facts. React chooses localized wording from those facts. | +| Evidence hierarchy | Prefer, in order: process spawn errno, command exit code, structured JSON from Docker, filesystem/socket errno, then error text. Error text is a tiebreaker and is never the only evidence for a verdict. | +| One linear orchestrator | Readiness follows an explicit sequence with early returns; no nested promise chains or nested ternaries. | +| Pure classification | Captured probe evidence and endpoint facts are converted to a typed failure in a pure function with table-driven tests. | +| Explicit platform behavior | Use exhaustive `switch` statements over typed environment and action values. | +| Positive provider evidence | Never infer Docker Desktop solely from `process.platform` or the presence of a Docker CLI. | +| Asymmetric action cost | Weigh a wrong offer against a missing offer per environment. A harmless user-clicked launch may use a lower evidence bar than a stated failure cause. | +| Diagnosis is advisory | A readiness verdict must never be the only thing standing between a user and a Docker that actually works. Indeterminate results always keep a path forward. | +| Conservative fallback | If provider or failure detection is uncertain, report an indeterminate outcome, say `Not accessible`, and offer details/retry rather than guessing a cause. | +| No hidden privilege changes | The extension may open documentation, offer a copyable command, or launch an identified unprivileged desktop application; it must not alter system configuration. | +| Centralized heuristics | Endpoint patterns, error signatures, recovery commands, and known application/service locations are named constants in the owning host-side module. | +| Testable I/O | Environment, filesystem, process launch, and command execution dependencies are injected at the service boundary. | +| Correct execution target | Keep extension-host environment, Docker endpoint, daemon platform, and image platform as distinct facts. | +| Bounded external work | Every prerequisite command accepts cancellation and runs under one shared, documented readiness deadline. | +| Single-flight probing | Readiness runs at most one probe set at a time and is briefly memoized, so polling and concurrent callers never stack Docker processes. | +| Learn from misses | Unclassified failures emit a redacted fingerprint so new rules come from real data instead of imagination. | + +## Proposed Structure + +Keep container operations separate from Docker prerequisite diagnosis. + +| File | Responsibility | +| ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/services/localQuickStart/ContainerRuntime.ts` | Container pull/run/inspect/start/stop operations. Delegate readiness to the new service and remove provider-launch logic. | +| `src/services/localQuickStart/dockerProbes.ts` | Run a bounded Docker command and return captured evidence (exit code, spawn errno, stdout, stderr, how it ended). Also probes endpoint reachability. | +| `src/services/localQuickStart/DockerReadinessService.ts` | Linear readiness orchestration, single-flight/TTL memoization, provider memory, and collection of command/environment facts. | +| `src/services/localQuickStart/dockerReadinessClassification.ts` | Pure functions that classify captured evidence and provider evidence. No VS Code, filesystem, or process I/O. | +| `src/services/localQuickStart/dockerRecoveryCommands.ts` | The fixed table of copyable, never-executed recovery commands keyed by failure and environment. | +| `src/services/localQuickStart/DockerProviderLauncher.ts` | Explicit launch strategies for positively identified Desktop providers and rootless Linux Docker Engine. | +| `src/services/localQuickStart/quickStartTypes.ts` | Shared readiness, failure, environment, provider, and recovery-action contracts. | +| `src/webviews/documentdb/localQuickStart/dockerReadinessPresentation.ts` | Pure mapping from typed readiness results to semantic card/action content. No host detection. | +| `src/webviews/documentdb/localQuickStart/LocalQuickStart.tsx` | Render the presentation result and invoke the selected router action. No platform or provider heuristics. | +| `src/webviews/documentdb/localQuickStart/localQuickStartRouter.ts` | Expose readiness and a provider-aware start mutation; record categorized telemetry. | + +The implementation may combine a proposed file with a closely related file if the resulting module remains small and has one responsibility. It must not move all behavior back into `ContainerRuntime.ts` or `LocalQuickStart.tsx`. + +## Typed Result Model + +Use string unions and interfaces consistent with the repository's TypeScript conventions. Keep the values semantic and serializable over tRPC. + +```typescript +type DockerHostEnvironment = + | 'windows' + | 'macos' + | 'linux' + | 'wsl' + | 'ssh' + | 'devContainer' + | 'codespaces' + | 'otherRemote' + | 'unsupported'; + +type DockerProvider = 'dockerDesktop' | 'dockerEngine' | 'unknown'; + +type DockerEndpointKind = 'unixSocket' | 'namedPipe' | 'tcp' | 'ssh' | 'unknown'; + +/** + * Three-valued so that "we do not know" is a state of the model rather than a review rule. + * `probeTimedOut` and `unknown` may ONLY appear with `indeterminate`. + */ +type DockerReadinessOutcome = 'ready' | 'diagnosed' | 'indeterminate'; + +type DockerFailureKind = + | 'cliMissing' + | 'permissionDenied' + | 'daemonUnavailable' + | 'daemonStarting' + | 'contextUnavailable' + | 'endpointUnreachable' + | 'probeTimedOut' + | 'unsupportedHost' + | 'windowsContainers' + | 'unknown'; + +type DockerStartAction = + | 'startDockerDesktopWindows' + | 'startDockerDesktopMacOS' + | 'startDockerDesktopLinux' + | 'startDockerDesktopWindowsFromWsl' + | 'startRootlessDockerEngineLinux'; + +/** `launchAttempted` is the honest result for a detached GUI launch that cannot be confirmed. */ +type DockerLaunchResult = 'started' | 'launchAttempted' | 'notAvailable' | 'failed'; + +/** Everything a probe learned. This is the ONLY input the failure classifier may read. */ +interface DockerProbeEvidence { + readonly probe: 'cliVersion' | 'info' | 'contexts'; + readonly exitCode?: number; + /** `error.code` from `child_process`, e.g. `ENOENT` when the CLI is not on PATH. */ + readonly spawnErrorCode?: string; + readonly stdout: string; + readonly stderr: string; + readonly endedBy: 'exit' | 'deadline' | 'cancellation'; + readonly durationMs: number; +} + +/** Direct reachability facts for the resolved endpoint; locale- and version-independent. */ +interface DockerEndpointProbe { + readonly kind: DockerEndpointKind; + /** `EACCES`, `ENOENT`, or `ECONNREFUSED` from `fs.access` / `net.connect`. */ + readonly accessErrorCode?: string; + /** How the endpoint was resolved, so `Show details` can explain a surprising value. */ + readonly source: 'dockerHostEnv' | 'dockerContextEnv' | 'currentContext' | 'platformDefault'; +} + +/** A documented fix shown as read-only text with a Copy button. The extension NEVER runs it. */ +interface DockerRecoveryCommand { + readonly id: 'linuxDockerGroup' | 'linuxStartService' | 'wslStartServiceNoSystemd' | 'wslRestartFromWindows'; + readonly commandLine: string; + readonly requiresElevation: boolean; +} + +/** Persisted after any successful `docker info`; used as evidence when the daemon is down. */ +interface DockerProviderMemory { + readonly provider: DockerProvider; + readonly endpointKind: DockerEndpointKind; + readonly hostEnvironment: DockerHostEnvironment; + readonly daemonArchitecture?: string; + readonly osType?: 'linux' | 'windows'; + readonly recordedAtMs: number; +} +``` + +Extend `DockerReadiness` with: + +- `outcome` +- `environment` +- `endpointKind` +- `provider` +- `providerEvidence`, one of `liveDaemon`, `activeContext`, `installedApplication`, `rememberedProvider`, or `none` +- `failureKind`, absent when ready +- `startAction`, present only when the extension can perform that exact action without elevation +- `recoveryCommand`, present only for the failures listed in [Copyable Recovery Commands](#copyable-recovery-commands) +- `canContinueAnyway`, true only when `outcome` is `indeterminate` +- `checkedAtMs`, when this result was produced, so the UI can label a memoized or remembered answer as old +- `osType`, when returned by a reachable daemon +- `daemonArchitecture`, normalized, when returned by a reachable daemon +- an execution-target category suitable for localized Review-screen copy +- a safe optional diagnostic summary for `Show details` + +Keep `cliInstalled`, `cliVersion`, and `daemonReachable` during this change to limit call-site churn. Deprecate `arch` and `platformSupported` after the UI moves to daemon architecture; `process.arch` may remain an extension-host diagnostic but must not gate image compatibility. Do not encode contradictory combinations. Builder functions or explicit return branches in the service should construct each valid result. + +Daemon architecture is normalized by a pure, tested function: `x86_64` becomes `amd64`, `aarch64` becomes `arm64`, and any other value is passed through unchanged. The Platform card and the test matrix both consume the normalized value. + +## Readiness Execution Flow + +`DockerReadinessService.getReadiness()` should be readable from top to bottom: + +1. If a probe set is already in flight, await it. If a result younger than `READINESS_MEMO_TTL_MS` exists and the caller did not ask for a forced refresh, return it. `Retry` always forces a refresh. +2. Detect the extension-host environment once. +3. If the extension-host platform is unsupported, return `unsupportedHost` immediately. +4. Open one `CancellationTokenSource` for the whole check, armed with `READINESS_DEADLINE_MS`, and linked to the caller's cancellation token. +5. Run `docker -v` and `docker info --format {{json .}}` **concurrently** under that single deadline, capturing evidence for each. These two probes are independent: `docker info` does not need `docker -v` to have succeeded first. +6. If the `docker info` probe failed to spawn with `ENOENT`, return `cliMissing` immediately. The spawn errno is the evidence; do not infer this from the version probe's text. +7. If `docker info` succeeded, parse `InfoItem.raw` with a local schema to read `OSType`, `Architecture`, `ServerVersion`, and `ServerErrors`. A body carrying `ServerErrors` is a failure even when the exit code is zero. Otherwise: record the normalized daemon architecture, reject Windows-container mode, classify the provider, persist the provider memory record, and return `ready`. +8. Only in the failure branch, resolve the active endpoint and probe it. This keeps the happy path at two spawned processes. +9. Classify the failure from the captured evidence, in the precedence order below, and set `outcome` accordingly. +10. Only after failure classification, determine whether a safe provider start action and a copyable recovery command are available. +11. Build an execution-target category for Review-screen copy. +12. Return one typed readiness result and memoize it. + +### Endpoint Resolution + +Resolve the active endpoint in this exact precedence, and record which source won: + +1. `DOCKER_HOST` from the extension host's environment. +2. `DOCKER_CONTEXT` from the extension host's environment. +3. The `current` entry returned by `listContexts()`. +4. The platform default: `unix:///var/run/docker.sock` or `npipe:////./pipe/docker_engine`. + +Two notes that matter for real reports. First, the extension host's environment is not the user's terminal environment: a `DOCKER_HOST` exported from a shell profile is invisible to a VS Code instance launched from the macOS Dock or the Windows Start menu, which produces the classic "it works in my terminal" report. When the endpoint came from `DOCKER_HOST` or `DOCKER_CONTEXT`, say so in `Show details`. Second, `docker context ls` works with the daemon down, so it is safe to run in the failure branch. + +### Endpoint Reachability Probe + +For a `unixSocket` endpoint, `fs.access(socketPath, R_OK | W_OK)` distinguishes `EACCES` from `ENOENT` without reading a single word of English, and `net.connect` adds `ECONNREFUSED` for "the socket file exists but nothing is listening". For a `namedPipe` endpoint, existence of the pipe is the equivalent signal. This is the primary evidence for the reported Ubuntu and WSL failures; Docker's error sentence is only the tiebreaker. Apt, snap, rootless, and third-party installs all word that sentence differently, but they all produce the same errno. + +### Deadline Policy + +Use one overall `READINESS_DEADLINE_MS` rather than a per-command timeout, so probes cannot stack into a multiple of the budget. Implement it as a `CancellationTokenSource` plus a timer: the runner's cancellation path already calls `treeKill`, so the child process is genuinely terminated rather than abandoned. Because timeout and user cancellation both surface as `CancellationError`, the service must record which source fired and set `endedBy` accordingly. + +A slow Docker is not a failed Docker. While a provider launch is in flight, or when the provider memory says Docker Desktop was in use, an expired deadline is classified as `daemonStarting` under a longer `READINESS_LAUNCH_DEADLINE_MS`, not as `probeTimedOut`. Docker Desktop routinely needs 30 to 90 seconds to become reachable after being started, and telling that user `Check timed out` is worse copy than the message they get today. + +Use structured JSON output from Docker commands where available. Do not parse human-formatted tables. Prefer `--format {{json .}}` over the newer bare `--format json`, which older CLIs do not accept. Do not use shell pipelines. Each command must be represented as an executable plus an argument array through the existing command-runner abstraction. Centralize timeout durations as named constants and distinguish timeout from user cancellation. + +Probes must keep stdin ignored. With `DOCKER_HOST=ssh://…`, an interactive passphrase prompt would otherwise block the probe until the deadline instead of failing immediately. + +### Probe Noise + +Post-launch polling re-runs the probe set repeatedly. The masked OutputChannel is the fallback diagnostic for every failure state in this plan, so it must stay readable: suppress the `$ docker info` command echo for poll probes and write only the first probe and any failing probe. + +## Checks + +| Check | Exists today | Planned behavior | Platform dependence | Owner | +| ---------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------- | ------------------------------ | +| Probe evidence capture | No | Tee stdout and stderr per probe and record exit code, spawn errno, and how the probe ended. Nothing downstream can classify without this. | None | Probe module | +| Docker CLI on `PATH` | Yes | Keep `docker -v` for the version string. `cliMissing` is decided by a spawn `ENOENT`, not by the version probe's text. | None | Readiness service | +| Daemon reachable | Yes | Keep `docker info`; read `OSType`, `Architecture`, `ServerVersion`, and `ServerErrors` from `InfoItem.raw`. A `ServerErrors` body is a failure. | None | Readiness service | +| Extension-host architecture | Yes | Keep only as optional diagnostics; do not use it as proof that the daemon can run the image. | Remote-dependent | Readiness service | +| Docker daemon architecture | No | Read it from the raw info body, normalize `x86_64`/`aarch64`, and display that fact in the Platform card. | Endpoint-dependent | Readiness service | +| Image platform compatibility | No | Do not guess before image resolution. Classify a real no-matching-manifest failure during pull and require consent before emulation. | Daemon/image-dependent | Provisioning follow-up | +| Extension-host environment | No | Prefer `vscode.env.remoteName`; use explicit process/environment fallbacks for WSL tests and unusual hosts. | Yes | Readiness service | +| Execution target disclosure | No | Tell users whether Docker will run locally, in WSL, or in another remote extension host before provisioning. | Yes | Presentation | +| Active endpoint/context | No | Resolve by the documented precedence and record the winning source. Use the existing `listContexts()`; do not add a `docker context inspect` command. | Endpoint-dependent | Readiness service | +| Unix socket permission | No | Probe the endpoint directly with `fs.access`/`net.connect`. `EACCES` is the **primary** evidence for `permissionDenied`; error text is the tiebreaker. | Linux, WSL, macOS | Probe module/classifier | +| Daemon unavailable | Partial | Separate `ENOENT`/`ECONNREFUSED` from `EACCES`, and both from an unreachable remote endpoint. | Endpoint-dependent | Classifier | +| Invalid/unavailable context | No | Classify context-not-found and endpoint-resolution failures separately. | None | Classifier | +| Linux-container mode | No | When `docker info` succeeds, reject Windows-container mode with targeted guidance. | Windows | Readiness service | +| Docker provider | No | Use daemon metadata, active context metadata, known endpoint evidence, an installed application, and the remembered record. Default to `unknown`. | Yes | Classifier | +| Remembered provider | No | Persist a small last-known-good record on every successful `docker info` and use it as evidence once the daemon is unreachable. | None | Readiness service | +| Launch capability | Assumed | Return an action only when the evidence bar for that environment is met and the launch needs no elevation. | Yes | Provider launcher | +| Copyable recovery command | No | Return a fixed, never-executed command for the failures listed below. | Yes | Recovery-command table | +| Probe deadline/cancellation | No | One shared deadline for the whole check; propagate panel/query cancellation; classify a genuine expiry separately from user cancellation. | None | Readiness service | +| Probe deduplication | No | Single-flight plus a short TTL memo, so the panel, Retry, polling, and the provisioning `checking` stage cannot stack Docker processes. | None | Readiness service | +| Diagnostic output access | Partial | Reuse the masked OutputChannel, expose it from every failure state, and keep poll probes from flooding it. | None | Router/presentation | +| Provisioning daemon failures | No | Route daemon-class failures raised during pull/run through the same classifier and the same recovery card. | None | Provisioning flow | +| Published-port reachability | No | When a readiness timeout follows a successful run in a dev container, say the daemon may be the host's and the published port may be unreachable. | Dev container | Provisioning flow | +| Registry/proxy reachability | No | Remove generic proxy advice from this prerequisite card. Diagnose registry failures during pull instead. | None | Provisioning flow, future work | + +## Classification Precedence + +Classification order matters. More actionable evidence must win over broad provider guesses, and structured evidence must win over sentences. + +| Priority | Evidence | Result | Outcome | +| -------- | ------------------------------------------------------------------------------------------- | --------------------- | --------------- | +| 1 | Extension-host OS is outside the supported set | `unsupportedHost` | `diagnosed` | +| 2 | Spawning the Docker executable fails with `ENOENT` | `cliMissing` | `diagnosed` | +| 3 | Endpoint probe returns `EACCES` for the active local socket or pipe | `permissionDenied` | `diagnosed` | +| 4 | `ServerErrors` body or stderr matches a permission signature | `permissionDenied` | `diagnosed` | +| 5 | Endpoint probe returns `ENOENT` or `ECONNREFUSED` for the active local socket or pipe | `daemonUnavailable` | `diagnosed` | +| 6 | The named context is absent, or no endpoint can be resolved | `contextUnavailable` | `diagnosed` | +| 7 | `ServerErrors` body or stderr matches a cannot-connect signature for a local endpoint | `daemonUnavailable` | `diagnosed` | +| 8 | A launch is in flight, or provider memory identifies Desktop and the endpoint is not yet up | `daemonStarting` | `diagnosed` | +| 9 | The endpoint is `tcp` or `ssh` and no local signature matched | `endpointUnreachable` | `diagnosed` | +| 10 | A probe ended by deadline expiry without user cancellation | `probeTimedOut` | `indeterminate` | +| 11 | Failure does not match a tested category | `unknown` | `indeterminate` | + +`windowsContainers` is not part of this ladder: it is decided on the success branch, from a reachable daemon reporting `OSType: windows`. + +Keep error signatures in named, anchored constants or small predicate functions, and keep them subordinate to the errno evidence above them. Each signature needs a test. Do not spread regular expressions across service, router, and React code. The classifier must be total: wrap it so that any unexpected exception yields `unknown` with an `indeterminate` outcome rather than propagating. + +## Provider And Launch Matrix + +| Extension host | Positive Docker Desktop evidence | Allowed action | Otherwise | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------- | +| Local Windows | Active context/daemon identifies Desktop and the standard executable exists | Launch `Docker Desktop.exe` | No start button; show provider-neutral guidance | +| Local macOS | Active context/daemon identifies Desktop and `Docker.app` exists | Use `open -a Docker` | No start button; show provider-neutral guidance | +| Native Linux Docker Desktop | Active context/daemon identifies Linux Docker Desktop and its user service is available | Run `systemctl --user start docker-desktop` | Show provider-neutral guidance | +| Native Linux rootless Engine | Endpoint and available user service positively identify rootless Docker Engine | Run `systemctl --user start docker` | Show rootless Docker setup guidance | +| Native Linux root-managed Engine | Native system socket or other root-managed endpoint | No automatic action | Show service documentation; never invoke `sudo` | +| WSL using Docker Desktop | Active endpoint/context points to Docker Desktop integration and the Windows executable is available through the WSL mount | Launch Docker Desktop on Windows | Show WSL integration guidance | +| WSL using native Docker Engine | Native WSL socket/context | No automatic start; diagnose permissions or native service state | Show Linux/WSL guidance | +| SSH/dev container/Codespaces | Provider is in the remote extension-host context | No local-machine launch action | Explain that Docker must be available in the remote environment | + +The presence of `/mnt/c/Program Files/Docker/Docker/Docker Desktop.exe` alone is not enough to classify a WSL endpoint as Docker Desktop. A native WSL daemon may coexist with that executable. + +### Evidence Bar Per Environment + +A single strict evidence bar creates a regression on the most common first-run failure. When the daemon is down, the main provider oracle is `docker info`, which is exactly the probe that failed. A local Windows user with Docker Desktop installed, stopped, and running on the `default` npipe context rather than `desktop-linux` would produce no provider evidence at all, so a strict rule removes the Start button they get today and leaves them at a dead end. + +Weigh the two error costs per environment. A wrong user-clicked launch on local Windows or macOS is a harmless no-op; a missing one is a dead end. On Linux and WSL the asymmetry reverses, because starting the wrong daemon is genuinely confusing and can contradict the user's setup. + +| Environment | Bar to **state a cause** naming Docker Desktop | Bar to **offer a launch action** | +| ------------------------------ | --------------------------------------------------- | ------------------------------------------------------------------------------------ | +| Local Windows, macOS | Live daemon, active context, or remembered provider | Also satisfied by the Desktop application existing at its standard path | +| Native Linux | Live daemon, active context, or remembered provider | Same strict bar; no launch from an installed application alone | +| WSL | Live daemon, active context, or remembered provider | Same strict bar; the Windows executable visible through `/mnt/c` is never sufficient | +| SSH, dev container, Codespaces | Never names a local application | No launch action in any case | + +When a launch is offered from the lower bar, the failure wording stays provider-neutral and the button names the application it found. The evidence that produced the decision is recorded in `providerEvidence` so tests can assert it. + +### Remembered Provider + +On every successful `docker info`, persist a `DockerProviderMemory` record in `globalState`. When the daemon is later unreachable, treat that record as positive evidence. + +This is a small change with a large effect on real sessions: a user who provisions successfully on Monday teaches the extension "Docker Desktop, npipe endpoint, amd64". On Tuesday, with Docker stopped, the extension can state the correct cause and offer the correct button instead of falling back to `unknown`. Record only the fields listed in the type; never a path, hostname, or context name. + +#### Remembered facts go stale, so they must be visible and disposable + +People change their Docker setup. They uninstall Docker Desktop and install Engine, delete the context the record was learned from, switch a WSL distribution off Desktop integration, or move a laptop between local and remote work. A remembered record that silently outlives the configuration it describes turns a helpful shortcut into a trap: the card keeps insisting on Docker Desktop, the Start button keeps launching something that is no longer installed, and nothing the user does in the panel changes the verdict. + +Three rules keep that from happening. + +**1. Always show when the facts were established.** Any card value or action derived from remembered evidence carries a quiet secondary label naming the time of the last successful check, for example `Last checked 3 days ago`. Remembered evidence is never presented as if it were observed just now. + +**2. Always offer a full re-check.** A `Refresh` control sits at the bottom of the readiness form in **every** state, including `ready`, including `unsupportedHost`, and including any state that already has a primary action. It is also available as an inline link next to the `Last checked` label. `Refresh` discards the memoized readiness result, discards the remembered provider record, and reruns every check from scratch. This is the guaranteed exit from any wrong verdict, so it must never be conditional on the current failure kind. + +**3. Expire and contradict aggressively.** Discard the record when any of the following holds, and fall back to provider-neutral behavior rather than guessing: + +| Condition | Reason | +| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| `hostEnvironment` differs from the current environment | A laptop that moves between local and remote sessions must not inherit it | +| Older than `PROVIDER_MEMORY_MAX_AGE_MS` | Bounded trust; a stale record is worse than no record | +| The endpoint now resolves to a different kind than the remembered `endpointKind` | The user changed their configuration | +| The context the record describes is absent from `listContexts()` | The context was deleted or renamed | +| The launch action derived from it returned `notAvailable` or `failed` | The application is gone; do not offer it again on the next check | +| The user pressed `Refresh` | Explicit intent beats remembered state | + +A record is also overwritten, not merged, on every successful `docker info`, so a live observation always wins over a remembered one. + +The net effect is that remembered evidence can only ever shorten the path to a correct answer. It can never become the reason a user is stuck, because the label tells them the answer is old and the `Refresh` control is one click away in every state. + +## UI Plan + +Keep the card label `Docker daemon`. Change its value, guidance, link, and primary action from the typed result. + +| State | Card value | Guidance | Primary action | Also offered | +| ----------------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| Ready | `Reachable` | None | None | None | +| Desktop identified and unavailable | `Docker Desktop not running` | `Start Docker Desktop and wait until it is ready.` | `Start Docker Desktop` | Retry, View Docker output | +| Docker starting | `Starting…` | `Waiting for Docker to start. This can take a minute.` | None; keep polling with a visible elapsed time and a `Stop waiting` control | View Docker output | +| Native daemon unavailable | `Not running` | `Start the Docker service, then check again.` | `Start Docker` only for positively identified rootless Engine; otherwise platform setup guide | `Copy command`, Retry, View Docker output | +| Unix socket permission, group fix needed | `Access denied` | Environment-aware instructions to run the group command and start the required new login or WSL session. | `Copy command` | Recovery note, Linux setup guide, Retry, View Docker output | +| Unix socket permission, session restart pending | `Access denied` | Explain that group membership is already configured and name the exact Linux, WSL, SSH, or container session action. | `Copy command` only for WSL shutdown | Recovery note when a command is present, Linux setup guide, Retry, View Docker output | +| WSL Desktop integration unavailable | `Not accessible from WSL` | `Enable Docker Desktop integration for this WSL distribution, then check again.` | WSL integration guide | Retry, View Docker output | +| Remote daemon unavailable | `Not accessible` | `Docker must be available in the remote environment where this extension is running.` | Remote Docker guide | Retry, View Docker output | +| Remote endpoint unreachable | `Endpoint unreachable` | `The configured Docker endpoint did not respond.` | `Show details`, which names the endpoint source | Retry, View Docker output | +| Invalid context | `Context unavailable` | `The active Docker context is unavailable. Select or repair a valid context, then check again.` | Docker context guide | Retry, View Docker output | +| Probe timed out | `Check timed out` | `Docker did not respond before the readiness check timed out.` | `Retry` | `Continue anyway`, View Docker output | +| Unsupported extension host | `Unsupported` | `Local Quick Start is supported when the extension runs on Windows, macOS, or Linux.` | Learn more | None | +| Windows-container mode | `Linux containers required` | `Switch Docker to Linux containers, then check again.` | Setup guide | Retry, View Docker output | +| Unknown daemon failure | `Not accessible` | `The extension could not connect to the Docker daemon.` | `Show details` | `Continue anyway`, Retry, View Docker output | +| CLI missing | CLI card: `Not found` | `Install Docker Engine or Docker Desktop, then reopen Quick Start.` | Platform-appropriate install guide | Retry | + +### Continue Anyway + +`Continue anyway` appears only when `outcome` is `indeterminate`, and it proceeds straight to provisioning so the real `docker pull` and `docker run` produce the authoritative error. + +This exists because the diagnosis is a heuristic and the feature is not. Consider a user whose endpoint-scanning security agent delays `docker info` past the deadline, or whose `docker` is a wrapper script that does not implement `info` cleanly, while `docker run` works perfectly. Without this control the extension has locked a working environment out of the feature and told the user something untrue about their machine. With it, the worst case is that provisioning fails a few seconds later with Docker's own words, which is strictly more informative than our guess. + +`Continue anyway` is never shown for `diagnosed` outcomes: if the socket returned `EACCES`, provisioning cannot succeed and offering the attempt would be dishonest. + +### Copyable Recovery Commands + +For the failures listed below, render the documented fix as read-only, non-editable text with a `Copy command` button next to it. The extension never executes it, never opens a terminal for it, and never elevates. + +| Command ID | Failure, environment, and refinement selector | Command offered | Note shown with it | +| -------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `linuxDockerGroup` | `permissionDenied`, Linux or WSL unix socket, `notInGroup` or `unknown` | `sudo usermod -aG docker $USER` | `Group membership applies to new login sessions only.` | +| `linuxStartService` | `daemonUnavailable`, native Linux, or WSL with positively detected active systemd | `sudo systemctl start docker` | `Runs the system Docker service.` | +| `wslStartServiceNoSystemd` | `daemonUnavailable`, WSL without active systemd and with a positively detected service wrapper | `sudo service docker start` | `Runs the system Docker service.` | +| `wslRestartFromWindows` | `permissionDenied`, WSL unix socket, `pendingSessionRestart` | `wsl --shutdown` | `This stops all running WSL distributions so the new group membership applies when WSL starts again.` | + +Native Linux with `pendingSessionRestart` intentionally receives no command. The user must sign out of the desktop session and sign back in; reloading the VS Code window does not refresh process groups. SSH users must kill the remote VS Code server and reconnect. Dev-container and Codespaces users must rebuild the container. + +Rules: + +- The command strings live in one constant table keyed by `DockerRecoveryCommand['id']`. They are never assembled from user input, never interpolated with a detected path, and never localized. Only the surrounding description is localized. +- `requiresElevation` is a fact carried on the record so the presentation can label it, not a permission for the extension to elevate. +- Telemetry records the command `id` when it is copied. It never records the command line or the outcome of running it. +- This does not weaken the "no privilege escalation" rule. The user reads the command, decides, and runs it themselves. It replaces a six-click documentation journey with one click for the single most-reported failure in this feature. + +Additional UI changes: + +- Add a `Refresh` control at the bottom of the readiness form in every state, next to a `Last checked ` label. It discards the memoized result and the remembered provider record and reruns every check. It is present even when Docker is ready and even when the state already has a primary action, because it is the guaranteed way out of a wrong or outdated verdict. +- When a card value or action was derived from remembered rather than live evidence, say so with the same `Last checked` label instead of presenting it as a fresh observation. +- Rename the router mutation and handler from `startDockerDesktop` to `startDockerProvider`. +- The server must recompute or validate the start action instead of trusting an action supplied by the webview. +- Return a typed `DockerLaunchResult` rather than a boolean. For `open -a Docker` and `systemctl --user start …`, await the exit code under a short bound and map a non-zero exit to `failed`; only a detached GUI launch may report `launchAttempted`. Today `systemctl --user start docker-desktop` printing `Unit docker-desktop.service not found` still returns `true`, and the user is later told the check timed out while the extension already held the real answer. +- When a launch is attempted, poll readiness with backoff under the launch deadline, never with overlapping probes, and stop on success, deadline expiry, panel close, or component unmount. +- Keep `Retry` available for every failure. +- Keep `View Docker output` available for every failure, using the existing masked OutputChannel. +- Show `Start Docker Desktop` only when `startAction` is present and identifies Desktop. +- Show `Continue anyway` only for `indeterminate` outcomes. +- Show `Copy command` only when the readiness result carries a `recoveryCommand`. +- Use provider-neutral Docker installation and troubleshooting links unless a platform/provider-specific guide is selected. +- Remove the unconditional corporate-proxy guidance because no registry check has happened on this screen. +- Replace `This machine (Docker)` with execution-target-aware copy. WSL and remote sessions must get a visible notice before Start, even when Docker is ready. +- In SSH, dev-container, and Codespaces sessions, state on the success card that the endpoint lives on the remote host. The saved connection string is `localhost:10260`, which is correct for the extension host and wrong for any tool the user runs on their own machine, so `Copy Connection String` must not imply otherwise. +- Change the Platform card to report Docker daemon architecture when known. If the daemon is unreachable, show `Unknown until Docker is reachable`; do not substitute `process.arch` as image compatibility. + +## Work Items + +### WI-0: Capture probe evidence + +This is the prerequisite for every classification work item. Without it the classifier has only `Process exited with code 1` to work with, and would return `unknown` for the exact Ubuntu failure this plan exists to fix. + +- Add a probe helper that runs one Docker command and returns `DockerProbeEvidence`. +- Tee stdout and stderr into local accumulators **in addition to** the masked OutputChannel writables, so a rejected command still yields its output. +- Record the child-process `error.code` separately from the exit code, so `ENOENT` is a first-class signal. +- Read the `docker info` JSON body from the captured stdout even when the exit code is non-zero, and treat a `ServerErrors` array as a failure regardless of exit code. +- Parse the raw info body with a local schema for `OSType`, `Architecture`, `ServerVersion`, and `ServerErrors`, because the library schema strips everything but `OperatingSystem` and `OSType`. +- Add the daemon architecture normalization function with tests. +- Add the endpoint reachability probe over injected `fs`/`net` dependencies. + +#### Slice A implementation checkpoint (completed 2026-08-03) + +Implemented in [commit `d832ebc1`](https://github.com/microsoft/vscode-documentdb/commit/d832ebc149a060152b517ff4a15c965448f6f0f3). `runDockerProbe()` normalizes the existing Docker client command descriptor, runs its executable and argument array through `ShellStreamCommandRunnerFactory`, and deliberately omits the client parser so raw output remains available on both successful and rejected commands. Capturing tee writables retain stdout and stderr while forwarding the same chunks to caller-provided masked OutputChannel writables. The returned evidence distinguishes numeric process exit codes from string spawn errno values and records whether completion came from process exit, the shared deadline, or caller cancellation. + +The module also adds a local Zod schema for `OSType`, `Architecture`, `ServerVersion`, and `ServerErrors`; architecture normalization for `x86_64`/`aarch64`; and an endpoint probe that checks unix-socket read/write access before attempting a connection. Filesystem and network operations are injected for deterministic tests. Non-local endpoint probing remains for Slice B. + +Nine focused tests passed, covering rejected stdout/stderr capture, spawn `ENOENT`, raw info parsing (including `ServerErrors`), invalid JSON, architecture normalization, endpoint `EACCES`, and endpoint `ECONNREFUSED`. Targeted ESLint and the repository TypeScript build also passed. + +**Implementation choice:** The helper strips the Docker client response parser and runs the normalized command base through the same runner rather than attempting to recover the runner's destroyed accumulator. Two options were considered: modify or wrap the third-party parser path, or capture the raw streams at the command boundary and parse the retained info body locally. The latter was selected because it preserves the existing runner's quoting, masking, cancellation, and stdin behavior while making failed output available without changing a dependency. + +**Corrections before commit:** The first focused run found that the test shell double returned the broader `CommandLineArgs` type rather than the required `string[]`; it was replaced with the real `Bash` implementation. Targeted ESLint then required the repository's inline type-import form for `Writable`; that import was corrected before the work-item commit. No committed implementation was rewritten or reset. + +**Follow-up correction:** The required repository-wide Prettier pass found formatting drift in this module after the work-item commit. The mechanical-only correction is preserved in [commit `4265d8f3`](https://github.com/microsoft/vscode-documentdb/commit/4265d8f3b732d0e0e963e3e495c57812c2c2cf75) rather than rewriting the WI-0 commit. + +### WI-1: Add typed readiness contracts + +- Add outcome, environment, provider, provider-evidence, failure, start-action, launch-result, recovery-command, and provider-memory types. +- Extend the readiness result without changing provisioning behavior. +- Update router serialization and telemetry typing. +- Add compile-time exhaustive checks for all semantic switches. +- Encode the invariant that `probeTimedOut` and `unknown` occur only with an `indeterminate` outcome. + +#### Slice B implementation checkpoint (completed 2026-08-03) + +Implemented and pushed in [commit `d6254c1c`](https://github.com/microsoft/vscode-documentdb/commit/d6254c1cd1f30b099b817addee3a36b0f4657140). The shared contract now includes provider, provider-evidence, start-action, launch-result, execution-target, and provider-memory types, and the failure union includes every Slice B category. `DockerReadiness` is now a discriminated union: ready results require a reachable daemon and forbid a failure kind, diagnosed results exclude `probeTimedOut` and `unknown`, and indeterminate results allow only those two kinds and require `canContinueAnyway: true`. + +Current readiness construction populates neutral `unknown`/`none` provider facts and a typed execution target, so this checkpoint does not change provisioning or launch decisions before WI-2 through WI-4 implement the evidence. Docker `OSType` is normalized before entering the narrower serialized contract. The classifier result was also changed to a correlated diagnosed/indeterminate union, and presentation switches remain compile-time exhaustive. Newly declared Slice B failures temporarily use the existing provider-neutral fallback presentation; their distinct states are deliberately deferred to WI-6 so partially classified behavior is not exposed. + +The focused verification passed 77 readiness, presentation, and provisioning tests. The repository test script also completed its workspace pre-build, targeted ESLint passed, and editor diagnostics were clear for all six changed files. + +**Corrections before commit:** The first compile exposed two test fixtures that omitted the new provider facts, a raw string `OSType`, and a classifier return whose outcome and failure kind were not correlated. Those were corrected by constructing only legal union members, normalizing the OS fact, and returning a correlated classifier union. Later checks exposed literal widening in a shared failure result and a TypeScript narrowing issue in the presentation's exhaustive default; the result now preserves literals and the switch narrows a local failure-kind value. No committed history was reset or rewritten. + +**Follow-up correction:** WI-3 analysis exposed that the diagnosed variant was too strict: a daemon reporting Windows-container mode is reachable even though Local Quick Start must diagnose `windowsContainers` and block provisioning. [Commit `0720fbe4`](https://github.com/microsoft/vscode-documentdb/commit/0720fbe4eebd3deee9b008cce4c4c2fd3dd57fb3) changes diagnosed `daemonReachable` from the literal `false` to `boolean`; ready and indeterminate invariants remain unchanged. This correction passed 52 focused readiness and presentation tests and is preserved as a separate commit rather than rewriting WI-1. + +### WI-2: Extract and test pure classification + +- Add predicates for permission, context, unavailable-daemon, and unknown failures, all subordinate to the errno evidence captured in WI-0. +- Define precedence in one exported classifier that returns both a failure kind and an outcome. +- Make the classifier total: any unexpected exception becomes `unknown` with an `indeterminate` outcome. +- Add provider classification from structured daemon facts, context facts, endpoint facts, installed applications, and the remembered record, reporting which evidence won. +- Add table-driven unit tests for representative Linux, WSL, Windows, macOS, and remote errors. + +#### Slice A implementation checkpoint (completed 2026-08-03) + +Implemented the Slice A half of this work item in [commit `8d0cb52d`](https://github.com/microsoft/vscode-documentdb/commit/8d0cb52da7ce5ab53b44732136a6fb8de083eb6c). The new pure classifier applies the required evidence precedence for a missing CLI, local endpoint permission denial, structured or textual permission evidence, missing or refused local endpoints, deadline expiry, and the indeterminate fallback. The classifier is total and returns `unknown`/`indeterminate` if its internal classification path throws. Provider, context-unavailable, remote-endpoint, and platform-specific classification remain intentionally unimplemented for Slice B. + +The first executable behavior check was the fixture-backed Ubuntu `EACCES` case required by the delivery plan. It was run red first and returned `unknown`/`indeterminate`; after implementing the classifier, the focused suite passed all seven cases. The changed files also passed targeted ESLint and the repository TypeScript build. + +**Implementation-order deviation:** This partial WI-2 checkpoint was completed before WI-0, even though WI-0 is the runtime prerequisite for classification. Two options were considered: finish probe capture first, or write and execute the specified permission-denied classifier test before WI-0 was complete. The second option was selected because the plan explicitly requires that test to be the first executable behavior check. This does not expose the classifier in production yet; WI-0 and WI-3 still have to deliver the endpoint errno to it. + +**Fixture provenance gap, now closed:** The original report did not preserve exact versions, so the first checkpoint correctly recorded them as unknown. Follow-up testing confirmed WSL2, Ubuntu-20.04, Docker Engine 28.1.1, socket GID 998, and permissions `srw-rw----`. Commit [`4f363411`](https://github.com/microsoft/vscode-documentdb/commit/4f36341104b21c83fe9f83f9418ee4acd68f10d2) updates the fixture header with those facts rather than inventing provenance. + +**Minimal contract dependency:** The probe, endpoint, failure, and outcome contracts needed to compile this checkpoint were added with the classifier. This is the minimum Slice A subset of WI-1, not completion of WI-1; the broader environment, provider, launch, recovery, and provider-memory contracts remain in Slice B. + +**Follow-up correction:** The required repository-wide Prettier pass found formatting drift in the classifier and its tests after the work-item commit. The mechanical-only correction is preserved in [commit `4265d8f3`](https://github.com/microsoft/vscode-documentdb/commit/4265d8f3b732d0e0e963e3e495c57812c2c2cf75) rather than rewriting the WI-2 commit. + +#### Slice B implementation checkpoint (completed 2026-08-03) + +Completed and pushed in [commit `54e13d9d`](https://github.com/microsoft/vscode-documentdb/commit/54e13d9dfb1d6bd818d200c16262a2d494b01ee6). The failure classifier now covers unavailable contexts, remote TCP/SSH endpoints, and provider-start-in-progress while preserving the documented precedence: local errno and structured permission evidence still win over provider state, remote classification, timeout, and the unknown fallback. The classifier remains total and returns `unknown`/`indeterminate` if unexpected evidence throws. + +The same pure module now classifies providers from live daemon metadata, active Desktop context/endpoint signatures, rootless Engine endpoint evidence, a valid remembered provider, or an installed Desktop application. Live evidence wins over remembered evidence. Installed-application evidence is accepted only for local Windows and macOS; WSL and remote extension hosts remain provider-neutral, so a Windows Desktop installation cannot override a native WSL socket diagnosis. + +Twenty-two focused classifier tests passed. They cover the new failure categories, precedence, total fallback, representative live Desktop and Engine metadata, Desktop contexts, rootless Engine endpoints, remembered facts, local installed-application evidence, and the WSL/remote negative cases. Targeted ESLint passed and editor diagnostics were clear. + +**Implementation boundary:** Provider classification is exported but not consumed by the service in this commit. Two options were considered: wire the first provider branches immediately, or keep the pure work item independently testable until WI-3 can apply provider memory, endpoint resolution, and discard rules together. The second option was selected because exposing provider facts without those orchestration rules would create the partially classified UI state prohibited by the delivery plan. + +**Follow-up formatting correction:** The required repository-wide Prettier pass normalized the Slice B classifier and test layout in [commit `478cb836`](https://github.com/microsoft/vscode-documentdb/commit/478cb83625cac5bf7678b4899a7a89e65478d687). This commit is mechanical only and preserves the original WI-2 commit. + +### WI-3: Add the readiness orchestrator + +- Move prerequisite command sequencing out of `ContainerRuntimeImpl`. +- Add environment detection and the documented endpoint-resolution precedence, recording the winning source. +- Run the version and info probes concurrently under one shared deadline, and resolve the endpoint lazily in the failure branch only. +- Implement the deadline as a `CancellationTokenSource` plus a timer so the child process is killed, and track which source fired to distinguish expiry from user cancellation. +- Add single-flight plus a short TTL memo, with a forced-refresh path for `Retry` and `Refresh`. +- Read daemon architecture from the raw info body rather than treating `process.arch` as image compatibility. +- Persist and read the provider-memory record, and apply every discard rule in [Remembered Provider](#remembered-provider): environment mismatch, maximum age, endpoint-kind change, a context that no longer exists, a launch action that reported `notAvailable` or `failed`, and an explicit refresh. +- Stamp `checkedAtMs` on every returned result so the UI can label an old answer. +- Preserve masked OutputChannel command logging, but suppress command echo for poll probes. +- Return early for CLI failures and use one explicit branch for daemon success/failure. +- Keep a compatibility delegate on `IContainerRuntime` only if needed to avoid unrelated service churn. +- After a unix-socket `permissionDenied` diagnosis, collect socket ownership, process-group, and best-effort local group-membership facts and resolve `permissionDetail` without changing classifier precedence. +- For WSL daemon-unavailable recovery, detect active systemd or an available service wrapper through injected filesystem facts before selecting a command. + +#### Slice A implementation checkpoint (completed 2026-08-03) + +Implemented the Slice A subset in [commit `e076243a`](https://github.com/microsoft/vscode-documentdb/commit/e076243a72c6585b21ccf3a80dff90c145084d2f). `DockerReadinessService` now owns concurrent `docker -v` and structured `docker info` probes, one shared 15-second deadline, caller cancellation linking, host-environment detection, lazy failure-only context lookup, the documented endpoint precedence, direct endpoint probing, failure classification, a two-second memo, single-flight behavior, and forced refresh. `ContainerRuntime.isDockerReady()` is now a compatibility delegate to that service; pull, run, inspect, lifecycle, and Desktop-launch behavior were not changed. + +The success branch reads the locally parsed raw info facts, treats any `ServerErrors` body as a failure even with exit code zero, and reports normalized daemon architecture. The failure branch returns the Slice A outcome/failure fields, `canContinueAnyway`, a safe diagnostic category/source summary, and a fixed copyable recovery command where applicable. `dockerRecoveryCommands.ts` is the only source of command lines; no recovery command is executed. + +Eighteen focused orchestrator tests cover the reported Linux `EACCES` path, copyable group command, concurrent callers, one-token deadline cancellation, `ServerErrors` with exit code zero, memo/forced-refresh behavior, host detection, and all endpoint-resolution precedence levels. Together with probe, classifier, and provisioning compatibility tests, 57 host-side tests passed. The root TypeScript build passed and targeted ESLint reported no errors. + +**Cancellation implementation deviation:** The plan named `vscode.CancellationTokenSource`. The implementation instead creates one standard `AbortController` and adapts its signal with `CancellationTokenLike.fromAbortSignal()` from the same processutils package used by the command runner. Two options were considered after the focused deadline test exposed that `jest-mock-vscode` explicitly does not implement `CancellationTokenSource`: inject a custom cancellation-source factory used only by tests, or use the existing production adapter that provides the same structural cancellation token to `spawnStreamAsync`. The adapter was selected because it keeps one real production path, preserves process-tree termination in the runner, distinguishes deadline from caller cancellation with explicit flags, and is directly testable. Confidence in this deviation was above 80% because the adapter is supplied by the runner's own dependency for exactly this token-bridging purpose. + +**Deliberately deferred WI-3 scope:** Provider memory and all of its discard rules remain in Slice B. Poll-specific OutputChannel echo suppression remains deferred because Slice A does not start provider polling. Unsupported-host early return and context-unavailable/remote-endpoint diagnoses remain deferred with the corresponding Slice B presentation states. On a ready result, `endpointKind` is populated from `DOCKER_HOST` when explicit and otherwise remains `unknown`; querying contexts on the happy path was rejected because it would violate the plan's two-process happy-path requirement. Failure results always resolve and report the active endpoint through the full precedence chain. + +**Corrections before commit:** The first service run needed an explicit test-only `CancellationToken` type import. The next exposed that the VS Code Jest host leaves `vscode.env` undefined, so the default remote-name lookup was made host-safe. The deadline test then exposed the unimplemented VS Code cancellation source and led to the adapter decision above. Targeted ESLint also required replacing a dynamic VS Code type annotation with the repository's inline type-import style. No committed implementation was reset or rewritten. ESLint continues to report the pre-existing warning that the unchanged `startDockerDesktop()` function is `async` without `await`; replacing that launcher belongs to WI-4 in Slice B. + +**Follow-up correction:** The required repository-wide Prettier pass found formatting drift in the orchestrator and its tests after the work-item commit. The mechanical-only correction is preserved in [commit `4265d8f3`](https://github.com/microsoft/vscode-documentdb/commit/4265d8f3b732d0e0e963e3e495c57812c2c2cf75) rather than rewriting the WI-3 commit. + +#### Pending-session refinement checkpoint (completed 2026-08-03) + +Implemented the host-side refinement in [commit `8a7780c3`](https://github.com/microsoft/vscode-documentdb/commit/8a7780c341fa271e7d3ec39e1494c93f4cdf073c). After the existing classifier has returned `permissionDenied` for a unix socket, `probeDockerSocketGroup()` reads the socket owner GID, compares it with both the extension host's effective and supplementary GIDs, and performs a best-effort lookup of the current user in the matching local group entry. `resolveDockerPermissionDetail()` implements the four-row refinement table and records `pendingSessionRestart`, `notInGroup`, or `unknown` without changing `DockerFailureKind` or `dockerReadinessClassification.ts`. + +The reporter's captured state is covered directly: socket GID 998, process groups `1000 4 20 24 25 27 29 30 44 46 118`, and local `docker:x:998:tnaum` membership resolve to `pendingSessionRestart`. The service calls this probe only after a unix-socket permission diagnosis; named pipes and every non-permission result skip it. The result selects the fixed `wsl --shutdown` command for WSL pending-restart state, while native Linux pending restart deliberately carries no command. + +The recovery table now also contains `wslStartServiceNoSystemd` with the fixed `sudo service docker start` command. **Evidence-safety deviation:** the proposed design selected that command whenever `/run/systemd/system` was absent. Two options were considered: treat systemd absence as sufficient, or positively verify that a standard `service` executable is available. The latter was selected with greater than 80% confidence because systemd absence proves only which command will fail; it does not prove another service manager exists. `detectDockerServiceManager()` therefore returns `systemd`, `service`, or `unknown`, and WSL receives no service command when neither mechanism is positively detected. This keeps the recovery action aligned with the plan's positive-evidence rule. + +Forty-nine focused probe, resolver, orchestration, and recovery-selection tests passed. Targeted ESLint and the root TypeScript build passed. The first focused run exposed only a heterogeneous `it.each` tuple inferring the empty path list as `never[]`; the test fixture was widened before commit. The root build later caught the new fixed command ID missing from the router clipboard enum; the typed enum was updated in the same work-item commit. No committed history was rewritten. + +#### Slice B implementation checkpoint (completed 2026-08-03) + +Completed and pushed in [commit `d79aa505`](https://github.com/microsoft/vscode-documentdb/commit/d79aa505fda4809b0ebb8180701cfda2fb97ed07). The orchestrator now returns before spawning Docker on unsupported hosts and returns immediately from `docker info` spawn `ENOENT`. It distinguishes a successfully enumerated but absent `DOCKER_CONTEXT` from a context probe that failed, diagnoses Windows-container mode while honestly retaining `daemonReachable: true`, consumes the WI-2 provider classifier, and persists live provider facts after successful info probes. `OperatingSystem` was added to the retained structured info facts so live Docker Desktop evidence is not inferred from platform. + +Provider memory is stored under one global-state key with a seven-day maximum age. It is rejected and cleared on future timestamps, age expiry, environment mismatch, known endpoint-kind mismatch, an explicitly selected context that is positively absent, contradictory active-context provider evidence, explicit Refresh, and a `notAvailable` or `failed` launch result. Concurrent forced refresh callers now wait for any old probe set and share exactly one fresh run; normal callers, memoization, and the two-process happy path remain single-flight. Poll requests can suppress successful command echoes while still writing the failing probe command to the masked OutputChannel. + +The broadened Local Quick Start check passed 131 probe, classifier, orchestration, provisioning, and presentation tests; the final orchestrator suite contains 40 tests. Targeted ESLint passed, editor diagnostics were clear, and the workspace package pre-build completed through the repository test script. + +**Privacy-preserving context-memory deviation:** The plan says both that provider memory must contain only the listed fields and never a context name, and that it must be discarded when "the context the record describes" is deleted. Those requirements cannot both be implemented literally because the approved record has no context identity. Two options were considered: add and persist a context name, violating the explicit data-minimization contract, or keep the approved record and discard on every observable contradiction. The second option was selected with greater than 80% confidence. An absent explicit `DOCKER_CONTEXT`, endpoint-kind change, or active provider contradiction clears the record; a deleted implicit current context with the same endpoint kind cannot be identified until Refresh, expiry, or another contradiction. This residual limitation is visible and escapable because every state retains Refresh. + +**Launcher ownership boundary:** Installed-application and user-service availability evidence is not collected in this commit. Two options were considered: duplicate filesystem/service paths in the orchestrator, or let WI-4's injected launcher own both path detection and launch revalidation. The latter was selected because the maintainability rules require executable and service paths to remain in the launcher, and the local Windows/macOS lower evidence bar cannot be applied safely until that owner exists. + +**Corrections before commit:** The initial implementation treated every empty context list as positive absence evidence; it was corrected to carry whether the context probe and parse succeeded. The first forced-refresh implementation also returned an existing in-flight result before clearing memory; it was replaced with a dedicated forced-refresh single flight that queues behind the old check and runs once. No committed history was reset or rewritten. + +**Follow-up formatting correction:** The required repository-wide Prettier pass normalized the readiness service layout in [commit `478cb836`](https://github.com/microsoft/vscode-documentdb/commit/478cb83625cac5bf7678b4899a7a89e65478d687). This commit is mechanical only and preserves the original WI-3 commit. + +### WI-4: Replace the launcher + +- Move process launching out of `ContainerRuntime.ts`. +- Implement one named function per supported launch action. +- Select actions through an exhaustive switch, honoring the per-environment evidence bar. +- Await the exit code for short-lived launchers and return a precise `DockerLaunchResult`; reserve `launchAttempted` for detached GUI launches. +- Inject filesystem/process dependencies for unit tests. +- Refuse unavailable, stale, remote, or privilege-requiring actions. + +#### Slice B implementation checkpoint (completed 2026-08-03) + +Implemented and pushed in [commit `eb3c6828`](https://github.com/microsoft/vscode-documentdb/commit/eb3c6828b68d662998f2f3209e22fd6112fdfd79). `DockerProviderLauncher.ts` now owns every executable, application, and user-service path plus both capability selection and process launch. Local Windows and macOS may offer Desktop from the installed-application evidence bar unless positive Engine evidence contradicts it. Linux Desktop requires positive provider evidence and a loaded `docker-desktop.service`; rootless Engine requires a rootless endpoint and loaded `docker.service`. WSL requires positive Desktop evidence plus the mounted Windows executable. SSH, dev-container, Codespaces, other-remote, unsupported, root-managed Engine, and privilege-requiring cases receive no action. + +Availability is rechecked immediately before launch. Windows and WSL GUI launches return `launchAttempted` only after the detached process emits `spawn`. macOS `open -a Docker` and Linux `systemctl --user start ...` are bounded, awaited, and map nonzero exits to `failed`; disappeared applications or services return `notAvailable`. No launcher uses a shell, invokes `sudo`, or starts a root-managed service. + +Failure readiness now carries the capability selected by the launcher owner. The exported coordinator force-refreshes readiness, launches only the returned typed action, and passes `notAvailable`/`failed` back to the readiness service so remembered provider state is invalidated. Fifty-nine focused launcher and orchestrator tests passed, including the installed-application asymmetry, native WSL negative case, rootless versus root-managed Linux, remote refusal, launch revalidation, and typed result mapping. Targeted ESLint and the full root TypeScript build passed with no warnings. + +**Temporary compatibility boundary:** The old `startDockerDesktop(): Promise` export remains for the unchanged WI-5 router name, but it no longer selects by `process.platform` or launches anything directly. It delegates to the typed force-refresh coordinator and maps only `started`/`launchAttempted` to `true`. Two options were considered: change the router in the WI-4 commit, mixing work-item history, or retain a behavior-safe adapter for one commit. The adapter was selected so WI-4 stays independently buildable and WI-5 can record the public procedure rename and telemetry changes in its own commit. WI-5 must remove this adapter. + +**Follow-up formatting correction:** The required repository-wide Prettier pass normalized the launcher, launcher tests, and runtime imports in [commit `478cb836`](https://github.com/microsoft/vscode-documentdb/commit/478cb83625cac5bf7678b4899a7a89e65478d687). This commit is mechanical only and preserves the original WI-4 commit. + +### WI-5: Update router and telemetry + +- Return the enriched readiness result. +- Accept a `forceRefresh` input on the readiness query that bypasses the memo and clears the remembered provider record. +- Replace `startDockerDesktop` with `startDockerProvider`. +- Revalidate start capability on the extension host immediately before launch. +- Add a `continueAnyway` path that skips the gate for `indeterminate` outcomes only, and tag the resulting provision telemetry with that fact. +- Record only categorized readiness and launch outcomes, plus the copied recovery-command `id`. +- Record a redacted fingerprint for `unknown` classifications: lowercase the captured stderr, replace digits, paths, and hex runs with placeholders, truncate, and hash. This is what turns unclassified real-world failures into new rules instead of leaving the classifier frozen at whatever this plan imagined. +- Never record raw errors, executable paths, socket paths, context names, or environment variable values. + +#### Slice B implementation checkpoint (completed 2026-08-03) + +Implemented and pushed in [commit `149025d5`](https://github.com/microsoft/vscode-documentdb/commit/149025d5129e3064422530abd70599ee49640bb6). The public mutation is now `startDockerProvider` and returns `DockerLaunchResult`. It calls the WI-4 coordinator, which force-refreshes readiness and revalidates the selected action on the extension host immediately before launch. Launch telemetry records only `started`, `launchAttempted`, `notAvailable`, or `failed`. The temporary WI-4 boolean adapter and old router procedure were removed. + +Readiness telemetry is produced by one pure projection containing only outcome, environment, endpoint kind, provider, provider evidence, failure kind, permission detail, start action, daemon OS type, and boolean readiness/continuation categories. The existing copied-command telemetry remains the fixed command ID only, and provisioning telemetry continues to record only whether the host-validated Continue anyway path was requested. + +Unknown diagnostics now receive a host-side redacted fingerprint before the readiness result is serialized. The normalizer lowercases and replaces context names, endpoint URIs, Windows and Unix paths, host/lookup/dial values, IP addresses, DNS-like names, long hex runs, and digits; it collapses whitespace, truncates to 512 characters, hashes with SHA-256, and emits only the first 16 hexadecimal characters. Empty diagnostics produce no fingerprint, and diagnosed failures never emit one. Raw errors, paths, endpoint values, context names, and environment variable values are not telemetry properties. + +Twenty-eight focused fingerprint, telemetry-projection, and direct tRPC caller tests passed. The caller test proves the renamed mutation returns and records a typed launch result. Targeted ESLint, editor diagnostics, and the full root TypeScript build passed. + +**Contract-boundary choice:** The React call site was changed from `startDockerDesktop` to `startDockerProvider` in this work item, while its fixed five-second delay and presentation behavior remain for WI-6. Two options were considered: leave the client temporarily uncompilable until WI-6, or include the minimal generated-contract consumer rename with WI-5. The latter was selected because each work-item commit must build independently; no WI-6 presentation or polling behavior was pulled forward. + +**Correction before commit:** The first direct router-test compile used a string literal for `dbExperience`; it was replaced with the repository's `API.DocumentDB` enum before commit. No committed history was reset or rewritten. + +**Follow-up formatting correction:** The required repository-wide Prettier pass normalized the router, telemetry projection, classifier fingerprint, and associated tests in [commit `478cb836`](https://github.com/microsoft/vscode-documentdb/commit/478cb83625cac5bf7678b4899a7a89e65478d687). This commit is mechanical only and preserves the original WI-5 commit. + +### WI-6: Update the webview + +- Add a pure semantic presentation mapper. +- Render card values and recovery actions from its result. +- Remove platform checks and raw error matching from JSX. +- Replace the fixed five-second wait with cancelable, non-overlapping polling with backoff. +- Add the `Docker starting` state with visible elapsed time and a `Stop waiting` control. +- Add `Continue anyway` for indeterminate outcomes and `Copy command` for recovery commands. +- Add the always-present `Refresh` control and the `Last checked` label, and make the relative time announce politely when it changes after a refresh. +- Add execution-target-aware Review copy and a remote-session notice for ready Docker environments. +- Expose the existing masked Docker output from every readiness failure. +- Render daemon architecture without claiming unverified image compatibility. +- Localize all added or changed user-facing strings, but never the command lines themselves. +- Use no em dashes (U+2014) and no en dashes (U+2013) in any added or changed string. +- Preserve accessible announcements for status changes and launch failures, and announce a successful copy. +- Return environment-aware guidance keys from the pure mapper, including the pending-session restart state; React only localizes and renders those keys. +- Render the mapper-selected recovery note beneath every copyable command. + +#### Slice A implementation checkpoint (completed 2026-08-03) + +Implemented the Slice A subset in [commit `e0f3251a`](https://github.com/microsoft/vscode-documentdb/commit/e0f3251a490671d1c6f7d9a5beb585cd23eb572b). A pure `getDockerReadinessPresentation()` mapper now owns semantic readiness states and action visibility. The not-ready view renders `Access denied`, `Not running`, `Check timed out`, or provider-neutral `Not accessible` instead of collapsing every failure to `Stopped`. It no longer selects behavior from `process.platform`, raw errors, or Docker error strings. + +Every Slice A failure now exposes masked `View Docker output`, forced `Retry`, forced `Refresh`, and a `Last checked` label. Indeterminate outcomes alone expose `Continue anyway`; the provisioning service revalidates that the current result is still indeterminate, so a crafted webview request cannot bypass a diagnosed permission or daemon-unavailable failure. Fixed recovery commands are rendered as read-only code and copied through a host mutation that accepts only a typed command ID; the command line is never accepted from the webview, localized, or executed. Copy success and launch failure are announced accessibly. + +The Docker Platform card now uses normalized `daemonArchitecture` and shows `Unknown until Docker is reachable` instead of substituting `process.arch`. The speculative registry/proxy advice was removed. Docker install and troubleshooting guidance is provider-neutral, with the Linux post-install guide selected for permission failures. The ready Review state also includes the always-present Refresh control. + +The readiness query now accepts `forceRefresh`, links the tRPC abort signal into the host probe token, and records categorized failure telemetry. The webview cancels superseded readiness queries and aborts an outstanding query on unmount. Recovery-command telemetry records only the fixed command ID, and provisioning telemetry records only whether Continue anyway was requested. + +Twelve pure presentation tests cover state/action mapping, including the Continue anyway and copy-command invariants. Two provisioning tests prove that explicit continuation bypasses only an indeterminate result and never a diagnosed result. The final focused run passed 55 presentation, orchestration, and provisioning tests; targeted ESLint and the TypeScript build passed. `npm run l10n` regenerated the localization bundle, and the added-line scan found no U+2014 or U+2013 characters after comment cleanup. + +**Intentional Slice A launcher boundary:** The existing `startDockerDesktop` mutation and five-second post-launch delay remain only for local Windows and macOS, selected by the pure mapper. Linux, WSL, SSH, dev-container, and Codespaces states no longer receive that action. Two options were considered: remove the start action everywhere until WI-4, or preserve the existing behavior on the two local platforms that Slice A explicitly promises not to regress. Preserving it on Windows/macOS was selected because the Slice A delivery definition says those launch paths remain untouched. The provider-aware launcher, typed launch result, bounded polling, Docker-starting state, and Stop waiting control remain WI-4/WI-6 work for Slice B. + +**Deliberately deferred WI-6 scope:** Provider-specific presentation beyond the Slice A local Desktop compatibility action, execution-target-aware Review copy, remote-session notices, remembered-provider labels, provider-start polling/backoff, and the Docker-starting state remain in Slice B. The `Last checked` label is computed when the readiness result renders; periodic relative-time updates are deferred with the remembered-provider UI because Slice A results are live or at most two seconds memoized. + +**Corrections before commit:** The first combined host patch failed to match a test insertion context and applied no changes; it was split into smaller service and router edits. The initial punctuation scan command used unavailable `rg`, so the installed `grep` fallback was used. Added-line scans then found two pre-existing em dashes in comments whose surrounding blocks had been rewritten; both comments were changed to punctuation that also keeps the complete Slice A diff clean. No committed implementation was reset or rewritten. + +#### Pending-session presentation checkpoint (completed 2026-08-03) + +Implemented in [commit `4f363411`](https://github.com/microsoft/vscode-documentdb/commit/4f36341104b21c83fe9f83f9418ee4acd68f10d2). The pure mapper now returns `accessDeniedPendingRestart`, an environment-aware guidance key, and an optional recovery-note key. React contains no environment switch; it localizes those semantic keys through fixed lookup tables and renders the note beneath the command. Exact guidance now distinguishes native Linux sign-out, WSL shutdown from Windows, remote SSH server restart, and dev-container or Codespaces rebuild. + +The WSL reporter state renders `Access denied`, explains that the group change is already configured but the session is stale, offers `wsl --shutdown`, and notes that the command restarts the distribution so membership applies. Unknown membership remains conservative: it retains the usermod command and environment-specific first-time guidance. Telemetry records only `permissionDetail`; no GID, group name, username, or path is emitted. + +The focused end-to-end run passed 83 tests across probes, recovery selection, orchestration, presentation, and the unchanged classifier. Targeted ESLint and the root TypeScript build passed, localization added nine keys, and the added-line punctuation scan found no U+2014 or U+2013 characters. The first consolidated JSX patch applied only its usage hunks and left the old switch plus one duplicated line; inspection caught this before validation, and a follow-up working-tree edit completed the lookup declarations and removed the duplicate before the work-item commit. No committed history was rewritten. + +#### Slice B implementation checkpoint (completed 2026-08-03) + +Completed and pushed in [commit `aea5b048`](https://github.com/microsoft/vscode-documentdb/commit/aea5b048a7c924e1c78a6193d0f0ef55b084d2e9). The pure mapper now covers every Slice B state: identified Desktop unavailable, native daemon unavailable, starting, WSL integration unavailable, remote Docker unavailable, remote endpoint unreachable, invalid context, timeout, unsupported host, Windows-container mode, and the unknown fallback. It owns guide selection and maps only host-returned start actions to `Start Docker Desktop` or `Start Docker`; React no longer infers an action from environment. Installed-application evidence deliberately keeps provider-neutral failure wording while naming the application only on the button. + +The fixed five-second delay was replaced with sequential, abortable polling under a 90-second launch deadline and 1/2/3/5-second backoff. Polls never overlap, the first command is echoed, later successful poll echoes are suppressed, and a failing probe remains visible in the masked output. Polling stops on readiness, non-transient diagnosis, deadline, Stop waiting, superseding action, or unmount. The starting state shows visible elapsed time and one polite state announcement; elapsed quarter-second updates are intentionally not live-region announcements. + +Review copy now reports the typed execution target for local, WSL, SSH, dev-container, Codespaces, and other remote hosts. WSL and remote targets receive a visible pre-provisioning notice. Success copy no longer implies that a remote `localhost` endpoint is reachable from the user's local machine; it says the connection string is for tools running on the extension host. The daemon architecture card continues to use only normalized daemon facts. + +Remembered provider evidence now carries `providerRecordedAtMs` separately from the current probe's `checkedAtMs`, so the relative Last checked label names when the provider fact was actually established. The label updates periodically and remains a polite status. Refresh remains present in every state, including ready and unsupported. Copy success, launch failure, Docker-starting, provisioning, and terminal outcomes retain accessible announcements. + +Eighty-seven focused mapper, polling, and orchestrator tests passed. Targeted ESLint, editor diagnostics, the full TypeScript build, localization generation, and the development webview webpack build passed. The source and generated localization added-line scan found no U+2014 or U+2013 characters. + +**Implementation choice:** Polling was extracted into a small injected helper rather than embedded entirely in the component. Two options were considered: manage timers and query overlap directly in JSX callbacks, or put deadline/backoff/cancellation sequencing behind a pure async boundary. The helper was selected because tests can prove one in-flight query, cancellation during backoff, transient versus terminal failures, echo suppression after the first poll, and deadline exit without mounting a VS Code webview. + +**Corrections before commit:** The first mapper test table widened the WSL literal to `string`, and two exhaustive switches narrowed the whole readiness object to `never`; the tuple was frozen and the switches now narrow local discriminants. The first polling test spread a readiness union into an illegal fixture and was replaced with exact ready/diagnosed variants. React lint then caught `Date.now()` in a state initializer; the clock now initializes to zero and is set when readiness arrives. No committed history was reset or rewritten. + +**Manual verification boundary:** This checkpoint validates behavior through focused tests, TypeScript, lint, localization, and webpack. Platform-specific visual and workflow verification is intentionally recorded under WI-9 rather than claimed here. + +**Follow-up formatting correction:** The required repository-wide Prettier pass normalized the React, mapper, polling, and test layout in [commit `478cb836`](https://github.com/microsoft/vscode-documentdb/commit/478cb83625cac5bf7678b4899a7a89e65478d687). This commit is mechanical only and preserves the original WI-6 commit. + +### WI-7: Add integration-focused tests + +- Test service sequencing with mocked command results. +- Test that a rejected probe still yields its stderr and stdout, so a permission failure is classified rather than reduced to an exit code. +- Test that a `ServerErrors` body with exit code zero is treated as a failure. +- Test that permission denial wins even when Docker Desktop is installed on the Windows host of WSL. +- Test that native WSL Docker never receives a Desktop launch action. +- Test that only positively identified rootless Linux Engine receives `Start Docker`; root-managed Engine never does. +- Test that a local Windows Desktop installation still yields a launch action when the daemon is down and no context evidence exists. +- Test that the provider-memory record produces a Desktop diagnosis on a later unreachable daemon, and that a record from a different host environment is ignored. +- Test every provider-memory discard rule: age, environment mismatch, endpoint-kind change, missing context, and a failed launch action. +- Test that `Refresh` clears the memo and the remembered record and reruns all checks, and that it is rendered in every readiness state including `ready` and `unsupportedHost`. +- Test that a result derived from remembered evidence is labeled with its check time rather than presented as fresh. +- Test that remote environments never launch a local desktop application. +- Test each presentation state and its exact semantic action. +- Test polling cleanup on success, deadline expiry, and unmount, and that polls never overlap. +- Test probe deadline expiry versus user cancellation, and that expiry during a launch yields `daemonStarting`. +- Test single-flight and TTL behavior, including that `Retry` forces a refresh. +- Test that `Continue anyway` is offered only for indeterminate outcomes. +- Test that probes spawn with stdin ignored. +- Test Review-screen execution-target copy for local, WSL, SSH, dev-container, and Codespaces environments. +- Test that normalized daemon architecture, not `process.arch`, drives the Platform card. + +#### Slice B implementation checkpoint (completed 2026-08-03) + +Completed and pushed in [commit `8e0c0483`](https://github.com/microsoft/vscode-documentdb/commit/8e0c048339c16e10eb3b171f243cea8346d6edf0). A new integration-focused service suite combines the boundaries that isolated unit tests could not: a native WSL unix-socket permission failure remains `permissionDenied` with no Desktop action even when the Windows Desktop executable is visible, and an SSH extension host on an arm64 client reports its reachable remote daemon as normalized `amd64` with the `ssh` execution target. + +Additional regressions now prove that caller cancellation rejects instead of becoming a timeout category, probe runner options omit `stdInPipe`, cancellation during an active poll query drops the late result, remembered evidence selects `providerRecordedAtMs` instead of the current probe time, and live evidence selects `checkedAtMs`. Existing tests already covered rejected stdout/stderr capture, zero-exit `ServerErrors`, every provider-memory discard rule, forced Refresh, launch selection/revalidation, single flight, memo TTL, every presentation state, Continue anyway gating, and polling success/deadline/non-overlap. + +The complete Local Quick Start test set passed all 15 suites and 227 tests. Targeted ESLint, editor diagnostics, and the full root TypeScript build passed. + +**Testability choice:** The command-runner options used by `runDockerProbe()` are now built by an exported pure helper so the stdin omission can be asserted without spawning an SSH process or monkey-patching processutils. The relative-time source selection was similarly extracted from JSX into a pure selector. Both production call sites use the helpers, so the tests cover the exact behavior rather than parallel test-only logic. + +**Corrections before commit:** The first cancellation assertion depended on `vscode.CancellationError`, which `jest-mock-vscode` does not implement, and the first stdin test tried to spy on a non-configurable processutils barrel export. The cancellation test now asserts rejection plus cancellation of both probes without relying on the missing mock class, and the stdin test inspects the production runner-options helper. No committed history was reset or rewritten. + +**WI-8 boundary:** Daemon disappearance during pull/run and the dev-container published-port explanation are not counted as WI-7 coverage. They require provisioning-path behavior that did not yet exist and remain explicitly assigned to WI-8. + +**Follow-up formatting correction:** The required repository-wide Prettier pass normalized the integration, polling, presentation, and probe test layout in [commit `478cb836`](https://github.com/microsoft/vscode-documentdb/commit/478cb83625cac5bf7678b4899a7a89e65478d687). This commit is mechanical only and preserves the original WI-7 commit. + +### WI-8: Reuse the classifier on the provisioning path + +The readiness gate is a snapshot, and the daemon can disappear between the gate and the first `docker pull`. A Docker Desktop auto-update three seconds after the check produces `error during connect: … The system cannot find the file specified`, which today lands in the failure card as a raw string with none of the recovery UI this plan builds. + +- Route daemon-class failures raised during pull and run through the same classifier. +- Render the same recovery card, including start action, recovery command, and `View Docker output`. +- Keep registry- and image-specific classification out of scope; only daemon-class failures are in this work item. +- When a readiness timeout follows a successful `docker run` in a dev-container session, add the published-port explanation: the daemon may be the host's, so the container's published port is not necessarily reachable from inside the dev container. + +#### Slice B implementation checkpoint (completed 2026-08-03) + +Completed and pushed in [commit `77c4bfed`](https://github.com/microsoft/vscode-documentdb/commit/77c4bfede508af9bd20719030f9beb2b6a6f3d6d). Provisioning now tracks only the active `docker pull` and `docker run` stages. If either operation fails, it performs one forced, bounded readiness check through the existing `DockerReadinessService`. A non-ready result is attached to the terminal `StageEvent` and React returns to the same readiness recovery screen, preserving provider start, copyable recovery command, Retry/Refresh, Continue anyway when indeterminate, details, and masked Docker output. The raw operation error is replaced with localized provider-neutral copy in this branch. + +If the forced recheck says Docker is ready, the event carries no readiness result and the original provisioning error remains visible. This keeps manifest, registry, proxy, image, and other non-daemon failures out of readiness classification. Provision telemetry records only the categorized Docker failure kind or `none`. + +A dev-container readiness timeout after a successful run now appends a localized explanation that Docker may be on the dev-container host, so the published localhost port might not be reachable from inside the dev container. The existing timed-out event carries that message, and the failed view renders it instead of replacing it with generic timeout copy. + +Focused tests cover daemon disappearance during both pull and run, preservation of a manifest error when Docker remains ready, and environment-selective dev-container timeout guidance. The complete Local Quick Start set passed all 15 suites and 231 tests. Targeted ESLint, editor diagnostics, localization generation, the source/localization punctuation scan, and the full root TypeScript build passed. + +**Classification-path implementation choice:** Two options were considered: retrofit pull/run execution to tee and classify each operation's rejected stderr directly, or re-run the established bounded readiness probes after an operation failure. The second option was selected with greater than 80% confidence. It keeps one evidence collector and one classifier owner, obtains endpoint errno and structured `ServerErrors` rather than relying on operation text, distinguishes a daemon that recovered from a real image error, honors single-flight/deadline behavior, and keeps raw pull/run output in the masked OutputChannel. The tradeoff is one extra bounded probe set after a failed pull or run. + +**Corrections before commit:** The first full build found an unused `DockerReadiness` import after event narrowing made an explicit cast unnecessary; the import was removed before commit. No committed history was reset or rewritten. + +**Follow-up formatting correction:** The required repository-wide Prettier pass normalized the provisioning service and React event-consumer layout in [commit `478cb836`](https://github.com/microsoft/vscode-documentdb/commit/478cb83625cac5bf7678b4899a7a89e65478d687). This commit is mechanical only and preserves the original WI-8 commit. + +### WI-9: Build a fixture corpus and manual verification pass + +Every test above feeds the classifier text that the implementer wrote, which validates the classifier against its own assumptions. The fragile input is real CLI output, and the readiness gap notes record that macOS and Linux were never verified at all. + +- Add `__fixtures__/docker/--.txt` files holding real captured stdout and stderr, each with a provenance comment naming the OS, Docker version, and provider. +- Table-drive the classifier tests from those files rather than from inline strings. +- Record a short manual verification checklist covering, at minimum: Windows with Desktop stopped, macOS with Desktop stopped, native Ubuntu without `docker` group membership, native Ubuntu with the service stopped, and WSL with and without Desktop integration. +- Treat a new Docker version that breaks a signature as a fixture addition, not a rewrite. + +#### Slice B implementation checkpoint (completed 2026-08-03) + +The verified fixture corpus is implemented and pushed in [commit `c1876d20`](https://github.com/microsoft/vscode-documentdb/commit/c1876d20b6de95ca826f86061cbccb85876d9532). The historical reporter capture is now named `wsl2-ubuntu20.04-docker28.1.1-permission-denied.txt` and carries its confirmed WSL2, Ubuntu, Docker Engine, socket-GID, and permission provenance. A second fixture, `wsl2-ubuntu20.04-docker28.1.1-ready-info.txt`, comes from a live `docker info --format {{json .}}` capture on 2026-08-03. It stores the exact structured subset consumed by the implementation and explicitly discloses that machine ID, hostname, paths, counts, timestamps, and resource values were omitted. + +The permission classifier, raw info parser, architecture normalizer, and live Engine provider classifier now consume those files. Both fixture-focused suites passed 43 tests; the complete Local Quick Start set passed all 15 suites and 232 tests. Targeted ESLint and the full root TypeScript build passed. + +##### Manual verification checklist + +| Scenario | Status | Evidence and remaining action | +| ------------------------------------------------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Windows with Docker Desktop stopped | **Not run** | No Windows extension host is available in this workspace. Verify the installed-application action on the default npipe context, launch result, starting poll, and ready transition on Windows. | +| macOS with Docker Desktop stopped | **Not run** | No macOS extension host is available. Verify `open -a Docker`, nonzero launch handling, starting poll, and ready transition on macOS. | +| Native Ubuntu without `docker` group membership | **Not run on native Linux** | The real permission capture is WSL2 rather than native Ubuntu. Automated endpoint/classifier/presentation tests cover native Linux, but a native host must verify the panel and sign-out guidance. | +| Native Ubuntu with Docker service stopped | **Not run** | Stopping a host service would disrupt the operator environment and may require elevation. Verify `Not running`, the copy-only service command, no privileged automatic launch, and recovery after the operator starts the service. | +| WSL using Docker Desktop integration | **Not run** | Docker Desktop is installed on Windows, but the active WSL endpoint is native Engine. A Desktop-integrated distribution must verify integration-unavailable and Desktop-start paths. | +| WSL using native Docker Engine, Desktop also installed | **Live host facts verified; panel automation covered** | On 2026-08-03: WSL2 Ubuntu 20.04, Docker Engine 28.1.1, `/var/run/docker.sock`, systemd active, daemon OS Ubuntu 20.04.6 LTS, daemon architecture `x86_64`, and the Windows Desktop executable present. The process now includes socket GID 998. Integration tests prove this evidence yields Engine/WSL/`amd64` and no Desktop action. An interactive panel inspection was not available from this agent session. | +| WSL permission denied before the new session | **Historically captured and operator-confirmed in Slice A** | The fixture preserves the real `EACCES` output. The operator previously confirmed the panel's `pendingSessionRestart` guidance. The destructive `wsl --shutdown` and reconnect sequence remains unverified, as recorded in the Slice A summary. | + +**Corpus-scope deviation:** The plan asks for real fixtures across operating systems and failure modes, but this workspace provides only WSL2 with native Docker Engine plus the historical WSL permission capture. Three options were considered: invent representative outputs, copy unattributed text from documentation, or commit only verified captures and leave an explicit acquisition checklist. The third option was selected with greater than 80% confidence because classifier fixtures are evidence, and fabricated provenance would make the test suite less trustworthy. New Windows, macOS, native Ubuntu stopped-service, and WSL Desktop captures must be added as new files when those environments are available. + +**Manual-pass safety deviation:** The agent did not stop the active Docker service, remove group access, run `sudo`, launch/stop Docker Desktop on the operator's machine, or execute `wsl --shutdown`. Those actions would disrupt the current session, require elevation, or need unavailable platforms. The safe alternatives were automated injected-dependency tests plus command-level inspection of the existing host. The unrun rows above are the operator handoff and are not claimed as acceptance successes. + +**Follow-up formatting correction:** The required repository-wide Prettier pass normalized the real-fixture consumer tests in [commit `478cb836`](https://github.com/microsoft/vscode-documentdb/commit/478cb83625cac5bf7678b4899a7a89e65478d687). This commit is mechanical only and preserves the original WI-9 commit and fixture contents. + +### WI-10: Update documentation + +- Replace statements that imply Docker Desktop is universally required. +- Document Docker Engine and Docker Desktop as supported provider choices. +- Document Linux group/session restart and WSL integration guidance. +- Keep multi-step setup procedures in linked documentation. The card carries at most the single copyable command from the recovery-command table; it never becomes a shell tutorial. + +#### Slice B implementation checkpoint (completed 2026-08-03) + +Completed and pushed in [commit `6117a83a`](https://github.com/microsoft/vscode-documentdb/commit/6117a83a0cf74e7e35c2a6337ff31f1a17557e44). The new [DocumentDB Local Quick Start user guide](../../../user-manual/local-quick-start.md) documents Docker Engine and Docker Desktop as supported provider choices, the extension-host execution target, the no-install/no-silent-start/no-elevation rules, starting Quick Start, readiness cards, Refresh, masked output, Linux/WSL group recovery, native service recovery, rootless launch limits, WSL Desktop integration, context/remote endpoint recovery, Linux-container mode, provisioning-time Docker recovery, and dev-container published-port behavior. + +The local-connection overview, DocumentDB Local manual page, user-manual index, and repository README now link to the guide and distinguish container management from connecting to an already-running instance. The v2 design reference has a prominent supersession note and its normative prerequisite table, Docker-not-ready mockup, and cross-cutting launch rule are provider-neutral rather than Desktop-only. + +All six documentation surfaces pass Prettier and whitespace checks. New local documentation links were checked for existing targets. The new guide contains no standalone product use of `MongoDB`, no U+2014/U+2013 characters, and no speculative proxy/registry prerequisite advice. + +**Historical-design choice:** Two options were considered for old Desktop-only design material: rewrite every historical iteration and readiness-gap record as if it had always described the final behavior, or preserve history while correcting the authoritative v2 rules and adding an explicit supersession link. The second option was selected because the earlier documents explain past implementation decisions and Slice A boundaries; erasing those statements would make the repository's design history misleading. The user manual and current implementation plan are the authoritative operational guidance. + +## Required Test Matrix + +| Scenario | Expected failure/provider | Expected action | +| ---------------------------------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------- | +| CLI absent on Linux | `cliMissing` / `unknown` | Linux install guide | +| Native Ubuntu daemon reachable | Ready / `dockerEngine` | None | +| Native Ubuntu socket returns `EACCES` | `permissionDenied` / `dockerEngine` or `unknown` | `Copy command` for the group fix, plus Linux setup guide | +| Native Linux user is configured in the socket group but the process is stale | `permissionDenied`, `pendingSessionRestart` | Sign out of the desktop session and sign back in; never suggest Reload Window | +| WSL user is configured in the socket group but the process is stale | `permissionDenied`, `pendingSessionRestart` | Copy `wsl --shutdown` for Windows, then reopen the folder | +| Socket-group membership cannot be established | `permissionDenied`, `unknown` | Conservative usermod command and environment-specific login guidance | +| WSL daemon stopped, systemd absent, service wrapper present | `daemonUnavailable` | Copy `sudo service docker start` | +| Native Ubuntu daemon stopped | `daemonUnavailable` / `dockerEngine` or `unknown` | Service guide and copyable service command, no auto start | +| Native rootless Ubuntu user service stopped | `daemonUnavailable` / `dockerEngine` | Start Docker | +| WSL native socket permission denied while Windows Desktop is installed | `permissionDenied` / native endpoint | Linux/WSL setup guide, no Desktop button | +| WSL Desktop integration endpoint unavailable | `daemonUnavailable` / `dockerDesktop` | Start Desktop on Windows or WSL integration guide | +| Local Windows Desktop stopped | `daemonUnavailable` / `dockerDesktop` | Start Docker Desktop | +| Local Windows Desktop stopped on the `default` npipe context | `daemonUnavailable` / `dockerDesktop` | Start Docker Desktop from the installed-application bar | +| Local macOS Desktop stopped | `daemonUnavailable` / `dockerDesktop` | Start Docker Desktop | +| Linux Docker Desktop user service stopped | `daemonUnavailable` / `dockerDesktop` | Start Docker Desktop | +| Invalid Docker context | `contextUnavailable` | Context guide | +| Windows daemon reports Windows containers | `windowsContainers` | Linux-container guidance | +| SSH remote with no daemon | `daemonUnavailable` / `unknown` | Remote Docker guide, no local launch | +| Unknown nonzero `docker info` error | `unknown`, `indeterminate` | Show details, Retry, Continue anyway | +| `docker info` never responds | `probeTimedOut`, `indeterminate` | View Docker output, Retry, Continue anyway | +| Deadline expires while a Desktop launch is in flight | `daemonStarting` | Keep waiting with elapsed time and a Stop waiting control | +| `DOCKER_HOST=tcp://:2375` | `endpointUnreachable` | Show details naming the `DOCKER_HOST` source | +| `DOCKER_HOST=ssh://` prompting for a passphrase | Probe fails fast, never hangs | Show details naming the `DOCKER_HOST` source | +| `docker info` exits zero but the body carries `ServerErrors` | Classified as a daemon failure, not ready | Matching recovery card | +| Readiness query is canceled | Cancellation, not a failure category | Stop probes and render no stale error | +| Two callers request readiness at once | One probe set runs | Both receive the same result | +| Remembered Desktop record, Desktop since uninstalled | Launch reports `notAvailable`, record discarded | Next check is provider-neutral, not a repeated Desktop claim | +| Remembered record older than the maximum age | Record ignored | Provider-neutral guidance plus `Last checked` label | +| Remembered record whose context no longer exists | Record discarded | Provider-neutral guidance | +| User presses `Refresh` in any state | Memo and remembered record cleared | All checks rerun and the `Last checked` label updates | +| Unsupported Node extension-host platform | `unsupportedHost` / `unknown` | Learn more, no Docker launch | +| SSH extension host with reachable remote amd64 daemon on arm64 client | Ready / daemon architecture `amd64` | Show remote target, daemon architecture, remote-endpoint note | +| WSL extension host with reachable native daemon | Ready / `dockerEngine` | Show WSL execution-target notice | +| Daemon disappears between the gate and `docker pull` | Same daemon-class failure as readiness | Same recovery card, not a raw error string | +| Dev container: run succeeds, readiness probe times out | Readiness timeout plus published-port explanation | Existing timeout recovery actions | + +## Maintainability Requirements + +The implementation is not complete unless these structural constraints hold: + +- Every function has an explicit return type. +- No `any` is introduced. +- No platform checks appear in React components. +- No user-facing text is selected in the host-side launcher. +- No error-string matching appears outside the classifier. +- No classification is decided by error text when an errno or structured field answers the same question. +- No classifier input comes from a rejected promise's message; all evidence is captured. +- No executable/service paths appear outside the launcher or named platform constants. +- No recovery command line is built anywhere except the recovery-command constant table. +- No recovery command is executed, and none is passed to a shell, a terminal, or a task. +- No user-facing string contains U+2014 or U+2013. +- No readiness state renders without a `Refresh` control. +- No remembered fact is presented without its check time. +- No nested ternary is used for readiness, provider, failure, or action selection. +- No shell command is assembled as a single interpolated string. +- No command output is parsed with ad hoc line splitting when JSON is available. +- No external readiness command can wait indefinitely. +- No two probe sets run concurrently. +- No start action is inferred from operating system alone. +- No image compatibility decision is inferred from `process.arch` alone. +- All semantic `switch` statements are exhaustive. +- Raw command errors stay out of telemetry and primary UI copy. +- New modules remain focused; avoid a generic framework or class hierarchy for the small set of launch actions. + +Prefer straightforward named functions such as: + +- `runDockerProbe()` +- `probeDockerEndpoint()` +- `detectHostEnvironment()` +- `resolveDockerEndpoint()` +- `normalizeDaemonArchitecture()` +- `classifyDockerFailure()` +- `classifyDockerProvider()` +- `getAvailableStartAction()` +- `getRecoveryCommand()` +- `startDockerProvider()` +- `getDockerReadinessPresentation()` +- `getDockerExecutionTargetPresentation()` + +These names are illustrative, but the final code should preserve this visible execution flow. + +## Acceptance Criteria + +1. A native Ubuntu or WSL socket permission error is displayed as `Access denied`, not `Stopped`, and that verdict comes from the endpoint errno rather than from an English sentence. +2. Probe evidence reaches the classifier: a failing probe's stderr and JSON body are available, and no classification is derived from `Process exited with code 1`. +3. Installing Docker Desktop on the Windows host does not override a native WSL permission diagnosis. +4. `Docker Desktop` is named as a cause only when Desktop is positively identified, and a launch action is offered under the documented per-environment evidence bar. +5. A local Windows or macOS user with Docker Desktop installed but stopped still gets a working start action, including on the `default` context. +6. Remembered provider facts are labeled with the time of the last successful check, are discarded by every documented rule, and are never the reason a user cannot reach a correct answer. +7. A `Refresh` control that reruns every check and clears remembered state is available in every readiness state, including `ready`. +8. Native Linux Docker Engine users never receive a `Start Docker Desktop` action. +9. Root-managed Linux Docker Engine never triggers a privileged start; rootless Engine receives `Start Docker` only from positive evidence. +10. Remote extension hosts never launch a Docker application on the user's local machine. +11. Unknown and timed-out failures are reported as indeterminate, use provider-neutral language, and retain Retry, details, and `Continue anyway`. +12. `Continue anyway` never appears for a diagnosed failure. +13. The copyable recovery command is shown for the documented failures, is never executed by the extension, and is never localized. +14. WSL and other remote users are told where the container will run before provisioning, and remote users are told the endpoint is remote after it succeeds. +15. A hung Docker probe is killed at the shared deadline and cannot leave the webview spinning indefinitely. +16. A Docker that is merely slow to start is reported as starting, not as timed out. +17. Concurrent readiness callers and post-launch polling never run overlapping probe sets. +18. The Platform card reports normalized daemon architecture when known and does not claim image support from `process.arch`. +19. Masked Docker output is reachable from every readiness failure and is not flooded by polling. +20. A daemon-class failure during provisioning renders the same recovery card as the readiness gate. +21. Existing ready-Docker provisioning behavior is unchanged. +22. Classifier tests are driven by captured real-world fixtures, and unclassified failures emit a redacted fingerprint. +23. Added classification and launch-selection branches have focused tests. +24. All changed user-facing strings are localized and contain no U+2014 or U+2013 characters. +25. A user who runs the offered group command and returns to the panel is told the exact next session action for Linux, WSL, SSH, or a container; Linux is never told that Reload Window is sufficient. +26. The repository completion checks pass in order: + - `npm run l10n` + - `npm run prettier-fix` + - `npm run lint` + - `npx jest --no-coverage` + - `npm run build` + +## Suggested Delivery Order + +Split this into two shippable slices. The work with the highest user value is not the work with the highest regression risk, and they should not ride together: slice B touches the platform combinations that the v1 readiness notes record as never having been verified. + +### Slice A: fix the reported failure + +WI-0, the permission and daemon-unavailable half of WI-2, the parts of WI-3 needed to run bounded probes and resolve the endpoint, the `Access denied` and indeterminate states from WI-6, the copyable recovery command, `Continue anyway`, the always-present `Refresh` control, and `View Docker output` from every failure. + +This resolves the Ubuntu and WSL report end to end while leaving the existing Windows and macOS launch behavior untouched, so its regression surface on unverified platforms is close to zero. + +#### Slice A executive summary (completed 2026-08-03) + +Slice A is implemented and pushed. Probe evidence capture is in [`d832ebc1`](https://github.com/microsoft/vscode-documentdb/commit/d832ebc149a060152b517ff4a15c965448f6f0f3), pure failure classification is in [`8d0cb52d`](https://github.com/microsoft/vscode-documentdb/commit/8d0cb52da7ce5ab53b44732136a6fb8de083eb6c), bounded readiness orchestration is in [`e076243a`](https://github.com/microsoft/vscode-documentdb/commit/e076243a72c6585b21ccf3a80dff90c145084d2f), and the actionable webview/recovery flow is in [`e0f3251a`](https://github.com/microsoft/vscode-documentdb/commit/e0f3251a490671d1c6f7d9a5beb585cd23eb572b). Repository-wide formatting corrections are intentionally preserved as the follow-up commit [`4265d8f3`](https://github.com/microsoft/vscode-documentdb/commit/4265d8f3b732d0e0e963e3e495c57812c2c2cf75). + +The pending-session refinement is in [`8a7780c3`](https://github.com/microsoft/vscode-documentdb/commit/8a7780c341fa271e7d3ec39e1494c93f4cdf073c) for host facts and recovery selection, and [`4f363411`](https://github.com/microsoft/vscode-documentdb/commit/4f36341104b21c83fe9f83f9418ee4acd68f10d2) for environment-aware presentation, telemetry, localization, and confirmed fixture provenance. It resolves the post-usermod Retry loop without adding a failure kind or changing classifier precedence. + +The reported Ubuntu and WSL socket-permission failure now reaches the UI as `Access denied` from endpoint `EACCES` evidence. Rejected Docker probes retain stdout and stderr, structured `ServerErrors` are honored, and readiness is bounded by one cancellation deadline with single-flight and short memoization. The UI offers the fixed group-membership command as copy-only text, never executes it, exposes masked Docker output for every failure, and keeps forced Retry/Refresh controls. Unknown and timed-out results are indeterminate and alone may use `Continue anyway`; provisioning revalidates that invariant on the extension host. + +Linux, WSL, and remote environments no longer receive the unconditional Docker Desktop start action. Existing local Windows/macOS launch behavior is deliberately retained until Slice B replaces it with provider-aware launch selection. The Platform card now reports normalized daemon architecture when known and otherwise says it is unknown until Docker is reachable. All added or changed user-facing strings are localized, and the final added-line scan contains no U+2014 or U+2013 characters. + +The main Slice A deviation is the shared-deadline cancellation implementation: it uses `AbortController` plus processutils' `CancellationTokenLike.fromAbortSignal()` instead of `vscode.CancellationTokenSource`. The pending-session refinement adds one evidence-safety deviation: WSL receives `sudo service docker start` only when the service wrapper is positively detected, not merely when systemd is absent. The alternatives and rationale are documented in WI-3. The fixture provenance gap is closed with the confirmed reporter facts. No correction rewrote an existing commit. + +The required completion sequence after the pending-session refinement passed in order: localization generation, repository-wide Prettier, repository-wide ESLint, all 194 Jest suites (3,181 tests and 4 snapshots), and the root TypeScript build. ESLint emitted only the existing flat-config migration warning for `webpack.config.views.js`. The classifier implementation remained unchanged, and the final refinement source/localization added-line scan contains no U+2014 or U+2013 characters. + +**Manual verification status:** During final implementation checks the machine temporarily represented the `notInGroup` case. The operator then ran the group fix again, restoring the target condition: `id -G` omits socket GID 998 while `id -G "$USER"` and `getent group 998` include it. The operator confirmed that the panel correctly detects `pendingSessionRestart` and renders the WSL guidance. The destructive `wsl --shutdown`, disconnect, and reopen sequence has not yet been completed or claimed as verified. + +#### Post-Slice A add-on: better session reset and restart guidance + +This add-on was completed after Slice A testing exposed a Retry loop for users who had already run the group-membership fix. Host evidence and recovery selection are implemented in [`8a7780c3`](https://github.com/microsoft/vscode-documentdb/commit/8a7780c341fa271e7d3ec39e1494c93f4cdf073c); environment-aware presentation, telemetry, localization, and fixture provenance are implemented in [`4f363411`](https://github.com/microsoft/vscode-documentdb/commit/4f36341104b21c83fe9f83f9418ee4acd68f10d2). The detailed work-item records are in the WI-3 and WI-6 pending-session checkpoints above. + +The add-on keeps `permissionDenied` as the failure kind and adds `permissionDetail` as refining evidence. A unix-socket permission failure now distinguishes a user who still needs the group fix from a user whose configured membership is waiting on a new process session. The latter receives the exact action for the extension-host environment: desktop sign-out and sign-in on native Linux, `wsl --shutdown` from Windows for WSL, killing the remote VS Code server for SSH, or rebuilding a dev container or Codespaces container. Reload Window is never presented as sufficient for a stale native-Linux session. + +Recovery commands remain fixed, copy-only, and never executed. WSL daemon recovery uses `sudo systemctl start docker` only with active systemd and `sudo service docker start` only when the service wrapper is positively detected. This is intentionally stricter than selecting the service command from systemd absence alone. The add-on passed the full completion sequence with 194 Jest suites and 3,181 tests; the destructive WSL shutdown verification remains pending for an operator session that again satisfies the captured pending-restart precondition. + +Testing produced one wording correction in [`08832118`](https://github.com/microsoft/vscode-documentdb/commit/08832118dfa39b056bcb4c01b4ee642a0d457522). The WSL pending-restart guidance now says to run `wsl --shutdown` in a Windows terminal, warns that the current VS Code WSL window will disconnect, and instructs the user to reopen the folder in WSL. Restarting the local VS Code application is not required. The recovery note also accurately says that the command stops all running WSL distributions; WSL starts again when the user reconnects. + +The full suite after that wording change exposed a test-isolation mistake: one orchestration test used the default socket-group probe, so changing the operator machine from `notInGroup` to `pendingSessionRestart` changed the test's expected recovery command. [`1522cdab`](https://github.com/microsoft/vscode-documentdb/commit/1522cdab2b1ac394615995c15bec5291e292bf2b) injects explicit unknown group facts into tests that are not testing group refinement. Production behavior is unchanged, and the service suite is now deterministic across operator group changes. + +### Slice B: complete the model + +Provider classification and provider memory, the evidence bar and launch matrix (WI-4), router and telemetry (WI-5), the remaining presentation states and execution-target copy (WI-6), the full integration test set (WI-7), provisioning reuse (WI-8), fixtures and manual verification (WI-9), and documentation (WI-10). + +Keep each work item independently testable, and do not expose partially classified states in the UI. + +The first executable behavior check should be the Linux/WSL permission-denied classifier test, driven by a real captured fixture. It directly reproduces the reported failure and will disconfirm the implementation if it still falls through to `daemonUnavailable` or Docker Desktop guidance. Write it before WI-0 is complete: with today's code it fails because the evidence is missing, which is the point. + +#### Slice B executive summary (completed 2026-08-03) + +Slice B is implemented and pushed. Typed readiness contracts are in [`d6254c1c`](https://github.com/microsoft/vscode-documentdb/commit/d6254c1cd1f30b099b817addee3a36b0f4657140), with the reachable-diagnosed-daemon correction preserved separately in [`0720fbe4`](https://github.com/microsoft/vscode-documentdb/commit/0720fbe4eebd3deee9b008cce4c4c2fd3dd57fb3). Pure failure/provider classification is in [`54e13d9d`](https://github.com/microsoft/vscode-documentdb/commit/54e13d9dfb1d6bd818d200c16262a2d494b01ee6), provider memory and orchestration are in [`d79aa505`](https://github.com/microsoft/vscode-documentdb/commit/d79aa505fda4809b0ebb8180701cfda2fb97ed07), and the provider-aware launcher is in [`eb3c6828`](https://github.com/microsoft/vscode-documentdb/commit/eb3c6828b68d662998f2f3209e22fd6112fdfd79). + +The typed tRPC launch contract and categorized/redacted telemetry are in [`149025d5`](https://github.com/microsoft/vscode-documentdb/commit/149025d5129e3064422530abd70599ee49640bb6). The complete webview state model, provider-start polling, remembered timestamps, execution-target copy, remote notices, and accessibility updates are in [`aea5b048`](https://github.com/microsoft/vscode-documentdb/commit/aea5b048a7c924e1c78a6193d0f0ef55b084d2e9). Integration-focused coverage is in [`8e0c0483`](https://github.com/microsoft/vscode-documentdb/commit/8e0c048339c16e10eb3b171f243cea8346d6edf0), provisioning-time readiness reuse is in [`77c4bfed`](https://github.com/microsoft/vscode-documentdb/commit/77c4bfede508af9bd20719030f9beb2b6a6f3d6d), verified fixtures are in [`c1876d20`](https://github.com/microsoft/vscode-documentdb/commit/c1876d20b6de95ca826f86061cbccb85876d9532), and user documentation is in [`6117a83a`](https://github.com/microsoft/vscode-documentdb/commit/6117a83a0cf74e7e35c2a6337ff31f1a17557e44). The required final repository-wide formatting correction is intentionally preserved in [`478cb836`](https://github.com/microsoft/vscode-documentdb/commit/478cb83625cac5bf7678b4899a7a89e65478d687), not folded back into any work-item commit. + +The completed flow now distinguishes CLI absence, endpoint permission, native daemon availability, context failure, remote endpoint reachability, provider startup, timeout, unsupported hosts, Windows-container mode, and unknown failures. Docker Desktop is named as a cause only from positive live, context, or remembered evidence; the lower installed-application evidence bar can name only the launch button on local Windows/macOS. Native Engine, rootless Engine, WSL, and remote launch behavior follow the documented evidence matrix. Provider memory is time-limited, visibly dated, contradicted aggressively, and cleared by Refresh or failed launch. + +The webview renders every semantic recovery state, fixed copy-only commands, masked output access, indeterminate-only Continue anyway, and an always-present Refresh. Provider startup uses cancelable sequential backoff without overlapping probes. Review and success copy distinguish local, WSL, SSH, dev-container, Codespaces, and other remote extension hosts, and daemon architecture is never inferred from `process.arch`. Pull/run daemon failures return to the same readiness recovery UI, while ready-daemon image failures stay on the provisioning path. + +The principal Slice B deviations are deliberate and documented inline: provider memory does not persist context names, so an implicitly deleted same-kind context cannot be identified until another contradiction, expiry, or Refresh; installed-application and service-path evidence lives in the launcher rather than the orchestrator; provisioning re-runs the bounded readiness probes after pull/run failure instead of classifying raw operation text; and the fixture corpus contains only verified WSL captures rather than fabricated Windows/macOS/native-Linux outputs. The Slice A cancellation adapter and stricter WSL service-wrapper evidence remain unchanged. + +The required completion sequence passed in order after the final plan commit: localization generation, repository-wide Prettier, repository-wide ESLint, all 199 Jest suites (3,267 tests and 4 snapshots), and the root TypeScript build. ESLint emitted only the existing flat-config migration warning for `webpack.config.views.js`. An earlier full Jest pass reported one worker that required forced exit after all tests passed; the definitive post-summary run completed with the same passing counts and did not reproduce that warning. + +**Manual verification handoff:** The live WSL2 Ubuntu 20.04 native-Engine setup was command-verified with Docker Engine 28.1.1, a reachable `amd64` daemon, active systemd service, socket-group membership, and a coexisting Windows Docker Desktop installation. Windows Desktop stopped, macOS Desktop stopped, native Ubuntu group/service failures, WSL Desktop integration, interactive panel inspection, and the destructive `wsl --shutdown` reconnect remain explicitly unverified. WI-9 records the prerequisites and expected outcomes for each operator-run scenario; none is claimed as passed. + +#### Slice B review remediation executive summary (completed 2026-08-03) + +The post-implementation review findings selected for this PR are complete in eleven dedicated commits. Provider memory now survives polling, Retry, and pre-launch revalidation while explicit Refresh retains its reset behavior ([`4e37f886`](https://github.com/microsoft/vscode-documentdb/commit/4e37f8863ad0c5661e578b872791d365952b3755)). Provisioning reroutes only positively diagnosed Docker failures and preserves the original operation error ([`214ae1f1`](https://github.com/microsoft/vscode-documentdb/commit/214ae1f1e97ddd8921f017069ad73b535ec847d1)). Successful poll transcripts and poll telemetry are suppressed while failed transcripts remain available ([`d6acaeee`](https://github.com/microsoft/vscode-documentdb/commit/d6acaeeead1224f557dc3638e4d7994b720c0534), [`216abf4b`](https://github.com/microsoft/vscode-documentdb/commit/216abf4b2afa30cbcf019aeca779d340e7c9e39d)). + +Architecture compatibility is advisory and visible again on both readiness surfaces ([`ff07f2c8`](https://github.com/microsoft/vscode-documentdb/commit/ff07f2c8300066d71385c15340a39bdfc14306e8)). The panel now shows localized typed diagnostic facts while raw failure and timeout traces go to the Quick Start output channel ([`92b12e82`](https://github.com/microsoft/vscode-documentdb/commit/92b12e8265d5c90916816573dcd7dcef42c92eb1), [`ac83f088`](https://github.com/microsoft/vscode-documentdb/commit/ac83f0881034f91e223bc6eceafcbf87f63eb2a4)). Decorative status glyphs are hidden from assistive technology ([`68b79bef`](https://github.com/microsoft/vscode-documentdb/commit/68b79beffa705ae8dbfd509cba3d125a83900c77)), the missing-CLI state has a primary install action and no dead Refresh flag ([`ed7a1c61`](https://github.com/microsoft/vscode-documentdb/commit/ed7a1c6153c1751576fe1fd70a51aed2bb930e99)), the obsolete readiness error field is removed ([`c2278a24`](https://github.com/microsoft/vscode-documentdb/commit/c2278a245ae5455c6be7ef82df0ad40a13a8f681)), and CLI presence is derived from direct probe evidence rather than failure classification ([`33843404`](https://github.com/microsoft/vscode-documentdb/commit/338434043d8f0162cc307ad1fc0cb9c87f03c74e)). + +Two implementation-level deviations were selected with high confidence and disclosed in the review record and PR comments. F-03 reuses stdout/stderr already captured in `DockerProbeEvidence` instead of adding a duplicate writable buffer; this preserves failure-only transcript behavior with less stream state. F-14 uses successful version evidence or a non-`ENOENT` info spawn instead of the plan's literal classification-based fallback, because the literal expression was equivalent to the old value in that branch and did not remove the identified coupling. A focused F-02 test fixture was corrected before its work-item commit after it initially returned the post-failure result during preflight. No existing commit was amended, reordered, or rewritten. + +The operator-approved validation cadence used focused tests and checks per work item, broader checks for cross-layer changes, and one definitive final repository pass. The final sequence passed in order: localization generation, repository-wide Prettier, repository-wide ESLint, all 199 Jest suites (3,278 tests and 4 snapshots), and the root TypeScript build. ESLint emitted only the existing flat-config migration warning for `webpack.config.views.js`. F-06, F-10, F-11, and F-15 remain follow-up issues as planned; F-16 remains no-squash with no history rewrite. diff --git a/docs/ai-and-plans/local-quickstart/local-quickstart-v2.md b/docs/ai-and-plans/local-quickstart/local-quickstart-v2.md new file mode 100644 index 000000000..4b9936397 --- /dev/null +++ b/docs/ai-and-plans/local-quickstart/local-quickstart-v2.md @@ -0,0 +1,908 @@ +# Local Quick Start — Revised Design (Iteration 2) + +> **Supersedes:** [Iteration 1](./local-quickstart.md) — kept as reference +> for original rationale and edge-case analysis. +> +> **What changed:** Simplified tree architecture, removed dedicated local +> connection subtree, moved container creation to a webview, unified TLS +> exception handling into the regular connection wizard. +> +> **Scope:** UX design and architecture. Not an implementation plan. +> +> **Docker readiness update:** The provider-neutral behavior in +> [docker-readiness-implementation-plan.md](./docker-readiness-implementation-plan.md) +> supersedes the Docker Desktop-only examples in this historical design. Docker +> Engine and Docker Desktop are supported providers; the prerequisite is a Docker +> CLI that can reach a Linux-container daemon from the extension host. + +--- + +## 1. One-sentence goal + +From an empty machine-with-Docker to an open local DocumentDB connection, +without leaving VS Code. + +--- + +## 2. Key decisions (what changed from iteration 1) + +| Decision | Iteration 1 | Iteration 2 | +| ---------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| Tree root | `DocumentDB Local` with `Quick Start` + `Manual connections` groups | `DocumentDB Local - Quick Start` — container management only | +| Quick Start connection | Separate connection entry in the tree | Cluster is inline — expand the Quick Start node to browse databases | +| User-created localhost connections | Live inside the local subtree | Live alongside regular clusters in the Connections view | +| Emulator templates | Kept ("MongoDB Emulator RU", "DocumentDB Local", "Custom") | Dropped. Regular new-connection wizard only. | +| TLS exception | Gated to emulator wizard | Gated step in regular new-connection wizard (localhost + private IPs + local domains) | +| Container creation UI | Notification-based interstitial | Webview (tRPC router, card-based, same design language as query insights tab) | +| Legacy migration | Not addressed | First launch: existing emulator connections → `Local Connections (Legacy)` folder | +| Container runtime | Docker-specific | Docker-first (v1). OCI/podman follow-up issues tracked separately. | + +--- + +## 3. Tree shape + +### 3.1 Before Quick Start (first activation) + +``` +v Connections + > my-cloud-cluster + > another-cluster + v DocumentDB Local - Quick Start [rocket] + o Quick Start — Install & try DocumentDB locally + o Learn more... +``` + +### 3.2 After a successful Quick Start + +``` +v Connections + > my-cloud-cluster + > another-cluster + > my-manual-localhost (user added this themselves) + v Local Connections (Legacy) (one-time migration, if any existed) + > old-emulator-conn + v DocumentDB Local - Quick Start [rocket] + v DocumentDB Local Running · localhost:10260 + v admin + > mydb +``` + +The managed cluster is **inline** — expanding the Quick Start node is how +users browse databases and collections. No separate connection entry. + +### 3.3 Node glossary + +| Node | Label | Description | Icon | +| ------------------ | ------------------------------------------------ | ------------------------- | -------------------------- | +| Section header | `DocumentDB Local - Quick Start` | n/a | DocumentDB icon | +| Managed instance | `DocumentDB Local` | ` · :` | Colored state dot (see §6) | +| Empty-state action | `Quick Start — Install & try DocumentDB locally` | n/a | Rocket | +| Empty-state link | `Learn more...` | n/a | Link icon | + +The rocket `[rocket]` icon on the section header is the primary entry +point. Hidden once a managed instance exists (v1 is single-instance). + +--- + +## 4. Legacy migration + +On first activation after the update: + +1. Read all connections stored under `ConnectionType.Emulators`. +2. Create a folder named `Local Connections (Legacy)` in the regular + Connections tree. If that name already exists, use the existing + duplicate-suffix logic (e.g., `Local Connections (Legacy) (2)`). +3. Move each emulator connection into that folder as a regular cluster. +4. Preserve credentials, auth config, and `emulatorConfiguration` on + each moved connection. +5. Keep the old `ConnectionType.Emulators` storage zone for one release as + a deprecated, read-only rollback path; remove it in a follow-up release. + Do **not** delete it in the same release that performs the migration, so + a migration bug can never orphan a user's existing local connections. +6. Show a one-time toast: "Your local connections have been moved to + 'Local Connections (Legacy)' in the Connections view." + +The `LocalEmulatorsItem` tree node and the `New Local Connection...` +wizard entry point are removed. + +--- + +## 5. Container creation webview + +When the user clicks **Quick Start**, a webview opens. The webview uses the +existing tRPC/webview infrastructure (router + React + FluentUI). Design +language follows the query insights tab: **card-based layout** with +responsive columns, metric cards, and clear action buttons. + +### 5.1 Webview: Review & Start (happy path — Docker ready) + +``` ++======================================================================+ +| DocumentDB Local - Quick Start [x] | ++======================================================================+ +| | +| ┌────────────────────────────────────────────────────────────────┐ | +| │ 🚀 Start DocumentDB Local │ | +| | +| │ • Start the Docker service or identified provider │ | +| │ • Check the active Docker context or endpoint │ | +| │ │ | +| │ [ Start Docker (when available) ] [ Troubleshooting ] │ | +| │ Docker │ │ Port │ │ Data │ │ Security │ | +| │ ✅ Ready │ │ 10260 │ │ Persistent │ │ TLS local │ | +| │ │ │ │ │ volume │ │ self-sign │ | +| └──────────────┘ └──────────────┘ └──────────────┘ └────────────┘ | +| | +| ┌────────────────────────────────────────────────────────────────┐ | +| │ What we'll do │ | +| │ │ | +| │ Image ghcr.io/documentdb/...:latest │ | +| │ Runs on This machine │ | +| │ Credentials Auto-generated, stored securely │ | +| │ Lifetime Keeps running after VS Code closes │ | +| └────────────────────────────────────────────────────────────────┘ | +| | +| ▸ Advanced | +| | +| [ Start DocumentDB Local ] [ Cancel ] | +| | ++======================================================================+ +``` + +The four **metric cards** at the top (Docker / Port / Data / Security) +follow the same responsive grid pattern as the query insights metrics row: +1 column on narrow views, 2 on medium, 4 on wide. + +The **"What we'll do"** summary is a card with a two-column data grid +(same as the query insights SummaryCard). + +### 5.2 Webview: Advanced panel (expanded) + +``` +| v Advanced | +| ┌────────────────────────────────────────────────────────────────┐ | +| │ Container name [ vscode-documentdb-local ] │ | +| │ Port [ 10260 ] │ | +| │ Data volume Persistent local volume │ | +| │ Credentials (•) Generate strong password │ | +| │ ( ) Use these: │ | +| │ user [ admin ] │ | +| │ pass [ .......................... ] │ | +| │ Image tag [ latest ] │ | +| │ Seed sample data [ ] Load sample documents on start │ | +| └────────────────────────────────────────────────────────────────┘ | +``` + +### 5.3 Webview: Docker not ready + +When a blocking check fails, the metric cards turn into a diagnosis view +instead of the start flow: + +``` ++======================================================================+ +| DocumentDB Local - Quick Start [x] | ++======================================================================+ +| | +| ┌────────────────────────────────────────────────────────────────┐ | +| │ ⚠️ Docker is required │ | +| │ │ | +| │ Local Quick Start needs Docker to run DocumentDB on your │ | +| │ machine. The extension does not install Docker for you. │ | +| └────────────────────────────────────────────────────────────────┘ | +| | +| ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ | +| │ Docker CLI │ │ Docker │ │ Registry │ │ Platform │ | +| │ ✅ Found │ │ daemon │ │ ⚠️ Not │ │ ✅ amd64 │ | +| │ v1.27.0 │ │ ❌ Stopped │ │ reached │ │ │ | +| └──────────────┘ └──────────────┘ └──────────────┘ └────────────┘ | +| | +| ┌────────────────────────────────────────────────────────────────┐ | +| │ How to fix │ | +| │ │ | +| │ • Start Docker Desktop and sign in │ | +| │ • Check your corporate proxy settings │ | +| │ • Test reachability: ghcr.io │ | +| │ │ | +| │ [ Start Docker Desktop ] [ Troubleshooting ] │ | +| └────────────────────────────────────────────────────────────────┘ | +| | +| [ Retry ] [ Cancel ] | +| | ++======================================================================+ +``` + +### 5.4 Webview: Progress + +After the user clicks **Start DocumentDB Local**, the webview transitions +to a progress view. The user can keep working — the webview is not modal +(yet — modal webview API is expected from VS Code; once available we +switch). + +The heavy container work (pull / create / start) runs as **VS Code +terminal tasks**, so the raw `docker` commands and their streaming output +are visible in the integrated terminal. This mirrors the PostgreSQL +"Local Docker Server" reference (see §16): the webview shows friendly step +status while the terminal provides full command transparency. The +webview's **View Docker output** expander surfaces the same stream inline +for users who prefer not to switch to the terminal. + +**v1.0 keeps the webview side minimal** — matching the PostgreSQL reference, +which shows _no_ in-webview progress at all. While the container work runs, +the **Start** button is disabled and shows a spinner, and a failure renders +as a single inline error message with a **Retry** (the terminal carries the +detail). The staged progress list, per-stage percentages, and per-step +inline expansion shown below are the **v1.2** enriched view (§15); the +diagram illustrates that target, not the v1.0 surface. + +``` ++======================================================================+ +| DocumentDB Local - Quick Start [x] | ++======================================================================+ +| | +| ┌────────────────────────────────────────────────────────────────┐ | +| │ Setting up DocumentDB Local... 00:18 │ | +| └────────────────────────────────────────────────────────────────┘ | +| | +| ┌────────────────────────────────────────────────────────────────┐ | +| │ [✅] Checking Docker │ | +| │ [✅] Reserving port 10260 │ | +| │ [🔄] Pulling official image 42% │ | +| │ [ ] Creating container │ | +| │ [ ] Starting container │ | +| │ [ ] Waiting for DocumentDB to accept connections │ | +| └────────────────────────────────────────────────────────────────┘ | +| | +| ┌────────────────────────────────────────────────────────────────┐ | +| │ ▸ View Docker output │ | +| └────────────────────────────────────────────────────────────────┘ | +| | +| [ Cancel ] | +| | ++======================================================================+ +``` + +On failure (v1.2 enriched view), the failed step expands with guidance; in +v1.0 the same guidance is a single inline error message with **Retry** (the +terminal carries the detail). When `docker run` fails, distinguish the cause +via `docker inspect` — if the container exists it is a **start** failure, +otherwise a **create** failure — and word the message accordingly (matching +the PostgreSQL reference): + +``` +| │ [✅] Checking Docker │ | +| │ [✅] Reserving port 10260 │ | +| │ [❌] Pulling official image Failed │ | +| │ We couldn't pull the image from ghcr.io. │ | +| │ Check your network connection or proxy settings. │ | +| │ │ | +| │ [ Retry ] [ Troubleshooting ] │ | +| │ [ ] Creating container │ | +| │ [ ] Starting container │ | +| │ [ ] Waiting for DocumentDB to accept connections │ | +``` + +### 5.5 Webview: Success + +``` ++======================================================================+ +| DocumentDB Local - Quick Start [x] | ++======================================================================+ +| | +| ┌────────────────────────────────────────────────────────────────┐ | +| │ ✅ DocumentDB Local is running on localhost:10260 │ | +| └────────────────────────────────────────────────────────────────┘ | +| | +| ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ | +| │ Status │ │ Endpoint │ │ Image │ │ Data │ | +| │ ✅ Running │ │ localhost │ │ v1.2.3 │ │ Persisted │ | +| │ │ │ :10260 │ │ (latest) │ │ │ | +| └──────────────┘ └──────────────┘ └──────────────┘ └────────────┘ | +| | +| ┌────────────────────────────────────────────────────────────────┐ | +| │ [ Open Connection ] [ Copy Connection String ] │ | +| │ [ Load Sample Data ] [ View Logs ] │ | +| └────────────────────────────────────────────────────────────────┘ | +| | ++======================================================================+ +``` + +On readiness success the webview **auto-closes** and hands off to the +tree, which becomes the persistent control surface (this matches the +prototype: "progress in the terminal, and the webview closes +automatically"). The success card above is shown only briefly. +**Open Connection** expands the managed cluster in the tree (user browses +databases/collections from the Quick Start subtree). + +**Load Sample Data** is rendered only when a seed dataset is available at +ship time (see §8.4); otherwise it appears disabled with a "Coming soon" +tooltip, since it is a v1.2 item (§15). + +### 5.6 Cancel rules + +| Cancelled during | Rollback behavior | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Pull | Abort pull, no container created | +| Create | Remove partially created container | +| Start (first run) | Stop and remove container, release port | +| Waiting for readiness | Stop and remove container, surface last connection error | +| Starting (existing row) | Non-destructive: let the in-flight `docker start` finish and leave the instance Running. Cancel only detaches the UI from the spinner. | +| Stopping (existing row) | Non-destructive: let the in-flight `docker stop` finish; the instance lands in Stopped. Cancel never aborts the stop. | + +The first four rows are the **Provisioning** path, where destructive +rollback is safe because the instance is not yet established. The last two +are the lifecycle **Starting** / **Stopping** transitions of an +already-provisioned instance: there, Cancel is non-destructive — it +detaches the UI from a transition Docker will complete anyway, and never +deletes the container or its data. + +Generated credentials are kept in SecretStorage so a retry reuses them. + +--- + +## 6. Lifecycle states + +``` + NotInstalled ──(Quick Start)──▸ Provisioning ──(success)──▸ Running + │ │ ▲ + │ failure │ │ + ▼ ▼ │ + Error ◂── error ──── Stopping │ + ▲ │ │ + │ ▼ │ + Starting ◂──(start)── Stopped │ + │ │ + └────────(success)───────────┘ +``` + +### 6.1 State presentation + +| State | Icon | Color | Tree description | +| ------------ | ---------------- | ------ | ----------------------------------- | +| NotInstalled | n/a | n/a | (no row — empty state) | +| Provisioning | `loading~spin` | yellow | `Provisioning... · localhost:10260` | +| Starting | `loading~spin` | yellow | `Starting... · localhost:10260` | +| Running | `circle-filled` | green | `Running · localhost:10260` | +| Stopping | `loading~spin` | yellow | `Stopping... · localhost:10260` | +| Stopped | `circle-outline` | gray | `Stopped · localhost:10260` | +| Error | `warning` | red | `Error · click for details` | + +Badges (overlay any state): + +- **`Missing`** — extension has metadata but Docker has no matching + container. Shows `Missing · click to recreate`. Available actions on a + `Missing` instance: **Quick Start** (recreate the container, reusing the + stored credentials and data volume if present) and + **Delete Container...** (clear the stale metadata). No other lifecycle + actions apply. +- **`UpdateAvailable`** _(v1.2)_ — newer image detected. Shows + `Running · localhost:10260 · update available`. + +### 6.2 Action matrix + +| Action | NotInstalled | Provisioning | Running | Stopping | Stopped | Starting | Error | +| ---------------------- | :----------: | :----------: | :-----: | :------: | :-----: | :------: | :---: | +| Quick Start | v | | | | | | | +| Open Connection | | | v | | | | | +| Start | | | | | v | | v | +| Stop | | | v | | | | | +| Cancel | | v | | v | | v | | +| Restart | | | v | | | | v | +| View Logs | | v | v | v | v | v | v | +| Copy Connection String | | | v | | v | | | +| Copy Password | | | v | | v | | | +| Delete Container... | | | | | v | | v | + +Inline icon positions are fixed so buttons don't shift: + +``` +Position 1: primary [open] when Running, blank otherwise +Position 2: power [start] or [stop] or [cancel] +Position 3: overflow [...] +``` + +--- + +## 7. TLS exception in the regular connection wizard + +The emulator-specific `New Local Connection...` wizard is removed. TLS +exception handling moves to the **regular new connection wizard** as a +conditional step. + +### 7.1 Gating rules + +The "Allow invalid TLS certificates" step appears **only** when the +parsed host from the connection string matches any of: + +- Loopback: `localhost`, `127.0.0.0/8`, `::1`, `*.localhost` +- IPv4 private ranges (RFC 1918): + - `10.0.0.0/8` + - `172.16.0.0/12` + - `192.168.0.0/16` +- IPv4 link-local: `169.254.0.0/16` +- IPv6 unique-local and link-local: `fc00::/7`, `fe80::/10` +- Single-word hostnames (no dots): `home`, `devbox`, etc. +- `*.local` mDNS names + +All other hosts: no TLS exception step shown. + +> **Caveat — `.local` and single-word hosts can be corporate infra.** +> Many corporate Active Directory domains use a `.local` suffix (e.g. +> `db.corp.local`), and single-word names can resolve to real internal +> servers via DNS search domains. These are **not** the "self-signed is +> expected" case. The gate therefore only decides whether to **offer** the +> step; the step itself always **defaults to Enable TLS** (§7.2), and its +> copy warns when the host may be a managed internal host rather than the +> developer's own machine. + +### 7.2 Wizard step + +When the gate matches, a new step appears after the connection string / +host prompt: + +``` ++---- TLS Certificate Validation ----+ +| | +| This connection targets a local | +| or private network host. | +| | +| (•) Enable TLS (default) | +| ( ) Allow invalid certificates | +| (self-signed / untrusted CA) | +| | +| [Back] [Continue] | ++-------------------------------------+ +``` + +### 7.3 Future: connection edit dialog + +For hosts outside the gate, the TLS exception will be configurable via a +**connection edit / advanced settings dialog** (to be built as part of this +project — tracked as a separate issue). + +--- + +## 8. Defaults + +| Setting | Default | Notes | +| --------------- | -------------------------------- | ---------------------------------------------------------------- | +| Connection name | `DocumentDB Local` | Readable tree label | +| Container alias | `vscode-documentdb-local` | Visible in `docker ps`, in tooltip | +| Port | `10260` | Canonical port for both Quick Start and manual connections | +| Credentials | Auto-generated username/password | Stored in SecretStorage, passed via `--env-file` (not CLI flags) | +| Data volume | `vscode-documentdb-local-data` | Persistent; survives stop/restart/update | +| Image | Official DocumentDB local image | `ghcr.io/documentdb/...` | +| TLS | Self-signed local certificate | `tlsAllowInvalidCertificates=true` | + +### 8.1 Password generation + +Generated passwords must be safe to embed in a connection string. Apply +**both** defenses (belt-and-suspenders), never just one: + +1. **Generate from a curated safe alphabet** — `[A-Za-z0-9]` plus a small + set of unambiguous, URL-safe symbols. Never emit characters that have + meaning in a URI (`@ : / ? # [ ] %`). +2. **Percent-encode at composition time** — always `encodeURIComponent` + the username and password when building the connection string, even + though step 1 should make this a no-op. Relying on the alphabet alone is + fragile: a future change to the generator, or a user-supplied password + from the Advanced panel, can reintroduce unsafe characters. + +The same encoding rule applies to any user-entered credentials in the +Advanced panel and to the migrated legacy connections (§4). + +### 8.2 Credential transport + +Credentials are passed to the container via a temporary `--env-file` +(written to `os.tmpdir()`, deleted in a `finally` block). This keeps +passwords out of `ps -ef`, shell history, and process audit logs. + +Note: the password is still visible inside the container runtime +environment (`docker inspect`, `docker exec env`) to anyone with Docker +access on the host. This matches the trust boundary the user accepts by +running Docker locally. + +### 8.3 Port fallback + +Fallback applies **only to the default port** (one the user did not choose): + +1. Try `10260`. +2. If busy, try up to 10 random ports in `[10260, 10360)`. +3. If still no free port, show `Change port...` dialog. + +When a fallback is used, the webview shows a yellow banner: + +``` +⚠ Port 10260 is in use. We'll use port 10273 instead. + [ Change port... ] [ Use 10273 ] +``` + +**Explicit ports are never silently relocated.** If the user sets a port in +Advanced and it is busy, surface an error and let them change it — do not +move them to a different port behind their back. (This matches the +PostgreSQL reference, which only auto-allocates when the port field is left +blank or invalid.) + +**Use the actually-bound port, not the requested one.** After the container +starts, read the bound host port back from `docker inspect` +(`NetworkSettings.Ports`) and use _that_ value when composing and saving the +connection string. The requested and bound ports can differ (a race between +the free-port check and the bind), so the inspected value is the source of +truth. + +### 8.4 Container initialization and seed data + +Initialization uses the container image's **standard init-script +convention**, not a bespoke VS Code mechanism. This keeps the behavior +portable (it works identically when the user runs the image by hand) and +testable outside the extension. + +- On create, Quick Start mounts a host directory into the image's + documented init directory. Scripts placed there run once, the first time + the data volume is initialized. +- **Seed sample data** (the Advanced toggle and the success-card button) + simply drops a known, bundled init script into that directory before the + first start. It is therefore the same mechanism as user init scripts, not + a special path. +- **Init-script development.** The Advanced panel lets the user point at a + local scripts folder, which is mounted into the init directory. Editing a + script and resetting the data volume re-runs it, so users can iterate on + their own initialization without leaving VS Code. + +Because init scripts run only on first volume initialization, re-running +them requires a **Reset** (drops the data volume, §11), never a plain +restart. Seed and init scripts must never embed the generated password; +they receive credentials through the same `--env-file` the container uses +(§8.2). + +--- + +## 9. Prereq checks + +Run before showing the Review & Start webview. Results populate the +metric cards. + +| Check | Scope | Pass | Fail | +| ------------------------ | ----- | ------------------------ | --------------------------- | +| Docker CLI on PATH | v1.0 | ✅ Found (version shown) | ❌ "Install Docker" link | +| Docker daemon reachable | v1.0 | ✅ Ready | ❌ Provider-aware recovery | +| Port available | v1.0 | ✅ Free | ⚠️ Fallback port (see §8.3) | +| Platform supported | v1.0 | ✅ amd64/arm64 | ⚠️ "Use x86_64 emulation?" | +| Image registry reachable | v1.2 | ✅ OK | ⚠️ "Check proxy settings" | + +v1.0 ships the same minimal readiness the PostgreSQL reference ships (CLI +present + daemon reachable + a generic troubleshooting link, §15) plus two +cheap local checks — port-free and platform. The categorized +registry/proxy/Apple-Silicon diagnosis is v1.2. + +Platform check should detect unsupported CPU architectures per +[Azure emulator Docker issue #254](https://github.com/Azure/azure-cosmos-db-emulator-docker/issues/254#issuecomment-4515601488). + +### 9.1 Readiness contract + +Quick Start declares success only when the database accepts connections, +not when the container starts. Readiness is probed by issuing a +`hello`/`ping` command over the wire protocol against +`localhost:`. Timeout: 60 seconds (fixed in v1, setting later). + +On timeout: failure toast with `Wait longer`, `Logs`, `Reset`. + +--- + +## 10. Container recognition and adoption + +### 10.1 Labels + +Quick Start applies these Docker labels at container creation: + +- `vscode.documentdb.quickstart=1` +- `vscode.documentdb.alias=` + +These are the **only** way a container is recognized as a Quick Start +instance. Name, image, or port alone are never sufficient. + +### 10.2 Existing container conflict + +When a container with the planned name already exists: + +Before any image pull or container creation, Quick Start validates **both** +identifiers up front (matching the PostgreSQL reference, which refuses on a +duplicate of either): the **connection/cluster name** in the Connections +view and the **container name** in Docker. A duplicate connection name is +rejected with an inline error before any Docker work; a container-name +collision is resolved as follows. + +**Recognized** (has Quick Start labels): + +``` ++--- Existing Quick Start container found ---+ +| | +| Container vscode-documentdb-local | +| Status Exited 12 days ago | +| | +| (•) Adopt as managed instance | +| ( ) Reset and recreate | +| ( ) Cancel | +| | +| [ Continue ] [ Cancel ] | ++--------------------------------------------+ +``` + +**Unrecognized** (no labels): + +``` ++--- Container name already in use ---+ +| | +| Another container is using the | +| name 'vscode-documentdb-local'. | +| | +| (•) Create a manual connection | +| ( ) Reset and recreate | +| ( ) Cancel | +| | +| [ Continue ] [ Cancel ] | ++-------------------------------------+ +``` + +--- + +## 11. Lifecycle vocabulary + +| Verb | Container | Data volume | Credentials | Tree row | +| ---------------------------- | --------------- | ----------- | ----------- | ------------------------ | +| **Start** | Starts existing | Unchanged | Unchanged | → Running | +| **Stop** | Stops | Unchanged | Unchanged | → Stopped | +| **Restart** | Stop + start | Unchanged | Unchanged | → Running | +| **Delete Container...** | Removed | Kept | Kept | → NotInstalled (Missing) | +| **Update Image...** _(v1.2)_ | Recreated | Kept | Kept | → Running | +| **Move Port...** _(v1.2)_ | Recreated | Kept | Kept | → Running | +| **Reset...** _(v1.2)_ | Removed | **Dropped** | **Dropped** | → NotInstalled | + +Confirmations: + +- Stop / Restart: none (reversible) +- Delete Container: one-line confirm +- Reset: two-step confirm, user must type container alias + +--- + +## 12. Multi-window coordination + +The container is shared machine state. No window "owns" it. + +- v1: **polling only** (on activation, on view refresh, on overflow-menu + open). Docker event subscription deferred to v1.2. +- Destructive actions re-check live state before executing. +- If state changed, show: "The instance is now Stopping (from another + window). Action is no longer available." + +--- + +## 13. Cross-cutting rules + +1. **Opt-in only.** Never install Docker, never start Docker silently, + never modify containers the extension didn't create. +2. **Explicit Docker start.** If Docker is stopped, offer a user-clicked + provider action only when the extension has positive launch evidence. Never + start a root-managed service or use elevation. +3. **No background pulls.** Image pulled only inside user-initiated flows. +4. **No required form fields** in the happy path. +5. **Canonical port `10260`** for both Quick Start and manual connections. + The manual wizard's hardcoded `10255` (in `PromptConnectionTypeStep.ts` + and `PromptPortStep.ts`) must be fixed before Quick Start ships. +6. **No nag toasts.** Updates and warnings stay in the tree description. +7. **Uninstalling the extension does not remove the container.** +8. **Docker-first, OCI later.** v1 targets Docker. Podman/OCI follow-up + issues are tracked separately. The container runtime is accessed through + **`@microsoft/vscode-container-client`** — the Microsoft-maintained library + the PostgreSQL extension uses, which ships both a `DockerClient` and a + `PodmanClient` behind one interface (with mount/label/platform/port arg + helpers). Adding podman/OCI later is therefore a client swap, not a + rewrite, and we avoid hand-rolling `docker` CLI strings. +9. **No dependency on the Docker VS Code extension.** Quick Start manages + the container itself, in-tree. The Docker extension is optional and + aimed at advanced users; a hard dependency would break the zero-friction + goal and contradicts going beyond Docker. (Reviewer request to evaluate + reuse — decided out of scope.) +10. **Attach stays first-class.** Any locally reachable container — + including a retained test container — can be connected to through the + regular new-connection wizard at its `localhost:`; Quick Start + does not need to own it. Auto-discovery of unmanaged DocumentDB + containers is a v1.2 item. + +--- + +## 14. Telemetry + +``` +quickstart.review_shown source=tree|menu|command|welcome +quickstart.docker_readiness result=ok|cli_missing|daemon_stopped|... +quickstart.start_begin source=... +quickstart.start_stage stage=pull|create|start|connect + duration_ms, success=bool +quickstart.start_end result=success|cancelled|failed + elapsed_ms, port_fallback=bool + image_resolved_version=semver|unknown +quickstart.lifecycle action=start|stop|restart|delete + duration_ms, success=bool +quickstart.error stage=..., reason=... +quickstart.dismiss_welcome from=welcome_view|empty_state +quickstart.report_issue stage=..., from=error_state|readiness_timeout +``` + +Never send: container names, raw image tags, registry URLs, hostnames, +ports, credentials, or image digests. The **resolved semantic version** +(`image_resolved_version`, e.g. `1.2.3`) is the one allowed image +identifier — it is needed to correlate "version X has a bug" reports and is +not user-identifying, unlike a raw tag string or digest. + +--- + +## 15. Scope split + +### v1.0 (must ship) + +- Quick Start webview (review, success, Docker diagnosis); create progress + is terminal-first (spinner + inline error), not an in-webview step list +- Pull / create / start / readiness / connect / reveal +- Tree node with inline cluster (expand to browse) +- Seven lifecycle states + `Missing` badge +- v1.0 actions: Open, Start, Stop, Restart, View Logs, Copy Connection + String, Copy Password, Delete Container +- Single managed instance (rocket hidden after setup) +- Port fallback with random port band +- Credentials via `--env-file` +- Docker labels for recognition +- Legacy migration of existing emulator connections +- TLS exception in regular connection wizard (gated) +- Polling-only multi-window coordination +- Docker readiness: CLI present + daemon reachable + port-free + platform + supported, plus a generic troubleshooting link (categorized + registry/proxy/Apple-Silicon diagnosis is v1.2; see §9) +- Connection edit dialog (needed for TLS exception on non-gated hosts) +- Container initialization via the image's init-script convention (§8.4) + +### v1.1 (prefer to ship) + +- Goal: ship v1.1 with meaningful but lightweight webview progress + visibility +- Lightweight in-webview stage progress notification while create/start is + running (for example: current stage + completed stages + failure stage), + without full per-stage percentages or per-step inline retry controls +- Keep terminal-first transparency: integrated terminal remains the source + of detailed Docker command output +- Maintain v1.0 constraints for simplicity: no `docker pull` percentage + streaming into the webview + +### v1.2 (deferred) + +- Adopt-existing-container flow +- Update Image with version/digest diff +- Move to a different port +- Reset (drop data + credentials) +- In-webview staged progress card (per-stage percentages + per-step inline + retry); v1.1 ships lightweight stage notification and v1.0 remains + terminal-first (§5.4) +- Categorized Docker readiness (Apple Silicon, WSL2, sudo group, + proxy, Windows engine, etc.) +- Docker event subscription (replaces polling) +- Remote VS Code banner (SSH / WSL / dev container) +- Load Sample Data dataset (if not bundled in v1.0) +- Multiple managed instances (and multiple image versions side by side) +- Auto-discovery of unmanaged / retained test containers (§13 rule 10) +- Report Issue action on Error / readiness timeout — pre-filled GitHub + issue with sanitized diagnostics (no creds, hostnames, or ports per §14) +- OCI/podman support +- View Logs + tracing integration + +--- + +## 16. Webview implementation notes + +The Quick Start webview follows existing extension patterns: + +- **tRPC router** — same pattern as CollectionView/DocumentView. + Procedures: `getDockerStatus`, `startQuickStart`, `cancelQuickStart`, + `getInstanceState`, etc. +- **React + FluentUI v9** — card-based layout using the same component + vocabulary as the query insights tab (`Card`, `Text`, `Badge`, + `Button`, responsive grid with CSS grid/flexbox). +- **Design reference**: the query insights tab's `metricsRow` (4 metric + cards in responsive grid), `SummaryCard` (2-column data grid), and + `AnimatedCardList` (step transitions) are the closest starting points. +- **Modal webview** — the VS Code API is expected to support modal + webviews in the future. Once available, the Quick Start webview should + use it. Until then, it opens as a regular editor tab. +- **Flow reference: the PostgreSQL "Local Docker Server" webview.** Its + shipped flow is the closest working model for ours: + - A Docker-branded header and a benefits panel (One-Click creation, + Fully automated setup, Easy management, Code without distractions) + frame the welcome and form steps. + - Prereq checks render as a vertical list of expandable status cards + ("Checking if Docker is installed", "Checking if Docker is running"), + each backed by a real `docker` command. + - The actual `docker` commands execute as **VS Code terminal tasks**, so + their output is visible in the integrated terminal (full transparency) + while the webview shows friendly step status. + - When the server is up, the **webview closes automatically** and the + tree takes over as the control surface. + + Our happy path diverges in one way (§13 rule 4): PostgreSQL's form has + several required fields; ours has **none** — credentials and names are + generated, and everything optional lives under Advanced. + +- **Container runtime client** — use **`@microsoft/vscode-container-client`** + (§13.8) instead of hand-rolling `docker` commands, and run each operation + as a **VS Code terminal task** so the raw commands and output stay visible + in the integrated terminal (the PostgreSQL-proven model). +- **v1.0 progress is terminal-first** — the webview shows a spinner + inline + error (with **Retry**); lightweight stage progress notification is the + v1.1 target, while the staged in-webview progress card (§5.4) remains a + v1.2 enrichment, avoiding `docker pull` percentage streaming into the + webview for v1.0/v1.1. + +--- + +## 17. Open questions + +1. **Persistent volume naming.** Should the user-chosen alias in Advanced + be reflected in the volume name? Pro: discoverable in `docker volume ls`. + Con: renaming breaks the link. **Leaning:** keep a stable, alias-derived + name fixed at creation and never rename it on a later alias change, so + the container↔volume link cannot break. +2. **Self-signed cert trust.** Long term, the image's local CA could be + auto-trusted in Node's trust store. Out of scope for v1. +3. **Welcome card scope.** Show only when `DocumentDB Local - Quick Start` + is empty, or when the entire Connections view is empty? **Leaning:** + scope to the empty Quick Start section, and store the dismissal in a + user **Setting** (e.g. `documentdb.quickStart.welcomeDismissed`) rather + than `globalState`, which is wiped on uninstall/reinstall. + +--- + +## 18. Review resolutions (iteration 2) + +This revision folds in the PR review feedback (notably @xgerman's +comments), the design intent shown in @tnaum-ms's prototype screenshots, +and the gaps surfaced in design review. Each row links a comment to where +it is resolved. + +| Source / finding | Resolution | Section | +| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | +| OCI / podman / Apple containers (xgerman) | Docker-first for v1; runtime behind a thin abstraction so an OCI driver is a later swap; follow-up issues tracked | §2, §13.8 | +| Platform / CPU not supported (xgerman, Azure emulator #254) | Platform prereq check with the unsupported-CPU reference | §9 | +| URL-encode generated passwords (xgerman) | Belt-and-suspenders: safe alphabet **and** percent-encode at composition; applies to Advanced + migrated creds | §8.1 | +| Check a custom (Advanced) port is free (xgerman) | "Port available" prereq check + fallback band | §8.3, §9 | +| Reuse the Docker VS Code extension? (xgerman) | Out of scope — no hard dependency; in-tree management only | §13.9 | +| View logs + tracing (xgerman) | Deferred to v1.2 | §15 | +| Connect to a retained test container (xgerman) | Attach via the regular wizard at `localhost:`; auto-discovery is v1.2 | §13.10, §15 | +| Manage multiple containers / versions / other DBs (xgerman) | Single instance in v1; labels keep the model forward-compatible; multi-instance + multi-version are v1.2 | §10.1, §15 | +| Container initialization & init-script dev (xgerman) | Use the image's standard init-script convention; Seed = a bundled init script; Advanced can mount a local scripts folder | §8.4 | +| New image available — notify or auto-update? (xgerman) | Notify only via the `UpdateAvailable` badge; never auto-update (no-surprises rule) | §6.1, §11 | +| Help file a DocumentDB issue on failure (xgerman) | Report Issue action (pre-filled, sanitized) on Error / readiness timeout — v1.2 | §14, §15 | +| Progress location / webview lifetime (screenshots, tnaum-ms) | Heavy work runs as terminal tasks (transparency); webview auto-closes on success; tree takes over | §5.4, §5.5, §16 | +| TLS gate over-matched local/private hosts (gap) | Tightened ranges (added IPv6 ULA/link-local, IPv4 link-local, loopback block); `.local`/single-word only _offer_ the step and default to Enable TLS | §7.1, §7.2 | +| Cancel undefined for Starting/Stopping (gap) | Defined as non-destructive for already-provisioned instances | §5.6 | +| Legacy storage deleted in the migration release (gap) | Retain the old zone read-only for one release as a rollback path | §4 | +| `Missing` badge actions undefined (gap) | Specified: Quick Start (recreate) and Delete Container (clear metadata) | §6.1 | +| `Load Sample Data` shown but is v1.2 (gap) | Rendered disabled with "Coming soon" until a dataset ships | §5.5, §15 | +| Telemetry: version-vs-tag tension (gap) | Resolved semantic version is allowed; raw tags and digests are not | §14 | + +### 18.1 PostgreSQL source-benchmark learnings + +The design was benchmarked against the PostgreSQL extension's "Local Docker +Server" flow (`ms-ossdata.vscode-pgsql`, read at the source level). Changes +folded in from that study: + +| Learning from the reference | Change | +| ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| Runs container work as terminal tasks with **no in-webview progress**; webview auto-closes on success | v1.0 create progress is terminal-first (spinner + inline error); lightweight stage notification is v1.1, and the staged card is v1.2 — §5.4, §15, §16 | +| Uses **`@microsoft/vscode-container-client`** (`DockerClient` + `PodmanClient`) | Adopt the same library instead of a hand-rolled abstraction — §13.8, §16 | +| Auto-allocates a port **only when the field is blank/invalid**; never relocates an explicit user port | Fallback applies to the default port only; explicit Advanced ports error instead of moving — §8.3 | +| Reads the bound port from `docker inspect` | Use the inspected bound port when composing the connection string — §8.3 | +| Distinguishes **failed-to-create vs failed-to-start** after a failed run | Same distinction in error copy — §5.4 | +| Checks duplicate **connection name and container name** before side effects | Validate both up front — §10.2 | +| Ships only CLI + daemon prereqs | v1.0 prereqs labeled (CLI/daemon/port/platform); registry/proxy diagnosis is v1.2 — §9, §15 | +| (Kept **better in v2**) | Zero required fields, persistent volume, 60 s wire-protocol readiness, full lifecycle, labels+adopt, stricter telemetry | + +A reviewer-facing decision note for this PR lives at +[`../PRs/653-local-quickstart-design/description.md`](../PRs/653-local-quickstart-design/description.md). diff --git a/docs/ai-and-plans/local-quickstart/local-quickstart.md b/docs/ai-and-plans/local-quickstart/local-quickstart.md new file mode 100644 index 000000000..01da1aabc --- /dev/null +++ b/docs/ai-and-plans/local-quickstart/local-quickstart.md @@ -0,0 +1,1538 @@ +# Local Quick Start — UX Design Reference (Iteration 1) + +> **⚠️ This is Iteration 1.** This document was the initial comprehensive UX +> exploration. It has been superseded by +> **[Iteration 2 — Revised Design](./local-quickstart-v2.md)**, which +> incorporates PR review feedback, simplifies the tree architecture, and +> removes the dedicated local connection subtree. Keep this document as +> reference for the original rationale and edge-case analysis. + +> **What this is:** A user-facing design specification for the proposed +> _Local Quick Start_ feature — "install and try DocumentDB locally from +> inside VS Code". ASCII flows show what the user sees at every step. +> +> **Audience:** Maintainers, reviewers, QA, technical writers. Not an +> implementation plan. +> +> **Scope:** End-to-end user experience and lifecycle. Implementation details +> (process orchestration, Docker SDK calls, container labels, retries) are +> intentionally omitted. +> +> **Related docs:** +> +> - User manual entry that will replace the current +> `docs/user-manual/local-connection-documentdb-local.md` page once shipped. +> - `docs/user-manual/local-connection.md` — manual connection wizard that +> continues to exist side-by-side. + +--- + +## 0. UX references from adjacent database extensions + +### 0.1 Azure Cosmos DB emulator flow + +The closest in-repo-adjacent reference is the Azure Cosmos DB extension's +emulator flow. It is useful mostly as a baseline to improve on: it helps +users **attach** to an emulator, but it does not install, start, stop, or +clean up the emulator for them. + +``` +Azure Cosmos DB extension today + +v Cosmos DB Accounts + v Local Emulators + o New Emulator Connection... + | + v + Select emulator type + | + v + Enter or confirm port + | + v + Save attached emulator connection + | + v + User can browse only if emulator was already installed and running +``` + +Observed UX patterns worth keeping: + +| Pattern from vscode-cosmosdb | Keep / adapt for DocumentDB Local Quick Start | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Dedicated `Local Emulators` tree section | Keep a dedicated `DocumentDB Local` section so local resources are visually separate from cloud connections. | +| "New Emulator Connection..." helper row | Keep a simple helper row, but add a stronger `Quick Start` primary action before manual attach. | +| Preconfigured vs custom connection choices | Keep manual `New Local Connection...` for users who already run their own container. | +| Port is visible and configurable | Keep port visible in the review screen and tree description; never hide fallback ports. | +| Secrets stored outside tree IDs | Keep connection strings and generated passwords out of labels, IDs, telemetry, and logs. | +| Newly attached connection is revealed in the tree | After Quick Start succeeds, expand the `Quick Start` group and focus the managed instance row. | +| Learn-more escape hatch | Keep a `Learn more...` entry, but it must not be the main path. | + +The UX gap this design closes: + +``` +Cosmos DB attach flow: + "I already installed and started the emulator. Help me connect." + +DocumentDB Local Quick Start: + "I do not have anything installed. Give me a safe local DocumentDB I can use now." +``` + +Design implication: Quick Start owns the **local lifecycle** (download image, +create container, start, connect, stop, reset), while the existing manual +connection wizard remains the attach-only path. + +### 0.2 Primary reference: PostgreSQL local Docker server flow + +The PostgreSQL extension already lets users create a local Docker PostgreSQL +server from the extension. Its flow is closer to the DocumentDB Quick Start +goal than the Cosmos DB emulator flow because it owns creation, readiness, +connection save, and reveal: + +``` +PostgreSQL extension local Docker flow + +Create local Docker PostgreSQL server + | + v +Home page: benefits of local Docker server + | + v +Prerequisite checks + [ ] Docker installed + [ ] Docker service running + | + v +Create form + required: connection name, container name, user, password, database + advanced: port, registry, image name, image version, platform + | + v +Run detached container + | + v +Wait for database readiness + | + v +Save connection, connect, reveal in Object Explorer +``` + +Observed UX patterns worth adapting: + +| Pattern from vs-code-postgresql | Keep / adapt for DocumentDB Local Quick Start | +| ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Creates the container and connection in one guided flow | Quick Start owns create -> wait -> connect -> reveal, so the user never needs to run Docker commands or paste a connection string. | +| Starts with value, not Docker mechanics | Welcome / Review copy should say "try DocumentDB locally now"; Docker details stay secondary. | +| Keeps common fields separate from advanced image / port / platform fields | The default DocumentDB path should require **zero form fields**; alias, port, image tag, credentials, platform, and sample data live under Advanced. | +| Defaults most fields before the user acts | Pre-fill alias, port, username, password, image, volume, security mode, and connection name. | +| Validates duplicate connection and container names before launch | Detect conflicts before pull/create so errors are explained before side effects. | +| Allocates a free random port when the user did **not** supply a port | DocumentDB Quick Start goes further: when the default port is busy, also pick a free random port from a small band and show the visible port-fallback banner. (PostgreSQL only allocates a random port when the input is empty/invalid — it does not auto-fall-back from a valid-but-busy port. See sec. 7.4.) | +| Waits for database readiness before connecting | Do not declare success at "container started"; success means DocumentDB accepts connections. PostgreSQL uses `pg_isready` inside the container; DocumentDB has no equivalent baked-in CLI we can rely on, so readiness is defined over the wire protocol (see sec. 4 and sec. 7.2). | +| Saves the connection and reveals it after readiness succeeds | Keep "success means opened/revealed usable connection," not merely "container exists." | + +What **not** to copy literally from PostgreSQL: + +``` +PostgreSQL has: Home page -> Prereq page -> Create form -> Create + +DocumentDB Quick Start should compress this to: + Quick Start click -> Review & Start -> Progress -> Open connection + +No required home page. +No required prerequisite page when Docker is ready. +No required form fields in the default path. +No separate "create connection" step after the container starts. +``` + +### 0.3 Secondary reference: MSSQL local container deployment flow + +The MSSQL extension reinforces a few useful failure and progress patterns, but +its multi-page wizard should **not** become the default DocumentDB Quick Start +shape. + +``` +Use from MSSQL: + - explicit Docker start action if Docker is installed but stopped + - visible provisioning steps for long-running work + - retry at the failed step + - expandable full Docker output + +Do not copy from MSSQL: + - mandatory info page + - mandatory multi-page wizard + - required version/password/profile form before the user can start +``` + +### 0.4 Design updates after comparing all three references + +The final direction is PostgreSQL-inspired, but simpler: + +``` +Cosmos DB teaches: keep local resources visually separate and preserve manual attach. +PostgreSQL teaches: create, connect, and reveal can be one easy managed flow. +MSSQL teaches: long Docker work needs optional details, retry, and full-error text. + +DocumentDB Quick Start should therefore be: + attach-compatible like Cosmos DB, + creation-capable like PostgreSQL, + but with fewer required screens than both PostgreSQL and MSSQL. +``` + +> **Scope note.** This design is a **strict superset** of the PostgreSQL Docker +> creation slice. PostgreSQL only implements create -> wait -> save -> connect -> +> reveal; everything else in this doc (the seven-state lifecycle in sec. 6, the +> categorized Docker readiness diagnosis in sec. 7.1, adopt-existing-container in +> sec. 9.1, multi-window coordination in sec. 9.3, Update Image / Move Port / +> Reset / Forget verbs in sec. 11) is genuinely new work that has no equivalent +> in the PostgreSQL extension. The v1 scope cut in sec. 17.4 makes the v1.0 / +> v1.1 split explicit. + +--- + +## 1. What the user can do + +Local Quick Start gives a developer who has never used DocumentDB a working +local instance and an open Collection View from one entry point, one +one-screen review, and one progress flow — without leaving VS Code and +without touching a terminal. + +The default path is intentionally **not a setup wizard**. Quick Start borrows +PostgreSQL's "create local Docker server and reveal the connection" outcome, +but removes the required form by generating safe defaults. + +Key capabilities: + +- **Install, run, and connect** to an official DocumentDB local container by + clicking _Quick Start_ in the Connections view. +- **Review before action.** A single one-screen interstitial summarizes what + the extension is about to do to the user's machine (image, port, data + persistence, security, lifetime) before anything is downloaded or started. +- **Zero required fields.** Container name, connection name, username, + password, port, image tag, data volume, and TLS behavior all have defaults. + The user opens Advanced only when they want to override them. +- **Manage** the resulting instance directly from the tree — start, stop, + restart, view logs, copy connection string, copy password, update the + image, delete the container, or reset everything. +- **Recover** from common breakage: existing container with the same name, + port already in use, Docker not installed or not running, missing + credentials, multi-window conflicts. +- **Coexist** with the existing _New Local Connection..._ wizard. Users who + prefer to run Docker themselves can still attach a manual local + connection; both paths show up in the same `DocumentDB Local` section + with clear "managed" vs "manual" badges. + +Prerequisite promise: + +- Quick Start installs and starts the **DocumentDB local container image**. +- Quick Start does **not** install Docker. +- If Docker is installed but stopped, Quick Start may offer an explicit + `Start Docker Desktop` / `Start Docker` action on platforms where that can + be done without privilege escalation. It never starts Docker silently. +- If Docker is missing or stopped, the user gets a readiness screen with + next actions instead of a failed mystery operation. + +Local Quick Start is **opt-in**. The extension never installs, starts, or +modifies a container without an explicit user gesture. + +--- + +## 2. Entry-point map + +``` ++------------------ DocumentDB activity bar ------------------+ +| | +| Connections view | +| | | +| v | +| v DocumentDB Local | +| | | +| +-- (empty section) | +| | | "Try DocumentDB locally" welcome card | +| | | [Quick Start] [New Local Connection...] | +| | | [Don't show again] | +| | v | +| | Empty-state child rows: | +| | o Quick Start - Install & try DocumentDB locally | +| | o New Local Connection... | +| | o Learn more... | +| | | +| +-- (populated section) | +| Inline icon on "DocumentDB Local" header: | +| [rocket] Quick Start - Install local instance | +| Right-click menu on header: | +| - Quick Start - Install & try DocumentDB ... | +| - New Local Connection... | +| - Learn more... | +| | +| Command Palette: | +| > DocumentDB: Quick Start - Install Local DocumentDB | +| | +| Walkthrough / Welcome (first activation only): | +| Card "Try DocumentDB locally" | +| [Quick Start] [Open docs] [Skip] | +| | ++-------------------------------------------------------------+ +``` + +All four entry points open the **same Review & Start interstitial** described +in section 4. They differ only in where the user enters from. + +The welcome card and the empty-state copy share a single dismissal flag — +dismissing in one place dismisses both. The flag is stored as a user +**Setting** (`documentdb.quickStart.welcomeDismissed`), not in `globalState`, +so the dismissed state survives an extension uninstall/reinstall and roams +with Settings Sync. Re-installing the extension does **not** revive the +dismissed state. + +--- + +## 3. Tree shape (before and after) + +### 3.1 Before Quick Start exists (today) + +``` +v Connections + v DocumentDB Local + > New Local Connection... +``` + +### 3.2 After Quick Start exists, before user ever ran it + +``` +v Connections + v DocumentDB Local [rocket] [+] + +-- (empty area) + o Quick Start - Install & try DocumentDB locally + o New Local Connection... + o Learn more... +``` + +Inline icons on the `DocumentDB Local` header row, left to right: + +| Icon | Action | Surfaces | +| ---------- | ----------------------- | ------------------------- | +| `[rocket]` | Quick Start | inline + right-click menu | +| `[+]` | New Local Connection... | inline + right-click menu | + +### 3.3 After a successful Quick Start + +``` +v Connections + v DocumentDB Local [rocket] [+] + v Quick Start [group header] + v DocumentDB Local Running . localhost:10260 + | description: Quick Start . official local image . v1.2.3 + | inline: [open] [stop] [...] + v admin + > _vscode_quickstart_seed (optional) + v Manual connections [group header] + > my-laptop Manual . localhost:27017 + > local-dev Manual . same target as Quick Start + > New Local Connection... [+] +``` + +Per-row glossary: + +| Node | Label | Description suffix | Icon | +| ------------------------- | ------------------------- | ----------------------------------------------- | -------------------------------------- | +| Section header | `DocumentDB Local` | n/a | DocumentDB icon | +| Group: Quick Start | `Quick Start` | n/a | `rocket` | +| Group: Manual connections | `Manual connections` | n/a | `plug` | +| Managed instance | `DocumentDB Local` | ` . : [. update available]` | colored disk state icon (see sec. 8.1) | +| Manual connection | user name | `Manual . : [. same target as ...]` | existing connection icon | +| New Local Connection... | `New Local Connection...` | n/a | `plus` | + +The tree row label is the **human connection name** (`DocumentDB Local`), +not the docker container alias. The container alias +(`vscode-documentdb-local`) and the resolved image digest live in the +tooltip (see sec. 8.2). This keeps the tree readable while still letting +the user correlate the row with `docker ps` output. + +The two groups (`Quick Start`, `Manual connections`) appear only when there +is at least one child of either kind. With no children of either kind, the +section falls back to the **empty-state child rows** in section 3.2. + +In v1 a user has **exactly one managed Quick Start instance** at any time +(see sec. 14). The `Quick Start` group exists for forward compatibility +with a future multi-instance flow but always contains a single row in v1. + +### 3.4 Duplicate-target indicator + +When a manual connection points at the same `host:port` as a managed Quick +Start instance, the manual row gets a soft description suffix: + +``` +> local-dev Manual . same target as Quick Start +``` + +The Quick Start row does **not** get a reverse marker — Quick Start is the +authoritative actor for that endpoint. + +--- + +## 4. First-time happy path + +The first time a user clicks Quick Start, they see **one confirmation +screen** and then **one compact progress surface**. There is no required +multi-page wizard in the happy path, but the user can open detailed step +cards when the pull is slow or something fails. + +PostgreSQL's local Docker creation proves the value of `create -> wait -> +connect -> reveal`; DocumentDB keeps that outcome but avoids PostgreSQL's +required create form by pre-filling every setup choice. + +``` +[Quick Start clicked anywhere] + | + v ++--------------------- Review & Start ---------------------+ +| | +| Start DocumentDB Local? | +| | +| Docker Required; start offered if stopped | +| Runs on This machine | +| Image ghcr.io/documentdb/...:latest | +| version shown after pull | +| Port 10260 | +| same default as New Local Connection | +| Data Persistent local volume | +| (kept until you choose "Reset") | +| Security TLS with self-signed local certificate | +| Credentials Auto-generated and stored securely | +| Lifetime Keeps running after VS Code closes | +| Sample data Optional after start | +| | +| [Start DocumentDB Local] [Advanced] [Cancel] | ++----------------------------------------------------------+ + | + v ++----------- Background progress notification -------------+ +| | +| Starting DocumentDB Local... | +| | +| [x] Checking Docker | +| [x] Reserving port 10260 | +| [>] Pulling official image 42% | +| [ ] Creating container | +| [ ] Starting container | +| [ ] Waiting for the database to accept connections | +| | +| [Show Details] [Cancel] | ++----------------------------------------------------------+ + | + v ++------------------ Success notification ------------------+ +| | +| DocumentDB Local is running on localhost:10260. | +| | +| [Open Connection] [Load Sample Data] | +| [Copy Connection String] [Logs] | +| | ++----------------------------------------------------------+ + | + v + Tree refreshes, Quick Start group is expanded, + focus lands on the new managed instance row. +``` + +### 4.1 Easy setup defaults + +These defaults are visible in Review & Start, but they are not prompts. They +exist so the user can start without thinking about Docker, credentials, or +connection-string shape. + +| Setup choice | Default the user sees | Why this keeps setup easy | +| --------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Connection name | `DocumentDB Local` | The tree row label is understandable immediately. The docker alias `vscode-documentdb-local` is hidden in the tooltip. | +| Container alias | `vscode-documentdb-local` | Stable name for lifecycle actions and adoption; visible in `docker ps` and in the row tooltip, not in the row label. | +| Port | `10260` | Matches DocumentDB Local documentation **and** the `documentDB.local.port` setting used by the manual wizard. Both paths must agree (sec. 13). | +| Credentials | Generated username/password | No password decision before first run. | +| Secret storage | Store generated password in SecretStorage; pass to the container via `--env-file` (temp file, deleted after run) | The password is **not on the host shell command line** (so it does not appear in `ps -ef`, shell history, or process-audit logs). It IS in the container's runtime environment and remains visible via `docker inspect` / `docker exec env` to anyone with Docker access on the host — this matches the security boundary the user already accepts by running Docker locally. Copy password is available later if needed. | +| Data volume | Persistent local volume | Data survives stop/restart/update by default. | +| Image | Official DocumentDB local image | No registry/image decision in the happy path. | +| TLS | Local self-signed setup | Works for local development without cert setup. | +| Sample data | Offered after start (may be deferred in v1; see sec. 14) | Keeps the first run fast and still discoverable. | + +**Readiness contract.** Quick Start declares success only when the database +accepts connections, not when the container starts. Readiness is probed by +issuing a `hello` (or `ping`) command over the wire protocol against +`localhost:` using the generated credentials. The v1.0 timeout is a +fixed **60 seconds** (not user-configurable); a future v1.x release may +expose it as a setting. On timeout the user sees a non-modal failure +toast offering `Wait longer`, `Logs`, and `Reset`. This is the DocumentDB +equivalent of PostgreSQL's `pg_isready` probe; we cannot rely on a +baked-in CLI inside the image. + +If the user clicks `[Open Connection]`, the standard Collection View opens. +If the database is empty, the Collection View shows a first-run callout: + +``` ++-------------------- Empty local database --------------------+ +| | +| DocumentDB Local is ready. | +| | +| Create your first database, or load sample documents to | +| try queries immediately. | +| | +| [Load Sample Data] [Create Database] [Learn More] | +| | ++-------------------------------------------------------------+ +``` + +The "first delightful query" (sample data) is **not** auto-loaded in v1, +but it is one action away from the success card and empty Collection View. + +If the user clicks `[Cancel]` from the progress notification, the extension +rolls back: the container is removed and the port reservation is released. +Generated credentials are kept in storage so a retry can reuse them or +discard them at the user's choice. + +### 4.2 The Advanced panel + +`[Advanced]` opens a single inline expansion on the Review screen — not a +new dialog — so the user never loses the original review context. + +``` ++--------------------- Review & Start ---------------------+ +| | +| ... summary unchanged ... | +| | +| v Advanced | +| Container name [vscode-documentdb-local ] | +| Port [10260 ] | +| Data volume Persistent local volume | +| Credentials (*) Generate strong password | +| ( ) Use these: | +| user [admin ] | +| pass [..........] [show] | +| Image tag [latest ] | +| Seed sample data [ ] Load sample documents on | +| first start | +| | +| [Start DocumentDB Local] [Advanced ^] [Cancel] | ++----------------------------------------------------------+ +``` + +The Advanced panel is sticky per workspace — if the user opens it once, the +next Review screen opens with it expanded. + +Ephemeral data volumes are out of scope for v1. The only v1 data mode is a +persistent local volume that survives Stop, Restart, Update Image, Move Port, +and Delete Container, and is removed only by Reset. + +### 4.3 Remote-session review banner + +When VS Code is connected to SSH, WSL, a dev container, or another remote +extension host, "local" means local to that remote context. The Review screen +adds a banner before the user starts: + +``` +! This will run DocumentDB Local on: ssh://devbox-01 + It will not run on your laptop unless Docker is available in this remote + environment. + + [Start on devbox-01] [Cancel] +``` + +The tree row continues to show the reachable endpoint from the extension's +point of view, but the tooltip includes the remote context. + +--- + +## 5. Subsequent starts (true one-click after setup) + +Once a managed instance exists (even if currently stopped), the entry +points change behavior: + +- The `[Quick Start]` rocket icon on the section header is **hidden** in + v1; with a single managed instance, the rocket would otherwise either + re-trigger the Review screen or silently restart something the user + did not click on. Start is initiated from the row (inline `[start]` + icon, right-click `Start`) or from the command palette + (`DocumentDB: Start Local DocumentDB`, `DocumentDB: Stop Local DocumentDB`). +- Right-click on the managed row offers `Start` / `Stop` / `Restart`. +- Command palette gains `DocumentDB: Start Local DocumentDB` and + `DocumentDB: Stop Local DocumentDB`. + +``` +Managed instance is "Stopped" + | + click row [start] / palette Start + | + v ++--- Background progress (compact, status-bar) ---+ +| Starting DocumentDB Local... [Cancel] | ++--------------------------------------------------+ + | + v + Tree row flips to "Running . localhost:10260". + No notification toast on routine start/stop. +``` + +The Review screen is **never re-shown** during routine start/stop. It only +re-appears when: + +1. The user explicitly creates a **new** managed instance via Command + Palette `DocumentDB: Quick Start - Install Local DocumentDB`. +2. The existing managed instance no longer exists at the Docker level (it + was removed outside the extension) and the user clicks any Start + action — the Review screen returns in "recreate" mode (see section 9.5). + +--- + +## 6. Lifecycle states and the action matrix + +### 6.1 Seven states and two badges + +Each managed instance occupies exactly one of these **seven states**. The +state is shown by an icon color and an inline status word in the +description. + +``` + +---------------+ +---------------+ +---------------+ + | NotInstalled | click QS | Provisioning | success | Running | + | (no row) | -----------> | (spinner) | -----------> | (green dot) | + +---------------+ +---------------+ +---------------+ + | | ^ + | failure | | + v v | + +---------------+ user stop +---------------+ + | Error | <--- error - | Stopping | + | (red dot) | | (yellow dot) | + +---------------+ +---------------+ + ^ ^ | + | | v + +---------------+ user start +---------------+ + | Starting | <----------- | Stopped | + | (yellow dot) | | (gray dot) | + +---------------+ +---------------+ + --- success --> Running +``` + +Running -> Stopping is explicit and is initiated by the user's Stop action; +the diagram should be read end-to-end without implicit edges. + +Two **soft badges** overlay any state without changing the state itself: + +| Badge | Meaning | Tree presentation | +| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| `UpdateAvailable` | A newer image tag was detected (see sec. 9.4) | `Running . localhost:10260 . update available` | +| `Missing` | The extension has metadata for this instance but Docker has no matching container (e.g., removed in a terminal). Effective hard state is `NotInstalled`. | `Missing . click to recreate` (see sec. 9.5) | + +Badges are not states. `Missing` is the _label_ applied to a `NotInstalled` +row when prior metadata still exists; the row's underlying state, action +matrix, and recovery flow are the `NotInstalled` ones. + +### 6.2 Action matrix + +`v` = action exposed for that state. Inline columns are always shown in +the same position so icons never shift under the cursor (see review finding +R10). + +| Action | NotInstalled (incl. `Missing` badge) | Provisioning | Running | Stopping | Stopped | Starting | Error | Where it shows | +| -------------------------------- | :----------------------------------: | :----------: | :-----: | :------: | :-----: | :------: | :---: | -------------------- | +| Quick Start | v | | | | | | | rocket inline + menu | +| Open Connection | | | v | | | | | inline `[open]` | +| Start | | | | | v | | v | inline `[start]` | +| Stop | | | v | | | | | inline `[stop]` | +| Cancel | | v | | v | | v | | inline `[cancel]` | +| Restart | | | v | | | | v | overflow `[...]` | +| View Logs | | v | v | v | v | v | v | overflow `[...]` | +| Copy Connection String | | | v | | v | | | overflow `[...]` | +| Copy Password | | | v | | v | | | overflow `[...]` | +| Reveal in Docker | | v | v | v | v | v | v | overflow `[...]` | +| Delete Container... | | | | | v | | v | overflow `[...]` | +| Check for Image Update _(v1.1)_ | | | v | | v | | v | overflow `[...]` | +| Rename Alias... _(v1.1)_ | | | v | | v | | v | overflow `[...]` | +| Reset DocumentDB Local… _(v1.1)_ | | | v | | v | | v | overflow `[...]` | +| Forget Quick Start... _(v1.1)_ | | | v | | v | | v | overflow `[...]` | + +Actions marked _(v1.1)_ are documented here for completeness but are +**not shipped in v1.0** — see sec. 17.4 for the v1.0 / v1.1 split. The +v1.0 overflow menu contains only Restart, View Logs, Copy Connection +String, Copy Password, Reveal in Docker, and Delete Container. + +Inline icons reserve fixed positions so the layout is stable across state +transitions: + +``` +Position 1: primary action ([open] when Running, blank otherwise) +Position 2: power action ([start] or [stop] or [cancel]) +Position 3: overflow ([...]) +``` + +`Delete Container` is intentionally **not** offered while the instance is +running — the user must `Stop` first. This avoids an accidental delete on a +hover misclick. + +--- + +## 7. Detailed screens + +### 7.1 Docker readiness diagnosis (shown only if a check fails) + +``` ++------------------ Docker readiness ----------------------+ +| | +| Local Quick Start needs Docker to run DocumentDB on | +| your machine. | +| | +| [x] Docker CLI found v1.27.0 | +| [!] Docker daemon reachable stopped | +| [!] Image registry not reached (proxy or offline?) | +| [?] Image architecture unknown until pull | +| | +| How to fix | +| - Start Docker Desktop and sign in | +| - Check your corporate proxy settings | +| - Test reachability: ghcr.io | +| | +| [Start Docker Desktop] [Troubleshooting] [Retry] | +| | ++----------------------------------------------------------+ +``` + +The diagnosis screen replaces the Review & Start screen when any _blocking_ +check fails. Non-blocking warnings (e.g., insufficient free disk) appear as +a yellow banner **inside** the Review screen instead, and the user can +proceed. + +Categorized failure messages cover, at minimum: + +| Symptom | Action surfaced | +| --------------------------------------- | ------------------------------------------------------------------------- | +| Docker CLI not on PATH | "Install Docker" link, "Already installed? Open settings" link | +| Daemon socket not reachable | "Start Docker Desktop" where supported; otherwise platform-specific setup | +| Linux user not in `docker` group | "Open setup guide for Linux" | +| Windows engine is Windows containers | "Switch to Linux containers?" confirmation, or setup guide | +| Windows Home / WSL2 missing | "Open WSL2 setup guide" | +| Apple Silicon, but image lacks arm64 | "Use x86_64 emulation? (slower)" choice | +| Authenticated proxy blocks registry | "Configure registry credentials" link | +| Docker Desktop resource limits too low | "Open Docker resources" link | +| Remote VS Code session, no local daemon | Explanation + "Use SSH-host Docker" link | + +### 7.2 Progress notification + +Always rendered as a single VS Code progress notification (not a modal) so +the user can keep working. Cancel is always available and rolls back. + +``` ++--------- Starting DocumentDB Local ---------+ +| | +| [x] Checking Docker | +| [x] Reserving port 10260 | +| [>] Pulling official image 42% | +| [ ] Creating container | +| [ ] Starting container | +| [ ] Waiting for connection | +| | +| Elapsed 00:18 [Show Details] [Cancel] | ++---------------------------------------------+ +``` + +`[Show Details]` opens a lightweight details panel with the same step list, +friendly error summaries, and expandable full Docker output: + +``` ++---------------- Quick Start details ----------------+ +| | +| Setting up vscode-documentdb-local | +| | +| [x] Checking Docker | +| [x] Reserving port 10260 | +| [>] Pulling official image | +| This might take a few minutes. | +| [ ] Creating container | +| [ ] Starting container | +| [ ] Waiting for DocumentDB to accept connections | +| | +| [View logs] [Cancel] | ++------------------------------------------------------+ +``` + +On failure, the current step expands automatically: + +``` ++---------------- Quick Start details ----------------+ +| | +| Pulling official image Failed | +| | +| We couldn't pull the image from ghcr.io. | +| Check your network connection or proxy settings. | +| | +| [Show full Docker output] | +| [Retry] [Troubleshooting] [Cancel] | ++------------------------------------------------------+ +``` + +Cancel rules: + +- Cancel during pull -> abort pull, no container created. +- Cancel after pull but before container start -> no container created. +- Cancel during start -> container is created, then stopped and removed, + port released. Generated credentials are kept in storage; the user is + told they will be reused on retry or can be discarded. +- Cancel during "Waiting for connection" -> same as above, plus surface + the most recent connection error in the failure toast. + +### 7.3 Success card + +``` ++---- DocumentDB Local is running on localhost:10260 -----+ +| | +| [Open Connection] [Copy Connection String] | +| | +| [Logs] [Load Sample Data] [Don't show again] | +| | ++----------------------------------------------------------+ +``` + +`[Load Sample Data]` is offered only when the Advanced panel had +_Load sample data on first start_ unchecked. If the user opted in, the +seed is already loaded and this button is hidden. + +`[Don't show again]` mutes the success card for routine starts (it +already isn't shown for non-first starts; this is for users who installed +multiple managed instances). + +### 7.4 Visible port fallback + +The canonical local port is `10260`. When that port is busy, the extension +allocates a free port from a small band — it does **not** silently pick +`10261`, which is commonly also taken on developer machines: + +1. Try `10260`. +2. If busy, try up to **N=10 random ports** in the band `[10260, 10360)`. +3. If still no free port is found, surface the **Change port...** dialog + instead of auto-picking. The user then types a port and the same + conflict check repeats. + +(The PostgreSQL extension only allocates a random port when the input is +empty or invalid; it does **not** auto-fall-back from a valid-but-busy +user-supplied port. The DocumentDB design is intentionally stronger here so +that the zero-form happy path keeps working when the default is occupied.) + +When a fallback is used, the user sees it explicitly in two places: + +1. A yellow banner in the Review screen: + + ``` + ! Port 10260 is in use. We'll use port 10273 instead. + [Change port...] [Use 10273] + ``` + +2. A persistent description on the tree row: + + ``` + v DocumentDB Local Running . localhost:10273 + description: 10260 was already in use + ``` + +The connection string everywhere always reflects the **actual** port. + +--- + +## 8. Managed-instance presentation + +### 8.1 Status icons and colors + +| State | Icon glyph | Color | Tree row example | +| ------------ | ---------------- | ------ | --------------------------------------------- | +| NotInstalled | n/a | n/a | (no row, empty state instead) | +| Provisioning | `loading~spin` | yellow | `Provisioning... . localhost:10260` | +| Starting | `loading~spin` | yellow | `Starting... . localhost:10260` | +| Running | `circle-filled` | green | `Running . localhost:10260` | +| Stopping | `loading~spin` | yellow | `Stopping... . localhost:10260` | +| Stopped | `circle-outline` | gray | `Stopped . localhost:10260` | +| Error | `warning` | red | `Error . click for details . localhost:10260` | + +A small `UpdateAvailable` badge on Running / Stopped: + +``` +v DocumentDB Local Running . localhost:10260 . update available +``` + +The `Missing` badge applies when prior metadata exists but Docker has no +matching container (sec. 6.1, sec. 9.5): + +``` +v DocumentDB Local Missing . click to recreate +``` + +### 8.2 Description format + +``` + . localhost: [. ] +``` + +`` is reserved for the most important contextual fact: + +- `update available` +- `10260 was already in use` +- `same target as a manual connection` +- `stopped from another VS Code window` (transient, see section 9) + +Only one secondary note at a time. Tooltip lists all applicable notes. + +Tooltip example for the managed row: + +``` +DocumentDB Local +Container alias: vscode-documentdb-local + +State: Running +Endpoint: localhost:10260 +Image: ghcr.io/documentdb/...:latest +Resolved version: v1.2.3 (if the image carries a version label; otherwise "unknown") +Resolved digest: sha256:12ab...90ef +Data volume: vscode-documentdb-local-data +Runs on: This machine +``` + +The tree row keeps the UI simple; the tooltip carries the container alias +(for `docker ps` correlation) plus the resolved image digest and — when +the image carries a version label — the resolved version. The digest is +always available; the semver version is image-label-dependent and may +read `unknown`. + +--- + +## 9. Conflict resolution + +### 9.1 An existing container with the same name + +When the user clicks Quick Start but a Docker container already exists +under the planned name (`vscode-documentdb-local`), the extension first +decides whether it is a recognized DocumentDB Local Quick Start resource. +Only recognized containers can be adopted as managed Quick Start instances. + +**Recognition contract.** A container is recognized as a Quick Start +instance if and only if it carries the labels +`vscode.documentdb.quickstart=1` and `vscode.documentdb.alias=`. +These labels are applied at creation time by Quick Start itself; they are +not derived from name, image, or port. `docker container update` does not +support label modification — labels can only be changed by recreating the +container, so a user who wants to manually opt out of adoption must +recreate the container without the labels. The extension also maintains a +local **forgotten-instances list** (sec. 11) that suppresses adoption for +specific container IDs even when the labels still match. Image name, +container name, and port are never sufficient on their own to recognize a +container as managed. + +Recognized container: + +``` ++----- Existing container 'vscode-documentdb-local' found -----+ +| | +| We found a recognized DocumentDB Local container. | +| | +| Container name vscode-documentdb-local | +| Image ghcr.io/documentdb/...:latest | +| Recognized as DocumentDB Local Quick Start | +| Port binding 0.0.0.0:10260 -> 10260 | +| Status Exited 12 days ago | +| Volume vscode-documentdb-local-data | +| | +| What would you like to do? | +| | +| ( ) Adopt as managed Quick Start instance | +| Existing data and credentials are kept where possible. | +| ( ) Reset and recreate | +| Removes the container and its data volume. | +| ( ) Cancel | +| | +| [Continue] [Cancel] | ++--------------------------------------------------------------+ +``` + +Unrecognized container: + +``` ++---- Container name 'vscode-documentdb-local' is already used ----+ +| | +| A container already uses the Quick Start name, but we cannot | +| verify that it is a DocumentDB Local Quick Start container. | +| | +| Container name vscode-documentdb-local | +| Image unknown-or-custom-image | +| Status Running | +| | +| To avoid taking over the wrong container, Quick Start will not | +| adopt it automatically. | +| | +| ( ) Create a manual local connection to this endpoint | +| ( ) Reset and recreate as DocumentDB Local | +| Removes this container and its data volume. | +| ( ) Cancel | +| | +| [Continue] [Cancel] | ++------------------------------------------------------------------+ +``` + +Adopt path resolves credentials in this order: + +1. Local SecretStorage entry from a previous Quick Start. +2. If missing, the user is offered: + ``` + We can't find the saved credentials for this container. + ( ) Reset credentials and recreate the container + ( ) Delete the container and start fresh + ( ) Cancel + ``` + "Adopt without credentials" is intentionally not offered because the + resulting row could not actually open a connection. + +### 9.2 Same-target manual connection already exists + +When the new managed instance points at the same `host:port` as an existing +manual connection, the Review screen shows a soft warning, not a block: + +``` +i You already have a manual connection to localhost:10260. + After Quick Start finishes, both will appear in the tree. + The Quick Start instance owns lifecycle actions. +``` + +Tree presentation rule from section 3.4 then applies. + +### 9.3 Multi-window coordination + +The container is **shared machine state**. The extension never assumes a +window "owns" it. + +UX rules: + +- All windows reflect state changes within a few seconds. **v1 implements + polling only** (on activation, on overflow-menu open, and on the + Connections view refresh tick). Subscription to the Docker event stream + is deferred to v1.x; under polling-only, cross-window latency is bounded + by the poll interval. +- Every destructive action (Stop, Delete, Reset) re-checks the live state + immediately before executing. If the state changed under the user, the + confirmation dialog is replaced: + ``` + ! The instance is now Stopping (from another VS Code window). + The action is no longer available. + [OK] + ``` +- A transient secondary note appears for ~10 seconds when an action was + initiated by a different window: + ``` + DocumentDB Local Running . stopped from another VS Code window + ``` + +### 9.4 Image is outdated + +Discovery is **passive**: the extension does not check for image updates on +activation, and does not show a toast. The check runs when: + +- The user opens the overflow menu on the managed row (lazy). +- The user clicks `Check for Image Update` explicitly. +- The user restarts the instance after at least 7 days. (The 7-day + threshold uses a `lastUpdateCheckAt` timestamp persisted with the + managed-instance metadata.) + +If an update is found, the `update available` badge appears (section 8.1) +and the overflow menu offers: + +``` +Overflow menu + Update Image... opens a Review-style dialog with diff + View Current Version + Ignore This Version +``` + +The Update dialog is the only place the user is asked to confirm an image +change. It restates the data implications: + +``` +Update DocumentDB Local Image? + + Current image ghcr.io/documentdb/...:latest + Current version v1.2.3 (if available) sha256:12ab...90ef + New image ghcr.io/documentdb/...:latest + New version v1.3.0 (if available) sha256:45cd...67ab + Container will be recreated + Data volume will be kept + Credentials will be kept + + [Update] [Cancel] +``` + +When the image does not carry a version label, the `Current version` / +`New version` rows read `unknown`; the digest rows remain authoritative. + +### 9.5 Container disappeared outside the extension + +If the user removed the container in a terminal, the tree row enters the +`NotInstalled` state with the `Missing` badge applied (see sec. 6.1 for the +state/badge distinction) and changes its label to: + +``` +v DocumentDB Local Missing . click to recreate +``` + +Click triggers the Review screen in **recreate** mode (pre-filled with the +last known port, alias, persistence choice). + +### 9.6 Port already in use after a Quick Start once worked + +The same rules as section 7.4 apply. Additionally, the overflow menu gains +a one-shot `Move to a different port...` action that: + +``` ++-------- Move DocumentDB Local to a different port --------+ +| | +| Current port 10260 | +| New port [10261 ] | +| | +| The container will be recreated. Data is kept. | +| The saved connection string will be updated. | +| | +| [Move] [Cancel] | ++-----------------------------------------------------------+ +``` + +--- + +## 10. Error and edge cases + +| Category | What the user sees | +| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| Docker not installed | Docker readiness screen (sec. 7.1) | +| Docker not running | Docker readiness screen with "Open Docker Desktop" | +| Permission denied (Linux group) | Docker readiness screen with platform-specific guidance | +| Apple Silicon, no arm64 image | Readiness warning + opt-in to amd64 emulation | +| Disk space below 2 GB | Yellow banner in Review screen, not a block | +| Network offline / proxy blocked | Readiness "Image registry not reached" + retry | +| Pull aborted mid-way | Failure toast: "Pull failed. [Retry] [View logs]" | +| Container fails to start | Failure toast: "Container failed to start. [Logs] [Reset]" | +| Health check timeout (default 60s) | Failure toast: "Database didn't accept connections in time. [Wait longer] [Logs] [Reset]" | +| SecretStorage cleared | Adopt flow with credential-reset path (sec. 9.1) | +| Quick Start invoked on an unsupported OS | Toast: "Local Quick Start is supported on Windows, macOS, and Linux." (Same gate as the manual emulator wizard already uses.) | +| User clicks Open Connection while still Provisioning | Action is hidden until Running | +| Remote VS Code (SSH / WSL / dev container) | Readiness explains where the container will live and asks the user to confirm | + +All **post-readiness** errors (pull, create, start, health-check timeout, +container fails to start, etc.) render in the same shape: a single VS Code +toast with at most three actions, and never block the editor. +**Pre-start** readiness failures (Docker not installed, daemon stopped, +permission denied, registry unreachable, remote-host ambiguity) render in +the Docker readiness screen (sec. 7.1), not as toasts, because they +require categorized guidance and a Retry that re-runs the check +sequence. + +--- + +## 11. Lifecycle vocabulary (definitions the UI strictly follows) + +Wording mistakes here cause data loss. The UI uses these exact verbs and +never mixes them. + +| Verb | Effect on container | Effect on data volume | Effect on credentials | Effect on `quickstart.*` Docker labels | Effect on extension's local management metadata | Effect on tree row | +| ---------------------------------------- | ------------------------------------------------------------------- | --------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| **Start** | starts existing | unchanged | unchanged | unchanged | unchanged | -> Running | +| **Stop** | stops | unchanged | unchanged | unchanged | unchanged | -> Stopped | +| **Restart** | stop + start | unchanged | unchanged | unchanged | unchanged | -> Running | +| **Rename Alias...** _(v1.1)_ | `docker rename` to new alias; container is not stopped or recreated | unchanged | unchanged | `vscode.documentdb.alias` updated to new value (via container recreation, since labels are immutable); other labels re-applied | alias field updated; container ID unchanged unless recreation is required for label rewrite | label and tooltip update; row position unchanged | +| **Update Image...** _(v1.1)_ | recreate | kept | kept | re-applied on the new container | updated (new digest, new container ID) | -> Running | +| **Move to a different port...** _(v1.1)_ | recreate | kept | kept | re-applied on the new container | updated (new port, new container ID) | -> Running | +| **Delete Container...** | removes container | kept | kept | n/a (no container) | kept (re-create reuses alias, port, volume) | -> NotInstalled with `Missing` badge (sec. 9.5) | +| **Reset DocumentDB Local...** _(v1.1)_ | removes container | **dropped** | **dropped** | n/a (no container) | **dropped** | -> NotInstalled (row removed) | +| **Forget Quick Start...** _(v1.1)_ | **unchanged** | unchanged | kept and re-keyed to a Manual connection ID | **unchanged** (see implementation note below) | **dropped** | row converted to a Manual connection | + +Note on `Rename Alias...`: because Docker labels are immutable on an +existing container, changing the `vscode.documentdb.alias` label requires +container recreation. The v1.1 implementation does this transparently +(stop, `docker commit` not used; just recreate with same image digest + +new alias + persistent volume), so the user-visible effect is just "the +name changed." The data volume is intentionally **not** renamed to follow +the alias — the volume keeps its original name to preserve the +metadata-to-volume link if the user renames repeatedly. This trade-off is +also flagged in sec. 15 open question 1. + +`Forget Quick Start` deliberately keeps the credentials (the verb forgets +the **management relationship**, not the secret). After Forget: + +- The container keeps running with its Docker labels intact. + `docker container update` does **not** support label removal (it only + changes resource limits and restart policy), and the design + intentionally avoids recreating the container just to drop labels, + because Forget must be non-destructive. +- The extension drops its local management metadata for this container ID + and adds the container ID to a local **forgotten-instances list**. + This list is consulted whenever the recognition contract (sec. 9.1) + runs: a container whose ID appears in the forgotten list is **not** + offered as an Adopt candidate even though its labels still match. The + user can clear the list from the command palette + (`DocumentDB: Reconsider Forgotten Local Instances`). +- The stored credentials are re-keyed from the Quick Start + SecretStorage namespace into the Manual connection SecretStorage + namespace so the converted row still opens. +- The row moves out of the `Quick Start` group and into the + `Manual connections` group; lifecycle actions + (Start/Stop/Update/Move/Reset) disappear from the overflow menu because + the extension no longer owns the container. + +Confirmation phrasing: + +- _Stop_ — no confirmation. Reversible. +- _Restart_ — no confirmation. Reversible. +- _Delete Container_ — one-line confirm: "Delete the container? Your data + is kept and will be re-attached if you Quick Start again." +- _Reset DocumentDB Local_ — two-step confirm. User must type the + container alias to confirm. Names the volume that will be deleted and + warns "Data cannot be recovered." +- _Forget Quick Start_ — one-line confirm: "Stop managing this container + from the extension? The container keeps running and the saved + credentials are kept so the Manual connection still works. The + extension will stop offering lifecycle actions for it and will not + re-adopt it automatically. You can re-adopt it from the command + palette later." + +--- + +## 12. Telemetry hints (informational; no PII) + +Per the existing telemetry conventions of the extension. Listed here for +UX completeness so reviewers can see what we plan to measure. + +``` +event: quickstart.review_shown prop: source=tree|menu|command|welcome +event: quickstart.review_advanced prop: opened_first_time=bool +event: quickstart.docker_readiness prop: result=ok|cli_missing|...|unknown, + os=win|mac-x64|mac-arm|linux|... +event: quickstart.start_begin prop: source=... +event: quickstart.start_stage prop: stage=pull|create|start|connect, + duration_ms, success=bool +event: quickstart.start_end prop: result=success|cancelled|failed, + elapsed_ms, + port_fallback=bool, + recreate=bool, adopted=bool, + image_resolved_version=semver|unknown +event: quickstart.lifecycle prop: action=start|stop|restart|delete|reset|move|update|forget, + initiated_by=user|other_window, + duration_ms, success=bool +event: quickstart.error prop: stage=..., reason=... +event: quickstart.dismiss_welcome prop: from=welcome_view|empty_state +``` + +Container name, user-edited image tag, registry URL, hostnames, ports, +credentials, and image digest are never sent. The **resolved image +version** (semver from the image label, or `unknown`) IS sent so that +"v1.2.3 has a bug" can be correlated with telemetry. Whether the user +opted into sample data IS sent. + +--- + +## 13. Cross-cutting rules + +- **Opt-in only.** The extension never installs Docker, never starts Docker + silently, and never modifies a container that wasn't created by Quick Start + unless the user explicitly chooses Adopt. +- **Explicit Docker start.** If Docker is installed but stopped, the extension + can offer `Start Docker Desktop` / `Start Docker` as a user-clicked action + where supported. It does not invoke `sudo` or perform privileged daemon + setup. +- **No background pulls.** Image is pulled only inside a user-initiated + Quick Start or Update Image flow. +- **No required form in the happy path.** The default Quick Start path has no + mandatory fields. Any setting that would otherwise become a setup step must + either have a safe default or move to Advanced. +- **Canonical local port.** Quick Start and the manual DocumentDB Local path + use `10260` by default. The current manual wizard hardcodes `10255` for + the preconfigured DocumentDB/MongoRU paths + (`PromptConnectionTypeStep.ts` and `PromptPortStep.ts`); this is a + pre-ship bug and must be fixed so both paths agree on the + `documentDB.local.port` setting (default `10260`) before Quick Start + lands. +- **No nag toasts.** Updates and warnings stay in the tree row description + unless the user opens the overflow menu. +- **Reversibility.** Stop, Restart, and Cancel are always safe. +- **Symmetry with the manual wizard.** Manual connections continue to work + exactly as today. Nothing is removed. +- **Uninstalling the extension does not remove the container.** A separate + "Clean Up Quick Start Resources..." command is offered for that, before + uninstall, in the command palette and in the overflow menu. + +--- + +## 14. Non-goals for v1 + +- **Multiple concurrent managed instances per user.** v1 ships strictly + single-managed-instance. The `Quick Start` group in the tree exists for + forward compatibility but always contains exactly one row in v1. The + rocket icon on the section header is hidden once a managed instance + exists; a future v1.x release will reintroduce it as + `Create another local instance...`. +- **Bundled sample data.** The `[Load Sample Data]` action is described + throughout this document, but if no curated dataset can be bundled in + the extension (extension-size impact) or fetched safely on demand + (offline behavior, proxy, signature verification) by ship time, the + action ships **disabled with a "Coming soon" affordance** in v1 and is + enabled in v1.x. The success card and empty Collection View callout + still render the button so the discovery surface is preserved. +- Auto-loading sample data by default. The flag is in Advanced; the + separate `Load Sample Data` command on the managed row remains the + primary path. +- Ephemeral data volumes. Persistent local storage is the only v1 mode so + Stop, Restart, Update, Move Port, and Delete Container have predictable + data behavior. +- Resource usage charts (CPU, memory, disk) in the tree row. +- Authentication beyond username / password. No client certs in v1. +- Bring-your-own-image. The image tag is editable in Advanced, but only + for the official image. Custom images are deferred. +- Managing non-managed containers as Quick Start. The Adopt path requires + the container to be recognized as a previous DocumentDB Local Quick Start + resource via the labels contract in sec. 9.1; a matching name alone is + not enough. + +--- + +## 15. Remaining open questions + +1. **Persistent volume naming.** Default `vscode-documentdb-local-data` is + easy to find in `docker volume ls`. Should the alias the user picks in + Advanced be reflected in the volume name? Pro: discoverable. Con: + renaming the instance breaks the link. +2. **Self-signed certificate trust.** Today both wizards either skip TLS + verification (`tlsAllowInvalidCertificates=true`) or use a global + `disableEmulatorSecurity` flag. Quick Start uses the same approach. + Long term, the official image's local CA could be auto-trusted in the + user's Node trust store, which would let the connection string drop + the allow-invalid-certs flag. Out of scope for v1; worth tracking. +3. **Welcome card scope.** Should the welcome card appear only when the + Connections view is empty _overall_, or whenever the DocumentDB Local + section is empty? Current draft says the latter; some reviewers may + prefer the former to avoid showing the card to users who already have + many cloud connections. +4. **Linux + sudo Docker.** Linux machines without the user in the docker + group need `sudo`. Quick Start does **not** invoke sudo. The Docker + readiness diagnosis surfaces the fix instead. Confirm this matches the + extension's existing posture on privilege escalation. + +--- + +## 16. Out of scope (for this design doc) + +The following implementation-detail topics intentionally do not belong +here, but each has a downstream implication that the companion +implementation plan must address: + +- **Choice of orchestration mechanism (Docker SDK vs. `docker` CLI).** The + cancellation contract in sec. 4 and sec. 7.2 means + `vscode.ShellExecution` (the PostgreSQL approach) is insufficient, + because it cannot abort an in-flight `docker pull`. The implementation + must use a cancellable process surface (e.g., + `child_process.spawn` with kill-on-cancel, or a streaming API from + `@microsoft/vscode-container-client`), and the cancellation handler + must explicitly remove any container that was created and release any + port that was reserved before declaring the operation aborted. +- **How healthchecks are implemented.** The user-visible contract is in + sec. 4.1 (a `hello`/`ping` over the wire protocol). The implementation + decides how to issue that probe (raw socket, driver, `mongosh` if + available, etc.), the back-off curve, and the cancellation behavior. +- **`LocalEmulatorsItem` migration contract.** The current + `LocalEmulatorsItem` (`src/tree/connections-view/LocalEmulators/LocalEmulatorsItem.ts`) + renders a `DocumentDB Local` row with a single `New Local Connection...` + child when empty. Quick Start replaces this empty state with three + child rows plus inline header icons (sec. 3.2). Existing manual + connections continue to render at the top level; the + `Quick Start` / `Manual connections` grouping (sec. 3.3) is applied + only once at least one Quick Start instance exists. The implementation + plan must call out this migration explicitly so existing users with + many manual emulator connections do not regress. +- **Credential transport.** The implementation must pass generated + credentials to the container via a temp `--env-file` (written under + `os.tmpdir()` and removed in a `finally` block), not as repeated `-e` + flags. `-e` flags appear on the host CLI command line and therefore in + `ps -ef` and shell history; `--env-file` does not. Note the precise + security boundary: `--env-file` removes the **host-side** exposure + (CLI, history, process audit), but the password is still present in + the container's runtime environment and is therefore visible via + `docker inspect ` (Config.Env) and `docker exec env` + to anyone with Docker access on the host. This matches the trust + boundary the user already accepts by running Docker locally. PostgreSQL + already does this (see `dockerCreateWebviewController.ts:280-287`); + DocumentDB must match. +- **Docker labels for the recognition contract.** The user-facing rule is + in sec. 9.1. The exact label names (`vscode.documentdb.quickstart=1`, + `vscode.documentdb.alias=`) are stable wire format; the + implementation plan should treat them as a versioned contract and not + rename them silently. +- **Welcome-card dismissal storage.** Stored in the user Setting + `documentdb.quickStart.welcomeDismissed` (sec. 2), not in + `globalState`. This is the only way the dismissal survives an extension + uninstall/reinstall. +- **Telemetry property data types or sampling rules.** +- **Localization of strings** (handled at implementation time via + `vscode.l10n.t()`, per repo convention). +- **Tests, build wiring, or settings keys.** + +These belong in a companion implementation plan that references this +document. + +--- + +## 17. Design review: comments, findings, and suggestions + +### 17.1 Review outcome + +**Approve the UX direction for implementation planning.** + +The design correctly moves beyond the Cosmos DB extension's attach-only +emulator pattern and uses the PostgreSQL local Docker creation flow as the +primary UX reference. The strongest parts are the explicit Review & Start +screen, zero required fields in the default path, visible Docker/readiness +progress when needed, the tree as the persistent control surface, the +separation between managed Quick Start and manual connections, and the careful +lifecycle vocabulary for Stop, Delete, Reset, and Forget. + +The initial review findings below have been folded back into this draft. The +remaining open questions are intentionally limited to follow-up product or +implementation decisions that should not block the core v1 workflow. + +### 17.2 Findings + +#### First-round findings (folded into the draft) + +| ID | Severity | Original finding | Resolution in this draft | +| --- | ------------ | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| R1 | Must fix | "One click" could conflict with first-run review. | Product copy now uses **Quick Start**; true one-click is scoped to subsequent starts after setup. | +| R2 | Must fix | Manual DocumentDB Local and Quick Start could disagree on default port. | `10260` is now a cross-cutting UX rule for both Quick Start and manual local connection; mismatch is called a pre-ship bug. | +| R3 | Must fix | Users could think the extension installs Docker. | The Review screen and prerequisite promise say Docker is required and not installed by the extension; if Docker is stopped, start is explicit. | +| R4 | Must fix | Ephemeral data mode was ambiguous. | Ephemeral volumes are removed from v1; persistent local volume is the only data mode. | +| R5 | Should fix | Empty database after success may not feel like "try DocumentDB." | `Load Sample Data` is promoted on the success card and empty Collection View callout. | +| R6 | Should fix | Existing-container adoption could take over the wrong container. | Adopt is offered only for recognized DocumentDB Local Quick Start resources; name-only matches get manual connection/reset/cancel choices. | +| R7 | Should fix | Remote VS Code makes "local" ambiguous. | Remote-session Review banner names the actual target context before start. | +| R8 | Should fix | `latest` makes image version hard to reason about. | Managed-row tooltip and update dialog show resolved version and image digest. | +| R9 | Nice to have | Multi-window coordination may expand v1 implementation scope. | User-facing rule remains; v1.0 ships polling-only (sec. 9.3, sec. 17.4); event subscription deferred to v1.1. | +| R10 | Nice to have | Inline actions should not shift under the cursor. | Three fixed action slots are retained as UX contract: primary, power, overflow. | +| R11 | Nice to have | Welcome card could annoy users with cloud connections but no local ones. | Empty `DocumentDB Local` section remains the default scope; dismissal is shared with empty-state card. | +| R12 | Should fix | PostgreSQL shows that local Docker creation should finish by saving, connecting, and revealing the new resource. | The success definition now requires DocumentDB readiness plus a usable revealed connection, not just a running container. | +| R13 | Should fix | PostgreSQL uses a form, but DocumentDB can be easier because its defaults are known. | The happy path now has zero required fields; all setup choices are generated or moved to Advanced. | +| R14 | Should fix | MSSQL reduces friction by starting Docker Desktop when possible, but that can feel surprising. | The design allows an explicit user-clicked Docker start action where supported, while preserving the no-silent-start rule. | + +#### Second-round findings (combined external review, folded into this revision) + +| ID | Severity | Original finding | Resolution in this revision | +| --- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| R15 | Must fix | §6.1 said "six states" but showed seven; the seventh "soft" badge sentence didn't reconcile the count. | Heading renamed to **"Seven states and two badges"** (sec. 6.1). The `Missing` badge is now a documented second badge alongside `UpdateAvailable`. Action matrix in §6.2 unchanged (already had seven cols). | +| R16 | Must fix | `Missing` was used inconsistently across §9.5 (label on NotInstalled) and §11 (apparent distinct state). | `Missing` is a **badge** over `NotInstalled` everywhere. §6.1, §8.1, §9.5, §11 updated. Action matrix column header is "NotInstalled (incl. `Missing` badge)". | +| R17 | Must fix | `Forget Quick Start` (§11) dropped credentials yet converted the row to a Manual connection, which §9.1 explicitly says cannot open without credentials. | Forget now **keeps** credentials; it drops only the `quickstart.*` Docker labels and the management relationship. Updated §11 verb table and confirmation copy. | +| R18 | Must fix | "Waiting for the database to accept connections" had no defined probe (PostgreSQL uses `pg_isready`; DocumentDB has no equivalent CLI baked into the image). | New "Readiness contract" paragraph in §4.1 defines a `hello`/`ping` over the wire protocol with a 60s default timeout. §10 references stay consistent. | +| R19 | Must fix | §7.4 deterministic `+1` fallback was fragile and misrepresented PostgreSQL. | §7.4 rewritten to random free port in `[10260, 10360)` with up to 10 attempts, then escalate to `Change port...`. §0.2 row corrected to state that PostgreSQL does **not** do this for valid-but-busy ports. | +| R20 | Must fix | Tree label inconsistent: §3.3 used `vscode-documentdb-local`, §4.1 said `DocumentDB Local`. | Tree row label is **`DocumentDB Local`** (sec. 3.3, sec. 4.1). The container alias `vscode-documentdb-local` lives in the tooltip (sec. 8.2). | +| R21 | Must fix | The manual wizard currently hardcodes port **10255** (`PromptConnectionTypeStep.ts:97,101`, `PromptPortStep.ts:23,25`) despite the `documentDB.local.port` setting defaulting to **10260**. The doc only treated this as hypothetical. | §13 now names the specific files and calls out the fix as a hard pre-ship dependency for Quick Start. | +| R22 | Should fix | Adopt-recognition contract was deferred to §16 even though it has user-visible consequences (whether Adopt is offered). | Recognition contract is now an explicit paragraph in §9.1 (labels `vscode.documentdb.quickstart=1` and `vscode.documentdb.alias=`, applied at creation, never inferred from name/image/port). | +| R23 | Should fix | §16 listed "out of scope" items that have hard downstream constraints (cancellation, env-file credential transport, process orchestration). | §16 rewritten to keep each item but call out its downstream implication explicitly so the implementation plan does not silently regress against them. | +| R24 | Should fix | §17.4 had no v1.0 / v1.1 split; readiness diagnosis (§7.1) and the Forget/Update/Move/Reset verbs would balloon v1. | §17.4 rewritten as an explicit **v1.0 / v1.1** split. v1.0 = PostgreSQL-parity slice + DocumentDB UX wins; v1.1 = adopt, update, move, reset, forget, event subscription, remote banner, categorized diagnosis. | +| R25 | Should fix | §3.3 enabled multi-instance ("every managed instance is listed") while §14 declared it a non-goal. | §3.3 now states v1 is single-managed-instance. §14 updated to make this concrete. Multi-instance moves to v1.1 (sec. 17.4). | +| R26 | Should fix | Welcome-card dismissal across uninstall (§2) needed an explicit storage commitment because `globalState` is wiped on uninstall. | §2 now states dismissal is stored in user Setting `documentdb.quickStart.welcomeDismissed`. §16 reiterates. | +| R27 | Should fix | `LocalEmulatorsItem` migration contract was implicit. | §16 calls out the file path and the v1 invariant: manual connections continue to render at the top level; grouping only kicks in once a Quick Start instance exists. | +| R28 | Should fix | `Load Sample Data` was treated as borrowed from PostgreSQL (it isn't) and committed to without a delivery mode. | §14 marks Load Sample Data as v1.0-if-feasible / v1.1-otherwise, with the button rendered disabled-with-"Coming soon" if no dataset can be bundled or fetched safely by ship time. | +| R29 | Nice fix | `Resolved version` was promised in the tooltip, but the image may not carry a version label. | §8.2 and §9.4 phrase the field as "version if available, digest always." | +| R30 | Nice fix | Telemetry omitted resolved image version (useful for "v1.2.3 has a bug" correlations) and included nothing about Forget. | §12 adds `image_resolved_version=semver\|unknown` to `quickstart.start_end` and adds `forget` to the lifecycle action enum. | +| R31 | Nice fix | §6.1 diagram had no explicit `Running → Stopping` arrow. | Diagram updated; "user stop" edge from Running to Stopping is now drawn explicitly, and a callout under the diagram repeats the rule. | + +#### Third-round findings (independent fresh-context review by a different model, folded into this revision) + +| ID | Severity | Original finding | Resolution in this revision | +| --- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| R32 | Must fix | §5 said the header rocket "turns into a direct start" of the existing instance after setup, while §14 and §17.4 said the rocket is hidden once a managed instance exists. The two readings conflicted. | §5 rewritten: with v1 single-managed-instance the rocket is **hidden** once a managed instance exists; Start happens from the row's inline `[start]`, the row's right-click menu, or the command palette. The hidden behavior is consistent with §14 and §17.4. | +| R33 | Should fix | §10 declared "all errors render as a single toast," but Docker readiness failures (§7.1) explicitly render as a screen with a Retry that re-runs the check sequence. | §10 closing paragraph now scopes the toast rule to **post-readiness** failures (pull, create, start, healthcheck timeout, etc.) and explicitly excludes pre-start readiness diagnosis, which continues to render via §7.1. | +| R34 | Must fix | §11 said `Forget Quick Start` removes labels from the existing container, but `docker container update` does **not** support label removal (only resource limits and restart policy). | §11 rewritten: Forget does **not** touch the container or its labels. Instead it drops the extension's local management metadata, adds the container ID to a local **forgotten-instances list** consulted by the recognition contract, and re-keys credentials into the Manual connection SecretStorage namespace. A new command `DocumentDB: Reconsider Forgotten Local Instances` clears the list. | +| R35 | Must fix | §4.1 secret-storage row and §16 credential-transport bullet both claimed `--env-file` keeps the password out of `docker inspect`. That is not true — `Config.Env` from `--env-file` is inspectable via `docker inspect` and `docker exec env`. | Both passages rewritten: `--env-file` removes the **host-side** exposure (CLI command line, `ps -ef`, shell history) but the password remains visible inside the container's runtime environment to anyone with Docker access on the host. This matches the trust boundary the user already accepts by running Docker. | + +### 17.3 UX principles to carry into implementation planning + +1. **Be transparent before side effects.** Downloading an image, creating a + container, binding a port, and persisting a volume are machine-level + changes. The Review screen must stay mandatory on first run. +2. **Do not turn Quick Start into a setup form.** PostgreSQL's flow is a good + creation reference, but DocumentDB should be easier: generate defaults and + use Advanced for overrides. +3. **Keep routine actions quiet.** After setup, Start and Stop should update + the tree and status bar without celebratory toasts. +4. **Keep manual attach first-class.** Quick Start should not replace users + who already run DocumentDB themselves. +5. **Use the tree as source of truth.** Status, port, update availability, + and lifecycle actions should be discoverable from the managed instance row. +6. **Prefer reversible defaults.** Persistent data, explicit reset, and no + automatic cleanup on extension uninstall are the safest defaults. +7. **Avoid terminal language in the happy path.** Docker details belong in + Review, Advanced, logs, and troubleshooting, not in the main success flow. + +### 17.4 Suggested v1.0 / v1.1 scope split + +The full design above is the multi-release roadmap. The shippable +**v1.0** matches what PostgreSQL actually demonstrates plus the +DocumentDB-specific UX wins; everything else is **v1.1**. + +#### v1.0 (must ship) + +Surface and behavior: + +- Entry points: rocket icon on the `DocumentDB Local` header, child row + in the empty state, command palette entry, walkthrough card. +- One Review & Start screen with zero required fields. +- Docker readiness: same two checks as PostgreSQL (CLI present, daemon + reachable) plus a single generic `See Troubleshooting` link. (The nine + categorized failure modes in sec. 7.1 — Apple Silicon arm64, WSL2 + missing, sudo group, Windows-vs-Linux engine, authenticated proxy, + etc. — are v1.1.) +- Progress notification with `Show Details` and `Cancel`. Cancel must + actually abort the in-flight `docker pull`, remove any created + container, and release the reserved port (sec. 16). +- Pull / create / start / wait-for-readiness / save / connect / reveal. +- **Readiness** is a `hello`/`ping` over the wire protocol, 60s + timeout (sec. 4.1). +- **Port fallback** picks a random free port in `[10260, 10360)` with up + to 10 attempts; falls through to the `Change port...` dialog (sec. + 7.4). Both Quick Start and the manual wizard use `10260` as the + canonical default (sec. 13). The current manual-wizard 10255 hardcode + is fixed. +- **Credentials** stored in SecretStorage, passed via `--env-file` only + (sec. 16). +- **Labels** `vscode.documentdb.quickstart=1` and + `vscode.documentdb.alias=` applied at creation (sec. 9.1). +- **Tree row** label is `DocumentDB Local`; alias and digest live in the + tooltip (sec. 3.3, sec. 8.2). +- **Lifecycle actions** in v1.0: Open Connection, Start, Stop, Restart, + View Logs, Copy Connection String, Copy Password, Reveal in Docker, + Delete Container. All other overflow actions are v1.1. +- **States** are the seven defined in sec. 6.1. `UpdateAvailable` and + `Missing` badges are wired through the rendering layer but only + `Missing` is reachable in v1.0 (Update is a v1.1 feature; the badge + renders no-op until then). +- **Multi-instance** is single-only in v1.0; the rocket icon is hidden + once a managed row exists (sec. 14). +- **Multi-window coordination** is polling-only on activation, + Connections-view refresh, and overflow-menu open (sec. 9.3). Docker + event subscription is v1.1. +- **Welcome card dismissal** stored in user Setting + `documentdb.quickStart.welcomeDismissed` (sec. 2). +- **Sample data** is v1.0 if a curated dataset can be bundled or fetched + safely by ship time; otherwise the button renders disabled with a + "Coming soon" affordance (sec. 14). + +#### v1.1 (deferred) + +- Adopt-existing-container flow (sec. 9.1) — the recognition contract + still ships in v1.0 (labels are applied on creation) so v1.1 can use + them immediately. +- Update Image with version/digest diff (sec. 9.4). +- Move to a different port (sec. 9.6). +- Reset DocumentDB Local and Forget Quick Start (sec. 11). +- Categorized Docker readiness diagnosis (sec. 7.1, rows beyond the two + baseline checks). +- Docker event subscription for multi-window coordination (sec. 9.3). +- Remote VS Code (SSH/WSL/dev container) banner (sec. 4.3). +- Load Sample Data if not bundled in v1.0. +- Multiple managed instances via Command Palette + `DocumentDB: Quick Start - Install Local DocumentDB`. + +The v1.0 user promise: **from an empty machine-with-Docker to an open +local DocumentDB connection, without leaving VS Code.** Everything in +v1.1 polishes lifecycle ownership on top of that promise without +changing the promise itself. diff --git a/docs/ai-and-plans/local-quickstart/ui-redesign-decisions.md b/docs/ai-and-plans/local-quickstart/ui-redesign-decisions.md new file mode 100644 index 000000000..82191001f --- /dev/null +++ b/docs/ai-and-plans/local-quickstart/ui-redesign-decisions.md @@ -0,0 +1,365 @@ +# Local Quick Start UI redesign decisions + +> **Read _Finalization_ (the last chapter) first.** It records the shipped design and supersedes any +> detail it contradicts in the chapters below, which are kept as the reasoning trail. + +## Selected design + +**Status:** Selected (2026-08-03). This supersedes the finalist comparison below. + +- **Concept F — Docker is verified as the first setup stage.** No readiness page, no readiness band, no readiness row, and no readiness section on the Introduction. Docker has exactly one reporting surface: stage 1 of the Set up stage list, which is where every other setup failure is already reported. +- **Flow:** `Introduction → Configure → Set up → Done`. Four steps, fixed breadcrumb shape, no page whose presence depends on a check result. +- **Introduction** shows the heading, the lead sentence, the sentence `Nothing is downloaded or created on your machine until you choose to start.`, and the full `What will happen` plan with all four details. Nothing is checked here. +- **Configure** shows settings only, plus the **note above the footer** as the pre-launch expectation setter: + + > Starting downloads the official image if needed, then creates and starts one container named documentdb-local. Nothing else on your machine is changed. + + Rejected alternatives at this point: no note at all (nothing reassures a cautious user), a repeated plan panel (redundant with the Introduction and costly in vertical space), and a confirm popover on Start (adds a click to every run for an action that is not hard to undo). + +- **Set up** runs the five existing provisioning stages, with **inline detail lines** on the stage rows. See _Stage detail lines_ below. +- **Docker failure** is a Set up failure: heading `Setup did not finish`, sub-copy `Setup stopped at the first stage. Nothing was created on your machine.`, stage 1 in error with its detail line, stages 2-5 pending, remediation beside the list, then `More details` and `Last checked` as the final line. Footer primary is `Retry setup`, secondary is `Back` to Configure. + +**Accepted cost:** the user configures before learning Docker is unusable, so a failure wastes the Configure step. This is deliberate — it optimizes the common case and keeps exactly one failure surface, one vocabulary for "something went wrong", and no readiness UI to place, size, or keep in sync. + +## Webview chrome baseline + +**Status:** Established convention, not a new decision. + +`src/webviews/documentdb/atlasCredentials/AtlasCredentialsView.tsx` is the reference implementation for wizard-style webviews in this extension. The Local Quick Start redesign adopts its chrome wholesale; the design lab prototype is a copy of it. Where the two disagree, the Atlas view wins. + +- Full-height flex root, a single scrollable content area, and a sticky footer that never scrolls away. +- Content column capped at `760px`, `24px` padding, `20px` between major sections, `12px` within a section, `4px` between a heading and its subtitle. +- Footer is `16px 24px` with `8px` between buttons, primary first then secondary, and it gains a top border plus `0 -2px 6px rgba(0, 0, 0, 0.08)` only when the content actually overflows — tracked with a `ResizeObserver`. +- Step indicator is a Fluent `Breadcrumb` inside `Overflow` with `minimumVisible={1}`, `BreadcrumbButton` with `current` and `aria-current="step"`, `disabledFocusable` for steps that cannot be navigated to, and a `MoreHorizontal` overflow menu for collapsed steps. +- One `h1` in a stable hero (icon, title, subtitle) that never changes between steps; each step owns an `h2`; focus moves to that `h2` on step change and never on first render. +- `Announcer` from `src/webviews/components/accessibility/Announcer.tsx` for status announcements, assertive for errors. +- Fluent v9 throughout, styled with `makeStyles` and `tokens`; SCSS is used only to remove VS Code's default body padding. + +## Stage detail lines + +**Status:** Selected (2026-08-03) + +Each stage row in the Set up list can carry a secondary, muted detail line under its label. The detail is **evidence, not narration** — it states what was actually observed, and it **persists after the stage completes** so the list reads as a receipt rather than a transient log. + +This matters most for stage 1, `Checking Docker`: + +- **Success path.** A bare checkmark tells the user the check passed but not _what_ passed. The detail line reassures them that the extension found the Docker they expect — the right provider, the right version, the right architecture, on the right machine. Users with several Docker installations, a remote or WSL setup, or Docker Desktop alongside Docker Engine need this to trust the result. + + Shape: provider and version, then platform and architecture, then where it runs. Prototype string: `Docker Engine 27.5.1 · Linux amd64 · runs on this machine`. + +- **Failure path.** The detail line must state what _was_ discovered before the failure, not only that something failed. This is what makes the remediation text actionable and what a user pastes into a bug report. A CLI that was found but a daemon that was unreachable is a different problem from no CLI at all, and the row must say which. + + Shape: the facts that were established, then the point of failure. Prototype string: `Docker needs attention · access denied`, which is the minimum; the real implementation should include the established facts as well, for example `Docker CLI 27.5.1 found · daemon unreachable`. + +- **While active,** the detail line carries the live status (`Checking…`) and is replaced by the result when the stage settles. + +Rules: + +- Facts come from the readiness result. Never invent, guess, or fill placeholders when a field is unknown — drop that segment instead. +- Use `·` as the segment separator, sentence case, no trailing period. +- Keep it to one line at normal width; it may wrap at narrow width but must not become a paragraph. +- The detail line is a summary. The full fact list stays in the collapsed `More details` accordion, and `Last checked` remains the last line of the status content. +- The same mechanism is available to the other stages (for example the resolved image tag, the container name, the bound port). Use it where a fact reassures or aids diagnosis; leave the detail empty otherwise. + +## Introduction copy + +**Status:** Accepted for the current design direction (2026-08-03) + +**Header** + +- Title: `DocumentDB Local` +- Subtitle: `Set up DocumentDB locally for development and testing with Docker.` + +**Introduction page** + +- Heading: `Develop and test locally` +- Body: + + > DocumentDB Local gives you an open-source, fully MongoDB-compatible database for development and testing on your machine. + +- In F only, a second sentence is added because nothing has been verified yet: + + > Nothing is downloaded or created on your machine until you choose to start. + +- A `What will happen` section follows the body. See _What will happen_ below. + +**Rationale** + +- Leads with the local development and testing use case. +- States the open-source and MongoDB-compatibility claims without marketing language. +- Keeps Docker in the stable subtitle. +- Deliberately avoids claims about data persistence because that behavior may change. +- The earlier sentence `Continue to set up DocumentDB Local. This wizard will check Docker, let you review the setup, and show progress while it creates and starts the database.` was **removed**. It described the wizard rather than the outcome, and a prose list of steps is harder to scan than the steps themselves. The `What will happen` list replaces it. + +## What will happen + +**Status:** Accepted for the current design direction (2026-08-03) + +The Introduction earns its place only if it removes uncertainty before the user commits. It should answer "what is about to be done to my machine?" rather than "what is this product?". A numbered plan does that better than a paragraph. + +- Sub-heading: `What will happen` +- An ordered list of four items, each with a label and a one-line detail: + +| # | Label | Detail | +| --- | ------------------------------ | -------------------------------------------------------------------- | +| 1 | Verify your Docker setup | Confirms Docker is installed and can run containers on this machine. | +| 2 | Download the official image | Downloaded once, then reused for later setups. | +| 3 | Create and start the container | One container named documentdb-local, using the settings you choose. | +| 4 | Save the connection | The connection appears in the Connections view, ready to open. | + +- The list mirrors the wizard's own sequence, so the user recognizes it again on the Set up page. +- Details are scoped to what changes on the machine: one image, one container, one saved connection. + +**How each concept uses the list** + +- **E** renders the list with step 1 live: spinner and `Checking…` while the check runs, a checkmark and the detected engine line on success, an error icon and `Docker needs attention · access denied` on failure. Details on steps 2-4 stay hidden so the live step remains the focus. The verified plan replaces both the removed prose sentence and any dedicated readiness surface. +- **F** renders the full list with all four details and no live state, because nothing has been checked yet. The detail text carries the entire expectation-setting burden. + +## Active flow exploration + +**Current finalists (2026-08-03): E and F.** A, B, C, D, and G were removed from the lab. The comparison that produced this narrowing is retained below for the record. + +- **A: Separate readiness page** keeps environment verification separate from setup configuration. +- **B: Status in Configure** keeps compact Docker evidence above configuration and disables settings when Docker needs attention. +- **C: Wizard status band** moves readiness into wizard chrome so Configure contains settings only. +- **D: Exception-only page** sends healthy users directly to Configure and inserts a dedicated System check page only when Docker needs attention. +- **E: Check on the Introduction** runs the check on the page the user already reads, so no step and no chrome are added. +- **F: Check as the first setup stage** removes the readiness surface entirely and reports Docker through the existing stage list. +- **G: Readiness row in Configure** treats readiness as the first row of the settings inventory. + +E and F frame the remaining question: verify before the user invests effort (E), or keep exactly one failure surface (F). Every other concept added a step, added chrome, or created a second place for the same failure to appear. + +> The compact three-card Docker treatment recorded under _A: Selected System check presentation_ is not used by either finalist. Neither E nor F has a dedicated readiness surface to host it. E expresses the same evidence as one live plan step plus `More details`. + +### A: Selected System check presentation + +**Status:** Selected for the current design direction (2026-08-03) + +- Breadcrumb and page name: `System check`. +- Three compact cards appear first: Docker, Platform, and Runs on. +- The `Docker is ready` or `Docker needs attention` statement follows the cards. +- Remediation appears next when Docker needs attention. +- Full detected facts live in a collapsed `More details` accordion. +- `Last checked: just now` is always a dedicated final line in the status content. + +The cards adapt the earlier prototype treatment but deliberately reduce the happy path to three facts. + +### Selected settings interaction + +**Status:** Selected for the current design direction (2026-08-03) + +- Use inline actions for editable settings. +- Address shows `localhost:10260`; only the port can be edited. +- Image shows the full official image reference and offers an inline Edit action. +- Credentials show the active mode with `Use custom` / `Use generated` inline actions. +- Sample data uses an inline toggle. + +### Additional flow concepts + +**C: Wizard status band** + +- Keeps readiness outside the Configure page. +- Uses little vertical space and keeps settings conceptually pure. +- Introduces persistent wizard chrome whose visibility on Set up and Done needs a clear rule. +- **Finalist:** yes. Compare new alternatives against its compactness and its separation of readiness from Configure content. + +**D: Exception-only page** + +- Healthy flow remains `Introduction → Configure → Set up → Done` with a small completed-check receipt. +- Failure dynamically becomes `Introduction → System check → Configure → Set up → Done`. +- Minimizes happy-path ceremony, but a breadcrumb whose shape changes after a check may feel less predictable. + +## Fresh alternatives (2026-08-03) + +Three new concepts were built to attack the same information-architecture problem from directions A and C do not cover. A and C both assume readiness is a _topic_ that needs somewhere to live — a page of its own or a persistent band. E, F, and G each reject that assumption in a different way. + +| Concept | Where readiness lives | Steps | Chrome added | Breadcrumb shape | +| ------- | -------------------------------- | ----- | ------------ | ---------------- | +| A | Its own page | 5 | none | fixed | +| C | Wizard band above the page | 4 | persistent | fixed | +| E | Bottom of the Introduction page | 4 | none | fixed | +| F | First stage of the Set up list | 4 | none | fixed | +| G | First row of the Configure table | 4 | none | fixed | + +### E: Check on the Introduction + +**Idea:** The Introduction already promises `This wizard will check Docker…`. Run the check while the user reads that sentence and show the result under a `System check` sub-heading on the same page. + +- Flow: `Introduction → Configure → Set up → Done`. +- Introduction gains three states: checking (spinner, `Continue` disabled), ready (compact cards + `Docker is ready` + `More details` + `Last checked`), needs attention (same block plus remediation, primary becomes `Check again`). +- Configure contains settings only, exactly as in A and C. + +**Compared with A:** one fewer step and no page whose entire purpose is to say "everything is fine". The user reads the introduction anyway, so the check costs no additional interaction on the happy path. A is still cleaner if the check is slow enough that a dedicated page with its own progress feels warranted. + +**Compared with C:** no persistent chrome, so there is no rule to invent about whether the band shows during Set up and Done. The readiness statement appears once, at the moment it is decided, and then stops competing for attention. C keeps the evidence visible while the user configures; E assumes that is not needed once the check passes. + +**Cost:** the Introduction page carries two topics, and a slow check delays `Continue`. The failure state keeps the introduction copy above the error. + +### F: Check as the first setup stage + +**Idea:** Setup already has a five-stage list whose first stage is `Checking Docker`, and every other stage failure is reported there. Delete the readiness concept entirely and let the existing stage list own it. + +- Flow: `Introduction → Configure → Set up → Done`; no readiness surface exists before Set up. +- On Docker failure the Set up page shows `Setup did not finish`, stage 1 failed, stages 2-5 pending, the remediation message bar beside the failed list, then `More details` and `Last checked`. +- Sub-heading states that nothing was created on the machine. +- Footer primary becomes `Retry setup`; `Back` returns to Configure. + +**Compared with A and C:** the strongest simplification. There is exactly one failure surface instead of two, one vocabulary for "something went wrong", and no readiness UI to place, size, or keep in sync. Both A and C must design a healthy-state readiness display that the great majority of users will glance at once and never act on. + +**Cost:** the user configures before learning that Docker is unusable, so a failure wastes the configuration step. This is the clearest trade in the set: F optimizes the common case and accepts a longer path in the uncommon one. It also drops the reassurance that A and C provide before any work starts. + +### G: Readiness row in Configure + +**Idea:** Configure is already an inventory of facts about the setup: address, image, credentials, sample data. Docker readiness is another fact about the same setup, so make it the first row rather than a separate topic. + +- Flow: `Introduction → Configure → Set up → Done`. +- Row value merges evidence and statement: status icon, `Docker is ready` / `Docker needs attention`, then `Engine 27.5.1 · Linux amd64 · this machine`. +- Inline `Check again` action matches the inline `Edit` actions of every other row. +- On failure the remediation message bar spans the table directly under the row, followed by `More details` and `Last checked`; the four editable rows are disabled while the `Check again` action stays enabled; the footer primary is a disabled `Start DocumentDB Local`. + +**Compared with B:** B stacks a separate status block above the settings table, so the page reads as two components. G has one component, so the compact-card grid disappears and the page gets shorter. + +**Compared with A and C:** no extra step and no chrome, and the disabled-settings behavior reads naturally because the blocking fact sits in the same table as the things it blocks. Against C specifically, G costs slightly more vertical space in Configure but removes the persistent-band visibility rule. + +**Cost:** it deviates from the documented card treatment, since the row replaces the three compact cards. The `More details` and `Last checked` lines sit between the Docker row and `Address`, which visually splits the inventory on the happy path. + +### Recommendation + +- **F** if the goal is the smallest possible information architecture and the Docker failure rate is low. +- **E** if failures must be caught before the user invests effort, without adding a step or chrome. +- G superseded B; A, C, and D were dropped along with it. A and C are still the reference points for anyone who later argues that readiness needs a stable, addressable location or must stay visible across pages. + +## Before Start: setting expectations at the commit point + +**Status:** Under evaluation (2026-08-03) + +The Introduction plan is read minutes before the user presses `Start DocumentDB Local`, and in E it is a page the user may skip past. The Configure page is the actual commit point, so the same question — "what happens when I press this?" — has to be answerable there. Four options are implemented in the lab under the `Before Start` control. + +| Option | What it is | Cost | +| ------------------------- | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| **None** | The button label is the only signal. | Nothing to read, but nothing to reassure a cautious user. | +| **Note above the footer** | One muted sentence with an info icon, directly above the footer. | Two lines of vertical space. Prose again instead of a list. | +| **Plan panel** | A bordered panel titled `When you select Start` containing the same four-step plan. | Repeats the Introduction. Largest vertical cost. | +| **Confirm on Start** | A popover on the primary button: `Start setup now?`, the four steps, then `Start` and `Cancel`. | Adds a click to the happy path. | + +**Copy** + +- Note: `Starting downloads the official image if needed, then creates and starts one container named documentdb-local. Nothing else on your machine is changed.` +- Plan panel heading: `When you select Start` +- Confirm popover: `Start setup now?` / `Four steps run in order. You can cancel while they run.` + +**Density is complementary, not repeated** + +- In **E**, the Introduction showed a dense plan (details hidden). The Configure plan panel therefore shows all four details, with step 1 already carrying its verified result — the user sees a plan that is partly complete rather than a duplicate. +- In **F**, the Introduction already showed all four details, so the Configure plan panel is dense: labels only, as a recap. +- The confirm popover is always a plain, dense, unverified list of four steps, so its title and content agree regardless of concept. + +## Finalization + +**Status:** Finalized and implemented (2026-08-04). Concept F shipped in `LocalQuickStart.tsx`. Where this chapter disagrees with an earlier one, this chapter wins; the earlier chapters remain as the reasoning trail. + +### Chrome is Atlas's, not a variant of it + +`AtlasCredentialsView.tsx` remains the reference implementation. The hero markup is now byte-for-byte the Atlas structure — icon, then a plain `div` holding the `h1` with the subtitle in a nested `div` — rather than the flex-column variant the prototype had drifted into. The breadcrumb was extracted to `src/webviews/components/wizard/WizardBreadcrumb.tsx` and both views consume it, so there is one implementation of overflow, `aria-current`, `disabledFocusable`, and the completed-step weight instead of three copies. + +### Names come from constants, not from prose + +The Introduction plan and the pre-launch note previously said `documentdb-local`; the container the service actually creates is `QUICK_START_CONTAINER_NAME` (`vscode-documentdb-local`). Both strings are now formatted from that constant. A user who runs `docker ps` after setup sees exactly the name the wizard promised, and the promise cannot drift from the code again. + +### The plan is scoped to the step it describes + +The Introduction sub-heading is `What will happen in the Set up step`, not `What will happen`. The old wording read as "this is about to happen", which is wrong: two more pages sit between the plan and any action. Naming the step ties the list to the breadcrumb and makes it explicit that there is still time to review everything. + +### The expectation note lives in the footer + +The pre-launch note moved out of the Configure page body and into the footer, directly above the primary button. It is no longer page content that can scroll away from the button it describes — it is part of the commit point. This deliberately increases footer height. The note runs the full footer width rather than being capped to the content column, and its info icon shares the text's first line box so the two align exactly. + +The same mechanism now labels the failure page: above `Retry setup` the footer says that retrying runs every step again from the beginning. The full-restart semantics of the big button were previously implicit in the word "Retry"; now they are stated. + +### Docker recovery has three explicit scopes + +The single-surface rule held, but one action was doing three jobs. `Check again` sat on the `Last checked` line, re-ran the Docker check, and then silently started the whole provisioning run if the check passed. The label described the smallest of those behaviours and the user got the largest. + +The three scopes are now separate and each is named for what it does: + +| Scope | Control | Effect | +| ---------- | ----------------------------------------------------- | ------------------------------------------------------------------------------ | +| One stage | `Check Docker again` link inside the failed stage row | Re-runs only the Docker check. Never starts provisioning. | +| Continue | Footer primary, relabelled `Continue setup` | Runs setup once the check stage is no longer failing. | +| Everything | Footer primary, `Retry setup` | Runs the whole process again, for users who do not want to reason about scope. | + +The recheck link lives in the stage row because that is the stage it re-runs — the same "one fact, one home" principle that put Docker on a single surface. It is deliberately **not** duplicated into the error `MessageBar`: two controls a few pixels apart with identical behaviour is the ambiguity we just removed. The `MessageBar` keeps the actions that _change_ something (`Install Docker`, `Start Docker`, `Copy command`, `Continue anyway`, `View Docker output`). + +`Start Docker` followed by successful polling now resolves the same way as a manual recheck: the blocker clears in place and the user chooses when to continue. Nothing auto-starts a run the user did not ask for. + +When a recheck passes, the check stage flips to done with its ready evidence line, a `Docker is ready` success bar appears, and the footer becomes `Continue setup`. This is only offered when the check stage was the failure — for a Docker problem hit during `pulling` or `creating` there is nothing to continue from, so the footer stays `Retry setup`. + +### `More details` was too generic + +The accordion is now `What the Docker check found`. It holds the detected problem, CLI, daemon, provider, platform, endpoint, and execution target — all facts from one check. The old title told the reader there was more without saying more about what. + +### The error bar follows the Atlas treatment + +The Docker error `MessageBar` takes an explicit `icon={}` and warning bars take ``, matching `AtlasCredentialsView`. Success bars keep the Fluent default, as Atlas does. + +Inside the bar: + +- The recovery command is a real code chip — neutral surface, `colorStatusDangerBorder1` outline, `fontFamilyMonospace`. Previously it inherited a flat grey `code` background that punched a neutral block through the error tint; a danger-tinted fill was tried and read as a second alert nested inside the first. +- Supplementary notes such as `Group membership applies to new login sessions only.` render at the default text size. They were `size={200}` and muted, which stacked a third type size into a bar that already has a title and body. +- The documentation link became an action button with a full label — `Open Linux setup guide` rather than `Linux setup guide` — so every control in the bar is a button and every label says what it does. + +### The plan list uses Fluent primitives + +The step numbers are Fluent `Badge` (`shape="circular"`, `appearance="tint"`) instead of a hand-rolled CSS circle. The list stays a semantic `
    ` laid out with `makeStyles`: Fluent v9 has no stable list primitive (only `@fluentui/react-list-preview`, which this repo does not depend on), and the Atlas view lays its own lists out the same way. + +### What still needs to be remembered, and what does not + +Two different memories were in play; only one of them belonged in the UI. + +- **The 2-second readiness memo** (`READINESS_MEMO_TTL_MS`) is kept. It is what lets the webview read back the classification the service just acted on, without probing Docker a second time. It is why the failure evidence on screen is exactly the evidence that caused the failure. +- **Provider memory** (remembering that this machine has Docker Desktop) is kept. It improves classification when the daemon is unreachable — `Start Docker Desktop` instead of a generic message — and that value is independent of where the check surfaces. +- **`getDockerLastCheckedAtMs` was deleted.** It reported `providerRecordedAtMs` for remembered evidence, which made `Last checked` show a possibly days-old timestamp. That was defensible when a readiness page could be rendered from memory. It is wrong now: the check only ever runs because the user pressed `Start DocumentDB Local` or `Check Docker again`, so `checkedAtMs` is always the honest answer. + +`Last checked` itself stays as the final line of Docker status content, and earns its place again now that `Check Docker again` exists — it tells the user how stale the evidence in front of them is while they work through the remediation. + +### The design lab is gone + +`QuickStartDesignLab.tsx`, its controller, its command, its `package.json` contribution, its static preview page, and the handoff document were removed once the design was implemented. This document is the surviving record. Reintroducing a lab is cheap if a future question needs one; keeping a dead one is not free. + +**Assessment** + +- The **note** is the best value per pixel: it states the two irreversible-looking actions (download, create a container) and the boundary (`Nothing else on your machine is changed`) in one sentence, without repeating the plan structure. +- The **plan panel** is the strongest for a first-time user and the most redundant for a repeat user. It is the right choice only if the Introduction is expected to be skipped. +- The **confirm popover** is the only option that guarantees the user reads the plan, and the only one that costs a click every time. Reserve it for a case where starting is genuinely hard to undo; setup here is not. +- **None** stays viable in E, where the Introduction plan was verified in front of the user moments earlier. + +**Decision:** the **note above the footer**, with concept F. See _Selected design_ at the top of this document. + +## Known follow-ups + +Deferred deliberately — recorded here so they are not rediscovered as new bugs. + +### Retry setup is not fully race-free yet + +`Retry setup` used to work only on every second click. `QuickStartService.provision` reports every +terminal failure by buffering it into `terminalEvent`, letting `finally` clear the `provisioning` +guard, and yielding it afterwards — but the Docker-readiness failure yielded in place, from inside +the `try`. The generator then sat suspended at that `yield` with `provisioning` still set, while the +webview already showed the failure and re-enabled the button. The next click unsubscribed the old +stream and immediately subscribed a new one, which reached the guard before the old generator had +unwound, and came back with `Setup is already in progress.`. The click after that worked, because by +then the unwind had completed. + +Fixed by routing the readiness failure through the same buffered path (a typed `DockerNotReadyError` +caught by the existing `catch`), with a regression test in `QuickStartService.test.ts`. + +**Still open:** `runStream` in `LocalQuickStart.tsx` does not await the unsubscribe before sending +the next subscription. Nothing exercises that window today, but the ordering is luck, not design. It +should become an explicit handshake that waits for the previous stream to end. + +### The error row in the tree is surprising + +When the wizard fails, `LocalQuickStartItem` adds a row under `DocumentDB Local - Quick Start` +carrying the raw error message. It is useful when the failure happened without the wizard being +open, and confusing when the user just closed the wizard that reported the same error. Decide +whether that row belongs at all, and if it does, what it should say. diff --git a/docs/ai-and-plans/local-quickstart/v1-readiness-gaps.md b/docs/ai-and-plans/local-quickstart/v1-readiness-gaps.md new file mode 100644 index 000000000..ff6b2e962 --- /dev/null +++ b/docs/ai-and-plans/local-quickstart/v1-readiness-gaps.md @@ -0,0 +1,388 @@ +# Local Quick Start — v1 production-readiness gaps + +**Status:** In progress · **Date:** 2026-06-26 +**Scope:** Gap analysis between the production design +([`local-quickstart-v2.md`](./local-quickstart-v2.md) §15 v1.0 "must ship" + UX sections +§3–§12) and the current implementation (the POC on branch `feature/local-quickstart/POC`). +**Companion:** [`decision-instance-model.md`](./decision-instance-model.md) (single-instance v1). + +This is the ranked work list to take Local Quick Start from a demo-POC to a shippable v1. +Ranking is by **user stakes**, not by effort. Each row cites the design section and the current +state. Checked rows are implemented in this branch. + +## ✅ Already implemented (POC baseline) + +Provision → 180 s wire-protocol readiness → inline browse · full 7-state machine + `Missing` +badge · all 8 lifecycle actions (Open/Start/Stop/Restart/View Logs/Copy Conn String/Copy +Password/Delete) · label-gated ownership (`vscode.documentdb.quickstart=1`) · restart-safe sample +data via `docker exec` of the image's native init script · masked OutputChannel · single managed +instance. + +## 🔴 P0 — Correctness & data loss (will bite real users) + +| # | Gap | Design | Current | What's needed | +| - | --- | ------ | ------- | ------------- | +| P0‑1 | **Persistent data volume** | §8 defaults; §11 | **Ephemeral** (no volume) | Named volume `vscode-documentdb-local-data` mounted at **`/data`** (verified `DATA_PATH=/data`). Make sample-seeding **idempotent** (skip if `sampledb` exists). Align Delete to §11 (keep volume + creds → Missing); recreate reuses both. | +| P0‑2 | **Port-conflict fallback** | §8.3 | Pre-checks 10260, hard-errors if busy | Try 10260, then up to 10 random ports in `[10260,10360)`; yellow "using 10273 instead" banner; **use the bound port from `docker inspect`** when composing/saving the conn string. Explicit (Advanced) ports are never relocated — error instead. | +| P0‑3 | **Credential transport via env-file** | §8.2 | Password on `--username/--password` **CLI args** (leaks to `ps`/history) | Pass creds as `USERNAME`/`PASSWORD` via a temp `--env-file` (deleted in `finally`). **Verified the image reads these env vars** (entrypoint `${USERNAME:-}/${PASSWORD:-}`; CLI args only override) — resolves OPEN‑1. | + +## 🟠 P1 — First-run UX (where impressions are made) + +| # | Gap | Design | Current | What's needed | +| - | --- | ------ | ------- | ------------- | +| P1‑1 | **Docker-not-ready diagnosis** | §5.3, §9 | One-line message + Retry | Per-check cards (CLI / daemon / platform), a **"Start Docker Desktop"** action (§13.2), and a Troubleshooting link. Docker-stopped is the most common first-run failure — a dead-end one-liner loses users. | +| P1‑2 | **Platform-supported check** | §9 | Not implemented | Detect unsupported CPU arch (amd64/arm64 ok); warn otherwise. | +| P1‑3 | **Success → tree handoff** | §5.5 | Auto-closes, no card buttons | Brief success card with **Open Connection** (reveal + expand the tree node) so the instance doesn't just "disappear". | +| P1‑4 | **Advanced panel** | §5.2 | ✅ **Done** | Collapsible Advanced panel: custom port (explicit-port branch of P0‑2), custom credentials, image tag, sample-data toggle. On reuse the creds/image fields hide (volume kept). 4-round 5-agent review (security + data-safety). | + +## 🟡 P2 — Ecosystem integration (upgrade trust) + +| # | Gap | Design | Current | What's needed | +| - | --- | ------ | ------- | ------------- | +| P2‑0 | **Decouple storage-zone from `isEmulator`** (prerequisite) | §7 | ✅ **Done** | Explicit `storageZone` on the model + `resolveStorageZone`; route all ops by it. Unblocks P2‑1/P2‑2. | +| P2‑1 | **Legacy emulator migration** | §4 | ✅ **Done** | One-time copy of `Emulators` → "Local Connections (Legacy)" folder (creds/auth/`emulatorConfiguration` preserved), keep Emulators as rollback, toast, retire `LocalEmulatorsItem`. 3-round 5-agent review; create-if-missing + race reconciliation. | +| P2‑2 | **TLS-exception step in the regular wizard** + connection edit dialog | §7, §7.3 | ✅ **Done** (step); §7.3 edit dialog deferred | The emulator wizard is being removed; this is its replacement. Gated host step defaulting to *Enable TLS*; TLS-allow-invalid now keyed off `disableEmulatorSecurity` alone and host-gated to local/private hosts only. | +| P2‑3 | **Manual-wizard `10255`→`10260`** | §13.5 | ✅ **Done** | Design: *"must be fixed before Quick Start ships."* DocumentDB-local default is now `10260`; `10255` retained only for the Cosmos Mongo‑RU experience. | + +## 🔵 P3 — Observability & robustness + +| # | Gap | Design | Current | What's needed | +| - | --- | ------ | ------- | ------------- | +| P3‑1 | **Telemetry** | §14 | None | Event taxonomy (`quickstart.*`); never send names/ports/creds, only resolved semver. Expected for production. | +| P3‑2 | **Multi-window coordination** | §12 | Refresh-on-expand only | Destructive actions re-check live state; *"now Stopping from another window"* message. | +| P3‑3 | **Terminal-first transparency** | §5.4 | OutputChannel stream | Design runs docker as VS Code **terminal tasks** (Tomaz emphasized this). Confirm v1 decision vs. accepting the OutputChannel. | +| P3‑4 | **Accessibility** | — | ✅ **Done (v1.1)** | Per-field validation, live regions for staged progress + terminal states, list semantics, focus management (committed `8a08c2c3`). | +| P3‑5 | **Readiness on-timeout actions** | §9.1 | ✅ **Done (v1.1)** | On a readiness timeout the container is KEPT running and the webview offers **Wait longer** (re-probe, no re-pull) / **View logs** / **Start over** (discard; data-safe). Retain-and-resume state machine; 3-round 5-agent review. | + +## Recommended v1 cut line + +- **Must-have:** P0‑1, P0‑2, P0‑3 · P1‑1, P1‑2 · P2‑1, P2‑3. +- **Strongly-want:** P1‑3 · P3‑1 · P3‑2 · P2‑2. +- **Can slip to v1.1:** P3‑3 terminal-first (**in progress** — decided to align with the design) · P3‑4 a11y (**done**) · P3‑5 on-timeout (**done**). + +## Implementation log + +- _2026-06-26_: doc created; image facts verified (`/data` volume mount, env-var creds, init dir + `/init_doc_db.d`). Starting P0. +- _2026-06-26_: **P0 complete + verified live.** + - **P0‑1 volume:** named volume `vscode-documentdb-local-data` at `/data`; seeding made + **idempotent** (skip if `sampledb` exists); **Missing→recreate reuses stored creds + volume** + (verified: data persists across container removal+recreate); **explicit Delete** now also drops + the volume (honest clean slate — the data-preserving Reset split is v1.2). + - **P0‑2 port fallback:** `findAvailablePort` (10260 → up to 10 random in band) + bound-port + readback; substitution surfaced in the `checking` stage message. (Interactive + "Change port" banner needs the Advanced panel, P1‑4.) + - **P0‑3 env-file:** creds now pass via a temp `--env-file` (mode 600, deleted in `finally`) as + `USERNAME`/`PASSWORD`; **verified live** the image authenticates with env-file creds and nothing + lands on the docker CLI. Resolves OPEN‑1. +- _2026-06-26_: **P1‑1 + P1‑2 complete (build-verified).** + - **P1‑2 platform:** `DockerReadiness` gains `arch`/`platformSupported` (host arch x64/arm64). + - **P1‑1 diagnosis:** Docker-not-ready view rebuilt into per-check cards (CLI / daemon / platform) + + **"Start Docker Desktop"** action (`startDockerDesktop`, best-effort per-OS launch) + Install / + Troubleshooting links. Review "Data" card corrected to **Persistent volume**. + - Gates green: l10n · prettier · lint · jest (2055/2055) · build · webpack-prod. +- _Remaining:_ P1‑4 Advanced panel, P2 (migration / TLS wizard / 10255), P3‑4 a11y, P3‑5 + readiness on-timeout actions. + +- _2026-06-26_: **P1‑3 + P3‑1 + P3‑2 complete (build + gates verified).** + - **P1‑3 success handoff:** success card now shows **Open Connection** (focuses the Connections + view, then closes) + **Copy Connection String**; auto-close delay extended so the buttons are + usable. New router mutations `openConnection` / `copyConnectionString`. + - **P3‑2 multi-window:** `start/stop/restart` now re-check live Docker state via `liveStateGuard` + immediately before acting; if another window already changed it, the tree refreshes and the user + is told *"changed in another window (now …)"* instead of acting on stale state (§12). + - **P3‑1 telemetry:** `documentDB.quickstart.provision` event (result · reused · portFallback · + provisionMs); `getDockerStatus` now reports `dockerReadiness` + `platformSupported`; lifecycle + commands tag `action`. No names/ports/creds sent (§14). + - Gates green: l10n · prettier · lint · jest (2055/2055) · build · webpack-prod. + +- _2026-06-26_: **P2‑1 attempted → REVERTED (architectural blocker found by 5-agent review).** + - A first cut (copy each `Emulators`-zone connection into a "Local Connections (Legacy)" folder + in the `Clusters` zone, keep the Emulators zone as rollback, gate the legacy node on a + completion flag) was implemented and passed all gates (build/lint/jest 2055). + - The mandatory 5-agent rubber-duck review **caught a release blocker** (GPT‑5.4 + GPT‑5.5 REJECT; + Opus 4.6/4.7 missed it). **Verified directly in code:** `emulatorConfiguration.isEmulator` is + **overloaded** — it is the **storage-zone selector** in connect/rename/delete/move/ + update-credentials/update-connection-string paths and in `DocumentDBClusterItem` + (`isEmulator ? Emulators : Clusters`), *and* `connectToClient.ts:25` **requires** + `isEmulator && disableEmulatorSecurity` for local TLS-allow-invalid. So a migrated connection + living in the `Clusters` zone cannot be made correct: keep `isEmulator=true` → all operations + look it up in the **wrong zone** (broken connect/delete/rename); set `isEmulator=false` → + **TLS-allow-invalid breaks** (can't reach the self-signed local server). The completion flag would + then hide the working originals → effectively unreachable. + - Citations: `src/commands/removeConnection/removeConnection.ts:81`, + `src/commands/connections-view/moveItems/moveItems.ts:129`, + `src/commands/updateCredentials/updateCredentials.ts:53`, + `src/commands/updateConnectionString/updateConnectionString.ts:42`, + `src/commands/connections-view/renameConnection/renameConnection.ts:24`, + `src/tree/connections-view/DocumentDBClusterItem.ts:61,101,173`, + `src/documentdb/connectToClient.ts:25`. + - Secondary findings (also valid): `getAll()` triggers storage bootstrap **cleanup that iterates + the Emulators zone** (weakens the "untouched rollback" guarantee); a snapshot **race** if emulator + data changes during the migration window; corrupt/folder items skipped by the storage wrapper + become invisible once the node is gated off. + - **Conclusion:** P2‑1 has a hard **prerequisite (P2‑0)** — decouple *storage-zone selection* from + `emulatorConfiguration.isEmulator` (add an explicit `storageZone`/`connectionType` on the + connection model and route all operations by it; make TLS-allow-invalid depend on + `disableEmulatorSecurity` alone). This is essentially the **§7** "move emulator/TLS handling out + of a dedicated zone" work, and P2‑2 (TLS wizard) shares the same root cause. Reverted the cut; + branch left clean. + +- _2026-06-26_: **P2‑0 decoupling + P2‑1 migration — DONE (3-round 5-agent review, consensus on correctness).** + - **P2‑0 (decouple zone from `isEmulator`):** added `storageZone?: StorageZone` to + `ConnectionClusterModel` + a `resolveStorageZone(cluster)` helper (prefers explicit zone, falls + back to the old `isEmulator` inference for safety). Stamped `storageZone` at the 3 construction + sites (`ConnectionsBranchDataProvider`→Clusters, `FolderItem`→`_connectionType`, + `LocalEmulatorsItem`→Emulators) and routed every zone decision through the helper + (`DocumentDBClusterItem` ×3, `removeConnection`, `moveItems`, rename/updateCredentials/ + updateConnectionString wizards). `isEmulator` is kept ONLY for behaviour (TLS/timeouts/icons). + +`resolveStorageZone` unit tests. + - **P2‑1 (migration), now correct on the decoupled arch:** copies keep `isEmulator:true` for TLS + and are rendered by `FolderItem` with `storageZone:Clusters`, so all operations route to Clusters. + - **3 review rounds (GPT‑5.4/5.5 xhigh, Opus 4.6/4.7/4.8 max):** + - R1 → caught the architectural blocker (above) → led to P2‑0. + - R2 on P2‑0+P2‑1 → **blocker resolved (5/5)**; found a partial-retry **BLOCKER** (overwrite could + revert user edits) + a URI-handler **MAJOR** (deep-link saves to the hidden Emulators zone). + Fixed: migration is now **create-if-missing** (never overwrites); URI handler routes new local + connections to Clusters once retired. + - R3 on the fixes → **unanimous the core is correct & data-safe; blocker stays resolved; no + regression.** Applied the reviewers' remaining hardening: a **reconciliation re-scan** before the + completion flag (closes the activation-window race), explicit `overwrite:false` (defense-in-depth), + an `isFolder` guard on the reused legacy folder, and telemetry refinement. + - **Known follow-up (pre-existing, documented):** the URI handler's deep-link **reveal** uses a flat + tree path, so auto-reveal of a connection *nested in a folder* (incl. a migrated one) can fail; the + connection is still found and navigable. Fix = folder-aware reveal via `buildFullTreePath` + + recursive `findNodeById` (tracked, not a regression). + - Gates green throughout: build · lint · jest (2058/2058, +3 tests) · l10n · prettier. + +- _2026-06-26_: **P2‑3 manual-wizard default port `10255`→`10260` — DONE (committed `441d9bda`).** + - `newLocalConnection/PromptConnectionTypeStep.ts`: the **DocumentDB** local branch now defaults to + `10260`; the **Cosmos Mongo‑RU** branch legitimately keeps `10255` (its real emulator port). + `PromptPortStep.ts` default is now experience-aware. Resolves the §13.5 "must fix before ship". + - Gates green: build · lint · jest · l10n. + +- _2026-06-26_: **P2‑2 TLS-exception wizard (§7) — DONE (3-round 5-agent review, consensus on correctness).** + - **Decouple TLS from `isEmulator`:** TLS-allow-invalid is now keyed off + `emulatorConfiguration.disableEmulatorSecurity` **alone** at all five option-builder sites + (`connectToClient`, `NativeAuthHandler`, `MicrosoftEntraIDAuthHandler`, `PlaygroundEvaluator`, + `ShellSessionManager`); the fail-fast `serverSelectionTimeoutMS=4000` and the `ClustersClient` + friendly-error messages were broadened from `isEmulator` to `isEmulator || disableEmulatorSecurity`. + All 5 reviewers confirmed this weakens **no** existing connection (only emulator paths set the flag). + Tree UX (`DocumentDBClusterItem`) keys its TLS description/tooltip off `disableEmulatorSecurity`. + - **Single source of truth canonicalizer:** new `tlsException.ts` (`canonicalizeTlsException`, + `stripTlsBypassParams`, `areAllHostsLocal`, `resolveAllowInvalidCertificates`). At **write time** it + strips every TLS-bypass URL param (`tls/sslAllowInvalidCertificates`, `tlsInsecure`, + `tls/sslAllowInvalidHostnames`, **and** `rejectUnauthorized` — inverse semantics, case-insensitive) + from the **stored** string and host-gates the exception, so the wizard/deep-link/update can never + create an accidental allow-invalid exception for a public host. Applied at all four write paths + (PromptConnectionStringStep, newConnection/ExecuteStep, updateConnectionString/ExecuteStep, + vscodeUriHandler). + - **Host classifier hardening (§7.1):** `isLocalOrPrivateHost` now IDNA-normalizes the host + (`normalizeHostForClassification`: maps the Unicode full-stop homographs U+3002/U+FF0E/U+FF61 → `.`, + then `domainToASCII`) so a public domain (e.g. `example。com`, which DNS resolves as `example.com`) + can't masquerade as a single-word local host. + - **Hybrid runtime policy:** `resolveAllowInvalidCertificates(disableEmulatorSecurity, cs)` returns + `true` only when `disableEmulatorSecurity && areAllHostsLocal(cs)`, else `undefined` (**never** + `false`). The 5 builders honor the stored flag **only for local/private hosts**; for a public host a + bare orphaned flag is **not** activated (so an old connection whose `tlsAllowInvalidCertificates` + param was later edited out can't silently disable validation), while an explicit URL param is still + honored by the driver (a self-hosted DB on a public hostname keeps working). + - **7 review rounds (GPT‑5.4/5.5 xhigh, Opus 4.6/4.7/4.8 max):** + - R1 → confirmed the decoupling is safe; found a connection-string second-source-of-truth, a + mixed-seed-list `.some` gap, the EntraID handler missing the flag, and `ClustersClient`/timeouts + still keyed off `isEmulator`. Fixed via the shared canonicalizer + `.every` gating. + - R2/R3 → caught + fixed a **latching BLOCKER** (the flag only ever *upgraded*), the **hostname-bypass** + strip gap, and a shell **`isEmulator` mislabel**; both ExecuteSteps now authoritatively host-gate. + - R4/R5 → caught the **Unicode-dot homograph** classifier bypass (fixed via IDNA normalization) and a + `rejectUnauthorized` hygiene gap (now stripped). A runtime "force-validate public hosts" attempt was + explored, then **rejected by the product owner** (it broke self-hosted public-host DBs and blocked the + future §7.3 public-exception dialog). + - R6/R7 → caught the **orphaned-flag** edge (a pre-existing public connection whose bypass param was + edited out keeps an inert flag the decoupling would activate) → resolved with the **hybrid runtime + policy** above, which honors explicit params but not bare flags on public hosts. + - R8 → **consistency pass**: extended the same host-gate (`resolveAllowInvalidCertificates`) to every + remaining flag-driven runtime surface — the 4s fail-fast `serverSelectionTimeoutMS` + (NativeAuthHandler/PlaygroundEvaluator/ShellSessionManager), the `ClustersClient` "local instance" + friendly-error copy, and the `DocumentDBClusterItem` "⚠ TLS/SSL Disabled" tree badge/tooltip — so an + orphaned public-host flag is now **fully inert** (no allow-invalid, no fast-fail, no mislabel). + - **§7.3 connection edit dialog deferred** — the design itself tracks it as a separate issue. + - Gates green: build · lint · jest (2135/2135) · l10n · prettier · production webpack. + +- _2026-06-30_: **P1‑4 Advanced panel (§5.2) — DONE (4-round 5-agent review, unanimous consensus).** + - **Feature:** collapsible FluentUI Advanced accordion on the review screen — custom **host port** + (feeds the explicit-port branch of P0‑2: a conflict errors, never auto-relocates), custom + **username/password**, **image tag**, and a **Load sample data** toggle. A new + `advancedOptionsSchema` (zod) validates on the wire; the summary + review cards reflect the + effective port/image/credentials; a failed provision gets an **Edit settings** button back to the + form. New `AdvancedQuickStartOptions`, `resolveQuickStartImage(tag)`, `StageEvent.boundPort`. + - **Security (creds off the host shell, §8.2):** sample-data seeding runs the image's init script via + `ContainerRuntime.execShellInContainer`, which references `"$USERNAME"`/`"$PASSWORD"` from the + **container's own** env (set by the `--env-file` at run) inside a **`ShellQuoting.Strong`**-quoted + `sh -c`. Verified end-to-end (WSL bash + lib trace + 5 agents): the host shell never expands the + refs (single-quoted on bash, escaped-double-quoted on cmd; cmd ignores `$`), so credentials never + hit the host argv/process list on **either** platform. This let the earlier `%`-in-password band-aid + be removed (creds are validated control-char-only now — the env-file newline-injection vector stays + blocked at zod + `writeEnvFile`; `%` round-trips safely via the env-file + percent-encoded conn + string). _A first cut used the default array-arg quoting (`Escape`), which leaks/empties the refs on + POSIX and word-splits on Windows — caught by the review and fixed to `Strong`._ + - **Data safety:** the reuse decision is now keyed on **stored credentials existing** (live + SecretStorage), not the in-memory `Missing` flag, so re-running setup can **never** silently wipe a + reusable data volume (e.g. after a window reload + external container removal). A fresh, + volume-wiping provision is strictly the explicit **Delete**-then-recreate path. + - **Recreate fidelity:** `InstanceMetadata.imageRef` + a durable `globalState` record + (`documentdb.quickstart.imageRef`, written on provision, **backfilled on reconcile/adopt**, cleared + on Delete) so a recreate — even across a reload — reuses the **original** image, not `latest` + (the "image is kept" promise is now true). `getDockerStatus` surfaces a `willReuse` flag computed + from the **same** predicate `provision()` uses, and the webview derives `isRecreate` strictly from + it — so the credential/image inputs are hidden (and the summary relabels "Reused/Kept from the + existing instance") whenever, and only when, the service will actually reuse. + - **Also:** server-side both-or-neither credential `.refine()` (parity with the client); whitespace + trim consistent client↔zod↔service; custom port preserved in the success message/conn string via a + `chosenPort` inspect fallback; telemetry stays booleans-only (`customPort/customCreds/customImage/ + sampleData`). + - **Review (GPT‑5.4/5.5 xhigh, Opus 4.6/4.7/4.8 max):** R1 → env-file newline + client/server + validation gaps. R2 → recreate image-tag loss + whitespace divergence + hardcoded review-card port. + R3 → **the `Escape`→`Strong` seed-quoting fix** (independently reproduced by 3 agents) + durable + reuse/UI-divergence blockers (GPT‑5.4/5.5/Opus‑4.8). R4 → durable `imageRef` + `willReuse` parity + landed; GPT‑5.4's last two refinements (reconcile backfill, `isRecreate` strictly `= willReuse`) + applied and re-confirmed → **unanimous APPROVE**. + - Gates green: build · lint · jest (2139/2139) · l10n · prettier. + +- _2026-06-30_: **P3‑4 Accessibility (v1.1) — DONE (committed `8a08c2c3`, 3-agent review).** + - Advanced inputs now surface errors via FluentUI `Field` `validationState`/`validationMessage` + (programmatically associated, `aria-invalid`) instead of one detached message; a polite live + region streams the current provisioning stage; failed / Docker-not-ready states are announced; + the stage list is `role="list"`/`listitem` with natural row `aria-label`s and decorative icons + hidden; on failure the active stage flips to error (no stuck "in progress"); focus moves to the + primary result action when provisioning ends. WCAG 2.4.3 / 3.3.1 / 1.1.1 / 4.1.3. + - Gates green: build · lint · jest (2139/2139) · l10n · prettier. + +- _2026-06-30_: **P3‑5 Readiness on-timeout actions (§9.1, v1.1) — DONE (3-round 5-agent review).** + - **Behavior:** a readiness timeout no longer tears the container down. It is KEPT running (it may + just be slow to initialize) and the webview offers **Wait longer** (re-probe the same container + for another window — no re-pull), **View Docker output** (the §9.1 "Logs"), and **Start over** + (discard; a fresh attempt's half-initialized volume is wiped, a reusing attempt's real data is + kept). New `ReadinessTimeoutError`, `PendingReadiness` retained state, `resumeReadiness` generator, + `discardTimedOutInstance`, `waitLonger` subscription + `discardTimedOut` mutation, and a + `canResumeReadiness` status flag so reopening the panel rehydrates the recovery actions. + - **Data safety (the hard part):** the provision `try/catch/finally` was refactored to extract a + shared non-yielding `finalizeReadyInstance` while preserving the `setStatus(Running)→success=true` + ordering (so an unsubscribe after the terminal event can never tear down a healthy container). + Terminal events are **buffered and yielded after `finally`** so the flags are clean before the + webview shows Wait longer / Retry (no fast-click race). A webview stream-generation guard ignores + trailing callbacks from a superseded/cancelled stream. + - **Reviewed 3 rounds (GPT‑5.4/5.5 xhigh, Opus 4.6/4.7/4.8 max):** R1 → ~12 findings (races, cancel + handling, resumable-state, telemetry, log streaming, focus) → hardening pass. R2 → **unanimous 5**; + caught a **data-loss BLOCKER** (a well-meaning `reconcile` volume-removal that `!stored` doesn't + prove is disposable — reverted to container-only cleanup). R3 (focused) → confirmed; the one + late-callback concern was **empirically disproven** (the webview tRPC client unregisters its + handler synchronously on unsubscribe, `vscodeLink.ts:187-199`) and additionally guarded. + - **Known limitation (deferred to v1.2, all reviewers non-blocking):** after a window reload, + `reconcile()` adopts a *reusing* timed-out container as `Running` without re-probing readiness — + it could briefly show a not-yet-ready connection as healthy (no data loss; recoverable via + Restart/Delete). Fix = a bounded `ping` before promoting to `Running`, or a durable pending marker. + - Gates green: build · lint · jest (2139/2139) · l10n · prettier. + +- _2026-07-01_: **Branch integration — POC merged into `dev/feature/local-quickstart` off `main`.** + - The POC was **305 commits behind / 38 ahead** of `origin/main`. Created `dev/feature/local-quickstart` + from `origin/main` and merged `feature/local-quickstart/POC` with `--no-ff` (commit `0a8b50e8`). + - **3 conflicts resolved:** `ClustersClient.ts` (kept both imports); `DocumentDBClusterItem.ts` imports + (kept main's superset); the **TLS badge** — semantically integrated POC's host-gated + `resolveAllowInvalidCertificates` + emulator ✅ with main's non-emulator `isTlsDisabled()` branch + (`if allow-invalid ⚠ · else if emulator ✅ · else if isTlsDisabled() ⚠`); `l10n/bundle.l10n.json` + regenerated (1653 keys). Main added the `@kubernetes/client-node` dependency → `npm install`. + - Verified: build · lint · **jest 2706/2706 (154 suites)** incl. the tlsException/hostClassification + suites. Pushed explicitly to `dev/feature/local-quickstart` (not main). + +- _2026-07-06_: **Holistic PR-readiness review (3 rubber-duck agents + direct code verification).** + Lenses: **rd-design** (GPT‑5.5, design-completeness) · **rd-prod** (Opus 4.8, production) · **rd-pr** + (Opus 4.7, PR/maintainer). rd-pr **independently validated the merge** — "no merge regression detected"; + the TLS-badge resolution matches runtime by construction (UI + `connectToClient`/auth handlers consult the + **same** `resolveAllowInvalidCertificates`). **Bottom line: clean merge, well-reasoned code, but NOT ready + to land as a single, always-on, unverified PR to main.** + - **✅ Verified concrete bugs (confirmed in code this session):** + - 🔴 **Published port binds `0.0.0.0`, not loopback** — `ContainerRuntime.ts:233` omits `hostIp`, and + `@microsoft/vscode-container-client` `withDockerPortsArg` emits `--publish 10260:10260` → LAN-exposed + DB, contradicting the "Runs on: This machine / localhost" UX (`LocalQuickStart.tsx:553,846`). **One-line + fix:** pass `hostIp: '127.0.0.1'` (also makes the bind consistent with the `127.0.0.1` `isPortFree` + pre-check at `ContainerRuntime.ts:170`). + - 🟡 **Running tree row loses its description** — `QuickStartClusterItem` (`LocalQuickStartItem.ts:34,37`) + sets `descriptionOverride = "Running · localhost:{port}"` but inherits `DocumentDBClusterItem.getTreeItem()` + (`:452-462`), which returns the TLS-computed `description` and **ignores `descriptionOverride`** (the + grandparent `ClusterItemBase.getTreeItem()` `:376` honors it; the subclass override drops it). So the + Running row shows "⚠ TLS/SSL Disabled" instead of the port/state. **Pre-existing in the POC, NOT a merge + regression** (verified against `feature/local-quickstart/POC`). Cosmetic. Fix = have the Quick Start item + merge `descriptionOverride` with the badge (other state rows are plain `TreeElement`s and render fine). + - **🔴 Consensus blockers (multiple agents):** + - **No automated tests on the risk-bearing surface** (rd-prod C1 + rd-pr B2). `provision`/`resumeReadiness`/ + `discardTimedOutInstance`/`reconcile`/`liveStateGuard`/lifecycle — the code where **data loss and races + live** — is guaranteed only by inspection; only pure functions are covered. Both note infra exists + (singletons + `src/__mocks__/vscode.js`); the seam to add is an **injectable `ContainerRuntime` interface**. + Highest-value: the cleanup matrix + `removeVolume` only-when-`!reusing` guard (`:315-317`), reuse decision + (`:251-266`), timeout→resume (`:435-443`), `reconcile` no-secret-keeps-volume (`:1041-1053`). + - **Never live-run in the *merged* state** (rd-prod C1/I8 + rd-pr B1). POC was live-verified 3× on Windows + (each caught a real bug tests can't — verbatim args, restart-safety, false-`Running`); since then +305 + commits + 3 conflicts. **macOS/Linux never verified at all**; `startDockerDesktop` Linux path is a guess + that returns `true` even when it no-ops (`ContainerRuntime.ts:399`). + - **Terminal-first (§P3‑3) unresolved** (rd-design + rd-pr B3). Doc says "design-required by Tomaz, in + progress"; code still uses `OutputChannel`. **Product decision — the item most likely to trigger a rewrite + request at review.** Needs an explicit yes/no from the design owner before opening the PR. + - **🟠 Missing (real gaps):** §7.3 TLS edit dialog · user-facing docs (CHANGELOG last at 0.9.1, no + release-notes, no walkthrough step) · **i18n of service-originated strings** (`QuickStartService.ts` success + headline `:428` + all error copy `:234,299,326,337,434,570,629,718,899,935` are hardcoded English; the l10n + gate does **not** catch un-wrapped literals) · telemetry taxonomy reconciled with §14 (`documentDB.quickstart.*` + vs design `quickstart.*`, **double-emitted** with the tRPC middleware auto-event) + verified against a real sink. + - **🟠 Not considered (ops/edge):** `viewQuickStartLogs` leaks an **uncancellable `docker logs -f` per click** + (`localQuickStartCommands.ts:102`) · **no timeout on a hung `docker info`** → dead spinner in `phase==='loading'` + with no Cancel (`LocalQuickStart.tsx:724-729`) · **unconditional `docker ps` on every activation** + (`reconcile()` cost for the non-user majority) · partial legacy-migration shows **silent duplicate** nodes · + Service Discovery port-forward hosts have **no `PromptTlsExceptionStep` path** · uninstall orphans + container+volume+secret (inherent; document a pre-uninstall cleanup note). + - **🔵 Reframed — intentional deviations, NOT oversights (reconcile with design owner; do NOT auto-"fix"):** + - **Delete drops the volume** — documented v1.0 decision (log above) + matches the user's explicit + delete-while-running data-loss-warning request. Design §11 keeps the volume on Delete (Reset is the + destructive v1.2 split). *Needs sign-off, not a code change.* + - **Success page stays open / no auto-close** and **Open Connection keeps the panel open** — explicit user + feedback (committed `9342dbff`), deliberately overriding the design's auto-close. + - **Single instance; recreate reuses volume+creds** — settled decision (`decision-instance-model.md`). + - **180 s readiness timeout** vs the design's 60 s — chosen for slow first-pull safety; accept or align. + - **📋 Procedural (rd-pr):** **split the 65-file / +8.8k-line PR into 3** — §7 TLS-exception (affects all + users, well-tested) · P2 legacy migration (self-contained) · Quick Start (gate behind a flag). A reviewer + can't catch a §7 regression buried under ~1.6k lines of Quick Start UI. **Add + `documentDB.experimental.enableLocalQuickStart`** (precedent: `enableAIQueryGeneration`), default off until + live-verified on all platforms. + - **What's left for a successful review:** + 1. **Product decisions first (gate everything — for Tomaz):** terminal-first now/defer/renegotiate · PR split + 1-vs-3 · preview flag yes/no. + 2. **Verification:** live E2E on the *merged* branch, Windows + macOS min (fresh provision → legacy migration + → stop/start/restart/delete → `reconcile()` after reload → Docker-daemon-absent). + 3. **Tests:** state-machine smoke tests (happy path + timeout→resume + `reconcile` cases) with an injected + `ContainerRuntime`, **or** maintainer sign-off that live-Docker is the v0.10 acceptance bar. + 4. **Ship hygiene:** CHANGELOG + release-notes + tracking issues (§7.3, §P3‑3 if deferred, reconcile-reused-timeout v1.2 slip). + 5. **Quick wins (do now):** loopback bind (🔴 1 line) · Running-row description · `viewQuickStartLogs` follow-stream leak. + - No code changed in this pass (review only); the three findings above are candidate fixes pending the product decisions. + +- _2026-07-06_: **3 quick-win fixes from the PR-readiness review — DONE (5-agent review, all-correct consensus).** + - **Loopback bind (security):** `ContainerRuntime.createAndRunContainer` now publishes the port with + `hostIp: '127.0.0.1'` (`--publish 127.0.0.1::`) so the local instance — auto-generated creds + + TLS-allow-invalid — is no longer reachable from the LAN. Matches the `127.0.0.1` `isPortFree` pre-check and + the "Runs on: This machine" UX. + - **Running tree row description:** `QuickStartClusterItem` now overrides `getTreeItem()` to keep the base + item (icon, security tooltip, context value) but force the state-aware description, so the Running row shows + "Running · localhost:" instead of the inherited "⚠ TLS/SSL Disabled" (the base + `DocumentDBClusterItem.getTreeItem()` derives description from TLS state and ignores `descriptionOverride`). + Only the Running row is affected; other states are plain `TreeElement` rows. + - **`viewQuickStartLogs` follow leak:** the command now keeps a single module-level `CancellationTokenSource` + and cancels/disposes the prior follow before starting a new one, so repeated "View Logs" clicks no longer + stack concurrent `docker logs -f` streams (verified end-to-end: cancellation `tree-kill`s the child process, + not just the read loop; the provisioning-side follow uses an independent CTS and is unaffected). + - **5-agent review (GPT‑5.4/5.5 xhigh, Opus 4.6/4.7/4.8 max):** all five confirmed the three changes are + **correct and regression-free**; Fixes 2 & 3 unanimous with zero findings. Gates: prettier · lint (0 new) · + jest 2706/2706 · build. + - **Tracked follow-up (out of scope for these quick-wins; feature is unreleased → no shipped users affected):** + the loopback bind applies only to *newly created* containers — a container created before this fix keeps its + 0.0.0.0 binding through Start/Stop/Restart (Docker fixes port bindings at create time); only a re-provision + (Delete → Quick Start) re-binds it. **Follow-up:** on `reconcile()`/adopt, detect a non-loopback published + binding and re-secure via the data-safe recreate (reuse volume + creds), or at minimum a release-note + callout. Raised by GPT‑5.4 (blocking) + Opus‑4.7 (non-blocking nit); deferred by decision (unreleased → + a `docker rm` clears interim test containers). \ No newline at end of file diff --git a/docs/atlas-mongodb-discovery-flow.md b/docs/atlas-mongodb-discovery-flow.md new file mode 100644 index 000000000..79b351d69 --- /dev/null +++ b/docs/atlas-mongodb-discovery-flow.md @@ -0,0 +1,159 @@ +# MongoDB Atlas Discovery Flow + +This document describes the current MongoDB Atlas Service Discovery flow, from provider +registration through credential management, merged resource discovery, and cluster connection. + +## Architecture overview + +```mermaid +flowchart TD + A[Extension activation] --> B[AtlasDiscoveryProvider] + B --> C[AtlasServiceRootItem] + C --> D{Stored credentials?} + D -->|No| E[Add credential webview] + D -->|Yes| F[AtlasDiscoveryService.listAll] + E --> G[AtlasCredentialStore] + G --> F + F --> H[AtlasCredentialSessionRegistry] + H --> I[Atlas Admin API per credential] + I --> J[Merge by organization, project, and cluster ID] + J --> K[Tree or list view] +``` + +The provider supports multiple API Keys and Service Accounts. Each credential owns an independent +storage item and session. Discovery fans out across all credentials, keeps healthy results when a +peer fails, and merges duplicate Atlas resources by their Atlas IDs. + +## Provider registration and root expansion + +1. `ClustersExtension.registerDiscoveryServices()` registers `AtlasDiscoveryProvider`. +2. `AtlasDiscoveryProvider` owns one `AtlasDiscoveryService` and returns an + `AtlasServiceRootItem` for the Service Discovery tree. +3. `AtlasServiceRootItem.getChildren()` reads all stored credentials. +4. With no credentials, the root shows **Sign in to view MongoDB Atlas clusters**. The command + opens the guided credential webview. +5. With credentials, the root calls `AtlasDiscoveryService.listAll()`. Tree mode renders + organizations; list mode renders a flat, deduplicated cluster list. + +## Adding a credential + +`openAtlasCredentialsWebview()` opens one guided surface for both supported authentication +methods: + +- **Service Account**: Client ID and Client Secret. The host first mints an OAuth2 access token, + then verifies that the credential can list projects. +- **API Key**: Public Key and Private Key. The host verifies the pair with an authenticated Atlas + Admin API project-list request. + +The webview submits through `atlasCredentialsRouter`. Validation happens in the extension host +before persistence. A failed authentication, access-list check, permissions check, or network call +returns an inline error and leaves storage unchanged. A successful submit calls +`upsertAtlasCredential()` and refreshes discovery. + +## Credential storage and identity + +`atlasCredentialStore` stores one `StorageItem` per credential under +`atlas-mongodb-discovery/credentials`: + +- `properties` contains non-secret metadata: authentication method, label, cached organization, + stable order, and an 8-character identity hint used only as a display fallback. +- `secrets` contains the complete credential identity and secret: Public Key plus Private Key, or + Client ID plus Client Secret and the cached Service Account token. +- `id` is a stable `randomUUID()` generated when the record is created. Tree paths, session state, + and saved connections use this record ID rather than secret material. + +The complete Public Key or Client ID is the credential identity. `upsertAtlasCredential()` reads +the stored secret slots and compares the complete identity; the short metadata hint is never used +for matching. This matters for Service Accounts because their Client IDs share the `mdb_sa_id_` +prefix. + +Re-entering the exact same identity updates its existing record and keeps the stable record ID. +Entering a different Public Key or Client ID creates a separate credential, even when both values +share the same display prefix. + +## Updating and removing a credential + +**Manage MongoDB Atlas Credentials** opens the AzureWizard-based credential manager. Selecting a +credential offers Retry, Open in MongoDB Atlas, Update credentials, Sign out, Back, and Exit. + +During **Update credentials**: + +1. The existing Public Key or Client ID is loaded from SecretStorage and passed to the webview as + non-secret configuration. +2. The identity input is populated and disabled. Only the Private Key or Client Secret can be + changed. +3. The router verifies that the submitted authentication method and complete identity still match + the stored record before contacting Atlas. +4. The replacement secret is validated against Atlas. +5. `replaceAtlasCredentialSecrets()` enforces the identity invariant again and replaces only the + paired secret while preserving the record ID, order, and metadata. + +Changing a Public Key or Client ID is intentionally not an update operation. The user signs out of +the old entry and adds a new credential. A failed update leaves the previous working secret intact. + +Retry refreshes only the selected credential. Sign out deletes only that credential and invalidates +its session. Sign out of all removes every stored credential. + +## Discovery aggregation + +`AtlasDiscoveryService.listAll()` is the shared discovery surface for the tree, list mode, and the +new-connection wizard: + +1. Credentials are queried through a bounded concurrency limiter. +2. `AtlasCredentialSessionRegistry` resolves an independent session for each credential. Service + Account token refresh touches only the owning credential. +3. Each credential lists organizations and projects. List mode also preloads clusters; tree mode + loads clusters when a project expands. +4. Individual failures are classified and returned beside healthy results rather than escaping the + aggregation. +5. Organizations, projects, and clusters are merged by Atlas ID. Each merged entry remembers all + credentials that can reach it and one healthy `ownerCredentialId` for follow-up requests. + +The snapshot has a short TTL to keep one expansion burst coherent without freezing the tree. +Explicit refresh discards the snapshot and re-derives Service Account sessions so role changes made +in Atlas become visible immediately. + +## Tree and recovery behavior + +Tree mode renders `organization -> project -> cluster`. List mode renders clusters directly with +`organization · project` context. Healthy nodes do not expose credential attribution. + +Credential failures do not remove healthy peer data. The root adds one recovery row whose action is +chosen from the failure taxonomy: + +- Authentication or permissions failures open credential management. +- Network, rate-limit, and other transient failures retry discovery. +- Mixed failures open the manager, which also provides fleet retry. + +A healthy empty result uses the standard `empty` placeholder because retrying an authoritative +empty response cannot change it. + +## Cluster connection + +Selecting a discovered cluster preserves the merged entry's healthy `ownerCredentialId` through +the connection wizard. The discovery credential can list available database users, but it does not +authenticate the database connection itself. Database access still uses a separate SCRAM username +and password, cached against the cluster's stable `clusterId`. + +## Primary files + +| File | Responsibility | +| -------------------------------------------------------------------------- | --------------------------------------------------------- | +| `src/plugins/service-atlas-mongodb/AtlasDiscoveryProvider.ts` | Provider registration, root creation, and wizard entry | +| `src/plugins/service-atlas-mongodb/credentials/atlasCredentialStore.ts` | Independent credential persistence and identity rules | +| `src/plugins/service-atlas-mongodb/auth/AtlasCredentialSessionRegistry.ts` | Per-credential sessions and Service Account token refresh | +| `src/plugins/service-atlas-mongodb/discovery/AtlasDiscoveryService.ts` | Fan-out, error isolation, caching, and resource merge | +| `src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts` | Tree/list rendering and root recovery behavior | +| `src/plugins/service-atlas-mongodb/credentialsManagement/` | Credential list and per-credential actions | +| `src/webviews/documentdb/atlasCredentials/` | Guided add/update webview and host-side validation | +| `src/plugins/service-atlas-mongodb/discovery-wizard/` | Project and cluster selection with credential ownership | +| `src/plugins/service-atlas-mongodb/discovery-tree/AtlasClusterItem.ts` | Cluster connection and database-user lookup | + +## Design decisions + +1. API Keys and Service Accounts are the supported Atlas Admin API authentication mechanisms. +2. Multiple credentials are first-class; one broken credential never blanks healthy peer data. +3. Resource identity comes from Atlas IDs, while credential identity comes from the complete Public + Key or Client ID. +4. Public credential identity is immutable during an update; only the paired secret rotates. +5. Discovery authentication and database SCRAM authentication remain separate layers. diff --git a/docs/index.md b/docs/index.md index 9aad3549b..9c918eca0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -54,13 +54,16 @@ The User Manual provides guidance on using DocumentDB for VS Code. It contains d - [Azure VMs (DocumentDB)](./user-manual/service-discovery-azure-vms) - [Kubernetes](./user-manual/service-discovery-kubernetes) - [Kubernetes getting started and test lab](./user-manual/service-discovery-kubernetes-getting-started) + - [MongoDB Atlas](./user-manual/service-discovery-mongodb-atlas) - [Managing Azure Subscriptions](./user-manual/managing-azure-discovery) - [Connecting to Local Instances](./user-manual/local-connection) + - [DocumentDB Local Quick Start](./user-manual/local-quick-start) - [Azure Cosmos DB for MongoDB (RU) Emulator](./user-manual/local-connection-mongodb-ru) - [DocumentDB Local](./user-manual/local-connection-documentdb-local) ### Data Management +- [Manage Indexes in Collection View](./user-manual/collection-view-index-management) - [Data Migrations (Experimental)](./user-manual/data-migrations) - [Copy and Paste Collections](./user-manual/copy-and-paste.md) - [Copy Connection String](./user-manual/copy-connection-string) diff --git a/docs/user-manual/collection-view-index-management-troubleshooting.md b/docs/user-manual/collection-view-index-management-troubleshooting.md new file mode 100644 index 000000000..79e468449 --- /dev/null +++ b/docs/user-manual/collection-view-index-management-troubleshooting.md @@ -0,0 +1,44 @@ +> **User Manual** | [Manage Indexes in Collection View](./collection-view-index-management) | [Back to User Manual](../index#user-manual) + +--- + +# Troubleshoot Index Management + +This guide covers common issues when viewing or changing collection indexes. + +## The index list does not load + +Select **Refresh** to load the current index metadata again. Confirm that the connection can access the database and collection. + +If the list loads but Size or Usage is unavailable, the server may not provide that statistic. Missing statistics do not necessarily prevent you from viewing or managing indexes. + +## An index is creating or building + +The tab shows **Creating** after the extension sends a create request. It shows **Building** while the server reports an active index build. + +The tab refreshes while an active build is present. Wait for the status to become **Ready**, or use **Refresh** to request the current server state. + +## Creating an index fails + +Review the validation message in the Create Index drawer. Confirm that the selected index type and options are supported by the target server. + +Select **Preview as JSON** to inspect the generated definition. You can also open the command in Playground or Shell, adjust it if necessary, and run it directly. + +For supported index definitions and service requirements, see the [Azure DocumentDB documentation](https://learn.microsoft.com/azure/documentdb/). + +## Hiding, unhiding, or deleting an index fails + +The default `_id_` index cannot be hidden or deleted. Other actions can fail when the server does not support the requested operation, the current account lacks permissions, or the index state changed before the request completed. + +Refresh the index list before trying again. If you are considering deletion, hide the index first to evaluate whether queries still require it. + +## Size or usage is missing or zero + +Size and Usage are server-reported statistics. Some servers or service configurations do not return them. + +A Usage value of zero means the server has not recorded use of the index since it began tracking that statistic. It does not prove that the index is safe to delete. Consider query patterns, application workload, and a hide-and-evaluate step before deleting an index. + +## Related documentation + +- [Manage Indexes in Collection View](./collection-view-index-management) +- [Create Wildcard indexes](./collection-view-wildcard-indexes) diff --git a/docs/user-manual/collection-view-index-management.md b/docs/user-manual/collection-view-index-management.md new file mode 100644 index 000000000..f9f542712 --- /dev/null +++ b/docs/user-manual/collection-view-index-management.md @@ -0,0 +1,79 @@ +> **User Manual** | [Back to User Manual](../index#user-manual) + +--- + +# Manage Indexes in Collection View + +The **Indexes** tab in Collection View lets you inspect and manage indexes for the current collection. You can review index status and statistics, create Standard or Wildcard indexes, and hide, unhide, or delete indexes. + +For information about index types, supported options, and index design, see the [Azure DocumentDB documentation](https://learn.microsoft.com/azure/documentdb/). + +## Open the Indexes tab + +Open a collection in Collection View, then select **Indexes**. + +You can also double-click the collection's **Indexes** node in the Explorer. This opens Collection View directly on the Indexes tab. The Explorer context menu provides quick actions for an individual index. + +## Inspect indexes + +The metrics at the top of the tab summarize the indexes on the collection. The list below them shows each index and its current properties. + +| Item | Description | +| ---------- | ------------------------------------------------------------- | +| Name | The index name. The default `_id_` index is always present. | +| Type | The index type reported by the server. | +| Properties | Options that apply to the index, when present. | +| Size | The server-reported storage used by the index. | +| Usage | The server-reported number of operations that used the index. | + +Use the filter box and quick filters to narrow the list. Select a column heading to sort the list. Expand a row to inspect the index fields and full set of properties. + +An index can have one of these states: + +| State | Meaning | +| -------- | ------------------------------------------------------------------------------------ | +| Ready | The index is available for use. | +| Creating | The create request has been sent and the extension is waiting for the server result. | +| Building | The server reports that the index build is still in progress. | + +The tab refreshes while an index is creating or building. You can also select **Refresh** at any time. + +## Create an index + +1. Select **Create Index**. +2. Choose an index tab: + - **Standard** for regular indexes on one or more fields. + - **Wildcard** for an index that covers many fields in documents with variable shapes. + - **Vector** for a future vector-index feature. Vector index creation is not currently implemented. +3. Complete the required fields for the selected index type. +4. Select **Create Index**. + +The drawer keeps less common settings under **Advanced**. Select **Preview as JSON** to review the generated index definition before you create it. + +For Standard indexes, add more fields to create a compound index. The order of fields is part of the index definition. + +For detailed guidance on choosing index types and options, see the [Azure DocumentDB documentation](https://learn.microsoft.com/azure/documentdb/). + +## Manage an existing index + +Each row has actions to manage the index. The extension asks for confirmation before it changes an index. + +| Action | Use it when | +| ------ | ------------------------------------------------------------------------------------------------------------------------ | +| Hide | You want to test whether queries need the index without deleting it. A hidden index is not considered by query planning. | +| Unhide | You want to make a hidden index available to query planning again. | +| Delete | You no longer need the index. Consider hiding the index first when you want to evaluate its impact before deletion. | + +The default `_id_` index cannot be hidden or deleted. + +## Use Playground or Shell instead + +The Create Index drawer can prepare the generated command in a Query Playground or Interactive Shell instead of creating the index directly. + +Use this path when you want to inspect or adjust the command before running it. The Interactive Shell is also useful when you want to interact directly with the collection after creating the index. + +## Next steps + +- [Create Wildcard indexes](./collection-view-wildcard-indexes) +- [Troubleshoot Index Management](./collection-view-index-management-troubleshooting) +- [Collection View: Querying](./collection-view-querying) diff --git a/docs/user-manual/collection-view-querying.md b/docs/user-manual/collection-view-querying.md index f5d3bede3..c8757c1a8 100644 --- a/docs/user-manual/collection-view-querying.md +++ b/docs/user-manual/collection-view-querying.md @@ -164,12 +164,12 @@ These type-aware suggestions appear at the top of the completion list, followed The autocompletion is aware of where your cursor is within the query expression and adjusts what it suggests accordingly: -| Cursor Position | What You See | -| --------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| **At a key position** (e.g., `{ \| }`) | Field names and logical operators (`$and`, `$or`, `$nor`, `$not`) | -| **At a value position** (e.g., `{ age: \| }`) | Type-aware suggestions, comparison operators, BSON constructors, JavaScript globals | -| **Inside an operator object** (e.g., `{ age: { \| } }`) | Comparison and query operators without outer braces | -| **Inside an array** (e.g., `{ $and: [ \| ] }`) | Same as key position (each array element is a query document) | +| Cursor Position | What You See | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| **At a key position** (e.g., `{ \| }`) | Field names and logical operators (`$and`, `$or`, `$nor`, `$not`) | +| **At a value position** (e.g., `{ age: \| }`) | Type-aware suggestions, comparison operators, BSON constructors, JavaScript globals | +| **Inside an operator object** (e.g., `{ age: { \| } }`) | Comparison and query operators without outer braces | +| **Inside an array** (e.g., `{ $and: [ \| ] }`) | Same as key position (each array element is a query document) | This means you see the right suggestions at the right time, instead of a flat list of everything. @@ -244,5 +244,6 @@ From the Collection View, you can move your query to other surfaces: - **Open in Shell**: Click the toolbar button to pre-feed your current query into a new Interactive Shell session. - **Copy**: Copy the full find expression to the clipboard for use elsewhere. - **Paste**: Paste a find expression from the clipboard into the query editors. The extension parses the `find(filter, project).sort(sort)` format and populates each editor. +- **Indexes**: Open the Indexes tab to inspect, create, and manage indexes for the current collection. See [Manage Indexes in Collection View](./collection-view-index-management). For more details, see the [Query Playground](./query-playground) and [Interactive Shell](./interactive-shell) documentation. diff --git a/docs/user-manual/collection-view-wildcard-indexes.md b/docs/user-manual/collection-view-wildcard-indexes.md new file mode 100644 index 000000000..14beda85d --- /dev/null +++ b/docs/user-manual/collection-view-wildcard-indexes.md @@ -0,0 +1,38 @@ +> **User Manual** | [Manage Indexes in Collection View](./collection-view-index-management) | [Back to User Manual](../index#user-manual) + +--- + +# Wildcard Indexes in Collection View + +Wildcard indexes can be useful when documents in a collection have variable fields and query patterns are not known in advance. When query patterns are known, a targeted Standard index is usually easier to evaluate and maintain. + +For detailed information about Wildcard index behavior and supported options, see the [Azure DocumentDB documentation](https://learn.microsoft.com/azure/documentdb/). + +## Create a Wildcard index + +1. Open the collection's **Indexes** tab. +2. Select **Create Index**. +3. Select **Wildcard**. +4. Choose the scope: + - **All fields** creates an index using the `$**` key. + - **Parent path** creates an index using a scoped key such as `metadata.$**`. + - **Projection** lets you include or exclude selected paths from an all-fields Wildcard index. +5. Review the generated definition in **Preview as JSON**. +6. Select **Create Index**, or prepare the command in Playground or Shell first. + +## Choose a scope + +Use **All fields** when the index should cover fields throughout the document. Use **Parent path** when only one nested area of the document needs flexible indexing. + +Use **Projection** to limit an all-fields Wildcard index to selected paths, or to exclude selected paths. An empty projection does not restrict the index. + +## Review before creating + +Wildcard indexes have different restrictions from Standard indexes. The form validates incompatible selections and shows the available choices for the selected scope. + +Use **Preview as JSON** to verify the generated definition. Use Playground or Shell when you want to modify the generated command before running it. + +## Related documentation + +- [Manage Indexes in Collection View](./collection-view-index-management) +- [Troubleshoot Index Management](./collection-view-index-management-troubleshooting) diff --git a/docs/user-manual/local-connection-documentdb-local.md b/docs/user-manual/local-connection-documentdb-local.md index 15bc234cb..b556225df 100644 --- a/docs/user-manual/local-connection-documentdb-local.md +++ b/docs/user-manual/local-connection-documentdb-local.md @@ -4,10 +4,14 @@ # DocumentDB Local -The **DocumentDB Local** option is designed to help you connect to a local instance of DocumentDB running on your machine. This is useful for development, prototyping, or testing scenarios where you want to work with DocumentDB without connecting to a remote or cloud-based instance. +The **DocumentDB Local** option helps you work with a local DocumentDB instance for development, prototyping, and testing. + +Use [DocumentDB Local Quick Start](./local-quick-start) when you want the extension to create and manage the official container. Quick Start supports Docker Engine and Docker Desktop. It requires a Docker CLI that the VS Code extension host can use to reach a Linux-container Docker daemon. + +Use the manual connection flow when DocumentDB Local is already running and you only need to save its connection details. ## How to Use -- Ensure you have a local DocumentDB instance running on your machine. +- Ensure you have a DocumentDB instance reachable from the VS Code extension host. - In DocumentDB for VS Code, select the **DocumentDB Local** option from the local connection area. - The extension will guide you through the connection process, allowing you to specify connection details and adjust security settings as needed. diff --git a/docs/user-manual/local-connection.md b/docs/user-manual/local-connection.md index 0ca85f317..e42d43bdb 100644 --- a/docs/user-manual/local-connection.md +++ b/docs/user-manual/local-connection.md @@ -12,6 +12,9 @@ You have two main options for connecting to a local instance: +- **Use DocumentDB Local Quick Start:** + [Create and manage the official DocumentDB Local container](./local-quick-start) with Docker Engine or Docker Desktop. + - **Use Preconfigured Options:** The extension provides ready-to-use configurations for popular local setups: - **[Azure CosmosDB for MongoDB (RU) Emulator](./local-connection-mongodb-ru)** diff --git a/docs/user-manual/local-quick-start.md b/docs/user-manual/local-quick-start.md new file mode 100644 index 000000000..79ab6ad86 --- /dev/null +++ b/docs/user-manual/local-quick-start.md @@ -0,0 +1,104 @@ +> **User Manual** — [Back to User Manual](../index#user-manual) + +--- + +# DocumentDB Local Quick Start + +Quick Start creates and manages a DocumentDB Local container from the Connections view. It pulls the official image, creates a persistent Docker volume, waits for DocumentDB to accept connections, and saves the connection in VS Code. + +## Docker requirement + +Quick Start requires: + +- A Docker CLI available to the VS Code extension host. +- Access from that CLI to a Docker daemon running Linux containers. +- An x64 or arm64 extension host. DocumentDB Local images are published for those architectures. + +Both Docker Engine and Docker Desktop are supported. Docker Desktop is not required when Docker Engine is already available. The extension never installs Docker, silently starts a provider, runs `sudo`, changes group membership, or switches Docker contexts. + +The extension host matters. In a local VS Code window, Docker and DocumentDB Local run on your machine. In WSL, SSH, a dev container, or Codespaces, they run in that extension-host environment. The Review screen shows the target before setup. In remote sessions, `localhost:10260` refers to the extension host, not necessarily your local computer. + +## Start Quick Start + +1. Open the **Connections** view. +2. Under **DocumentDB Local - Quick Start**, select **Quick Start**. +3. Review the Docker, port, platform, data, and security cards. +4. Select **Start DocumentDB Local**. + +The setup view shows pull, create, start, and connection-readiness progress. Docker command output is written to the **DocumentDB Local Quick Start** output channel. + +## Port + +The **Configure** step pre-fills the **Address** with a host port that is free at that moment, starting at `10260` and moving forward if that port is taken. You can change it, and the value is checked while you are still on the step. + +Setup then uses exactly that port. It never moves the instance to a different port after you select **Start DocumentDB Local**. If the port is taken by the time the container is created, setup stops with an explanation so you can go back and choose another one. + +## Docker is not ready + +The readiness screen separates Docker CLI, daemon, and daemon-platform facts. Use **View Docker output** for command details. Use **Refresh** to discard cached and remembered provider facts and run every check again. + +### Docker CLI not found + +Install [Docker Engine](https://docs.docker.com/engine/install/) or Docker Desktop, then reopen Quick Start. If Docker works in a terminal but not in VS Code, confirm that the extension host inherited the same `PATH`, `DOCKER_HOST`, and `DOCKER_CONTEXT` configuration. + +### Linux or WSL socket access denied + +The card may offer this fixed command as copy-only text: + +```bash +sudo usermod -aG docker $USER +``` + +The extension never runs the command. Group changes apply only to new login sessions: + +- **Native Linux:** Sign out of the desktop session and sign back in. Reloading the VS Code window is not enough. +- **WSL:** Run `wsl --shutdown` in a Windows terminal, then reopen the folder in WSL. This stops all running WSL distributions. +- **Remote SSH:** Run **Remote-SSH: Kill VS Code Server on Host**, then reconnect. +- **Dev container or Codespaces:** Rebuild the container. + +See [Linux post-installation steps for Docker Engine](https://docs.docker.com/engine/install/linux-postinstall/) for details and security considerations. + +### Native Docker Engine is stopped + +Start the system service outside the extension, then select **Retry** or **Refresh**. Depending on the Linux environment, the card may offer one of these commands as copy-only text: + +```bash +sudo systemctl start docker +``` + +```bash +sudo service docker start +``` + +Only a positively identified rootless Docker Engine user service can receive an automatic **Start Docker** action. Quick Start never starts a root-managed service or elevates privileges. + +### Docker Desktop and WSL integration + +When Quick Start positively identifies Docker Desktop, it may offer **Start Docker Desktop**. In WSL, the Windows application being installed is not enough to identify the active provider because native Docker Engine can coexist with Docker Desktop. + +If Docker Desktop is running but unavailable in a WSL distribution: + +1. Open Docker Desktop settings. +2. Open **Resources > WSL Integration**. +3. Enable integration for the distribution where VS Code is running. +4. Reopen the WSL folder and select **Refresh**. + +See [Docker Desktop WSL integration](https://docs.docker.com/desktop/features/wsl/). + +### Context or remote endpoint unavailable + +Quick Start respects `DOCKER_HOST`, `DOCKER_CONTEXT`, the current Docker context, and then the platform default endpoint, in that order. It never changes the selected context. + +- Repair or select a valid context using the [Docker context guide](https://docs.docker.com/engine/manage-resources/contexts/). +- For `tcp://` or `ssh://` endpoints, make sure the endpoint is reachable from the extension host. +- In SSH, dev-container, and Codespaces sessions, install and configure Docker in the remote environment. Quick Start does not launch an application on your local machine. + +### Linux containers required + +DocumentDB Local requires a Linux-container Docker daemon. If a reachable Windows daemon reports Windows-container mode, switch Docker to Linux containers and select **Retry**. + +## Recovery during setup + +If Docker becomes unavailable during image pull or container creation, Quick Start returns to the same Docker recovery screen. Registry, proxy, image-manifest, and other image-specific failures remain setup errors and are not presented as daemon diagnoses. + +If a dev-container setup creates the container but times out waiting for DocumentDB, Docker may be running on the dev-container host. A published `localhost` port is not always reachable from inside the dev container. Use **View Docker output** and verify port reachability in the extension-host environment. diff --git a/docs/user-manual/service-discovery-kubernetes-getting-started.md b/docs/user-manual/service-discovery-kubernetes-getting-started.md index c6a2f85ac..4a5bdcb82 100644 --- a/docs/user-manual/service-discovery-kubernetes-getting-started.md +++ b/docs/user-manual/service-discovery-kubernetes-getting-started.md @@ -462,7 +462,7 @@ kubectl port-forward pod/ 10260:10260 -n documentdb-ns In another terminal, connect with `mongosh`: ```bash -mongosh 'mongodb://dev_user:@127.0.0.1:10260/?directConnection=true&authMechanism=SCRAM-SHA-256&tls=true&tlsAllowInvalidCertificates=true' +mongosh 'mongodb://dev_user:@127.0.0.1:10260/?directConnection=true&authMechanism=SCRAM-SHA-256&tls=true&tlsAllowInvalidCertificates=true&replicaSet=rs0' ``` For the local setup script, the sample password is `DevPassword123`. For AKS, use the development password you put in the Secret. diff --git a/docs/user-manual/service-discovery-mongodb-atlas-browse.md b/docs/user-manual/service-discovery-mongodb-atlas-browse.md new file mode 100644 index 000000000..0ce2a9391 --- /dev/null +++ b/docs/user-manual/service-discovery-mongodb-atlas-browse.md @@ -0,0 +1,84 @@ +> **User Manual** | [MongoDB Atlas Service Discovery](./service-discovery-mongodb-atlas) | [Back to User Manual](../index#user-manual) + +--- + +# Browse and Connect to MongoDB Atlas + +This guide explains how to browse resources that are visible to your stored MongoDB Atlas credentials and connect to a cluster. + +## Browse the discovery tree + +The default view is a hierarchy: + +```text +v MongoDB Atlas + v Example Organization + v Production Project + > orders-prod + > inventory-prod +``` + +1. Expand **MongoDB Atlas** to load the organizations visible to your credentials. +2. Expand an organization to see its projects. +3. Expand a project to load its clusters. +4. Select a cluster to open it or save it as a connection. + +Resources that are visible through more than one configured credential appear once. The extension uses a working credential for later discovery requests, such as listing the cluster's database users. + +## Switch between tree and list views + +Use the view action on the **MongoDB Atlas** root item or its context menu to change the view. + +| View | Contents | +| --------- | ------------------------------------------------------------------------------------ | +| Tree view | Organizations, then projects, then clusters. This is the default. | +| List view | A flat list of clusters. Each cluster includes its `organization · project` context. | + +The selected view is remembered for future sessions. Both views show the same clusters and recovery actions. + +## Understand cluster status + +Atlas can show a cluster before it is ready for a database connection. The extension keeps these clusters visible and adds a status label. + +| Status | Meaning | +| ------------- | ---------------------------------------------------------- | +| Paused | Resume the cluster in MongoDB Atlas before connecting. | +| Creating | Atlas is provisioning the cluster. | +| Updating | Atlas is applying a configuration or topology change. | +| Repairing | Atlas is repairing the cluster. | +| Deleting | The cluster is being removed. | +| Unknown state | Atlas returned a state that the extension cannot classify. | + +A cluster can be connected only when it is running, reports the `IDLE` state, and provides an Atlas connection string. If these conditions are not met, wait for the Atlas operation to finish or correct the cluster configuration in Atlas. + +## Connect from the discovery tree + +1. Locate a ready cluster in tree or list view. +2. Select the cluster or use its connection action. +3. When prompted, enter an Atlas database username and password. +4. Continue through the standard connection flow. + +The extension may show database-user names that it can read through the Atlas Admin API. It does not retrieve database passwords. Enter the password for the selected database user. + +## Connect from New Connection + +You can create the same connection without first browsing the tree: + +1. Start **New Connection**. +2. Select **Service Discovery**, then **MongoDB Atlas**. +3. Select a project and a ready cluster. +4. Enter the Atlas database username and password when prompted. +5. Finish the connection flow. + +The project list includes a **Manage MongoDB Atlas Credentials** option. Select it when the project or cluster you need is not shown. After adding or updating a credential, start discovery again to load the updated resource list. + +## Saved connections + +After you save a discovered cluster, it appears in **DocumentDB Connections** like any other connection. You can work with the connection without reopening the discovery tree. + +Changing or removing an Atlas discovery credential does not delete a saved connection. The saved connection still requires valid Atlas database credentials and network access. + +## Next steps + +- [Manage MongoDB Atlas credentials](./service-discovery-mongodb-atlas-credentials) +- [Troubleshoot MongoDB Atlas Service Discovery](./service-discovery-mongodb-atlas-troubleshooting) diff --git a/docs/user-manual/service-discovery-mongodb-atlas-credentials.md b/docs/user-manual/service-discovery-mongodb-atlas-credentials.md new file mode 100644 index 000000000..4daf9388e --- /dev/null +++ b/docs/user-manual/service-discovery-mongodb-atlas-credentials.md @@ -0,0 +1,69 @@ +> **User Manual** | [MongoDB Atlas Service Discovery](./service-discovery-mongodb-atlas) | [Back to User Manual](../index#user-manual) + +--- + +# Manage MongoDB Atlas Credentials + +MongoDB Atlas Service Discovery supports Atlas API Keys and Atlas Service Accounts. These credentials are used to browse Atlas resources. They are separate from the database username and password used to connect to a cluster. + +## Add a credential + +Open **Manage MongoDB Atlas Credentials** from the MongoDB Atlas discovery item, then select **Add a credential…**. + +Choose one of these methods: + +| Method | Enter | Suitable for | +| --------------- | --------------------------- | ------------------------------------------- | +| API Key | Public Key and Private Key | Individual or operational Atlas API access. | +| Service Account | Client ID and Client Secret | Automation or shared operational access. | + +The extension verifies the credential with the Atlas Admin API before saving it. If verification fails, the credential is not saved. + +For instructions on creating access credentials and assigning roles, see the [MongoDB Atlas API access documentation](https://www.mongodb.com/docs/atlas/configure-api-access/). + +## Add credentials for more organizations + +Each Atlas API Key and Service Account belongs to one organization. Add a credential for every organization whose projects you need to browse. + +Adding another credential does not replace existing credentials. The extension queries all configured credentials and preserves resources that remain available when one credential fails. + +## Review a credential + +In **Manage MongoDB Atlas Credentials**, select a credential to see these actions: + +| Action | Use it when | +| --------------------- | ---------------------------------------------------------------------------------------------- | +| Retry | You corrected an Atlas role, access-list rule, or temporary network issue for this credential. | +| Open in MongoDB Atlas | You need to review the API Key, Service Account roles, or IP access list in Atlas. | +| Update credentials… | You rotated the credential secret. | +| Sign out | You no longer want this credential stored in the extension. | + +Use **Retry all** to recheck all configured credentials. Use **Sign out of all** to remove every stored Atlas discovery credential. + +## Update a rotated secret + +Use **Update credentials…** after rotating an Atlas Private Key or Client Secret. + +1. Select the existing credential in **Manage MongoDB Atlas Credentials**. +2. Select **Update credentials…**. +3. Enter the replacement Private Key or Client Secret. +4. Complete the validation step. + +The credential identity stays the same during an update. The extension keeps the existing credential until the replacement secret is accepted. To use a different Public Key or Client ID, add it as a new credential and then sign out of the old one. + +## Storage and token refresh + +Credential secrets are stored in VS Code Secret Storage. The extension stores only non-secret information needed to identify and display a credential with the extension settings. + +Service Account access tokens expire. When possible, the extension refreshes a Service Account token using its stored Client ID and Client Secret. If Atlas still rejects the credential, review the credential in Atlas and use **Retry** after correcting the issue. + +## Least privilege + +Assign only the Atlas access needed for the organizations and projects you intend to discover. A credential that can authenticate but has no access to a project cannot make that project appear in Service Discovery. + +Keep database-user credentials separate from API Keys and Service Account secrets. Do not place any of these credentials in source control. + +## Next steps + +- [Browse Atlas resources and connect to a cluster](./service-discovery-mongodb-atlas-browse) +- [Troubleshoot MongoDB Atlas Service Discovery](./service-discovery-mongodb-atlas-troubleshooting) diff --git a/docs/user-manual/service-discovery-mongodb-atlas-troubleshooting.md b/docs/user-manual/service-discovery-mongodb-atlas-troubleshooting.md new file mode 100644 index 000000000..335d8b3d0 --- /dev/null +++ b/docs/user-manual/service-discovery-mongodb-atlas-troubleshooting.md @@ -0,0 +1,86 @@ +> **User Manual** | [MongoDB Atlas Service Discovery](./service-discovery-mongodb-atlas) | [Back to User Manual](../index#user-manual) + +--- + +# Troubleshoot MongoDB Atlas Service Discovery + +This guide separates discovery problems from database connection problems. Atlas API Keys and Service Accounts control discovery. Atlas database usernames and passwords control database access. + +## A credential needs attention + +When one or more credentials fail, the MongoDB Atlas root item shows a recovery action. Healthy resources found through other credentials remain visible. + +| Recovery action | What to do | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| Click here to retry | Use this for a temporary network, service, or rate-limit failure. | +| Click here to revisit credentials | Open credential management when a credential is invalid, lacks permission, or requires a more detailed review. | + +In credential management, use **Retry** for one credential or **Retry all** for every credential. A retry fetches fresh resource information and rechecks Service Account access. + +## No organizations, projects, or clusters appear + +Check the following: + +1. The intended Atlas API Key or Service Account is stored in **Manage MongoDB Atlas Credentials**. +2. The credential belongs to the Atlas organization that owns the resources. +3. The credential has an Atlas role that can list the intended projects and clusters. +4. The organization contains projects and the project contains clusters. +5. You refreshed discovery after changing a role, project membership, or credential. + +Add another credential when the resources are in a different Atlas organization. + +## Atlas rejects the discovery credential + +Review the Public Key and Private Key for an API Key, or the Client ID and Client Secret for a Service Account. Then update the credential or add it again. + +An Atlas `401` response usually means the credential cannot be authenticated. An Atlas `403` response usually means Atlas accepted the credential but the credential lacks access to the requested resource. Both cases can also require changes to Atlas network access rules. + +Use **Open in MongoDB Atlas** from credential management to review the affected API Key or Service Account. The [Atlas API access documentation](https://www.mongodb.com/docs/atlas/configure-api-access/) describes API Key and Service Account setup. + +## Atlas IP access list or network restrictions + +Atlas can restrict requests by source IP address. If Atlas reports that the current address is not allowed, add the appropriate address or network range in Atlas, then use **Retry**. + +The required rule depends on the type of request: + +| Request | Access to check | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Resource discovery through the Atlas Admin API | The network and access restrictions that apply to the API credential. | +| Database connection to a cluster | The Atlas project IP access list and any private networking configuration for the cluster. | + +See [Configure IP Access List Entries](https://www.mongodb.com/docs/atlas/security/ip-access-list/) in the Atlas documentation for the current Atlas configuration steps. + +## A discovered cluster cannot be opened + +Check the cluster status first. A paused, creating, updating, repairing, or deleting cluster is not ready for a connection. Resume the cluster or wait for the Atlas operation to complete. + +If the cluster is ready but connection fails, check: + +1. The database username and password are correct. +2. The database user has access to the intended database. +3. Your machine can reach the Atlas endpoint. +4. The Atlas project IP access list allows the connection. +5. Private endpoint, VPC peering, firewall, DNS, and TLS settings match the cluster's network configuration. + +A TLS handshake error indicates that the connection did not complete at the transport layer. It does not, by itself, prove that the database username or password is incorrect. Review the cluster state, network path, and TLS configuration before replacing database credentials. + +## Rate limits and temporary failures + +Atlas can temporarily reject or delay requests because of a rate limit, a network interruption, or a service failure. Wait briefly and use the retry action. Retrying a single credential does not recheck the other configured credentials. + +## Get help + +When reporting a problem, include: + +- Whether the issue occurs during discovery or after selecting a cluster. +- The displayed Atlas error message and status, without including secrets. +- Whether the failure affects one credential or every configured credential. +- The cluster state and the network path you use to reach the cluster. + +Never include Private Keys, Client Secrets, database passwords, access tokens, or full connection strings in a support request. + +## Related documentation + +- [MongoDB Atlas Service Discovery](./service-discovery-mongodb-atlas) +- [Manage MongoDB Atlas credentials](./service-discovery-mongodb-atlas-credentials) +- [Browse Atlas resources and connect to a cluster](./service-discovery-mongodb-atlas-browse) diff --git a/docs/user-manual/service-discovery-mongodb-atlas.md b/docs/user-manual/service-discovery-mongodb-atlas.md new file mode 100644 index 000000000..9762ee164 --- /dev/null +++ b/docs/user-manual/service-discovery-mongodb-atlas.md @@ -0,0 +1,53 @@ +> **User Manual** | [Back to Service Discovery](./service-discovery) | [Back to User Manual](../index#user-manual) + +--- + +# MongoDB Atlas Service Discovery + +MongoDB Atlas Service Discovery lets you browse Atlas organizations, projects, and clusters from DocumentDB for VS Code. You can then create a connection from a discovered cluster without manually copying its endpoint. + +Use MongoDB Atlas Service Discovery from either location: + +- The **Service Discovery** view in the extension sidebar. +- **New Connection** > **Service Discovery** > **MongoDB Atlas**. + +## Before you start + +You need two kinds of access: + +| Purpose | What you need | +| --------------------------- | ----------------------------------------------------------------------------------------------------- | +| Browse Atlas resources | An Atlas API Key or Service Account that can list the organizations, projects, and clusters you need. | +| Connect to an Atlas cluster | An Atlas database user with a username and password, plus network access to the cluster. | + +The Atlas API Key or Service Account is used only to discover resources. It is not a database login. You enter a database username and password when you connect to a cluster. + +## Connect to your first cluster + +1. Open **Service Discovery** and expand **MongoDB Atlas**. +2. Select **Sign in to view MongoDB Atlas clusters**. +3. Choose one authentication method: + - **API Key**: enter an Atlas Public Key and Private Key. + - **Service Account**: enter an Atlas Client ID and Client Secret. +4. Complete the verification step. The extension saves the credential only after Atlas accepts it. +5. Expand an organization, then a project, and select a cluster that is ready to connect. +6. Enter the Atlas database username and password when prompted. +7. Save the connection or open the cluster to work with its databases and collections. + +The extension prefers the Atlas SRV connection string when Atlas provides one. The saved result is a regular DocumentDB for VS Code connection. + +## What to do next + +- [Browse Atlas resources and connect to a cluster](./service-discovery-mongodb-atlas-browse) +- [Manage MongoDB Atlas credentials](./service-discovery-mongodb-atlas-credentials) +- [Troubleshoot MongoDB Atlas Service Discovery](./service-discovery-mongodb-atlas-troubleshooting) + +## Multiple organizations + +An Atlas API Key or Service Account belongs to one Atlas organization. To browse clusters in more than one organization, add a credential for each organization. The extension combines resources found through all configured credentials. When more than one credential can access the same resource, the resource is shown once. + +## Related documentation + +- [Service Discovery](./service-discovery) +- [Connecting with a URL](./how-to-construct-url) +- [Copy Connection String](./copy-connection-string) diff --git a/docs/user-manual/service-discovery.md b/docs/user-manual/service-discovery.md index f8c95a708..cb868a8e8 100644 --- a/docs/user-manual/service-discovery.md +++ b/docs/user-manual/service-discovery.md @@ -28,6 +28,7 @@ Currently, the following service discovery plugins are available: - **[Azure VMs (DocumentDB)](./service-discovery-azure-vms)** - **[Kubernetes](./service-discovery-kubernetes)** - [Kubernetes getting started and test lab](./service-discovery-kubernetes-getting-started) +- **[MongoDB Atlas](./service-discovery-mongodb-atlas)** See each plugin guide for provider-specific setup, filtering, credential handling, and troubleshooting steps. diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 11bbcfbc7..bdee2777d 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -3,9 +3,14 @@ " and ": " and ", " on GitHub.": " on GitHub.", " or ": " or ", + "- Is the cluster paused, or still being provisioned?": "- Is the cluster paused, or still being provisioned?", + "- Is this machineȁs IP address on the projectȁs IP access list?": "- Is this machineȁs IP address on the projectȁs IP access list?", + "—": "—", + ", {0}": ", {0}", ", No public IP or FQDN found.": ", No public IP or FQDN found.", "! Task '{taskName}' failed. {message}": "! Task '{taskName}' failed. {message}", ".limit({0}) exceeds the display batch size ({1}), so only {1} documents will be shown. Use .toArray() to retrieve all {0}, or increase \"{2}\" in Settings.": ".limit({0}) exceeds the display batch size ({1}), so only {1} documents will be shown. Use .toArray() to retrieve all {0}, or increase \"{2}\" in Settings.", + "“{0}” already uses localhost:{1}.": "“{0}” already uses localhost:{1}.", "\"{0}\" is a file on a different remote host than the one the editor is connected to.": "\"{0}\" is a file on a different remote host than the one the editor is connected to.", "\"{0}\" is a file on your local (Windows) machine, which the editor running in WSL or a remote/container host cannot read directly.": "\"{0}\" is a file on your local (Windows) machine, which the editor running in WSL or a remote/container host cannot read directly.", "\"{0}\" is a network location, which the editor cannot read directly.": "\"{0}\" is a network location, which the editor cannot read directly.", @@ -14,6 +19,7 @@ "\"{0}\" is not implemented on \"{1}\".": "\"{0}\" is not implemented on \"{1}\".", "\"{0}\" is on the network share \"\\\\{1}\", which the editor cannot read directly.": "\"{0}\" is on the network share \"\\\\{1}\", which the editor cannot read directly.", "\"{0}\" uses the unsupported URI scheme \"{1}\".": "\"{0}\" uses the unsupported URI scheme \"{1}\".", + "\"{user}\" signs in with {method}.": "\"{user}\" signs in with {method}.", "\"mongodb://\" or \"mongodb+srv://\" must be the prefix of the connection string.": "\"mongodb://\" or \"mongodb+srv://\" must be the prefix of the connection string.", "\"registerAzureUtilsExtensionVariables\" must be called before using the vscode-azext-azureutils package.": "\"registerAzureUtilsExtensionVariables\" must be called before using the vscode-azext-azureutils package.", "\"registerUIExtensionVariables\" must be called before using the vscode-azureextensionui package.": "\"registerUIExtensionVariables\" must be called before using the vscode-azureextensionui package.", @@ -112,26 +118,42 @@ "{0}\n\nNothing is imported until you choose Import. Choose Preview to open the file(s) without importing.": "{0}\n\nNothing is imported until you choose Import. Choose Preview to open the file(s) without importing.", "{0}\n\nThe query did not complete successfully. Performance metrics shown are partial and measured up to the failure point.": "{0}\n\nThe query did not complete successfully. Performance metrics shown are partial and measured up to the failure point.", "{0}\n\nTip: copy the file into the same filesystem as the editor (for example your WSL or remote home directory), then drop it again, or use the \"Add Kubeconfig Source\" command to browse for it.": "{0}\n\nTip: copy the file into the same filesystem as the editor (for example your WSL or remote home directory), then drop it again, or use the \"Add Kubeconfig Source\" command to browse for it.", + "{0} · {1}": "{0} · {1}", + "{0} · Refreshing…": "{0} · Refreshing…", "{0} (Emulator)": "{0} (Emulator)", + "{0} {1}": "{0} {1}", + "{0} clusters": "{0} clusters", "{0} completed successfully": "{0} completed successfully", "{0} connections": "{0} connections", "{0} created": "{0} created", + "{0} credentials need attention:": "{0} credentials need attention:", "{0} failed: {1}": "{0} failed: {1}", "{0} file(s) were ignored because they do not match the \"*.json\" pattern.": "{0} file(s) were ignored because they do not match the \"*.json\" pattern.", "{0} inserted": "{0} inserted", + "{0} is signed in": "{0} is signed in", + "{0} is signed in again.": "{0} is signed in again.", "{0} item(s) already exist in the destination. Check the Output panel for details.": "{0} item(s) already exist in the destination. Check the Output panel for details.", "{0} lines detected in pasted text": "{0} lines detected in pasted text", "{0} more actions": "{0} more actions", + "{0} more steps": "{0} more steps", + "{0} needs attention: {1}": "{0} needs attention: {1}", + "{0} operations": "{0} operations", + "{0} operations since {1}": "{0} operations since {1}", "{0} processed": "{0} processed", "{0} replaced": "{0} replaced", + "{0} seconds": "{0} seconds", "{0} skipped": "{0} skipped", "{0} stage failed": "{0} stage failed", "{0} subfolders": "{0} subfolders", "{0} task(s) are using connections being moved. Check the Output panel for details.": "{0} task(s) are using connections being moved. Check the Output panel for details.", "{0} task(s) are using connections in this folder. Check the Output panel for details.": "{0} task(s) are using connections in this folder. Check the Output panel for details.", "{0} tenants available ({1} signed in)": "{0} tenants available ({1} signed in)", + "{0} tuning": "{0} tuning", "{0} was stopped": "{0} was stopped", + "{0}, {1}": "{0}, {1}", "{0}: v{1}": "{0}: v{1}", + "{0}. {1}": "{0}. {1}", + "{0}…": "{0}…", "{0}/{1} documents": "{0}/{1} documents", "{0}s": "{0}s", "{countMany} documents have been deleted.": "{countMany} documents have been deleted.", @@ -140,6 +162,8 @@ "{experienceName} Emulator": "{experienceName} Emulator", "**No public IP or FQDN available for direct connection.**": "**No public IP or FQDN available for direct connection.**", "/ (Root)": "/ (Root)", + "• Open Connection: browse your databases in the Connections view, under “DocumentDB Local”.": "• Open Connection: browse your databases in the Connections view, under “DocumentDB Local”.", + "• The container keeps running after VS Code closes. Manage it with Stop / Restart / Delete in the Connections view.": "• The container keeps running after VS Code closes. Manage it with Stop / Restart / Delete in the Connections view.", "^C": "^C", "■ Task '{taskName}' was stopped. {message}": "■ Task '{taskName}' was stopped. {message}", "► Task '{taskName}' starting...": "► Task '{taskName}' starting...", @@ -168,8 +192,11 @@ "$(sign-in) Select to sign in": "$(sign-in) Select to sign in", "$(warning) Only storage accounts in the region \"{0}\" are shown.": "$(warning) Only storage accounts in the region \"{0}\" are shown.", "$(warning) Some storage accounts were filtered because of their network configurations.": "$(warning) Some storage accounts were filtered because of their network configurations.", + "1 credential needs attention:": "1 credential needs attention:", "1 tenant available (0 signed in)": "1 tenant available (0 signed in)", "1 tenant available (1 signed in)": "1 tenant available (1 signed in)", + "2d (geospatial)": "2d (geospatial)", + "2dsphere (geospatial)": "2dsphere (geospatial)", "A collection with the name \"{0}\" already exists": "A collection with the name \"{0}\" already exists", "A connection name is required.": "A connection name is required.", "A connection with the same username and host already exists.": "A connection with the same username and host already exists.", @@ -181,35 +208,62 @@ "A kubeconfig source with identical YAML already exists.": "A kubeconfig source with identical YAML already exists.", "A new connection will be added to your Connections View.\nDo you want to continue?\n\nNote: You can disable these URL handling confirmations in the exension settings.": "A new connection will be added to your Connections View.\nDo you want to continue?\n\nNote: You can disable these URL handling confirmations in the exension settings.", "A playground is already running on this cluster. Wait for it to finish.": "A playground is already running on this cluster. Wait for it to finish.", + "A setup operation is already in progress.": "A setup operation is already in progress.", + "A specific problem was identified": "A specific problem was identified", "A value is required to proceed.": "A value is required to proceed.", + "A wildcard index key must be the only index key.": "A wildcard index key must be the only index key.", "Abort entire operation on first write error. Recommended for safe data copy operations.": "Abort entire operation on first write error. Recommended for safe data copy operations.", "Abort on first error": "Abort on first error", "About (v{0})": "About (v{0})", + "Accept a self-signed or untrusted certificate. Only choose this for a host you trust — a “.local” or single-word name can also be managed corporate infrastructure.": "Accept a self-signed or untrusted certificate. Only choose this for a host you trust — a “.local” or single-word name can also be managed corporate infrastructure.", "Acceptable execution time": "Acceptable execution time", + "access denied": "access denied", + "Access denied": "Access denied", "Access denied (403 Forbidden)": "Access denied (403 Forbidden)", "Access denied listing services (403 Forbidden)": "Access denied listing services (403 Forbidden)", + "Access denied: {0}": "Access denied: {0}", + "Access denied. Verify you have the required permissions.": "Access denied. Verify you have the required permissions.", "Account information is incomplete.": "Account information is incomplete.", "Account Management Completed": "Account Management Completed", "Action completed successfully": "Action completed successfully", "Action failed": "Action failed", + "Actions": "Actions", "Add a connection in the DocumentDB panel first": "Add a connection in the DocumentDB panel first", + "Add a credential…": "Add a credential…", + "Add a MongoDB Atlas connection": "Add a MongoDB Atlas connection", + "Add a MongoDB Atlas Credential": "Add a MongoDB Atlas Credential", + "Add Anyway": "Add Anyway", + "Add at least one index field to create the index.": "Add at least one index field to create the index.", + "Add at least one projection field to create the index.": "Add at least one projection field to create the index.", + "Add field": "Add field", + "Add field (compound)": "Add field (compound)", "Add Kubeconfig…": "Add Kubeconfig…", "Add new document": "Add new document", "Add or manage sources to see more contexts.": "Add or manage sources to see more contexts.", + "Add or update credentials to see more projects and clusters.": "Add or update credentials to see more projects and clusters.", "Add the {0} dropped kubeconfig files as Kubernetes discovery sources?": "Add the {0} dropped kubeconfig files as Kubernetes discovery sources?", "Add the dropped kubeconfig file as a Kubernetes discovery source?": "Add the dropped kubeconfig file as a Kubernetes discovery source?", "Added kubeconfig source \"{0}\" via drag-and-drop.": "Added kubeconfig source \"{0}\" via drag-and-drop.", "Added kubeconfig source \"{0}\".": "Added kubeconfig source \"{0}\".", "Additional write and storage overhead for maintaining a new index.": "Additional write and storage overhead for maintaining a new index.", + "Address": "Address", "Adjust Filters": "Adjust Filters", "Advanced": "Advanced", + "Advanced settings": "Advanced settings", "AI is analyzing…": "AI is analyzing…", "AI Performance Insights": "AI Performance Insights", "AI recommendations": "AI recommendations", "AI responses may be inaccurate": "AI responses may be inaccurate", + "Algorithm": "Algorithm", + "Algorithm tuning": "Algorithm tuning", + "Algorithm tuning, compression": "Algorithm tuning, compression", "All {count} connections have been removed.": "All {count} connections have been removed.", "All available providers are already visible.": "All available providers are already visible.", + "All fields": "All fields", "All port-forward tunnels stopped.": "All port-forward tunnels stopped.", + "All set": "All set", + "All stored MongoDB Atlas credentials will be removed.": "All stored MongoDB Atlas credentials will be removed.", + "Allow invalid certificates": "Allow invalid certificates", "Always reads your default kubeconfig ($KUBECONFIG or {0}).": "Always reads your default kubeconfig ($KUBECONFIG or {0}).", "Always upload": "Always upload", "An element with the following id already exists: {id}": "An element with the following id already exists: {id}", @@ -220,16 +274,25 @@ "An unknown error occurred while inserting documents.": "An unknown error occurred while inserting documents.", "Analyzing folder contents…": "Analyzing folder contents…", "Analyzing…": "Analyzing…", + "and select your **organization**.": "and select your **organization**.", + "API Key": "API Key", "API v0.3.0: Registered new migration provider: \"{providerId}\" - \"{providerLabel}\" from extension \"{extensionId}\"": "API v0.3.0: Registered new migration provider: \"{providerId}\" - \"{providerLabel}\" from extension \"{extensionId}\"", "API version \"{0}\" for extension id \"{1}\" is no longer supported. Minimum version is \"{2}\".": "API version \"{0}\" for extension id \"{1}\" is no longer supported. Minimum version is \"{2}\".", "API: Registered new migration provider: \"{providerId}\" - \"{providerLabel}\"": "API: Registered new migration provider: \"{providerId}\" - \"{providerLabel}\"", "Applying Azure discovery filters…": "Applying Azure discovery filters…", "Approx. Size: {count} documents": "Approx. Size: {count} documents", + "Approximate nearest-neighbor algorithm used to build the index.": "Approximate nearest-neighbor algorithm used to build the index.", "Are you sure you want to run all code?": "Are you sure you want to run all code?", "Are you sure?": "Are you sure?", + "ascending": "ascending", + "Ascending (1)": "Ascending (1)", "Ask Copilot to generate the query for you": "Ask Copilot to generate the query for you", + "Atlas API did not return a valid Digest challenge": "Atlas API did not return a valid Digest challenge", + "Atlas API error ({0}): {1}": "Atlas API error ({0}): {1}", + "Atlas project not selected": "Atlas project not selected", "Attempting to authenticate with \"{cluster}\"…": "Attempting to authenticate with \"{cluster}\"…", "Auth": "Auth", + "Authenticate to Connect with Your Atlas Cluster": "Authenticate to Connect with Your Atlas Cluster", "Authenticate to connect with your DocumentDB cluster": "Authenticate to connect with your DocumentDB cluster", "Authenticate to Connect with Your DocumentDB Cluster": "Authenticate to Connect with Your DocumentDB Cluster", "Authenticate using a username and password": "Authenticate using a username and password", @@ -240,9 +303,14 @@ "Authentication data (properties.connectionString) is missing for \"{cluster}\".": "Authentication data (properties.connectionString) is missing for \"{cluster}\".", "Authentication failed (401 Unauthorized)": "Authentication failed (401 Unauthorized)", "Authentication failed listing services (401)": "Authentication failed listing services (401)", + "Authentication failed: {0}": "Authentication failed: {0}", + "Authentication failed. Please sign in again.": "Authentication failed. Please sign in again.", "Authentication is required to run this action.": "Authentication is required to run this action.", "Authentication is required to use this migration provider.": "Authentication is required to use this migration provider.", + "Authentication method not supported": "Authentication method not supported", "Authentication: {0} | Database: {1}": "Authentication: {0} | Database: {1}", + "Auto-deletes documents after a set age.": "Auto-deletes documents after a set age.", + "Availability": "Availability", "Azure account added successfully.": "Azure account added successfully.", "Azure account management failed: {0}": "Azure account management failed: {0}", "Azure account management was cancelled by user.": "Azure account management was cancelled by user.", @@ -266,9 +334,17 @@ "Azure VMs (DocumentDB)": "Azure VMs (DocumentDB)", "Back": "Back", "Back to account selection": "Back to account selection", + "Back to Create Index": "Back to Create Index", "Back to tenant selection": "Back to tenant selection", + "Back to the list": "Back to the list", + "Balanced speed and recall for most workloads.": "Balanced speed and recall for most workloads.", "Bitmap index": "Bitmap index", "Bitmap index detected: typically used for low-cardinality fields": "Bitmap index detected: typically used for low-cardinality fields", + "Build candidates (efConstruction)": "Build candidates (efConstruction)", + "Build candidates (efConstruction) must be at least 2 × connections (m).": "Build candidates (efConstruction) must be at least 2 × connections (m).", + "Build candidates (lBuild)": "Build candidates (lBuild)", + "Build-time settings for the selected algorithm. The defaults follow the current service recommendations.": "Build-time settings for the selected algorithm. The defaults follow the current service recommendations.", + "Building index": "Building index", "Bulk write error during import into \"{0}.{1}\": {2} document(s) inserted.": "Bulk write error during import into \"{0}.{1}\": {2} document(s) inserted.", "Cancel": "Cancel", "Cancel this operation": "Cancel this operation", @@ -283,35 +359,72 @@ "Certificate error": "Certificate error", "Change display batch size (currently {0}) in settings": "Change display batch size (currently {0}) in settings", "Change page size": "Change page size", + "Change the image tag": "Change the image tag", + "Change the port": "Change the port", "Changelog": "Changelog", + "Check Docker again": "Check Docker again", + "Check result": "Check result", + "Check run": "Check run", "Check the output channel for details.": "Check the output channel for details.", "Check the output channel for details. The cluster may be unreachable or your credentials may need updating.": "Check the output channel for details. The cluster may be unreachable or your credentials may need updating.", + "check timed out": "check timed out", + "Check timed out": "Check timed out", + "Check your internet connection or proxy settings, then try again.": "Check your internet connection or proxy settings, then try again.", + "Checked by comparing the Docker socket owner group against your user and this process.": "Checked by comparing the Docker socket owner group against your user and this process.", + "Checking access to your projects": "Checking access to your projects", + "Checking Docker": "Checking Docker", "Checking for conflicts…": "Checking for conflicts…", + "Checking your MongoDB Atlas credential.": "Checking your MongoDB Atlas credential.", + "Checking…": "Checking…", "Choose a cluster…": "Choose a cluster…", "Choose a different folder": "Choose a different folder", "Choose a provider to show…": "Choose a provider to show…", "Choose a RU cluster…": "Choose a RU cluster…", "Choose a Subscription…": "Choose a Subscription…", "Choose a Virtual Machine…": "Choose a Virtual Machine…", + "Choose an authentication method": "Choose an authentication method", + "Choose method": "Choose method", "Choose the data migration provider…": "Choose the data migration provider…", "Choose the migration action…": "Choose the migration action…", "Choose what to copy…": "Choose what to copy…", + "Choose whether the listed fields are the only fields indexed or the fields omitted from the index.": "Choose whether the listed fields are the only fields indexed or the fields omitted from the index.", "Choose whether the task should succeed or fail": "Choose whether the task should succeed or fail", + "Choose which fields the wildcard index covers.": "Choose which fields the wildcard index covers.", "Choose your provider…": "Choose your provider…", "Choose your Service Provider": "Choose your Service Provider", + "Clear all filters": "Clear all filters", + "Clear field": "Clear field", + "Clear filters": "Clear filters", + "Clear parent path": "Clear parent path", "Clear Query": "Clear Query", + "Click here to delete the container and start over": "Click here to delete the container and start over", "Click here to open the shell": "Click here to open the shell", + "Click here to resolve the issues": "Click here to resolve the issues", "Click here to retry": "Click here to retry", + "Click here to revisit credentials": "Click here to revisit credentials", + "Click here to set up DocumentDB Local": "Click here to set up DocumentDB Local", "Click here to update credentials": "Click here to update credentials", + "Click here to view the setup log": "Click here to view the setup log", "Click to view resource": "Click to view resource", + "Client ID": "Client ID", + "Client Secret": "Client Secret", "Clipboard does not contain a recognizable find() query.": "Clipboard does not contain a recognizable find() query.", "Clipboard does not contain kubeconfig YAML. Copy it first and try again.": "Clipboard does not contain kubeconfig YAML. Copy it first and try again.", "Clipboard is empty.": "Clipboard is empty.", + "Close": "Close", + "Close this view and add the credential again.": "Close this view and add the credential again.", + "Closing…": "Closing…", "Cluster": "Cluster", "Cluster metadata not initialized. Client may not be properly connected.": "Cluster metadata not initialized. Client may not be properly connected.", + "Cluster not connectable yet": "Cluster not connectable yet", "Cluster not found (DNS resolution failed)": "Cluster not found (DNS resolution failed)", "Cluster support unknown $(info)": "Cluster support unknown $(info)", "Cluster-routed via node port": "Cluster-routed via node port", + "Clusters": "Clusters", + "Collapse fields for {0}": "Collapse fields for {0}", + "collation": "collation", + "Collation": "Collation", + "Collation: enter a JSON object": "Collation: enter a JSON object", "collection \"{0}\"": "collection \"{0}\"", "Collection \"{0}\" from database \"{1}\" has been marked for copy. You can now paste this collection into any database or existing collection using the \"Paste Collection...\" option in the context menu.": "Collection \"{0}\" from database \"{1}\" has been marked for copy. You can now paste this collection into any database or existing collection using the \"Paste Collection...\" option in the context menu.", "Collection name cannot begin with the system. prefix (Reserved for internal use).": "Collection name cannot begin with the system. prefix (Reserved for internal use).", @@ -324,22 +437,40 @@ "Collection names should begin with an underscore or a letter character.": "Collection names should begin with an underscore or a letter character.", "Collection scan": "Collection scan", "Collection View": "Collection View", + "Collection View {0} failed because the collection tree node could not be resolved. View ID: {1}; Cluster ID: {2}; Database: {3}; Collection: {4}": "Collection View {0} failed because the collection tree node could not be resolved. View ID: {1}; Cluster ID: {2}; Database: {3}; Collection: {4}", + "Collection views": "Collection views", "Collection: \"{collectionName}\"": "Collection: \"{collectionName}\"", "Collection: \"{targetCollectionName}\" {annotation}": "Collection: \"{targetCollectionName}\" {annotation}", + "Collection: {0}": "Collection: {0}", "Collections": "Collections", + "Combined on-disk size of all indexes on this collection.": "Combined on-disk size of all indexes on this collection.", + "Completed credential checks": "Completed credential checks", + "Completed setup steps": "Completed setup steps", + "Compressed dimensions": "Compressed dimensions", + "Compressed dimensions (optional)": "Compressed dimensions (optional)", + "Compressed dimensions must be smaller than the vector dimensions.": "Compressed dimensions must be smaller than the vector dimensions.", + "Compression": "Compression", + "Configure": "Configure", "Configure Azure Discovery Filters": "Configure Azure Discovery Filters", "Configure Azure VM Discovery Filters": "Configure Azure VM Discovery Filters", "Configure in Settings": "Configure in Settings", + "Configure setup": "Configure setup", "Configure Subscription Filter": "Configure Subscription Filter", "Configure Tenant & Subscription Filters": "Configure Tenant & Subscription Filters", "Configure TLS/SSL Security": "Configure TLS/SSL Security", + "Configured": "Configured", "Configuring subscription filtering…": "Configuring subscription filtering…", "Configuring tenant filtering…": "Configuring tenant filtering…", + "Confirms Docker is installed and can run containers on this machine.": "Confirms Docker is installed and can run containers on this machine.", "Conflict Resolution: {strategyName}": "Conflict Resolution: {strategyName}", "Connect": "Connect", + "Connect another API Key or Service Account to see more organizations and projects.": "Connect another API Key or Service Account to see more organizations and projects.", "Connect database": "Connect database", + "Connect MongoDB Atlas to browse, open, and manage your clusters without leaving VS Code.": "Connect MongoDB Atlas to browse, open, and manage your clusters without leaving VS Code.", "Connect this playground to a database": "Connect this playground to a database", "Connect to a different database…": "Connect to a different database…", + "Connect with a MongoDB Atlas API Key": "Connect with a MongoDB Atlas API Key", + "Connect with a MongoDB Atlas Service Account": "Connect with a MongoDB Atlas Service Account", "Connect without a username or password": "Connect without a username or password", "Connected to \"{cluster}\" using the decoded password. Would you like to update your saved credentials?": "Connected to \"{cluster}\" using the decoded password. Would you like to update your saved credentials?", "Connected to {0}": "Connected to {0}", @@ -356,6 +487,7 @@ "Connection refused": "Connection refused", "Connection string": "Connection string", "Connection String": "Connection String", + "Connection string available. Expand to connect and browse databases.": "Connection string available. Expand to connect and browse databases.", "Connection string cannot be empty.": "Connection string cannot be empty.", "Connection string is not set": "Connection string is not set", "Connection timed out": "Connection timed out", @@ -363,10 +495,18 @@ "Connection updated successfully.": "Connection updated successfully.", "Connection: \"{selectedConnectionName}\"\n\nThe connection will be added to the \"Connections View\" in the \"DocumentDB for VS Code\" extension. The \"Connections View\" will be opened once this process completes.\n\nDo you want to continue?": "Connection: \"{selectedConnectionName}\"\n\nThe connection will be added to the \"Connections View\" in the \"DocumentDB for VS Code\" extension. The \"Connections View\" will be opened once this process completes.\n\nDo you want to continue?", "Connection: {connectionName}": "Connection: {connectionName}", + "Connections (m)": "Connections (m)", "Connections have moved": "Connections have moved", "Connects through a Kubernetes node port. This only works if a cluster node address is reachable from this machine.": "Connects through a Kubernetes node port. This only works if a cluster node address is reachable from this machine.", "Connects to the LoadBalancer external address. The connection string is portable if that address is reachable from the client machine.": "Connects to the LoadBalancer external address. The connection string is portable if that address is reachable from the client machine.", + "Container host": "Container host", + "Containers run on": "Containers run on", + "context unavailable": "context unavailable", + "Context unavailable": "Context unavailable", "Continue": "Continue", + "Continue anyway": "Continue anyway", + "Continue setup": "Continue setup", + "Continuing runs every setup step from the beginning, starting with the Docker check. Nothing has been created on your machine yet.": "Continuing runs every setup step from the beginning, starting with the Docker check. Nothing has been created on your machine yet.", "Copied to clipboard": "Copied to clipboard", "Copy": "Copy", "Copy \"{sourceCollection}\" from \"{sourceDatabase}\" to \"{targetDatabase}/{targetCollection}\"": "Copy \"{sourceCollection}\" from \"{sourceDatabase}\" to \"{targetDatabase}/{targetCollection}\"", @@ -382,13 +522,17 @@ "Copy Query": "Copy Query", "Copy Reference: {0}": "Copy Reference: {0}", "Copy Reference: {0}.{1}": "Copy Reference: {0}.{1}", + "Copy the Client ID and Client Secret from a Service Account in MongoDB Atlas. We use them to sign in and show the clusters you can access.": "Copy the Client ID and Client Secret from a Service Account in MongoDB Atlas. We use them to sign in and show the clusters you can access.", + "Copy the Public Key and Private Key from an API Key in MongoDB Atlas. We use them to sign in and show the clusters you can access.": "Copy the Public Key and Private Key from an API Key in MongoDB Atlas. We use them to sign in and show the clusters you can access.", "Copy with password": "Copy with password", "Copy without password": "Copy without password", "Copy-and-Merge": "Copy-and-Merge", "Copy-and-Paste": "Copy-and-Paste", "Copying…": "Copying…", + "Cosine (COS)": "Cosine (COS)", "Could not add \"{0}\" as a kubeconfig source.": "Could not add \"{0}\" as a kubeconfig source.", "Could not add {0} of the dropped files as kubeconfig sources.": "Could not add {0} of the dropped files as kubeconfig sources.", + "Could not be resolved": "Could not be resolved", "Could not check existing collections for default name generation: {0}": "Could not check existing collections for default name generation: {0}", "Could not connect to \"{cluster}\".": "Could not connect to \"{cluster}\".", "Could not detect a collection name in this code block.": "Could not detect a collection name in this code block.", @@ -397,17 +541,22 @@ "Could not find parent folder.": "Could not find parent folder.", "Could not find the Azure Resource Groups extension": "Could not find the Azure Resource Groups extension", "Could not find unique name for new file.": "Could not find unique name for new file.", + "Could not load indexes.": "Could not load indexes.", "Could not open \"{0}\" for preview.": "Could not open \"{0}\" for preview.", "Could not open {0} of the dropped files for preview.": "Could not open {0} of the dropped files for preview.", "Counting documents in the source collection...": "Counting documents in the source collection...", "Covered query": "Covered query", "Create an Azure Account...": "Create an Azure Account...", "Create an Azure for Students Account...": "Create an Azure for Students Account...", + "Create and start the container": "Create and start the container", "Create collection": "Create collection", "Create Collection…": "Create Collection…", "Create database": "Create database", "Create Database…": "Create Database…", "Create Free Azure DocumentDB Cluster": "Create Free Azure DocumentDB Cluster", + "Create in the playground": "Create in the playground", + "Create in the shell": "Create in the shell", + "Create Index": "Create Index", "Create index \"{indexName}\" on collection \"{collectionName}\"?": "Create index \"{indexName}\" on collection \"{collectionName}\"?", "Create index on collection \"{collectionName}\"?": "Create index on collection \"{collectionName}\"?", "Create index?": "Create index?", @@ -419,13 +568,25 @@ "Created new folder: {folderName} in folder with ID {parentFolderId}": "Created new folder: {folderName} in folder with ID {parentFolderId}", "Creating \"{nodeName}\"…": "Creating \"{nodeName}\"…", "Creating {0}...": "Creating {0}...", + "Creating container": "Creating container", + "Creating index": "Creating index", "Creating index \"{indexName}\" on collection: {collection}": "Creating index \"{indexName}\" on collection: {collection}", "Creating resource group \"{0}\" in location \"{1}\"...": "Creating resource group \"{0}\" in location \"{1}\"...", "Creating storage account \"{0}\" in location \"{1}\" with sku \"{2}\"...": "Creating storage account \"{0}\" in location \"{1}\" with sku \"{2}\"...", "Creating user-assigned identity \"{0}\" in location \"{1}\"\"...": "Creating user-assigned identity \"{0}\" in location \"{1}\"\"...", + "Creating…": "Creating…", + "Credential added": "Credential added", + "Credential check progress": "Credential check progress", + "Credential management completed": "Credential management completed", + "Credential setup progress": "Credential setup progress", + "Credential updated": "Credential updated", + "Credentials": "Credentials", "Credentials may have expired. Re-authenticate with your cluster or update the kubeconfig.": "Credentials may have expired. Re-authenticate with your cluster or update the kubeconfig.", "Credentials may have expired. Re-authenticate with your cluster.": "Credentials may have expired. Re-authenticate with your cluster.", "Credentials updated successfully.": "Credentials updated successfully.", + "daemon not running": "daemon not running", + "daemon starting": "daemon starting", + "daemon unreachable": "daemon unreachable", "Data shown was correct": "Data shown was correct", "Data shown was incorrect": "Data shown was incorrect", "Database": "Database", @@ -442,22 +603,38 @@ "Delete {count} connections?": "Delete {count} connections?", "Delete {count} documents?": "Delete {count} documents?", "Delete collection \"{collectionId}\" and its contents?": "Delete collection \"{collectionId}\" and its contents?", + "Delete Container…": "Delete Container…", "Delete database \"{databaseId}\" and its contents?": "Delete database \"{databaseId}\" and its contents?", + "Delete DocumentDB Local container?": "Delete DocumentDB Local container?", "Delete Folder": "Delete Folder", "Delete folder \"{folderName}\"?": "Delete folder \"{folderName}\"?", + "Delete index": "Delete index", "Delete index \"{indexName}\" from collection \"{collectionName}\"?": "Delete index \"{indexName}\" from collection \"{collectionName}\"?", + "Delete index {0}": "Delete index {0}", "Delete index from collection \"{collectionName}\"?": "Delete index from collection \"{collectionName}\"?", "Delete index?": "Delete index?", "Delete selected document(s)": "Delete selected document(s)", "delete this collection": "delete this collection", "delete this database": "delete this database", + "Deleting this index is permanent and cannot be undone.": "Deleting this index is permanent and cannot be undone.", "Deleting...": "Deleting...", + "Deleting…": "Deleting…", "Demo Task {0}": "Demo Task {0}", "Demo Task Configuration": "Demo Task Configuration", + "Denied for a reason the check could not narrow down": "Denied for a reason the check could not narrow down", + "descending": "descending", + "Descending (-1)": "Descending (-1)", "Destination: \"{0}\"": "Destination: \"{0}\"", "detailed execution analysis": "detailed execution analysis", + "Detected problem": "Detected problem", + "Dev container": "Dev container", + "Develop and test locally": "Develop and test locally", "Development": "Development", + "Diagnostic reference {0}. Quote it when reporting this.": "Diagnostic reference {0}. Quote it when reporting this.", "Did you mean '{0}'?": "Did you mean '{0}'?", + "Dimensions": "Dimensions", + "Dimensions and similarity": "Dimensions and similarity", + "Dimensions is the fixed number of values in each vector; it comes from the embedding model. Similarity is the distance metric used to compare vectors.": "Dimensions is the fixed number of values in each vector; it comes from the embedding model. Similarity is the distance metric used to compare vectors.", "direct": "direct", "Direct external address": "Direct external address", "Direct fetch": "Direct fetch", @@ -467,19 +644,67 @@ "Disconnect and connect this playground to a different database?": "Disconnect and connect this playground to a different database?", "Discovery plugin error: clusterId \"{0}\" must start with provider ID \"{1}\". Plugin \"{2}\" must prefix clusterId with its provider ID.": "Discovery plugin error: clusterId \"{0}\" must start with provider ID \"{1}\". Plugin \"{2}\" must prefix clusterId with its provider ID.", "Discovery Plugins: {0}": "Discovery Plugins: {0}", + "DiskANN": "DiskANN", "Do not rely on case to distinguish between databases. For example, you cannot use two databases with names like, salesData and SalesData.": "Do not rely on case to distinguish between databases. For example, you cannot use two databases with names like, salesData and SalesData.", "Do not save credentials.": "Do not save credentials.", "Do you want to include the password in the connection string?": "Do you want to include the password in the connection string?", + "Docker": "Docker", + "Docker access denied": "Docker access denied", + "Docker answered too vaguely to name a cause, so setup can still be attempted.": "Docker answered too vaguely to name a cause, so setup can still be attempted.", + "Docker became unavailable during setup: {0}": "Docker became unavailable during setup: {0}", + "Docker check timed out": "Docker check timed out", + "Docker CLI": "Docker CLI", + "Docker CLI {0} found": "Docker CLI {0} found", + "Docker CLI found": "Docker CLI found", + "Docker CLI not found": "Docker CLI not found", + "Docker CLI was not found on your PATH. Install Docker and retry.": "Docker CLI was not found on your PATH. Install Docker and retry.", + "Docker context unavailable": "Docker context unavailable", + "Docker could not be started.": "Docker could not be started.", + "Docker daemon": "Docker daemon", + "Docker daemon starting": "Docker daemon starting", + "Docker daemon unavailable": "Docker daemon unavailable", + "Docker Desktop": "Docker Desktop", + "Docker Desktop not running": "Docker Desktop not running", + "Docker did not become ready before the wait timed out.": "Docker did not become ready before the wait timed out.", + "Docker did not respond before the readiness check timed out.": "Docker did not respond before the readiness check timed out.", + "Docker endpoint": "Docker endpoint", + "Docker endpoint unreachable": "Docker endpoint unreachable", + "Docker Engine": "Docker Engine", + "Docker is installed but the daemon is not reachable. Start Docker and retry.": "Docker is installed but the daemon is not reachable. Start Docker and retry.", + "Docker is ready": "Docker is ready", + "Docker is ready now. Nothing has been created on your machine yet.": "Docker is ready now. Nothing has been created on your machine yet.", + "Docker is ready. Setup has not run yet.": "Docker is ready. Setup has not run yet.", + "Docker must be available in the remote environment where this extension is running.": "Docker must be available in the remote environment where this extension is running.", + "Docker reports this only once a connection succeeds, so it stays unknown until then.": "Docker reports this only once a connection succeeds, so it stays unknown until then.", + "Docker started, but it is not usable yet. See the details below.": "Docker started, but it is not usable yet. See the details below.", + "Document actions": "Document actions", "Document already exists (skipped)": "Document already exists (skipped)", "Document Editor: Edit the document in JSON format": "Document Editor: Edit the document in JSON format", "Document must be an object.": "Document must be an object.", "Document must be an object. Skipping…": "Document must be an object. Skipping…", + "Document query utilities": "Document query utilities", "DocumentDB and MongoDB Accounts": "DocumentDB and MongoDB Accounts", + "DocumentDB did not accept connections in time. Docker may be running on the dev container host, so the published localhost port might not be reachable from inside the dev container.": "DocumentDB did not accept connections in time. Docker may be running on the dev container host, so the published localhost port might not be reachable from inside the dev container.", + "DocumentDB did not accept connections in time. It may still be initializing.": "DocumentDB did not accept connections in time. It may still be initializing.", "DocumentDB Documentation": "DocumentDB Documentation", "DocumentDB for VS Code has been updated. View the release notes?": "DocumentDB for VS Code has been updated. View the release notes?", "DocumentDB for VS Code is not signed in to Azure": "DocumentDB for VS Code is not signed in to Azure", + "DocumentDB is still initializing. Keep waiting, view the logs, or start over.": "DocumentDB is still initializing. Keep waiting, view the logs, or start over.", "DocumentDB Kubernetes Operator (DKO)": "DocumentDB Kubernetes Operator (DKO)", "DocumentDB Local": "DocumentDB Local", + "DocumentDB Local - Quick Start": "DocumentDB Local - Quick Start", + "DocumentDB Local already has data on this machine. What should setup do with it?": "DocumentDB Local already has data on this machine. What should setup do with it?", + "DocumentDB Local container deleted.": "DocumentDB Local container deleted.", + "DocumentDB Local gives you an open-source, fully MongoDB-compatible database for development and testing on your machine.": "DocumentDB Local gives you an open-source, fully MongoDB-compatible database for development and testing on your machine.", + "DocumentDB Local has data on disk but its saved credentials are missing, so it cannot be opened. Use \"Delete Container\" to remove it and start fresh (this erases the data).": "DocumentDB Local has data on disk but its saved credentials are missing, so it cannot be opened. Use \"Delete Container\" to remove it and start fresh (this erases the data).", + "DocumentDB Local images are published for x64 and arm64 only.": "DocumentDB Local images are published for x64 and arm64 only.", + "DocumentDB Local is already running": "DocumentDB Local is already running", + "DocumentDB Local is already set up": "DocumentDB Local is already set up", + "DocumentDB Local is not set up yet. Run Quick Start to create a local instance first.": "DocumentDB Local is not set up yet. Run Quick Start to create a local instance first.", + "DocumentDB Local is ready": "DocumentDB Local is ready", + "DocumentDB Local is ready. Next steps are shown below.": "DocumentDB Local is ready. Next steps are shown below.", + "DocumentDB Local is running on localhost:{0}.": "DocumentDB Local is running on localhost:{0}.", + "DocumentDB Local needs attention": "DocumentDB Local needs attention", "DocumentDB Shell: {0}": "DocumentDB Shell: {0}", "DocumentDB TS Plugin": "DocumentDB TS Plugin", "DocumentDB: {0}@{1}/{2}": "DocumentDB: {0}@{1}/{2}", @@ -491,29 +716,60 @@ "Don't Ask Again": "Don't Ask Again", "Don't upload": "Don't upload", "Don't warn again": "Don't warn again", + "done": "done", + "Done": "Done", "Double-click to open the collection view": "Double-click to open the collection view", + "Double-click to open the index management view": "Double-click to open the index management view", + "Download the official image": "Download the official image", + "Downloaded once, then reused for later setups.": "Downloaded once, then reused for later setups.", "Drafting…": "Drafting…", "Drop Index…": "Drop Index…", "Dropping index \"{indexName}\" from collection: {collection}": "Dropping index \"{indexName}\" from collection: {collection}", + "Duplicate index field.": "Duplicate index field.", "Duplicate key error for document with _id: {0}. {1}": "Duplicate key error for document with _id: {0}. {1}", "Duplicate key error: {0}": "Duplicate key error: {0}", "Duplicate key error. {0}": "Duplicate key error. {0}", + "e.g. 1536": "e.g. 1536", "e.g., 12345678-1234-1234-1234-123456789012 or 12345678123412341234123456789012": "e.g., 12345678-1234-1234-1234-123456789012 or 12345678123412341234123456789012", + "e.g., abcdef12": "e.g., abcdef12", "e.g., DocumentDB, Environment, Project": "e.g., DocumentDB, Environment, Project", + "e.g., mdb_sa_id_6501…": "e.g., mdb_sa_id_6501…", "Each line will be run independently.": "Each line will be run independently.", + "Edit and retry": "Edit and retry", "Edit Kubeconfig": "Edit Kubeconfig", "Edit selected document": "Edit selected document", "Efficient sorting": "Efficient sorting", "Element with id of {rootId} not found.": "Element with id of {rootId} not found.", "empty": "empty", + "Enable Docker Desktop integration for this WSL distribution, then check again.": "Enable Docker Desktop integration for this WSL distribution, then check again.", + "Enable TLS (default)": "Enable TLS (default)", "Enable TLS/SSL (Default)": "Enable TLS/SSL (Default)", + "endpoint unreachable": "endpoint unreachable", + "Endpoint unreachable": "Endpoint unreachable", "Enforce TLS/SSL checks for a secure connection.": "Enforce TLS/SSL checks for a secure connection.", "Enhanced Query Configuration\n(Projection, Sort, Skip, Limit)": "Enhanced Query Configuration\n(Projection, Sort, Skip, Limit)", "Ensuring target exists...": "Ensuring target exists...", + "Enter 4 to 1000 and at least 2 × connections (m).": "Enter 4 to 1000 and at least 2 × connections (m).", "Enter a collection name.": "Enter a collection name.", "Enter a database name.": "Enter a database name.", "Enter a new label for this kubeconfig source.": "Enter a new label for this kubeconfig source.", + "Enter a new secret. The stored one is replaced only after the new one validates.": "Enter a new secret. The stored one is replaced only after the new one validates.", + "Enter a parent path without $**. It is added automatically.": "Enter a parent path without $**. It is added automatically.", + "Enter a password": "Enter a password", + "Enter a positive whole number below the dimensions.": "Enter a positive whole number below the dimensions.", + "Enter a positive whole number using digits only.": "Enter a positive whole number using digits only.", + "Enter a positive whole number.": "Enter a positive whole number.", + "Enter a username": "Enter a username", "Enter a valid port number (1-65535)": "Enter a valid port number (1-65535)", + "Enter a valid TTL value to continue.": "Enter a valid TTL value to continue.", + "Enter a vector field to create the index.": "Enter a vector field to create the index.", + "Enter a whole number from 10 to 500.": "Enter a whole number from 10 to 500.", + "Enter a whole number from 1000 to 100000.": "Enter a whole number from 1000 to 100000.", + "Enter a whole number from 2 to 100.": "Enter a whole number from 2 to 100.", + "Enter a whole number from 20 to 2048.": "Enter a whole number from 20 to 2048.", + "Enter an initial collection name for the new database.": "Enter an initial collection name for the new database.", + "Enter both a username and a password, or leave both blank to auto-generate.": "Enter both a username and a password, or leave both blank to auto-generate.", + "Enter details": "Enter details", "Enter folder name": "Enter folder name", "Enter new folder name": "Enter new folder name", "Enter the Azure VM tag key used for discovering DocumentDB instances.": "Enter the Azure VM tag key used for discovering DocumentDB instances.", @@ -527,9 +783,13 @@ "Enter the tenant ID (GUID)": "Enter the tenant ID (GUID)", "Enter the username": "Enter the username", "Enter the username for {experience}": "Enter the username for {experience}", + "Enter the vector dimensions to create the index.": "Enter the vector dimensions to create the index.", + "Enter your MongoDB Atlas credential details.": "Enter your MongoDB Atlas credential details.", "Entra ID": "Entra ID", "Entra ID for Azure DocumentDB": "Entra ID for Azure DocumentDB", + "Erase the existing data and start empty": "Erase the existing data and start empty", "Error": "Error", + "Error · click for details": "Error · click for details", "Error creating index: {error}": "Error creating index: {error}", "Error creating resource: {0}": "Error creating resource: {0}", "Error deleting selected documents": "Error deleting selected documents", @@ -550,9 +810,14 @@ "Error: {0}": "Error: {0}", "Error: {error}": "Error: {error}", "Errors found in file \"{path}\". Please fix these:": "Errors found in file \"{path}\". Please fix these:", + "Euclidean (L2)": "Euclidean (L2)", + "Every MongoDB Atlas credential is signed in.": "Every MongoDB Atlas credential is signed in.", + "Everything checked out with MongoDB Atlas.": "Everything checked out with MongoDB Atlas.", + "Everything the readiness check established, in the order it was established.": "Everything the readiness check established, in the order it was established.", + "Everything was successful. Your credential was checked and saved.": "Everything was successful. Your credential was checked and saved.", "Excellent": "Excellent", + "Exclude selected fields": "Exclude selected fields", "Execute as One": "Execute as One", - "Execute the find query": "Execute the find query", "Executed in {0}ms": "Executed in {0}ms", "Executing explain(aggregate) for collection: {collection}, pipeline stages: {stageCount}": "Executing explain(aggregate) for collection: {collection}, pipeline stages: {stageCount}", "Executing explain(count) for collection: {collection}": "Executing explain(count) for collection: {collection}", @@ -561,7 +826,11 @@ "Execution timed out": "Execution timed out", "Execution timed out.": "Execution timed out.", "Exit": "Exit", + "Expand fields for {0}": "Expand fields for {0}", + "Expand row": "Expand row", "Expected a document object like { field: 1 }, got: {0}": "Expected a document object like { field: 1 }, got: {0}", + "Expire after (seconds)": "Expire after (seconds)", + "Expires after": "Expires after", "Explain(aggregate) completed [{durationMs}ms]": "Explain(aggregate) completed [{durationMs}ms]", "Explain(count) completed [{durationMs}ms]": "Explain(count) completed [{durationMs}ms]", "Explain(find) completed [{durationMs}ms]": "Explain(find) completed [{durationMs}ms]", @@ -580,8 +849,11 @@ "Extension dependency with id \"{0}\" must be updated.": "Extension dependency with id \"{0}\" must be updated.", "Extension Documentation": "Extension Documentation", "ExternalName services are not directly supported. Use the external DNS name to connect manually.": "ExternalName services are not directly supported. Use the external DNS name to connect manually.", + "failed": "failed", + "Failed to {0} documents.": "Failed to {0} documents.", "Failed to {action} index \"{indexName}\": {error} [{durationMs}ms]": "Failed to {action} index \"{indexName}\": {error} [{durationMs}ms]", "Failed to abort transaction: {0}": "Failed to abort transaction: {0}", + "Failed to authenticate Service Account: {0}": "Failed to authenticate Service Account: {0}", "Failed to bind 127.0.0.1:{0}.": "Failed to bind 127.0.0.1:{0}.", "Failed to commit transaction: {0}": "Failed to commit transaction: {0}", "Failed to complete operation after {0} attempts": "Failed to complete operation after {0} attempts", @@ -593,11 +865,16 @@ "Failed to count documents in the source collection.": "Failed to count documents in the source collection.", "Failed to create Azure management clients: {0}": "Failed to create Azure management clients: {0}", "Failed to create index: {error}": "Failed to create index: {error}", + "Failed to create index.": "Failed to create index.", "Failed to create role assignment \"{0}\" for the {2} resource \"{1}\".": "Failed to create role assignment \"{0}\" for the {2} resource \"{1}\".", "Failed to create role assignment(s).": "Failed to create role assignment(s).", "Failed to delete documents. Unknown error.": "Failed to delete documents. Unknown error.", + "Failed to delete index \"{0}\".": "Failed to delete index \"{0}\".", + "Failed to delete index \"{indexName}\".": "Failed to delete index \"{indexName}\".", + "Failed to delete index.": "Failed to delete index.", "Failed to delete item \"{0}\".": "Failed to delete item \"{0}\".", "Failed to delete secrets for item \"{0}\".": "Failed to delete secrets for item \"{0}\".", + "Failed to delete the DocumentDB Local container: {0}": "Failed to delete the DocumentDB Local container: {0}", "Failed to drop index: {error}": "Failed to drop index: {error}", "Failed to drop index.": "Failed to drop index.", "Failed to end session: {0}": "Failed to end session: {0}", @@ -611,6 +888,8 @@ "Failed to get optimization recommendations from index advisor.": "Failed to get optimization recommendations from index advisor.", "Failed to get public IP": "Failed to get public IP", "Failed to get response from language model": "Failed to get response from language model", + "Failed to hide index \"{0}\".": "Failed to hide index \"{0}\".", + "Failed to hide index \"{indexName}\".": "Failed to hide index \"{indexName}\".", "Failed to hide index.": "Failed to hide index.", "Failed to initialize Azure management clients": "Failed to initialize Azure management clients", "Failed to initialize task": "Failed to initialize task", @@ -624,9 +903,12 @@ "Failed to load {0}": "Failed to load {0}", "Failed to load custom prompt template from {path}: {error}. Using built-in template.": "Failed to load custom prompt template from {path}: {error}. Using built-in template.", "Failed to load databases for \"{0}\"": "Failed to load databases for \"{0}\"", + "Failed to load indexes.": "Failed to load indexes.", "Failed to load kubeconfig from \"{0}\": {1}. Ensure the file exists and contains valid YAML.": "Failed to load kubeconfig from \"{0}\": {1}. Ensure the file exists and contains valid YAML.", "Failed to load kubeconfig from pasted YAML: {0}. Ensure the clipboard contains a valid kubeconfig document.": "Failed to load kubeconfig from pasted YAML: {0}. Ensure the clipboard contains a valid kubeconfig document.", "Failed to load kubeconfig: {0}. Ensure a kubeconfig file exists at the Kubernetes default kubeconfig path or the KUBECONFIG environment variable is set.": "Failed to load kubeconfig: {0}. Ensure a kubeconfig file exists at the Kubernetes default kubeconfig path or the KUBECONFIG environment variable is set.", + "Failed to load MongoDB Atlas clusters.": "Failed to load MongoDB Atlas clusters.", + "Failed to load MongoDB Atlas discovery: {0}": "Failed to load MongoDB Atlas discovery: {0}", "Failed to load selected items from storage.": "Failed to load selected items from storage.", "Failed to load template file for {type}: {error}": "Failed to load template file for {type}: {error}", "Failed to load the Kubernetes client library: {0}. Try reloading the window; if the problem persists, reinstall the extension.": "Failed to load the Kubernetes client library: {0}. Try reloading the window; if the problem persists, reinstall the extension.", @@ -636,6 +918,7 @@ "Failed to open Collection View: {0}": "Failed to open Collection View: {0}", "Failed to open kubeconfig in editor: {0}": "Failed to open kubeconfig in editor: {0}", "Failed to open raw execution stats": "Failed to open raw execution stats", + "Failed to open the index definition.": "Failed to open the index definition.", "Failed to parse AI optimization response. {error}": "Failed to parse AI optimization response. {error}", "Failed to parse generated query. Query generation provided an invalid response.": "Failed to parse generated query. Query generation provided an invalid response.", "Failed to parse pasted kubeconfig YAML: {0}": "Failed to parse pasted kubeconfig YAML: {0}", @@ -643,6 +926,7 @@ "Failed to parse secrets for key {0}:": "Failed to parse secrets for key {0}:", "Failed to parse the response from the language model. LLM output:\n{output}": "Failed to parse the response from the language model. LLM output:\n{output}", "Failed to paste collection: {0}": "Failed to paste collection: {0}", + "Failed to prepare the index command.": "Failed to prepare the index command.", "Failed to process URI: {0}": "Failed to process URI: {0}", "Failed to remove all {count} connections. See the output channel for details.": "Failed to remove all {count} connections. See the output channel for details.", "Failed to remove connection \"{connectionName}\": {error}": "Failed to remove connection \"{connectionName}\": {error}", @@ -664,6 +948,8 @@ "Failed to start a transaction: {0}": "Failed to start a transaction: {0}", "Failed to start streaming optimization recommendations.": "Failed to start streaming optimization recommendations.", "Failed to store secrets for key {0}:": "Failed to store secrets for key {0}:", + "Failed to unhide index \"{0}\".": "Failed to unhide index \"{0}\".", + "Failed to unhide index \"{indexName}\".": "Failed to unhide index \"{indexName}\".", "Failed to unhide index.": "Failed to unhide index.", "Failed to update connection: {0}": "Failed to update connection: {0}", "Failed to update connection: connection not found in storage or missing connection string.": "Failed to update connection: connection not found in storage or missing connection string.", @@ -673,38 +959,76 @@ "Failed with code \"{0}\".": "Failed with code \"{0}\".", "Fair": "Fair", "Fast execution": "Fast execution", + "Fast, light build for smaller collections.": "Fast, light build for smaller collections.", + "Federated": "Federated", "Fetch Overhead": "Fetch Overhead", + "Field \"{0}\", {1} index": "Field \"{0}\", {1} index", + "Field name": "Field name", + "Field path is required.": "Field path is required.", + "Field type": "Field type", + "Fields": "Fields", + "Fields below a path": "Fields below a path", + "Filter indexes": "Filter indexes", + "Filter indexes…": "Filter indexes…", "Filter: Enter the DocumentDB query filter": "Filter: Enter the DocumentDB query filter", "Find Query": "Find Query", + "Find Query: execute the current editor values": "Find Query: execute the current editor values", "Finished importing": "Finished importing", + "Fix the highlighted values in Advanced settings to continue.": "Fix the highlighted values in Advanced settings to continue.", "Folder": "Folder", "Folder name cannot be empty": "Folder name cannot be empty", + "Footer experiment": "Footer experiment", + "Footer experiment is in preview": "Footer experiment is in preview", + "Found, version {0}": "Found, version {0}", + "Found, version not reported": "Found, version not reported", "Full collection scan": "Full collection scan", "Generate": "Generate", "Generate new _id values": "Generate new _id values", "Generate query with AI": "Generate query with AI", + "Generated automatically": "Generated automatically", "Generating recommendation…": "Generating recommendation…", "Generic Kubernetes service": "Generic Kubernetes service", + "geoHaystack (geospatial)": "geoHaystack (geospatial)", + "Geospatial (2dsphere)": "Geospatial (2dsphere)", "Get AI Performance Insights": "Get AI Performance Insights", + "Get Docker Desktop for Mac": "Get Docker Desktop for Mac", + "Get Docker Desktop for Windows": "Get Docker Desktop for Windows", "Get personalized recommendations to optimize your query performance. AI will analyze your cluster configuration, index usage, execution plan, and more to suggest specific improvements.": "Get personalized recommendations to optimize your query performance. AI will analyze your cluster configuration, index usage, execution plan, and more to suggest specific improvements.", + "GitHub Codespaces": "GitHub Codespaces", "GitHub Copilot is not available. Please install the GitHub Copilot extension and ensure you have an active subscription.": "GitHub Copilot is not available. Please install the GitHub Copilot extension and ensure you have an active subscription.", "Go back.": "Go back.", + "Go to **API Keys**, create a key, set its permissions, then copy the **Public Key** and **Private Key**.": "Go to **API Keys**, create a key, set its permissions, then copy the **Public Key** and **Private Key**.", + "Go to **Service Accounts**, create one, set its permissions, then copy the **Client ID** and **Client Secret**.": "Go to **Service Accounts**, create one, set its permissions, then copy the **Client ID** and **Client Secret**.", "Go to first page": "Go to first page", "Go to next page": "Go to next page", "Go to previous page": "Go to previous page", "Go to start": "Go to start", "Good": "Good", "Got a moment? Share your feedback on DocumentDB for VS Code!": "Got a moment? Share your feedback on DocumentDB for VS Code!", + "Group membership applies to new login sessions only.": "Group membership applies to new login sessions only.", + "Half precision": "Half precision", + "Half precision is only available for IVF and HNSW indexes.": "Half precision is only available for IVF and HNSW indexes.", + "Half precision stores index values at lower precision on IVF and HNSW indexes.": "Half precision stores index values at lower precision on IVF and HNSW indexes.", + "hashed": "hashed", + "Hashed": "Hashed", "Helped me understand the query execution": "Helped me understand the query execution", "hidden": "hidden", + "Hidden": "Hidden", "hide": "hide", - "Hide index \"{indexName}\" from collection \"{collectionName}\"?": "Hide index \"{indexName}\" from collection \"{collectionName}\"?", + "Hide": "Hide", + "Hide index": "Hide index", + "Hide index {0}": "Hide index {0}", "Hide index?": "Hide index?", "Hide Index…": "Hide Index…", + "Hide secret": "Hide secret", + "Hide the image tag setting": "Hide the image tag setting", + "Hide the port setting": "Hide the port setting", + "Hiding prevents the query planner from using this index.": "Hiding prevents the query planner from using this index.", "Hiding…": "Hiding…", "High efficiency ratio": "High efficiency ratio", "High multikey expansion": "High multikey expansion", "HIGH PRIORITY": "HIGH PRIORITY", + "HNSW": "HNSW", "Host": "Host", "How do you want to connect?": "How do you want to connect?", "How should conflicts be handled during the copy operation?": "How should conflicts be handled during the copy operation?", @@ -719,6 +1043,9 @@ "I want to connect using a connection string.": "I want to connect using a connection string.", "Ignore": "Ignore", "Ignoring the following files that do not match the \"*.json\" file name pattern:": "Ignoring the following files that do not match the \"*.json\" file name pattern:", + "Image": "Image", + "Image tag": "Image tag", + "Image tag may contain only letters, numbers, dots, dashes, and underscores.": "Image tag may contain only letters, numbers, dots, dashes, and underscores.", "Import": "Import", "Import canceled. Inserted {0} document(s) before cancellation.": "Import canceled. Inserted {0} document(s) before cancellation.", "Import completed with errors.": "Import completed with errors.", @@ -734,8 +1061,19 @@ "Importing documents…": "Importing documents…", "Importing…": "Importing…", "Improved my query performance": "Improved my query performance", + "in progress": "in progress", + "In the left sidebar, under **Identity & Access**, open **Applications**.": "In the left sidebar, under **Identity & Access**, open **Applications**.", "In-Memory Sort": "In-Memory Sort", "In-memory sort required": "In-memory sort required", + "Include only selected fields, or exclude selected fields while indexing all others.": "Include only selected fields, or exclude selected fields while indexing all others.", + "Include sample data": "Include sample data", + "Include selected fields": "Include selected fields", + "Included": "Included", + "Index \"{0}\" created.": "Index \"{0}\" created.", + "Index \"{0}\" deleted.": "Index \"{0}\" deleted.", + "Index \"{0}\" hidden.": "Index \"{0}\" hidden.", + "Index \"{0}\" unhidden.": "Index \"{0}\" unhidden.", + "Index \"{0}\" was not found.": "Index \"{0}\" was not found.", "Index \"{indexName}\" {action} successfully [{durationMs}ms]": "Index \"{indexName}\" {action} successfully [{durationMs}ms]", "Index \"{indexName}\" {operation} successfully": "Index \"{indexName}\" {operation} successfully", "Index \"{indexName}\" created successfully": "Index \"{indexName}\" created successfully", @@ -747,26 +1085,46 @@ "Index \"{indexName}\" has been unhidden.": "Index \"{indexName}\" has been unhidden.", "Index \"{indexName}\" is already hidden.": "Index \"{indexName}\" is already hidden.", "Index \"{indexName}\" is not hidden.": "Index \"{indexName}\" is not hidden.", + "Index actions": "Index actions", + "Index created.": "Index created.", "Index creation cancelled": "Index creation cancelled", "Index creation completed with warning: {note}": "Index creation completed with warning: {note}", "Index creation failed for \"{indexName}\": {error} [{durationMs}ms]": "Index creation failed for \"{indexName}\": {error} [{durationMs}ms]", + "Index creation may impact write performance during build.": "Index creation may impact write performance during build.", "Index deletion cancelled": "Index deletion cancelled", "Index drop completed with warning": "Index drop completed with warning", "Index drop failed for \"{indexName}\": {error} [{durationMs}ms]": "Index drop failed for \"{indexName}\": {error} [{durationMs}ms]", + "Index every field in each document with a single wildcard key.": "Index every field in each document with a single wildcard key.", + "Index fields": "Index fields", + "Index fields nested below one parent path.": "Index fields nested below one parent path.", "Index key covers {0}% of the collection per bucket": "Index key covers {0}% of the collection per bucket", + "Index kind": "Index kind", "Index modification cancelled": "Index modification cancelled", + "Index name": "Index name", "Index Name": "Index Name", "Index Options": "Index Options", + "Index specification": "Index specification", + "Index specification preview": "Index specification preview", "Index used": "Index used", "Index Used": "Index Used", "Index visibility modification completed with warning": "Index visibility modification completed with warning", + "Index-level properties applied to the whole index.": "Index-level properties applied to the whole index.", + "Index: {0}": "Index: {0}", + "Indexed fields": "Indexed fields", "Indexes": "Indexes", + "Indexes updated.": "Indexes updated.", + "Inferred from the Docker application installed on this machine.": "Inferred from the Docker application installed on this machine.", + "Inferred from the Docker context that is currently active.": "Inferred from the Docker context that is currently active.", "Info from the webview: ": "Info from the webview: ", "Information was confusing": "Information was confusing", "Initializing task...": "Initializing task...", "Initializing…": "Initializing…", + "Inner product (IP)": "Inner product (IP)", "Inserted {0} document(s).": "Inserted {0} document(s).", "Install Azure Account Extension...": "Install Azure Account Extension...", + "Install Docker Desktop, then restart VS Code. A VS Code that was already running does not pick up the PATH the installer adds, so Docker stays undetected until it is restarted. Reloading the window is not enough. Start Docker Desktop and wait until it is ready, then check again.": "Install Docker Desktop, then restart VS Code. A VS Code that was already running does not pick up the PATH the installer adds, so Docker stays undetected until it is restarted. Reloading the window is not enough. Start Docker Desktop and wait until it is ready, then check again.", + "Install Docker Desktop, then restart VS Code. A VS Code that was already running does not pick up the PATH the installer adds, so Docker stays undetected until it is restarted. Start Docker Desktop and wait until it is ready, then check again.": "Install Docker Desktop, then restart VS Code. A VS Code that was already running does not pick up the PATH the installer adds, so Docker stays undetected until it is restarted. Start Docker Desktop and wait until it is ready, then check again.", + "Install Docker Engine or Docker Desktop, then reopen Quick Start.": "Install Docker Engine or Docker Desktop, then reopen Quick Start.", "Internal error: connectionString must be defined.": "Internal error: connectionString must be defined.", "Internal error: connectionString, port, and api must be defined.": "Internal error: connectionString, port, and api must be defined.", "Internal error: Expected value to be neither null nor undefined": "Internal error: Expected value to be neither null nor undefined", @@ -774,7 +1132,10 @@ "Internal error: mode must be defined.": "Internal error: mode must be defined.", "Internal error. Invalid source node type.": "Internal error. Invalid source node type.", "Internal error. Invalid target node type.": "Internal error. Invalid target node type.", + "Introduction": "Introduction", "Invalid": "Invalid", + "Invalid {0}: {1}": "Invalid {0}: {1}", + "Invalid {0}: expected a JSON object.": "Invalid {0}: expected a JSON object.", "Invalid Azure Resource Group Id.": "Invalid Azure Resource Group Id.", "Invalid Azure Resource Id": "Invalid Azure Resource Id", "Invalid conflict resolution strategy selected.": "Invalid conflict resolution strategy selected.", @@ -797,14 +1158,19 @@ "It does not contain any Kubernetes contexts.": "It does not contain any Kubernetes contexts.", "It is not a regular file.": "It is not a regular file.", "It is not a valid kubeconfig (it may be a binary file or contain invalid YAML).": "It is not a valid kubeconfig (it may be a binary file or contain invalid YAML).", + "It is stopped. Start it to use it again, with all your data.": "It is stopped. Start it to use it again, with all your data.", "It looks like a binary file rather than a kubeconfig.": "It looks like a binary file rather than a kubeconfig.", "It looks like there aren't any other folders to move these items into.\nYou might want to create a new folder first.\n\nNote: You can't move items between 'DocumentDB Local' and regular connections.": "It looks like there aren't any other folders to move these items into.\nYou might want to create a new folder first.\n\nNote: You can't move items between 'DocumentDB Local' and regular connections.", "item": "item", "items": "items", + "IVF": "IVF", + "JSON preview": "JSON preview", "JSON results view: Read-only display of query results in JSON format": "JSON results view: Read-only display of query results in JSON format", "JSON View": "JSON View", + "Keep the existing data": "Keep the existing data", "Keep-alive timeout exceeded": "Keep-alive timeout exceeded", "Keep-alive timeout exceeded: stream has been running for {0} seconds (limit: {1} seconds)": "Keep-alive timeout exceeded: stream has been running for {0} seconds (limit: {1} seconds)", + "Kept from the existing instance": "Kept from the existing instance", "Key Definition": "Key Definition", "Keys Examined": "Keys Examined", "Kubeconfig file…": "Kubeconfig file…", @@ -820,7 +1186,17 @@ "Kubernetes Service Discovery": "Kubernetes Service Discovery", "Kubernetes target \"{0}\" resolved successfully.": "Kubernetes target \"{0}\" resolved successfully.", "Label cannot be empty.": "Label cannot be empty.", + "Language-specific comparison rules. Enter a JSON object.": "Language-specific comparison rules. Enter a JSON object.", + "Large collection": "Large collection", "Large Collection Copy Operation": "Large Collection Copy Operation", + "Last check did not complete": "Last check did not complete", + "Last checked {0} days ago": "Last checked {0} days ago", + "Last checked {0} hours ago": "Last checked {0} hours ago", + "Last checked {0} minutes ago": "Last checked {0} minutes ago", + "Last checked 1 day ago": "Last checked 1 day ago", + "Last checked 1 hour ago": "Last checked 1 hour ago", + "Last checked 1 minute ago": "Last checked 1 minute ago", + "Last checked just now": "Last checked just now", "Learn more": "Learn more", "Learn more about {0}.": "Learn more about {0}.", "Learn more about AI Performance Insights": "Learn more about AI Performance Insights", @@ -831,22 +1207,32 @@ "Learn more about local connections.": "Learn more about local connections.", "Learn more about the utility model used.": "Learn more about the utility model used.", "Learn more…": "Learn more…", + "Legacy, simplest": "Legacy, simplest", "Length must be greater than 1": "Length must be greater than 1", "Level up": "Level up", "Limit": "Limit", "Lines will be joined into a single expression and executed.": "Lines will be joined into a single expression and executed.", + "Linux": "Linux", + "Linux containers required": "Linux containers required", + "Lists": "Lists", "Load More...": "Load More...", "LoadBalancer external IP is not assigned. Using node InternalIP as a fallback — this address may not be reachable outside the cluster. Verify that the node is accessible from your machine.": "LoadBalancer external IP is not assigned. Using node InternalIP as a fallback — this address may not be reachable outside the cluster. Verify that the node is accessible from your machine.", "LoadBalancer external IP is not yet assigned and no NodePort fallback is available. The service may still be provisioning.": "LoadBalancer external IP is not yet assigned and no NodePort fallback is available. The service may still be provisioning.", "LoadBalancer pending": "LoadBalancer pending", "Loaded {0} document(s) from \"{1}\"": "Loaded {0} document(s) from \"{1}\"", "Loading \"{0}\"...": "Loading \"{0}\"...", + "Loading Atlas clusters…": "Loading Atlas clusters…", + "Loading Atlas projects…": "Loading Atlas projects…", "Loading Azure Accounts Used for Service Discovery…": "Loading Azure Accounts Used for Service Discovery…", "Loading cluster details for \"{cluster}\"": "Loading cluster details for \"{cluster}\"", "Loading Clusters…": "Loading Clusters…", "Loading Content": "Loading Content", + "Loading database users for \"{cluster}\"…": "Loading database users for \"{cluster}\"…", "Loading document {num} of {countUri}": "Loading document {num} of {countUri}", "Loading documents…": "Loading documents…", + "Loading indexes": "Loading indexes", + "Loading indexes…": "Loading indexes…", + "Loading MongoDB Atlas credentials…": "Loading MongoDB Atlas credentials…", "Loading performance rating": "Loading performance rating", "Loading resources...": "Loading resources...", "Loading Subscriptions…": "Loading Subscriptions…", @@ -860,26 +1246,52 @@ "Local emulators": "Local emulators", "Local port {0} is already used by Kubernetes tunnel \"{1}/{2}\". Choose a different local port for \"{3}/{4}\".": "Local port {0} is already used by Kubernetes tunnel \"{1}/{2}\". Choose a different local port for \"{3}/{4}\".", "Local port-forward required": "Local port-forward required", + "Local Quick Start is supported when the extension runs on Windows, macOS, or Linux.": "Local Quick Start is supported when the extension runs on Windows, macOS, or Linux.", + "localhost:{0}": "localhost:{0}", "Location": "Location", "Low efficiency ratio": "Low efficiency ratio", "Low filter selectivity": "Low filter selectivity", "LOW PRIORITY": "LOW PRIORITY", "Low-cardinality index": "Low-cardinality index", + "macOS": "macOS", "Make sure the correct kubeconfig YAML is in your clipboard before continuing.": "Make sure the correct kubeconfig YAML is in your clipboard before continuing.", "Manage Accounts": "Manage Accounts", "Manage Azure Accounts": "Manage Azure Accounts", "Manage Azure Accounts…": "Manage Azure Accounts…", + "Manage credentials to add or update a credential.": "Manage credentials to add or update a credential.", + "Manage MongoDB Atlas Credentials": "Manage MongoDB Atlas Credentials", + "Manage MongoDB Atlas Credentials…": "Manage MongoDB Atlas Credentials…", "Manually enter a custom tenant ID": "Manually enter a custom tenant ID", + "Maximum degree": "Maximum degree", + "mdb_sa_sk_…": "mdb_sa_sk_…", "MEDIUM PRIORITY": "MEDIUM PRIORITY", "Microsoft will process the feedback data you submit on behalf of your organization in accordance with the Data Protection Addendum between your organization and Microsoft.": "Microsoft will process the feedback data you submit on behalf of your organization in accordance with the Data Protection Addendum between your organization and Microsoft.", "Migration Providers: {0}": "Migration Providers: {0}", + "Missing · click to recreate": "Missing · click to recreate", "Missing important information": "Missing important information", "Mode: {0}": "Mode: {0}", "Moderate efficiency ratio": "Moderate efficiency ratio", "Modify index?": "Modify index?", "Modify Index…": "Modify Index…", "Modifying index visibility ({action}) for \"{indexName}\" on collection: {collection}": "Modifying index visibility ({action}) for \"{indexName}\" on collection: {collection}", + "MongoDB Atlas": "MongoDB Atlas", + "MongoDB Atlas accepted the credential but returned no projects. The organization may not contain any projects, or the credential may need an organization or project role.": "MongoDB Atlas accepted the credential but returned no projects. The organization may not contain any projects, or the credential may need an organization or project role.", + "MongoDB Atlas asked us to slow down": "MongoDB Atlas asked us to slow down", + "MongoDB Atlas asked us to slow down. Wait briefly, then try again.": "MongoDB Atlas asked us to slow down. Wait briefly, then try again.", + "MongoDB Atlas blocked this request because IP address {0} isn't on the allowed access list. Add this IP address in MongoDB Atlas, then retry.": "MongoDB Atlas blocked this request because IP address {0} isn't on the allowed access list. Add this IP address in MongoDB Atlas, then retry.", + "MongoDB Atlas blocked this request because your IP address isn't on the allowed access list. Add your current IP address in MongoDB Atlas, then retry.": "MongoDB Atlas blocked this request because your IP address isn't on the allowed access list. Add your current IP address in MongoDB Atlas, then retry.", + "MongoDB Atlas closed the TLS connection with an internal error. This is a transport-level failure rather than an authentication response, so it is not what an incorrect username or password looks like: those report \"bad auth : Authentication failed\".": "MongoDB Atlas closed the TLS connection with an internal error. This is a transport-level failure rather than an authentication response, so it is not what an incorrect username or password looks like: those report \"bad auth : Authentication failed\".", + "MongoDB Atlas could not be reached. Check your connection or proxy settings, then try again.": "MongoDB Atlas could not be reached. Check your connection or proxy settings, then try again.", + "MongoDB Atlas could not be reached. The stored credentials are most likely fine.": "MongoDB Atlas could not be reached. The stored credentials are most likely fine.", + "MongoDB Atlas could not complete the sign-in right now. Wait briefly, then try again.": "MongoDB Atlas could not complete the sign-in right now. Wait briefly, then try again.", + "MongoDB Atlas credential added.": "MongoDB Atlas credential added.", + "MongoDB Atlas credentials used for service discovery": "MongoDB Atlas credentials used for service discovery", + "MongoDB Atlas did not accept the Client ID and secret. Check both values and try again.": "MongoDB Atlas did not accept the Client ID and secret. Check both values and try again.", + "MongoDB Atlas did not accept the public and private key. Check both values and try again.": "MongoDB Atlas did not accept the public and private key. Check both values and try again.", + "MongoDB Atlas Service Discovery": "MongoDB Atlas Service Discovery", "MongoDB Emulator": "MongoDB Emulator", + "More access is required": "More access is required", + "More options": "More options", "Move": "Move", "Move \"{0}\"?": "Move \"{0}\"?", "Move {0} items?": "Move {0} items?", @@ -893,16 +1305,23 @@ "name=\"{0}\", family={1}, id={2}, version={3}": "name=\"{0}\", family={1}, id={2}, version={3}", "Namespace": "Namespace", "Namespaces that were scanned but where no DocumentDB target was found. These are grouped here to keep the list of connectable namespaces uncluttered. Expand to see which namespaces were checked.": "Namespaces that were scanned but where no DocumentDB target was found. These are grouped here to keep the list of connectable namespaces uncluttered. Expand to see which namespaces were checked.", + "Needs attention · review setup": "Needs attention · review setup", "New Connection": "New Connection", "New connection has been added to your DocumentDB Connections.": "New connection has been added to your DocumentDB Connections.", "New connection has been added.": "New connection has been added.", "New Connection…": "New Connection…", "New Local Connection": "New Local Connection", "New Local Connection…": "New Local Connection…", + "Next steps": "Next steps", "No": "No", + "No accessible projects found": "No accessible projects found", "No Action": "No Action", - "No active Kubernetes cluster was found. Check your kubeconfig and try again.": "No active Kubernetes cluster was found. Check your kubeconfig and try again.", "No additional cost for most GitHub Copilot subscribers.": "No additional cost for most GitHub Copilot subscribers.", + "No Atlas cluster connection string available.": "No Atlas cluster connection string available.", + "No Atlas cluster selected": "No Atlas cluster selected", + "No Atlas clusters available": "No Atlas clusters available", + "No Atlas project selected": "No Atlas project selected", + "No Atlas projects available": "No Atlas projects available", "No authenticated tenants found. Use \"Manage Azure Accounts\" in the Discovery View to sign in to tenants.": "No authenticated tenants found. Use \"Manage Azure Accounts\" in the Discovery View to sign in to tenants.", "No Authentication": "No Authentication", "No authentication method selected.": "No authentication method selected.", @@ -911,6 +1330,9 @@ "No Azure subscription found for this tree item.": "No Azure subscription found for this tree item.", "No Azure Subscriptions Found": "No Azure Subscriptions Found", "No Azure VMs found with tag \"{tagName}\" in subscription \"{subscriptionName}\".": "No Azure VMs found with tag \"{tagName}\" in subscription \"{subscriptionName}\".", + "No clear answer": "No clear answer", + "No clusters available": "No clusters available", + "No clusters found in project \"{0}\"": "No clusters found in project \"{0}\"", "No code to run. Place the cursor in a code block.": "No code to run. Place the cursor in a code block.", "No collection has been marked for copy. Please use \"Copy Collection...\" first to select a source collection.": "No collection has been marked for copy. Please use \"Copy Collection...\" first to select a source collection.", "No collection selected.": "No collection selected.", @@ -920,9 +1342,11 @@ "No credentials found for cluster {0}": "No credentials found for cluster {0}", "No credentials found for id {clusterId}": "No credentials found for id {clusterId}", "No credentials found for the selected cluster.": "No credentials found for the selected cluster.", + "no Docker CLI found": "no Docker CLI found", "No DocumentDB services found in this context.": "No DocumentDB services found in this context.", "No DocumentDB services found in this namespace.": "No DocumentDB services found in this namespace.", "No DocumentDB targets were found in context \"{0}\". DKO resources are preferred, and generic fallback currently looks for DocumentDB gateway services.": "No DocumentDB targets were found in context \"{0}\". DKO resources are preferred, and generic fallback currently looks for DocumentDB gateway services.", + "No endpoint could be resolved, so the check had nothing to dial.": "No endpoint could be resolved, so the check had nothing to dial.", "No entries": "No entries", "No folder selected.": "No folder selected.", "No index changes needed at this time.": "No index changes needed at this time.", @@ -944,10 +1368,16 @@ "No matches": "No matches", "No matching documents": "No matching documents", "No matching resources found.": "No matching resources found.", + "No MongoDB Atlas projects are currently visible to your stored credentials. Manage credentials to add or update a credential.": "No MongoDB Atlas projects are currently visible to your stored credentials. Manage credentials to add or update a credential.", "No namespaces found in context \"{0}\".": "No namespaces found in context \"{0}\".", "No namespaces found in this context.": "No namespaces found in this context.", "No node selected.": "No node selected.", + "No override was set, so the default location for this platform was used.": "No override was set, so the default location for this platform was used.", "No parent folder selected.": "No parent folder selected.", + "No password is stored for the DocumentDB Local instance, so there is nothing to copy.": "No password is stored for the DocumentDB Local instance, so there is nothing to copy.", + "No projects are visible here yet. Check the project access and roles of the credentials for this organization in MongoDB Atlas.": "No projects are visible here yet. Check the project access and roles of the credentials for this organization in MongoDB Atlas.", + "No projects available": "No projects available", + "No projects available for these credentials": "No projects available for these credentials", "No public connectivity": "No public connectivity", "No ready pods found backing service \"{0}\" in namespace \"{1}\". Check that the service has running pods.": "No ready pods found backing service \"{0}\" in namespace \"{1}\". Check that the service has running pods.", "No results found": "No results found", @@ -964,44 +1394,95 @@ "No, only copy documents": "No, only copy documents", "node-routed": "node-routed", "Node.js: {0}": "Node.js: {0}", + "Non-default indexes with zero recorded usage since the server started tracking. Consider reviewing them. Unused indexes consume storage and slow writes.": "Non-default indexes with zero recorded usage since the server started tracking. Consider reviewing them. Unused indexes consume storage and slow writes.", "None": "None", "None (collection scan)": "None (collection scan)", + "Not accessible": "Not accessible", + "Not accessible from WSL": "Not accessible from WSL", + "Not available": "Not available", + "not available in this WSL distribution": "not available in this WSL distribution", "Not directly reachable": "Not directly reachable", + "Not found": "Not found", + "Not included": "Not included", + "Not reported yet": "Not reported yet", + "not running": "not running", + "Not running": "Not running", "Not signed in to {0}. Please authenticate first.": "Not signed in to {0}. Please authenticate first.", + "Not supported yet": "Not supported yet", "Note: This confirmation type can be configured in the extension settings.": "Note: This confirmation type can be configured in the extension settings.", "Note: You can disable these URL handling confirmations in the extension settings.": "Note: You can disable these URL handling confirmations in the extension settings.", + "Nothing identified it: the check reached neither a daemon nor a usable context.": "Nothing identified it: the check reached neither a daemon nor a usable context.", + "Nothing is downloaded or created on your machine until you choose to start in the Configure step.": "Nothing is downloaded or created on your machine until you choose to start in the Configure step.", "Number of documents returned by the query": "Number of documents returned by the query", "Number of documents scanned to find matching results. Should be close to documents returned for optimal performance.": "Number of documents scanned to find matching results. Should be close to documents returned for optimal performance.", "Number of index keys scanned during query execution. Lower is better.": "Number of index keys scanned during query execution. Lower is better.", + "Number of indexes on this collection, including the default _id index.": "Number of indexes on this collection, including the default _id index.", + "Number of lists": "Number of lists", + "OAuth2 client ID and secret. More secure, and the secret expires (8 hours to 365 days) so it has to be rotated periodically.": "OAuth2 client ID and secret. More secure, and the secret expires (8 hours to 365 days) so it has to be rotated periodically.", "OK": "OK", + "One container named {0}, using the settings you choose.": "One container named {0}, using the settings you choose.", "Only file-based kubeconfig sources can be opened in the editor.": "Only file-based kubeconfig sources can be opened in the editor.", + "Only HTTP(S) URLs are supported.": "Only HTTP(S) URLs are supported.", + "Only index documents that match this filter. Enter a JSON object.": "Only index documents that match this filter. Enter a JSON object.", + "Only indexes documents that contain the field.": "Only indexes documents that contain the field.", "Only pasted kubeconfig sources can be viewed. Use \"Edit Kubeconfig\" for file sources.": "Only pasted kubeconfig sources can be viewed. Use \"Edit Kubeconfig\" for file sources.", + "Only the selected paths, and every field nested under them, are indexed. All other fields are excluded.": "Only the selected paths, and every field nested under them, are indexed. All other fields are excluded.", + "Only this credential and its secrets are removed. Other credentials stay signed in.": "Only this credential and its secrets are removed. Other credentials stay signed in.", "Open \"{0}.{1}\" in Query Playground": "Open \"{0}.{1}\" in Query Playground", + "Open access settings in MongoDB Atlas": "Open access settings in MongoDB Atlas", "Open batch size setting": "Open batch size setting", "Open Collection": "Open Collection", "Open collection \"{0}.{1}\" in Collection View": "Open collection \"{0}.{1}\" in Collection View", + "Open Connection": "Open Connection", "Open current query in a Query Playground": "Open current query in a Query Playground", "Open current query in an Interactive Shell": "Open current query in an Interactive Shell", + "Open Docker context guide": "Open Docker context guide", + "Open Docker documentation": "Open Docker documentation", + "Open Docker install guide": "Open Docker install guide", + "Open Docker troubleshooting guide": "Open Docker troubleshooting guide", + "Open Existing": "Open Existing", + "Open in MongoDB Atlas": "Open in MongoDB Atlas", "Open in Playground": "Open in Playground", "Open in Shell": "Open in Shell", + "Open Indexes": "Open Indexes", + "Open Linux containers guide": "Open Linux containers guide", + "Open Linux setup guide": "Open Linux setup guide", + "Open Network Access in Atlas": "Open Network Access in Atlas", + "Open remote Docker guide": "Open remote Docker guide", "Open setting: {0}": "Open setting: {0}", "Open settings to change the default behavior.": "Open settings to change the default behavior.", "Open the VS Code Marketplace to learn more about \"{0}\"": "Open the VS Code Marketplace to learn more about \"{0}\"", "Open this query in Collection View": "Open this query in Collection View", "Open this query in Interactive Shell": "Open this query in Interactive Shell", + "Open WSL integration guide": "Open WSL integration guide", "Opening DocumentDB connection…": "Opening DocumentDB connection…", "Operation cancelled.": "Operation cancelled.", "Operation timed out after {0} seconds.": "Operation timed out after {0} seconds.", "Optimization Opportunities": "Optimization Opportunities", "Optimizing the index on {0} can improve query performance by better matching the query pattern.": "Optimizing the index on {0} can improve query performance by better matching the query pattern.", + "Optional settings and a preview of the generated index specification.": "Optional settings and a preview of the generated index specification.", + "Options": "Options", + "Organization": "Organization", + "Organization ID": "Organization ID", "OS: {0}": "OS: {0}", "Other namespaces": "Other namespaces", "Overwrite existing documents": "Overwrite existing documents", "Overwrite existing documents that share the same _id; other write errors will abort the operation.": "Overwrite existing documents that share the same _id; other write errors will abort the operation.", + "Parent path": "Parent path", "Parsing file {0}: {1}": "Parsing file {0}: {1}", + "Partial": "Partial", + "Partial filter": "Partial filter", + "partial filter expression": "partial filter expression", + "Partial filter expression": "Partial filter expression", + "Partial filter expression, custom collation": "Partial filter expression, custom collation", + "Partial filter expression: enter a JSON object": "Partial filter expression: enter a JSON object", + "Password": "Password", "Password cannot be empty": "Password cannot be empty", "Password contains characters that cannot be safely encoded.": "Password contains characters that cannot be safely encoded.", + "Password copied to clipboard.": "Password copied to clipboard.", "Password for {username_at_resource}": "Password for {username_at_resource}", + "Password must be 256 characters or fewer.": "Password must be 256 characters or fewer.", + "Password must not contain control characters.": "Password must not contain control characters.", "Paste a find query from clipboard into the editors": "Paste a find query from clipboard into the editors", "Paste Collection": "Paste Collection", "Paste kubeconfig YAML…": "Paste kubeconfig YAML…", @@ -1010,9 +1491,11 @@ "Pasted kubeconfig YAML is empty.": "Pasted kubeconfig YAML is empty.", "Pasted YAML {0}": "Pasted YAML {0}", "Pasting…": "Pasting…", + "Paused": "Paused", "pending": "pending", "Performance Rating": "Performance Rating", "Pick \"{number}\" to confirm and continue.": "Pick \"{number}\" to confirm and continue.", + "Pick how we sign in to MongoDB Atlas.": "Pick how we sign in to MongoDB Atlas.", "Playground": "Playground", "Please authenticate first by expanding the tree item of the selected cluster.": "Please authenticate first by expanding the tree item of the selected cluster.", "Please confirm by re-entering the previous value.": "Please confirm by re-entering the previous value.", @@ -1024,12 +1507,18 @@ "Please enter the username": "Please enter the username", "Please enter the word \"{expectedConfirmationWord}\" to confirm the operation.": "Please enter the word \"{expectedConfirmationWord}\" to confirm the operation.", "Please provide the username for \"{resource}\":": "Please provide the username for \"{resource}\":", + "Please retry discovery to refresh the available MongoDB Atlas projects and clusters.": "Please retry discovery to refresh the available MongoDB Atlas projects and clusters.", "Please stop these tasks first before proceeding.": "Please stop these tasks first before proceeding.", "Poor": "Poor", + "Port": "Port", + "Port {0} belongs to another DocumentDB Local instance. Pick a different one.": "Port {0} belongs to another DocumentDB Local instance. Pick a different one.", "Port {0} is already in use (perhaps by kubectl port-forward or another tunnel). Connect using the existing port-forward?": "Port {0} is already in use (perhaps by kubectl port-forward or another tunnel). Connect using the existing port-forward?", "Port {0} is already in use. Choose a different local port.": "Port {0} is already in use. Choose a different local port.", + "Port {0} is already in use. Go back to Configure to pick a different port, or free it, then try again.": "Port {0} is already in use. Go back to Configure to pick a different port, or free it, then try again.", + "Port {0} is already in use. Pick a different one.": "Port {0} is already in use. Pick a different one.", "Port 127.0.0.1:{0} appeared busy on the first attempt; retrying after {1}ms before prompting…": "Port 127.0.0.1:{0} appeared busy on the first attempt; retrying after {1}ms before prompting…", "Port Forward: {0}": "Port Forward: {0}", + "Port must be a whole number between 1024 and 65535.": "Port must be a whole number between 1024 and 65535.", "Port number is required": "Port number is required", "Port number must be a number": "Port number must be a number", "Port number must be between 1 and 65535": "Port number must be between 1 and 65535", @@ -1046,14 +1535,32 @@ "Powered by {0} via GitHub Copilot · {1}s": "Powered by {0} via GitHub Copilot · {1}s", "Press Escape to exit editor": "Press Escape to exit editor", "Preview": "Preview", + "PREVIEW": "PREVIEW", + "Preview as JSON": "Preview as JSON", "Preview Clipboard": "Preview Clipboard", + "PREVIEW feature": "PREVIEW feature", "Privacy Statement": "Privacy Statement", + "Private Key": "Private Key", "Process exited: \"{command}\"": "Process exited: \"{command}\"", "Processing step {0} of {1}": "Processing step {0} of {1}", + "Product quantization": "Product quantization", + "Product quantization compresses vectors to support higher dimensions on DiskANN indexes.": "Product quantization compresses vectors to support higher dimensions on DiskANN indexes.", + "Product quantization is only available for DiskANN indexes.": "Product quantization is only available for DiskANN indexes.", "Production": "Production", "Project": "Project", + "Project ID": "Project ID", "Project: Specify which fields to include or exclude": "Project: Specify which fields to include or exclude", + "Projection": "Projection", + "Projection mode": "Projection mode", + "Properties": "Properties", + "Provide your MongoDB Atlas API Key": "Provide your MongoDB Atlas API Key", + "Provide your MongoDB Atlas Service Account": "Provide your MongoDB Atlas Service Account", + "Provider": "Provider", "Provider \"{0}\" does not have resource type \"{1}\".": "Provider \"{0}\" does not have resource type \"{1}\".", + "Provisioning… · localhost:{0}": "Provisioning… · localhost:{0}", + "Public and private key pair. Never expires, which suits a personal, set-and-forget setup.": "Public and private key pair. Never expires, which suits a personal, set-and-forget setup.", + "Public Key": "Public Key", + "Pulling official image": "Pulling official image", "Qualified Name": "Qualified Name", "Query completed in {0}ms.\n\nThis is acceptable for most use cases, though optimization could improve responsiveness.": "Query completed in {0}ms.\n\nThis is acceptable for most use cases, though optimization could improve responsiveness.", "Query completed in {0}ms.\n\nThis is excellent performance and provides a responsive user experience.": "Query completed in {0}ms.\n\nThis is excellent performance and provides a responsive user experience.", @@ -1067,12 +1574,14 @@ "Query generation failed": "Query generation failed", "Query generation failed with the error: {0}": "Query generation failed with the error: {0}", "query insights": "query insights", + "Query Insights": "Query Insights", + "Query Insights actions": "Query Insights actions", "Query Insights APIs not initialized. Client may not be properly connected.": "Query Insights APIs not initialized. Client may not be properly connected.", - "Query Insights feature is in preview": "Query Insights feature is in preview", "Query Insights is not available for Azure Cosmos DB for MongoDB (RU) accounts.": "Query Insights is not available for Azure Cosmos DB for MongoDB (RU) accounts.", "Query Insights is not supported on Azure Cosmos DB for MongoDB (RU) clusters.": "Query Insights is not supported on Azure Cosmos DB for MongoDB (RU) clusters.", "Query Insights Not Available": "Query Insights Not Available", "Query Insights Stage 2 completed with execution error": "Query Insights Stage 2 completed with execution error", + "Query Insights utilities": "Query Insights utilities", "query or queryObject is required when not using pre-loaded data": "query or queryObject is required when not using pre-loaded data", "Query Performance Analysis": "Query Performance Analysis", "Query Performance Insight": "Query Performance Insight", @@ -1083,8 +1592,17 @@ "Query took {0}ms to complete.\n\nThis may impact user experience.\n\nConsider adding indexes or optimizing your query structure.": "Query took {0}ms to complete.\n\nThis may impact user experience.\n\nConsider adding indexes or optimizing your query structure.", "Query took {0}s to complete.\n\nThis significantly impacts performance and user experience.\n\nImmediate optimization is recommended.": "Query took {0}s to complete.\n\nThis significantly impacts performance and user experience.\n\nImmediate optimization is recommended.", "Quick Actions": "Quick Actions", + "Quick Start added this instance to the Connections view, so there is no need to add it by hand. You can still create a separate connection if you want a different configuration for the same endpoint.": "Quick Start added this instance to the Connections view, so there is no need to add it by hand. You can still create a separate connection if you want a different configuration for the same endpoint.", + "Rate limited by Atlas API. Please try again shortly.": "Rate limited by Atlas API. Please try again shortly.", + "Re-attempt this credential only, leaving the others untouched.": "Re-attempt this credential only, leaving the others untouched.", + "Re-check every credential against MongoDB Atlas, including the healthy ones.": "Re-check every credential against MongoDB Atlas, including the healthy ones.", + "Re-checking every MongoDB Atlas credential…": "Re-checking every MongoDB Atlas credential…", + "Reachable": "Reachable", + "Read from the daemon that answered the check.": "Read from the daemon that answered the check.", "Reads a kubeconfig YAML file from disk and links to it by path.": "Reads a kubeconfig YAML file from disk and links to it by path.", "Reads clipboard content and saves a copy as a kubeconfig source.": "Reads clipboard content and saves a copy as a kubeconfig source.", + "Ready": "Ready", + "Ready to set up": "Ready to set up", "Receiving response.": "Receiving response.", "Receiving response…": "Receiving response…", "Recommendation: Create Index": "Recommendation: Create Index", @@ -1092,19 +1610,33 @@ "Recommendation: Modify Index": "Recommendation: Modify Index", "Recommendations were actionable": "Recommendations were actionable", "Recommendations were not helpful": "Recommendations were not helpful", + "Recommended": "Recommended", "Recommended Index": "Recommended Index", "Reconnect now with the updated credentials": "Reconnect now with the updated credentials", "Reconnecting...": "Reconnecting...", + "Recovery command copied.": "Recovery command copied.", + "Recreating replaces the container named {0} and keeps its data volume, so your documents, credentials and image version are preserved.": "Recreating replaces the container named {0} and keeps its data volume, so your documents, credentials and image version are preserved.", + "Recreating reuses the existing data volume, so the original credentials and image are kept.": "Recreating reuses the existing data volume, so the original credentials and image are kept.", "Refresh": "Refresh", - "Refresh current view": "Refresh current view", + "Refresh indexes": "Refresh indexes", + "Refresh query and query insights": "Refresh query and query insights", + "Refresh: {0}": "Refresh: {0}", "Refreshing Azure discovery tree…": "Refreshing Azure discovery tree…", + "Region": "Region", "Registering Providers...": "Registering Providers...", + "Rejects duplicate values.": "Rejects duplicate values.", "Release Notes": "Release Notes", "Reload": "Reload", "Reload document from the database": "Reload document from the database", "Reload Window": "Reload Window", "Reloading kubeconfig source \"{0}\"…": "Reloading kubeconfig source \"{0}\"…", + "Remembered from the last check on this machine that did reach a daemon.": "Remembered from the last check on this machine that did reach a daemon.", "Remind Me Later": "Remind Me Later", + "Remote extension host": "Remote extension host", + "Remote SSH host": "Remote SSH host", + "Remote SSH host (Docker)": "Remote SSH host (Docker)", + "Remove $** from the parent path. It is added automatically.": "Remove $** from the parent path. It is added automatically.", + "Remove field": "Remove field", "Remove kubeconfig source \"{0}\"?": "Remove kubeconfig source \"{0}\"?", "remove this connection": "remove this connection", "Removed {successCount} of {totalCount} connections. {failureCount} failed.": "Removed {successCount} of {totalCount} connections. {failureCount} failed.", @@ -1114,22 +1646,38 @@ "Rename kubeconfig source": "Rename kubeconfig source", "Rename Kubernetes context": "Rename Kubernetes context", "Renamed folder from \"{oldName}\" to \"{newName}\"": "Renamed folder from \"{oldName}\" to \"{newName}\"", + "Repairing…": "Repairing…", "Report a Bug": "Report a Bug", "report an issue": "report an issue", "Report an issue": "Report an issue", "Request sent. Analyzing…": "Request sent. Analyzing…", "Request sent. Awaiting response.": "Request sent. Awaiting response.", + "Rerun the last executed query": "Rerun the last executed query", + "Reset form": "Reset form", + "Reset image tag to {0}": "Reset image tag to {0}", + "Reset port to {0}": "Reset port to {0}", "Resource group \"{0}\" already exists in subscription \"{1}\".": "Resource group \"{0}\" already exists in subscription \"{1}\".", "Resource not found": "Resource not found", + "Resource not found.": "Resource not found.", "Result: {0}": "Result: {0}", "Result: Array ({0} elements)": "Result: Array ({0} elements)", "Result: Cursor ({0} documents)": "Result: Cursor ({0} documents)", "Results found": "Results found", + "Resume this cluster in MongoDB Atlas before connecting.": "Resume this cluster in MongoDB Atlas before connecting.", "Retry": "Retry", + "Retry all": "Retry all", "Retry Error: {error}": "Retry Error: {error}", + "Retry setup": "Retry setup", "Retry TS Plugin Setup": "Retry TS Plugin Setup", + "Retrying {0}…": "Retrying {0}…", + "Retrying runs every setup step again from the beginning, starting with the Docker check.": "Retrying runs every setup step again from the beginning, starting with the Docker check.", "Returns majority of collection": "Returns majority of collection", + "Reused from the existing instance": "Reused from the existing instance", "Reusing active connection for \"{cluster}\".": "Reusing active connection for \"{cluster}\".", + "Review the generated index specification before creating it.": "Review the generated index specification before creating it.", + "Review the organizationȁs API keys and their IP access lists in your browser.": "Review the organizationȁs API keys and their IP access lists in your browser.", + "Review the setup settings, then start DocumentDB Local.": "Review the setup settings, then start DocumentDB Local.", + "Review this Service Accountȁs roles and IP access list in your browser.": "Review this Service Accountȁs roles and IP access list in your browser.", "Revisit connection details and try again.": "Revisit connection details and try again.", "Right-click a cluster, database, or collection in the DocumentDB panel to open an interactive shell.": "Right-click a cluster, database, or collection in the DocumentDB panel to open an interactive shell.", "Right-click a database or collection in the DocumentDB panel to create a new Query Playground.": "Right-click a database or collection in the DocumentDB panel to create a new Query Playground.", @@ -1141,29 +1689,50 @@ "Run as Is": "Run as Is", "Run the entire file ({0}+Shift+Enter)": "Run the entire file ({0}+Shift+Enter)", "Run this block ({0}+Enter)": "Run this block ({0}+Enter)", + "running": "running", + "Running · localhost:{0}": "Running · localhost:{0}", "Running query…": "Running query…", "Running…": "Running…", + "runs in this Codespace": "runs in this Codespace", + "runs in this dev container": "runs in this dev container", + "runs in this WSL environment": "runs in this WSL environment", + "runs on the remote extension host": "runs on the remote extension host", + "runs on the remote SSH host": "runs on the remote SSH host", + "runs on this machine": "runs on this machine", + "Runs the system Docker service.": "Runs the system Docker service.", "Safe to share; the password is omitted": "Safe to share; the password is omitted", + "Sample data": "Sample data", + "Sample size (optional)": "Sample size (optional)", "Save": "Save", "Save credentials for future connections.": "Save credentials for future connections.", "Save credentials for future use?": "Save credentials for future use?", "Save credentials without reconnecting": "Save credentials without reconnecting", "Save document to the database": "Save document to the database", + "Save the connection": "Save the connection", "Save to the database": "Save to the database", "Saved connections that depend on this source will need to be reconfigured. Active port-forward tunnels for this source will be stopped.": "Saved connections that depend on this source will need to be reconfigured. Active port-forward tunnels for this source will be stopped.", + "Saved credentials for DocumentDB Local are missing, so this instance cannot be opened. Delete it and set it up again to start fresh (this erases its data).": "Saved credentials for DocumentDB Local are missing, so this instance cannot be opened. Delete it and set it up again to start fresh (this erases its data).", "Saving \"{path}\" will update the entity \"{name}\" to the cloud.": "Saving \"{path}\" will update the entity \"{name}\" to the cloud.", "Saving credentials for \"{clusterName}\"…": "Saving credentials for \"{clusterName}\"…", + "Saving the credential": "Saving the credential", + "Scalable graph recommended for large collections.": "Scalable graph recommended for large collections.", "Schema scan complete: {0} fields discovered in \"{1}\".": "Schema scan complete: {0} fields discovered in \"{1}\".", + "Scope": "Scope", "SCRAM": "SCRAM", "Security": "Security", "See output for more details.": "See output for more details.", + "See the details below.": "See the details below.", + "Select \"{0}\" to see error details.": "Select \"{0}\" to see error details.", "Select {0}": "Select {0}", + "Select a cluster": "Select a cluster", "Select a database to connect this playground to": "Select a database to connect this playground to", + "Select a database user for \"{cluster}\"": "Select a database user for \"{cluster}\"", "Select a DocumentDB target to connect to": "Select a DocumentDB target to connect to", "Select a Kubernetes context": "Select a Kubernetes context", "Select a location for new resources.": "Select a location for new resources.", "Select a tenant for Microsoft Entra ID authentication": "Select a tenant for Microsoft Entra ID authentication", "Select a workspace folder": "Select a workspace folder", + "Select an Atlas project": "Select an Atlas project", "Select an authentication method": "Select an authentication method", "Select an authentication method for \"{resourceName}\"": "Select an authentication method for \"{resourceName}\"", "Select an item": "Select an item", @@ -1171,25 +1740,46 @@ "Select Existing": "Select Existing", "Select kubeconfig file": "Select kubeconfig file", "Select kubeconfig source": "Select kubeconfig source", + "Select or type a field name": "Select or type a field name", "Select resource": "Select resource", "Select subscription": "Select subscription", "Select subscriptions to include in service discovery": "Select subscriptions to include in service discovery", "Select Subscriptions...": "Select Subscriptions...", "Select tenants (manage accounts to see more)": "Select tenants (manage accounts to see more)", "Select the error you would like to report": "Select the error you would like to report", + "Select the field(s) to index and a type for each. Add more fields to build a compound index.": "Select the field(s) to index and a type for each. Add more fields to build a compound index.", "Select the local connection type…": "Select the local connection type…", "Select view type": "Select view type", "Selected items cannot be moved.": "Selected items cannot be moved.", "Selected subscriptions: {0}": "Selected subscriptions: {0}", "Selected tenants: {0}": "Selected tenants: {0}", "Selectivity": "Selectivity", + "Server version": "Server version", + "Service Account": "Service Account", "Service Discovery": "Service Discovery", + "Service Discovery for MongoDB Atlas": "Service Discovery for MongoDB Atlas", "Service-based fallback target": "Service-based fallback target", "Session ID is required": "Session ID is required", "sessionId is required for query optimization": "sessionId is required for query optimization", "Set a display name for \"{0}\". The kubeconfig file is not modified. Leave empty to clear the alias.": "Set a display name for \"{0}\". The kubeconfig file is not modified. Leave empty to clear the alias.", "Set the KUBECONFIG environment variable or create a kubeconfig at the default path, then try again.": "Set the KUBECONFIG environment variable or create a kubeconfig at the default path, then try again.", + "Set up": "Set up", + "Set up DocumentDB Local": "Set up DocumentDB Local", + "Set up DocumentDB locally for development and testing with Docker.": "Set up DocumentDB locally for development and testing with Docker.", + "Set your own credentials": "Set your own credentials", + "Setting up DocumentDB Local": "Setting up DocumentDB Local", + "Setting up DocumentDB Local.": "Setting up DocumentDB Local.", + "Setting up…": "Setting up…", "Settings:": "Settings:", + "Setup did not finish": "Setup did not finish", + "Setup did not finish. {0}": "Setup did not finish. {0}", + "Setup failed.": "Setup failed.", + "Setup is already in progress.": "Setup is already in progress.", + "Setup progress": "Setup progress", + "Setup settings": "Setup settings", + "Setup steps": "Setup steps", + "Setup stopped at the first stage. Nothing was created on your machine.": "Setup stopped at the first stage. Nothing was created on your machine.", + "Setup was cancelled.": "Setup was cancelled.", "Severe multikey expansion": "Severe multikey expansion", "Shard Key": "Shard Key", "SHARD_MERGE · {0} shards": "SHARD_MERGE · {0} shards", @@ -1199,49 +1789,95 @@ "Shell Command": "Shell Command", "Shell Reference": "Shell Reference", "Shell session ended unexpectedly.": "Shell session ended unexpectedly.", + "Show details": "Show details", "Show Details": "Show Details", + "Show only hidden indexes": "Show only hidden indexes", + "Show only unused indexes": "Show only unused indexes", "Show Output": "Show Output", + "Show secret": "Show secret", "Show Stage Details": "Show Stage Details", + "Showing {0} of {1} indexes": "Showing {0} of {1} indexes", "Showing first {0} documents (batch size). To change: Settings → 'documentDB.batchSize'": "Showing first {0} documents (batch size). To change: Settings → 'documentDB.batchSize'", + "Sign in to": "Sign in to", "Sign in to additional accounts or authenticate with other tenants to see more options.": "Sign in to additional accounts or authenticate with other tenants to see more options.", "Sign in to additional accounts or authenticate with other tenants to see more subscriptions.": "Sign in to additional accounts or authenticate with other tenants to see more subscriptions.", "Sign in to Azure to continue…": "Sign in to Azure to continue…", "Sign in to Azure...": "Sign in to Azure...", "Sign in to other Azure accounts to access more tenants": "Sign in to other Azure accounts to access more tenants", + "Sign in to view MongoDB Atlas clusters": "Sign in to view MongoDB Atlas clusters", "Sign in with a different account…": "Sign in with a different account…", + "Sign out": "Sign out", + "Sign out of all": "Sign out of all", + "Sign out of every MongoDB Atlas credential?": "Sign out of every MongoDB Atlas credential?", + "Sign out of the MongoDB Atlas credential \"{0}\"?": "Sign out of the MongoDB Atlas credential \"{0}\"?", + "Sign out of this credential only. The others stay signed in.": "Sign out of this credential only. The others stay signed in.", "Sign-in to tenant was cancelled or failed: {0}": "Sign-in to tenant was cancelled or failed: {0}", "Signed in to tenant \"{0}\"": "Signed in to tenant \"{0}\"", + "Signed out of the MongoDB Atlas credential \"{0}\".": "Signed out of the MongoDB Atlas credential \"{0}\".", + "Signing in to MongoDB Atlas": "Signing in to MongoDB Atlas", "Signing out programmatically is not supported. You must sign out by selecting the account in the Accounts menu and choosing Sign Out.": "Signing out programmatically is not supported. You must sign out by selecting the account in the Accounts menu and choosing Sign Out.", + "Similarity": "Similarity", + "Similarity metric": "Similarity metric", "Simulated failure at step {0} for testing purposes": "Simulated failure at step {0} for testing purposes", + "Size": "Size", + "Size: {0}": "Size: {0}", "Skip": "Skip", "Skip and Log (continue)": "Skip and Log (continue)", "Skip for now": "Skip for now", "Skip problematic documents and continue; issues are recorded. Good for scenarios where partial success is acceptable.": "Skip problematic documents and continue; issues are recorded. Good for scenarios where partial success is acceptable.", "Slow execution": "Slow execution", "Small breadcrumb example with buttons": "Small breadcrumb example with buttons", + "Socket access": "Socket access", + "Some credentials need attention, so parts of your fleet may be missing from this list.": "Some credentials need attention, so parts of your fleet may be missing from this list.", "Some items could not be displayed": "Some items could not be displayed", + "Some projects could not be read:": "Some projects could not be read:", + "Some projects may be hidden. A credential for this organization needs attention; use \"Click here to revisit credentials\".": "Some projects may be hidden. A credential for this organization needs attention; use \"Click here to revisit credentials\".", "Sort": "Sort", "Sort exceeded memory limit": "Sort exceeded memory limit", "Sort: Specify sort order for query results": "Sort: Specify sort order for query results", "Source collection is empty.": "Source collection is empty.", "Source label cannot be empty.": "Source label cannot be empty.", "Source:": "Source:", + "Sparse": "Sparse", + "Sparse is not available together with a partial filter expression.": "Sparse is not available together with a partial filter expression.", "Specified character lengths should be 1 character or greater.": "Specified character lengths should be 1 character or greater.", + "SSH tunnel": "SSH tunnel", + "Standard": "Standard", + "Start": "Start", "Start a discussion": "Start a discussion", "Start Copy-and-Merge": "Start Copy-and-Merge", "Start Copy-and-Paste": "Start Copy-and-Paste", + "Start Docker": "Start Docker", + "Start Docker Desktop": "Start Docker Desktop", + "Start Docker Desktop and wait until it is ready.": "Start Docker Desktop and wait until it is ready.", + "Start DocumentDB Local": "Start DocumentDB Local", + "Start fresh": "Start fresh", + "Start over": "Start over", + "Start the Docker service, then check again.": "Start the Docker service, then check again.", "Started executable: \"{command}\". Connecting to host…": "Started executable: \"{command}\". Connecting to host…", "Starting Azure account management wizard": "Starting Azure account management wizard", "Starting Azure sign-in process…": "Starting Azure sign-in process…", + "Starting container": "Starting container", + "Starting downloads the official image if needed, then creates and starts one container named {0}. Nothing else on your machine is changed.": "Starting downloads the official image if needed, then creates and starts one container named {0}. Nothing else on your machine is changed.", "Starting executable: \"{command}\"": "Starting executable: \"{command}\"", "Starting export to: {filePath}": "Starting export to: {filePath}", "Starting import of {0} file(s) into collection \"{1}\"": "Starting import of {0} file(s) into collection \"{1}\"", "Starting sign-in to tenant: {0}": "Starting sign-in to tenant: {0}", + "Starting…": "Starting…", + "Starting… · localhost:{0}": "Starting… · localhost:{0}", "Starts with mongodb:// or mongodb+srv://": "Starts with mongodb:// or mongodb+srv://", + "State": "State", "Status: {0}": "Status: {0}", + "Still initializing. Keep waiting, view the logs, or start over.": "Still initializing. Keep waiting, view the logs, or start over.", + "Stop waiting": "Stop waiting", + "stopped": "stopped", + "Stopped · localhost:{0}": "Stopped · localhost:{0}", "Stopped {0} port-forward tunnel(s) for kubeconfig source \"{1}\".": "Stopped {0} port-forward tunnel(s) for kubeconfig source \"{1}\".", + "Stopped waiting for Docker.": "Stopped waiting for Docker.", "Stopping {0}": "Stopping {0}", "Stopping task...": "Stopping task...", + "Stopping… · localhost:{0}": "Stopping… · localhost:{0}", + "Stored credentials were rejected. Update them to continue.": "Stored credentials were rejected. Update them to continue.", "Submit": "Submit", "Submit Feedback": "Submit Feedback", "Submitting...": "Submitting...", @@ -1256,11 +1892,15 @@ "Successfully signed in to tenant: {0}": "Successfully signed in to tenant: {0}", "Suggest a Feature": "Suggest a Feature", "Sure!": "Sure!", + "Switch Docker to Linux containers, then check again.": "Switch Docker to Linux containers, then check again.", "Switch to the new \"Connections View\"…": "Switch to the new \"Connections View\"…", "Table View": "Table View", "Tag can only contain alphanumeric characters, underscores, periods, and hyphens.": "Tag can only contain alphanumeric characters, underscores, periods, and hyphens.", "Tag cannot be empty.": "Tag cannot be empty.", "Tag cannot be longer than 256 characters.": "Tag cannot be longer than 256 characters.", + "Taken from the Docker context that is currently active.": "Taken from the Docker context that is currently active.", + "Taken from the DOCKER_CONTEXT environment variable.": "Taken from the DOCKER_CONTEXT environment variable.", + "Taken from the DOCKER_HOST environment variable, which overrides everything else.": "Taken from the DOCKER_HOST environment variable, which overrides everything else.", "Target:": "Target:", "Task completed successfully": "Task completed successfully", "Task created and ready to start": "Task created and ready to start", @@ -1274,6 +1914,7 @@ "Task will fail at a random step for testing": "Task will fail at a random step for testing", "Task with ID {0} already exists": "Task with ID {0} already exists", "Task with ID {0} not found": "Task with ID {0} not found", + "TCP address": "TCP address", "Tell me more": "Tell me more", "Template file is empty: {path}": "Template file is empty: {path}", "Template file not found: {path}": "Template file not found: {path}", @@ -1283,10 +1924,13 @@ "Tenant Name: {0}": "Tenant Name: {0}", "Tenants for \"{0}\"": "Tenants for \"{0}\"", "Test": "Test", + "text": "text", + "Text": "Text", "Thank you for helping us improve!": "Thank you for helping us improve!", "Thank you for your feedback!": "Thank you for your feedback!", "The \"_id_\" index cannot be deleted.": "The \"_id_\" index cannot be deleted.", "The \"_id_\" index cannot be hidden.": "The \"_id_\" index cannot be hidden.", + "The \"_id_\" index visibility cannot be changed.": "The \"_id_\" index visibility cannot be changed.", "The \"{databaseId}\" database has been deleted.": "The \"{databaseId}\" database has been deleted.", "The \"{indexName}\" index cannot be dropped.": "The \"{indexName}\" index cannot be dropped.", "The \"{indexName}\" index cannot be modified.": "The \"{indexName}\" index cannot be modified.", @@ -1296,6 +1940,7 @@ "The account management flow has completed.\n\nPlease try Service Discovery again to see your available subscriptions.": "The account management flow has completed.\n\nPlease try Service Discovery again to see your available subscriptions.", "The account management flow has completed.\n\nPlease try the connection flow again to see your available tenants.": "The account management flow has completed.\n\nPlease try the connection flow again to see your available tenants.", "The account management flow has completed.\n\nPlease try updating the credentials again to see your available tenants.": "The account management flow has completed.\n\nPlease try updating the credentials again to see your available tenants.", + "The active Docker context is unavailable. Select or repair a valid context, then check again.": "The active Docker context is unavailable. Select or repair a valid context, then check again.", "The cluster certificate may have changed or expired. Update your kubeconfig with fresh credentials.": "The cluster certificate may have changed or expired. Update your kubeconfig with fresh credentials.", "The cluster did not respond in time. Check your network connection and firewall settings.": "The cluster did not respond in time. Check your network connection and firewall settings.", "The cluster may be stopped or unreachable. Verify the cluster is running and the server URL is correct.": "The cluster may be stopped or unreachable. Verify the cluster is running and the server URL is correct.", @@ -1303,12 +1948,28 @@ "The collection \"{0}\" already exists in the database \"{1}\".": "The collection \"{0}\" already exists in the database \"{1}\".", "The collection \"{0}\" appears to be empty. Add some documents first, then try discovering fields again.": "The collection \"{0}\" appears to be empty. Add some documents first, then try discovering fields again.", "The collection \"{collectionId}\" has been deleted.": "The collection \"{collectionId}\" has been deleted.", + "The configured Docker endpoint did not respond.": "The configured Docker endpoint did not respond.", + "The connection already exists in the Connections view. Opening it selects and expands it there.": "The connection already exists in the Connections view. Opening it selects and expands it there.", + "The connection appears in the Connections view, ready to open.": "The connection appears in the Connections view, ready to open.", "The connection string has been copied to the clipboard": "The connection string has been copied to the clipboard", "The connection string has been copied. This Kubernetes connection uses port-forwarding and only works on this machine while the tunnel is active.": "The connection string has been copied. This Kubernetes connection uses port-forwarding and only works on this machine while the tunnel is active.", "The connection string will include the password": "The connection string will include the password", "The connection string will not include the password": "The connection string will not include the password", "The connection will now be opened in the Connections View.": "The connection will now be opened in the Connections View.", + "The container and its data volume will be permanently removed. All data, logs, and the auto-generated credentials will be lost. This cannot be undone. You can recreate a fresh instance any time with Quick Start.": "The container and its data volume will be permanently removed. All data, logs, and the auto-generated credentials will be lost. This cannot be undone. You can recreate a fresh instance any time with Quick Start.", + "The container is created here, so localhost refers to this environment.": "The container is created here, so localhost refers to this environment.", + "The container is currently running. It will be stopped and permanently removed. All data, logs, and the auto-generated credentials will be lost. This cannot be undone. You can recreate a fresh instance any time with Quick Start.": "The container is currently running. It will be stopped and permanently removed. All data, logs, and the auto-generated credentials will be lost. This cannot be undone. You can recreate a fresh instance any time with Quick Start.", + "The container is running, but DocumentDB has not accepted connections yet. It may still be initializing. Keep waiting, view the logs, or start over.": "The container is running, but DocumentDB has not accepted connections yet. It may still be initializing. Keep waiting, view the logs, or start over.", + "The container restarted but exited shortly after. Check the Quick Start logs.": "The container restarted but exited shortly after. Check the Quick Start logs.", + "The container started but exited shortly after. Check the Quick Start logs.": "The container started but exited shortly after. Check the Quick Start logs.", + "The container was removed outside VS Code. Click to recreate it (your data is preserved), or use Delete Container to remove it and its data.": "The container was removed outside VS Code. Click to recreate it (your data is preserved), or use Delete Container to remove it and its data.", + "The credential for this project was rejected.": "The credential for this project was rejected.", + "The credential identity cannot be changed": "The credential identity cannot be changed", + "The credential is signed in but lacks access to this project. Review its roles and IP access list in MongoDB Atlas.": "The credential is signed in but lacks access to this project. Review its roles and IP access list in MongoDB Atlas.", + "The credential was accepted, but it cannot list projects. Add an appropriate organization or project role, then try again.": "The credential was accepted, but it cannot list projects. Add an appropriate organization or project role, then try again.", "The custom cloud choice is not configured. Please configure the setting `{0}.{1}`.": "The custom cloud choice is not configured. Please configure the setting `{0}.{1}`.", + "The daemon answered, so everything below was reported by Docker itself.": "The daemon answered, so everything below was reported by Docker itself.", + "The daemon did not answer, so anything only Docker can report is still unknown.": "The daemon did not answer, so anything only Docker can report is still unknown.", "The database \"{0}\" already exists in the DocumentDB cluster \"{1}\".": "The database \"{0}\" already exists in the DocumentDB cluster \"{1}\".", "The database did not sort data in memory.\n\nResults came back in the right order naturally, either from the index or because no sort was requested.": "The database did not sort data in memory.\n\nResults came back in the right order naturally, either from the index or because no sort was requested.", "The database retrieved your documents through a multikey index.\n\nAn index on an array field was used. Each array element creates a separate index entry, so the database examined more index keys than documents. This is expected for array indexes but adds overhead.": "The database retrieved your documents through a multikey index.\n\nAn index on an array field was used. Each array element creates a separate index entry, so the database examined more index keys than documents. This is expected for array indexes but adds overhead.", @@ -1318,19 +1979,36 @@ "The database sorted results in memory.\n\nThis uses RAM and can fail for very large result sets. Consider adding a compound index that includes your sort fields to let the database skip this step.": "The database sorted results in memory.\n\nThis uses RAM and can fail for very large result sets. Consider adding a compound index that includes your sort fields to let the database skip this step.", "The database used a bitmap index on a low-cardinality field.\n\nThis single-field index splits the collection into very few buckets, returning {0}% of documents. The ongoing write and storage cost on every insert and update outweighs the marginal read benefit.\n\nConsider hiding this index if no other queries depend on it.": "The database used a bitmap index on a low-cardinality field.\n\nThis single-field index splits the collection into very few buckets, returning {0}% of documents. The ongoing write and storage cost on every insert and update outweighs the marginal read benefit.\n\nConsider hiding this index if no other queries depend on it.", "The database used a bitmap index to execute this query.\n\nBitmap indexes are an internal optimization that DocumentDB applies to low-cardinality fields (fields with few distinct values, such as booleans or status codes). They are space-efficient but less selective than B-tree indexes on high-cardinality fields.\n\nThis is expected behavior and does not indicate a problem. If query performance is a concern, consider filtering on a more selective field or using a compound index.": "The database used a bitmap index to execute this query.\n\nBitmap indexes are an internal optimization that DocumentDB applies to low-cardinality fields (fields with few distinct values, such as booleans or status codes). They are space-efficient but less selective than B-tree indexes on high-cardinality fields.\n\nThis is expected behavior and does not indicate a problem. If query performance is a concern, consider filtering on a more selective field or using a compound index.", + "The default index cannot be deleted": "The default index cannot be deleted", + "The default index cannot be hidden": "The default index cannot be hidden", "The default port: {defaultPort}": "The default port: {defaultPort}", "The default port: 10255": "The default port: 10255", + "The default port: 10260": "The default port: 10260", + "The docker command is not on PATH, so no further probe could run.": "The docker command is not on PATH, so no further probe could run.", + "The docker command was located and run to read its version.": "The docker command was located and run to read its version.", + "The Docker readiness check failed.": "The Docker readiness check failed.", + "The document field that stores the embedding array. Only one vector is indexed per path.": "The document field that stores the embedding array. Only one vector is indexed per path.", "The document with the _id \"{0}\" has been saved.": "The document with the _id \"{0}\" has been saved.", + "The DocumentDB Local container can no longer be managed because it was created outside the extension. Remove it with Docker if you no longer need it.": "The DocumentDB Local container can no longer be managed because it was created outside the extension. Remove it with Docker if you no longer need it.", + "The DocumentDB Local container was deleted, but its data volume could not be removed. You can remove it with Docker.": "The DocumentDB Local container was deleted, but its data volume could not be removed. You can remove it with Docker.", + "The DocumentDB Local container was not removed because it was created outside the extension. Remove it with Docker if you no longer need it.": "The DocumentDB Local container was not removed because it was created outside the extension. Remove it with Docker if you no longer need it.", + "The DocumentDB Local container was removed outside VS Code. Click the instance to recreate it (your data is preserved), or use \"Delete Container\" to remove it and its data.": "The DocumentDB Local container was removed outside VS Code. Click the instance to recreate it (your data is preserved), or use \"Delete Container\" to remove it and its data.", + "The DocumentDB Local container was removed outside VS Code. Its data is still on this machine, and setting up creates the container again.": "The DocumentDB Local container was removed outside VS Code. Its data is still on this machine, and setting up creates the container again.", + "The DocumentDB Local instance changed in another window (now {0}). The view has been refreshed.": "The DocumentDB Local instance changed in another window (now {0}). The view has been refreshed.", "The dropped item could not be added as a kubeconfig source": "The dropped item could not be added as a kubeconfig source", + "The earlier failure is still shown below.": "The earlier failure is still shown below.", "The entered value does not match the original.": "The entered value does not match the original.", "The existing connection has been selected in the Connections View.\n\nSelected connection name:\n\"{0}\"": "The existing connection has been selected in the Connections View.\n\nSelected connection name:\n\"{0}\"", "The existing source has been selected in the Services view.\n\nSelected source name:\n\"{0}\"": "The existing source has been selected in the Services view.\n\nSelected source name:\n\"{0}\"", "The export operation was canceled.": "The export operation was canceled.", + "The extension could not connect to the Docker daemon.": "The extension could not connect to the Docker daemon.", "The file could not be read: {0}": "The file could not be read: {0}", "The file is {0} MB, which is far larger than any kubeconfig (limit {1} MB).": "The file is {0} MB, which is far larger than any kubeconfig (limit {1} MB).", "The following tasks are currently using {resourceDescription}:\n{taskList}\n\nPlease stop these tasks first before proceeding.": "The following tasks are currently using {resourceDescription}:\n{taskList}\n\nPlease stop these tasks first before proceeding.", + "The host is always localhost. This exact port is used. Setup checks it here and never picks a different one later.": "The host is always localhost. This exact port is used. Setup checks it here and never picks a different one later.", "The index examined {0}× more keys than documents.\n\nThis is common with indexes on array fields. Each array element generates a separate index entry, increasing the number of keys the database must examine.\n\nThis is usually acceptable but can become a concern as array sizes grow.": "The index examined {0}× more keys than documents.\n\nThis is common with indexes on array fields. Each array element generates a separate index entry, increasing the number of keys the database must examine.\n\nThis is usually acceptable but can become a concern as array sizes grow.", "The index examined {0}× more keys than documents.\n\nThis typically happens with indexes on array fields where each array element generates a separate index entry. The database must examine many index keys for each document.\n\nConsider restructuring the data to avoid indexing large arrays, or use a different query pattern.": "The index examined {0}× more keys than documents.\n\nThis typically happens with indexes on array fields where each array element generates a separate index entry. The database must examine many index keys for each document.\n\nConsider restructuring the data to avoid indexing large arrays, or use a different query pattern.", + "The index name \"*\" is reserved.": "The index name \"*\" is reserved.", "The index used has low cardinality: it does not differentiate well between documents.\n\n{0}\n\nConsider using a more selective index field or a compound index that includes high-cardinality fields.": "The index used has low cardinality: it does not differentiate well between documents.\n\n{0}\n\nConsider using a more selective index field or a compound index that includes high-cardinality fields.", "The issue text was copied to the clipboard. Please paste it into this window.": "The issue text was copied to the clipboard. Please paste it into this window.", "The kubeconfig file \"{0}\" could not be loaded: {1}. Fix the file and try again.": "The kubeconfig file \"{0}\" could not be loaded: {1}. Fix the file and try again.", @@ -1349,6 +2027,8 @@ "The name must be between {0} and {1} characters.": "The name must be between {0} and {1} characters.", "The name of the index used to look up matching documents.\n\nThe database used this index to locate matching documents directly, without scanning the entire collection.": "The name of the index used to look up matching documents.\n\nThe database used this index to locate matching documents directly, without scanning the entire collection.", "The new source has been selected in the Services view.": "The new source has been selected in the Services view.", + "The official image repository is fixed.": "The official image repository is fixed.", + "The operating system and CPU architecture the daemon builds and runs containers for.": "The operating system and CPU architecture the daemon builds and runs containers for.", "The percentage of your collection this query returns. Could not be determined for this query.": "The percentage of your collection this query returns. Could not be determined for this query.", "The playground file is empty. Add some code to run.": "The playground file is empty. Add some code to run.", "The query returned no documents.\n\nNo document fetching was needed because no documents matched the filter criteria.": "The query returned no documents.\n\nNo document fetching was needed because no documents matched the filter criteria.", @@ -1356,56 +2036,107 @@ "The selected connection has been removed.": "The selected connection has been removed.", "The selected folder has been removed.": "The selected folder has been removed.", "The selected item is not a database.": "The selected item is not a database.", + "The selected paths, and every field nested under them, are excluded. All other fields are indexed.": "The selected paths, and every field nested under them, are excluded. All other fields are indexed.", "The server hostname could not be resolved. The cluster may have been deleted or the URL may be incorrect.": "The server hostname could not be resolved. The cluster may have been deleted or the URL may be incorrect.", "The SORT stage exceeded the {0}MB memory limit.\n\n**Solutions:**\n1. Add .allowDiskUse(true) to allow disk-based sorting for large result sets\n2. Create an index matching the sort pattern: {1}\n3. Add filters to reduce the number of documents being sorted\n4. Increase server memory limit (requires server configuration)": "The SORT stage exceeded the {0}MB memory limit.\n\n**Solutions:**\n1. Add .allowDiskUse(true) to allow disk-based sorting for large result sets\n2. Create an index matching the sort pattern: {1}\n3. Add filters to reduce the number of documents being sorted\n4. Increase server memory limit (requires server configuration)", "The source cluster is no longer connected. Please reconnect and copy the collection again.": "The source cluster is no longer connected. Please reconnect and copy the collection again.", "The source collection \"{0}\" no longer exists in database \"{1}\". It may have been deleted or renamed.": "The source collection \"{0}\" no longer exists in database \"{1}\". It may have been deleted or renamed.", + "The stored credential could not be read. Sign out and add it again.": "The stored credential could not be read. Sign out and add it again.", + "The stored credential was rejected. Update it, then try again.": "The stored credential was rejected. Update it, then try again.", "The tag cannot be empty.": "The tag cannot be empty.", "The value must be {0} characters long.": "The value must be {0} characters long.", "The value must be {0} characters or greater.": "The value must be {0} characters or greater.", "The value must be {0} characters or less.": "The value must be {0} characters or less.", "The value must be between {0} and {1} characters long.": "The value must be between {0} and {1} characters long.", "The worker for this cluster is busy executing a playground": "The worker for this cluster is busy executing a playground", + "There is no DocumentDB Local container to follow. Run Quick Start to create one.": "There is no DocumentDB Local container to follow. Run Quick Start to create one.", + "There is nothing to resume.": "There is nothing to resume.", + "There is nothing to set up. Open the connection to start using it.": "There is nothing to set up. Open the connection to start using it.", + "These credentials cannot see any organizations yet. Check their project access and roles in MongoDB Atlas.": "These credentials cannot see any organizations yet. Check their project access and roles in MongoDB Atlas.", + "These defaults work for most people. Change them only if you need to.": "These defaults work for most people. Change them only if you need to.", "These signals help us improve, but more context in a discussion, issue report, or a direct message adds even more value. ": "These signals help us improve, but more context in a discussion, issue report, or a direct message adds even more value. ", "They look like binary files rather than kubeconfigs.": "They look like binary files rather than kubeconfigs.", + "This can take a few minutes. Elapsed time: {0}": "This can take a few minutes. Elapsed time: {0}", "This cannot be undone.": "This cannot be undone.", + "This cluster does not expose a connection string yet. Try refreshing once it finishes provisioning.": "This cluster does not expose a connection string yet. Try refreshing once it finishes provisioning.", + "This cluster is being created. It will be available to connect once creation is complete.": "This cluster is being created. It will be available to connect once creation is complete.", + "This cluster is being created. It will be connectable from the wizard once creation is complete and the cluster returns to IDLE.": "This cluster is being created. It will be connectable from the wizard once creation is complete and the cluster returns to IDLE.", + "This cluster is being deleted and cannot be connected to from the wizard.": "This cluster is being deleted and cannot be connected to from the wizard.", + "This cluster is being deleted and will no longer be available.": "This cluster is being deleted and will no longer be available.", + "This cluster is being repaired. It is visible here to match the discovery tree, but it is not connectable until repair completes and the cluster returns to IDLE.": "This cluster is being repaired. It is visible here to match the discovery tree, but it is not connectable until repair completes and the cluster returns to IDLE.", + "This cluster is being repaired. It may be temporarily unavailable.": "This cluster is being repaired. It may be temporarily unavailable.", + "This cluster is being updated. It is visible here to match the discovery tree, but it is not connectable until the update completes and the cluster returns to IDLE.": "This cluster is being updated. It is visible here to match the discovery tree, but it is not connectable until the update completes and the cluster returns to IDLE.", + "This cluster is being updated. It may be temporarily unavailable.": "This cluster is being updated. It may be temporarily unavailable.", + "This cluster is in an unknown state. Try refreshing to update its status before connecting from the wizard.": "This cluster is in an unknown state. Try refreshing to update its status before connecting from the wizard.", + "This cluster is in an unknown state. Try refreshing to update its status.": "This cluster is in an unknown state. Try refreshing to update its status.", + "This cluster is not connectable from the wizard right now.": "This cluster is not connectable from the wizard right now.", + "This cluster is paused. Resume it in MongoDB Atlas before connecting.": "This cluster is paused. Resume it in MongoDB Atlas before connecting.", "This ClusterIP service requires port-forwarding. Confirm or change the local port to forward to {0}/{1}:{2}.": "This ClusterIP service requires port-forwarding. Confirm or change the local port to forward to {0}/{1}:{2}.", + "This Codespaces environment (Docker)": "This Codespaces environment (Docker)", + "This connection targets a local or private network host. TLS certificate validation:": "This connection targets a local or private network host. TLS certificate validation:", + "This credential no longer exists": "This credential no longer exists", + "This deletes the container named {0} and its data volume, then creates a new one. Everything stored in DocumentDB Local is erased.": "This deletes the container named {0} and its data volume, then creates a new one. Everything stored in DocumentDB Local is erased.", + "This dev container environment (Docker)": "This dev container environment (Docker)", + "This extension can only connect with a username and a password.": "This extension can only connect with a username and a password.", "This field is not set": "This field is not set", "This functionality requires installing the Azure Account extension.": "This functionality requires installing the Azure Account extension.", "This functionality requires updating the Azure Account extension to at least version \"{0}\".": "This functionality requires updating the Azure Account extension to at least version \"{0}\".", "This index on {0} is not being used and adds unnecessary overhead to write operations.": "This index on {0} is not being used and adds unnecessary overhead to write operations.", + "This instance is already in the Connections view as “DocumentDB Local”. You do not need to create a connection for it.": "This instance is already in the Connections view as “DocumentDB Local”. You do not need to create a connection for it.", + "This IP address is not allowed": "This IP address is not allowed", + "This is the specification that will be passed to createIndex().": "This is the specification that will be passed to createIndex().", "This kubeconfig source has no stored content.": "This kubeconfig source has no stored content.", "This Kubernetes service type is not resolved automatically. Use a reachable service endpoint or connect manually.": "This Kubernetes service type is not resolved automatically. Use a reachable service endpoint or connect manually.", + "This machine": "This machine", + "This machine (Docker)": "This machine (Docker)", + "This MongoDB Atlas project does not currently contain any clusters to connect to.": "This MongoDB Atlas project does not currently contain any clusters to connect to.", "This operation is not supported as it would create a circular dependency and never terminate. Please select a different target collection or database.": "This operation is not supported as it would create a circular dependency and never terminate. Please select a different target collection or database.", "This operation is not supported.": "This operation is not supported.", "This operation will copy all documents from the source to the target collection. Large collections may take several minutes to complete.": "This operation will copy all documents from the source to the target collection. Large collections may take several minutes to complete.", "This playground has no connection.": "This playground has no connection.", "This playground has no connection. Click to connect it to a database.": "This playground has no connection. Click to connect it to a database.", "This playground is connected to {0} / {1}.": "This playground is connected to {0} / {1}.", + "This project does not contain any clusters yet.": "This project does not contain any clusters yet.", + "This project does not currently contain any MongoDB Atlas clusters.": "This project does not currently contain any MongoDB Atlas clusters.", "This query returns {0} of your collection.\n\nThis is a broad query that returns a large portion of the data. Consider adding more specific filters to narrow the results.": "This query returns {0} of your collection.\n\nThis is a broad query that returns a large portion of the data. Consider adding more specific filters to narrow the results.", "This query returns {0} of your collection.\n\nThis is a reasonable level of selectivity. The filter narrows results to a manageable portion of the data.": "This query returns {0} of your collection.\n\nThis is a reasonable level of selectivity. The filter narrows results to a manageable portion of the data.", "This query returns {0} of your collection.\n\nThis is highly selective: only a small fraction of documents pass the filter. The database does minimal work to produce results.": "This query returns {0} of your collection.\n\nThis is highly selective: only a small fraction of documents pass the filter. The database does minimal work to produce results.", + "This remote extension host (Docker)": "This remote extension host (Docker)", "this resource": "this resource", + "This stops all running WSL distributions so the new group membership applies when WSL starts again.": "This stops all running WSL distributions so the new group membership applies when WSL starts again.", "This table view presents data at the root level by default.": "This table view presents data at the root level by default.", "This will {operation} an index on collection \"{collectionName}\".": "This will {operation} an index on collection \"{collectionName}\".", "This will {operation} the index \"{indexName}\" on collection \"{collectionName}\".": "This will {operation} the index \"{indexName}\" on collection \"{collectionName}\".", - "This will allow the query planner to use this index again.": "This will allow the query planner to use this index again.", "This will also delete {0}.": "This will also delete {0}.", "This will execute all statements in the file against the connected cluster.": "This will execute all statements in the file against the connected cluster.", - "This will prevent the query planner from using this index.": "This will prevent the query planner from using this index.", + "This WSL environment (Docker)": "This WSL environment (Docker)", + "Tier": "Tier", "Tip: use .maxTimeMS() to increase the time limit for this query.": "Tip: use .maxTimeMS() to increase the time limit for this query.", "TLS/SSL certificate validation disabled": "TLS/SSL certificate validation disabled", "TLS/SSL Disabled": "TLS/SSL Disabled", "TLS/SSL Enabled": "TLS/SSL Enabled", "To connect to Azure resources, you need to sign in to Azure accounts.": "To connect to Azure resources, you need to sign in to Azure accounts.", + "To use a different Client ID, sign out and add a new credential.": "To use a different Client ID, sign out and add a new credential.", + "To use a different Public Key, sign out and add a new credential.": "To use a different Public Key, sign out and add a new credential.", "TODO: Share the steps needed to reliably reproduce the problem. Please include actual and expected results.": "TODO: Share the steps needed to reliably reproduce the problem. Please include actual and expected results.", + "Too many requests were made. Wait briefly, then try again.": "Too many requests were made. Wait briefly, then try again.", "Total documents to import: {0}": "Total documents to import: {0}", + "Total Indexes": "Total Indexes", + "Total number of operations that have used these indexes since the server began tracking usage.": "Total number of operations that have used these indexes since the server began tracking usage.", + "Total Size": "Total Size", "Total time taken to execute the query on the server": "Total time taken to execute the query on the server", + "Total Usage": "Total Usage", + "Training sample size": "Training sample size", "Transforming Stage 2 response to UI format": "Transforming Stage 2 response to UI format", "Tree View": "Tree View", + "Try again. If this persists, check the output channel for details.": "Try again. If this persists, check the output channel for details.", "Try with Decoded Password": "Try with Decoded Password", + "TTL": "TTL", + "TTL requires a single ascending or descending field.": "TTL requires a single ascending or descending field.", + "Type": "Type", "Type \"help\" for available commands.": "Type \"help\" for available commands.", "Type \"it\" for more": "Type \"it\" for more", + "Type a username that is not in this list": "Type a username that is not in this list", "TypeScript-powered completions are unavailable on this read-only extension install. Click to retry.": "TypeScript-powered completions are unavailable on this read-only extension install. Click to retry.", "Unable to connect to the local database instance. Make sure it is started correctly. See {link} for tips.": "Unable to connect to the local database instance. Make sure it is started correctly. See {link} for tips.", "Unable to connect to the local instance. Make sure it is started correctly. See {link} for tips.": "Unable to connect to the local instance. Make sure it is started correctly. See {link} for tips.", @@ -1416,46 +2147,80 @@ "Undo": "Undo", "Unexpected status code: {0}": "Unexpected status code: {0}", "unhide": "unhide", - "Unhide index \"{indexName}\" from collection \"{collectionName}\"?": "Unhide index \"{indexName}\" from collection \"{collectionName}\"?", + "Unhide": "Unhide", + "Unhide index": "Unhide index", + "Unhide index {0}": "Unhide index {0}", "Unhide index?": "Unhide index?", "Unhide Index…": "Unhide Index…", + "Unhiding makes this index available to the query planner again.": "Unhiding makes this index available to the query planner again.", "Unhiding…": "Unhiding…", + "Unique": "Unique", + "Unix socket": "Unix socket", "Unknown": "Unknown", "Unknown command type: {type}": "Unknown command type: {type}", "Unknown conflict resolution strategy: {0}": "Unknown conflict resolution strategy: {0}", "Unknown constructor '{0}'. Expected a BSON constructor (e.g., ObjectId, ISODate) or a known global (e.g., Date, RegExp).": "Unknown constructor '{0}'. Expected a BSON constructor (e.g., ObjectId, ISODate) or a known global (e.g., Date, RegExp).", + "Unknown Docker problem": "Unknown Docker problem", "unknown error": "unknown error", "Unknown error": "Unknown error", "Unknown Error": "Unknown Error", "Unknown function '{0}'. Expected a BSON constructor (e.g., ObjectId, ISODate) or a known global (e.g., Date, Math).": "Unknown function '{0}'. Expected a BSON constructor (e.g., ObjectId, ISODate) or a known global (e.g., Date, Math).", "Unknown identifier '{0}'. Expected a known global (e.g., Date, Math).": "Unknown identifier '{0}'. Expected a known global (e.g., Date, Math).", "Unknown query generation type: {type}": "Unknown query generation type: {type}", + "Unknown state": "Unknown state", "Unknown strategy": "Unknown strategy", "Unknown tenant": "Unknown tenant", "unsupported": "unsupported", + "Unsupported": "Unsupported", "Unsupported authentication method: {0}": "Unsupported authentication method: {0}", "Unsupported authentication method.": "Unsupported authentication method.", "Unsupported command type detected.": "Unsupported command type detected.", "Unsupported emulator type: \"{emulatorType}\"": "Unsupported emulator type: \"{emulatorType}\"", + "unsupported host": "unsupported host", + "Unsupported host": "Unsupported host", + "Unsupported platform": "Unsupported platform", "Unsupported query type: {queryType}": "Unsupported query type: {queryType}", "Unsupported resource: {0}": "Unsupported resource: {0}", "Unsupported service type: {0}": "Unsupported service type: {0}", "Unsupported view for an authentication retry.": "Unsupported view for an authentication retry.", + "Unused": "Unused", + "Unused Indexes": "Unused Indexes", "Up": "Up", "Update Azure Account Extension to at least version \"{0}\"...": "Update Azure Account Extension to at least version \"{0}\"...", "Update cluster credentials": "Update cluster credentials", "Update Connection String": "Update Connection String", + "Update credentials…": "Update credentials…", + "Update MongoDB Atlas connection": "Update MongoDB Atlas connection", + "Update MongoDB Atlas Credentials": "Update MongoDB Atlas Credentials", "Update Saved Password": "Update Saved Password", + "Updated {0} minutes ago": "Updated {0} minutes ago", + "Updated 1 minute ago": "Updated 1 minute ago", + "Updated a few seconds ago": "Updated a few seconds ago", "Updated entity \"{name}\".": "Updated entity \"{name}\".", + "Updated less than a minute ago": "Updated less than a minute ago", + "Updating index": "Updating index", + "Updating…": "Updating…", "Upload": "Upload", "URL handling aborted. Connection was unsuccessful or the specified database/collection does not exist.": "URL handling aborted. Connection was unsuccessful or the specified database/collection does not exist.", + "Usage": "Usage", + "Usage counted since {0}": "Usage counted since {0}", + "Usage statistics are not available for this index.": "Usage statistics are not available for this index.", + "Usage: {0}": "Usage: {0}", + "Use \"{0}\"": "Use \"{0}\"", + "Use a custom index name.": "Use a custom index name.", "Use existing": "Use existing", + "Use generated credentials": "Use generated credentials", "User": "User", "User: {0} | Authentication: {1} | Database: {2}": "User: {0} | Authentication: {1} | Database: {2}", + "Username": "Username", + "Username and password": "Username and password", "Username and Password": "Username and Password", + "Username and password (SCRAM)": "Username and password (SCRAM)", "Username cannot be empty": "Username cannot be empty", "Username contains characters that cannot be safely encoded.": "Username contains characters that cannot be safely encoded.", "Username for {resource}": "Username for {resource}", + "Username must be 128 characters or fewer.": "Username must be 128 characters or fewer.", + "Username must not contain control characters.": "Username must not contain control characters.", "Using custom prompt template for {type} query generation: {path}": "Using custom prompt template for {type} query generation: {path}", "Using custom prompt template for {type} query: {path}": "Using custom prompt template for {type} query: {path}", "Using existing port-forward on 127.0.0.1:{0} for {1}/{2}.": "Using existing port-forward on 127.0.0.1:{0} for {1}/{2}.", @@ -1464,9 +2229,21 @@ "Using the table navigation, you can explore deeper levels or move back and forth between them.": "Using the table navigation, you can explore deeper levels or move back and forth between them.", "Validate": "Validate", "Validate document syntax": "Validate document syntax", + "Validate the server certificate. Recommended.": "Validate the server certificate. Recommended.", "Validating source collection...": "Validating source collection...", + "vector": "vector", + "Vector": "Vector", + "Vector algorithm": "Vector algorithm", + "Vector field": "Vector field", + "Vector index compression": "Vector index compression", + "Verify": "Verify", + "Verify & Save": "Verify & Save", + "Verify your Docker setup": "Verify your Docker setup", + "Verify your MongoDB Atlas API Key": "Verify your MongoDB Atlas API Key", + "Verify your MongoDB Atlas Service Account": "Verify your MongoDB Atlas Service Account", "Verifying folder can be deleted…": "Verifying folder can be deleted…", "Verifying move operation…": "Verifying move operation…", + "Verifying with MongoDB Atlas": "Verifying with MongoDB Atlas", "Very low efficiency ratio": "Very low efficiency ratio", "Very slow execution": "Very slow execution", "View conflict details in the Output panel": "View conflict details in the Output panel", @@ -1474,30 +2251,70 @@ "View Kubeconfig": "View Kubeconfig", "View Raw Execution Stats": "View Raw Execution Stats", "View Raw Explain Output": "View Raw Explain Output", + "View Raw Index Definition": "View Raw Index Definition", "View selected document": "View selected document", + "View setup log": "View setup log", "Viewing Azure account information for: {0}": "Viewing Azure account information for: {0}", + "Visible in the tree, but not connectable until the cluster returns to IDLE.": "Visible in the tree, but not connectable until the cluster returns to IDLE.", "Visit the [documentation]({0}) for more information about TLS/SSL certificates.": "Visit the [documentation]({0}) for more information about TLS/SSL certificates.", + "VS Code cannot access the saved credentials for this instance. Review the setup options; your container and data have not been changed.": "VS Code cannot access the saved credentials for this instance. Review the setup options; your container and data have not been changed.", "VS Code connects through the Kubernetes PortForward API. Connection strings using 127.0.0.1 only work on this machine while the tunnel is active.": "VS Code connects through the Kubernetes PortForward API. Connection strings using 127.0.0.1 only work on this machine while the tunnel is active.", "VS Code: v{0}": "VS Code: v{0}", + "Wait longer": "Wait longer", + "Waiting {0}": "Waiting {0}", "Waiting for Azure sign-in...": "Waiting for Azure sign-in...", + "Waiting for Docker to start.": "Waiting for Docker to start.", + "Waiting for Docker to start. This can take a minute.": "Waiting for Docker to start. This can take a minute.", + "Waiting for DocumentDB to accept connections": "Waiting for DocumentDB to accept connections", + "warning": "warning", "WARNING: Cannot create resource group \"{0}\" because the selected subscription is a concierge subscription. Using resource group \"{1}\" instead.": "WARNING: Cannot create resource group \"{0}\" because the selected subscription is a concierge subscription. Using resource group \"{1}\" instead.", "WARNING: Provider \"{0}\" does not support location \"{1}\". Using \"{2}\" instead.": "WARNING: Provider \"{0}\" does not support location \"{1}\". Using \"{2}\" instead.", "WARNING: Resource does not support extended location \"{0}\". Using \"{1}\" instead.": "WARNING: Resource does not support extended location \"{0}\". Using \"{1}\" instead.", "We can't move items between \"DocumentDB Local\" and regular connections. Please select items from only one of those areas at a time.": "We can't move items between \"DocumentDB Local\" and regular connections. Please select items from only one of those areas at a time.", + "We check your credentials with MongoDB Atlas before saving your connection.": "We check your credentials with MongoDB Atlas before saving your connection.", + "We couldn't check this credential": "We couldn't check this credential", + "We couldn't close this view": "We couldn't close this view", + "We couldn't open this link.": "We couldn't open this link.", + "We couldn't reach MongoDB Atlas": "We couldn't reach MongoDB Atlas", + "We couldn't sign in": "We couldn't sign in", + "We couldn't verify your credentials. Review the details below.": "We couldn't verify your credentials. Review the details below.", "We found {0} naming conflict(s) in \"{1}\". To move these items, please rename them or choose a different folder:": "We found {0} naming conflict(s) in \"{1}\". To move these items, please rename them or choose a different folder:", + "We found an existing DocumentDB Local instance, but its saved credentials are unavailable. Without them, we cannot reopen or reuse the existing data, so you need to start fresh. Nothing has been changed yet. Starting fresh deletes the existing container and its data, then creates a new instance.": "We found an existing DocumentDB Local instance, but its saved credentials are unavailable. Without them, we cannot reopen or reuse the existing data, so you need to start fresh. Nothing has been changed yet. Starting fresh deletes the existing container and its data, then creates a new instance.", + "What should setup do with the existing data?": "What should setup do with the existing data?", + "What the Docker check found": "What the Docker check found", + "What will happen in the Set up step": "What will happen in the Set up step", "What's New": "What's New", + "Where do I find these values?": "Where do I find these values?", "Where to save the exported documents?": "Where to save the exported documents?", + "Where VS Code is running the extension, detected before Docker was contacted.": "Where VS Code is running the extension, detected before Docker was contacted.", + "Wildcard": "Wildcard", + "Wildcard index keys must use ascending direction.": "Wildcard index keys must use ascending direction.", + "Wildcard index scope": "Wildcard index scope", + "Wildcard indexes cannot be sparse.": "Wildcard indexes cannot be sparse.", + "Wildcard indexes cannot be unique.": "Wildcard indexes cannot be unique.", + "Wildcard indexes cannot use TTL.": "Wildcard indexes cannot use TTL.", + "wildcard projection": "wildcard projection", + "Wildcard projection": "Wildcard projection", + "Wildcard projection is only allowed on an all-fields wildcard index (the $** key).": "Wildcard projection is only allowed on an all-fields wildcard index (the $** key).", + "Wildcard projection mode": "Wildcard projection mode", + "Wildcard projection requires a wildcard index key.": "Wildcard projection requires a wildcard index key.", + "Windows": "Windows", + "Windows containers enabled": "Windows containers enabled", + "Windows named pipe": "Windows named pipe", + "Windows Subsystem for Linux": "Windows Subsystem for Linux", "with Popover": "with Popover", "Worker is not running": "Worker is not running", "Working...": "Working...", "Working…": "Working…", "Works on this machine while the port-forward tunnel is active": "Works on this machine while the port-forward tunnel is active", + "Worth checking in MongoDB Atlas:": "Worth checking in MongoDB Atlas:", "Would you like to open the Collection View?": "Would you like to open the Collection View?", "Would you like to reconnect with the updated credentials?": "Would you like to reconnect with the updated credentials?", "Write error: {0}": "Write error: {0}", "Write operation failed: {0}": "Write operation failed: {0}", "writing batch...": "writing batch...", "Writing…": "Writing…", + "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "Yes": "Yes", "Yes, continue": "Yes, continue", "Yes, copy all indexes": "Yes, copy all indexes", @@ -1505,10 +2322,14 @@ "Yes, open connection": "Yes, open connection", "Yes, save my credentials": "Yes, save my credentials", "You are already signed in to tenant \"{0}\"": "You are already signed in to tenant \"{0}\"", + "You are in the Docker group on the remote host, but the VS Code server started before that change. Run \"Remote-SSH: Kill VS Code Server on Host\", then reconnect.": "You are in the Docker group on the remote host, but the VS Code server started before that change. Run \"Remote-SSH: Kill VS Code Server on Host\", then reconnect.", + "You are in the Docker group, but this container started before that change. Rebuild the container.": "You are in the Docker group, but this container started before that change. Rebuild the container.", + "You are in the Docker group, but this session started before that change. Sign out of your desktop session and sign back in. Reloading the window is not enough.": "You are in the Docker group, but this session started before that change. Sign out of your desktop session and sign back in. Reloading the window is not enough.", "You are not signed in to an Azure account. Please sign in.": "You are not signed in to an Azure account. Please sign in.", "You are not signed in to the DocumentDB cluster. Please sign in (by expanding the node \"{0}\") and try again.": "You are not signed in to the DocumentDB cluster. Please sign in (by expanding the node \"{0}\") and try again.", "You can disable this confirmation by setting \"{0}\" to false.": "You can disable this confirmation by setting \"{0}\" to false.", "You can increase the timeout in Settings:": "You can increase the timeout in Settings:", + "You can now close this tab and explore your MongoDB Atlas clusters in the Service Discovery area.": "You can now close this tab and explore your MongoDB Atlas clusters in the Service Discovery area.", "You clicked a link that wants to open a DocumentDB connection in VS Code.": "You clicked a link that wants to open a DocumentDB connection in VS Code.", "You do not have permission to create a resource group in subscription \"{0}\".": "You do not have permission to create a resource group in subscription \"{0}\".", "You might be asked for credentials to establish the connection.\nDo you want to continue?\n\nNote: You can disable these URL handling confirmations in the extension settings.": "You might be asked for credentials to establish the connection.\nDo you want to continue?\n\nNote: You can disable these URL handling confirmations in the extension settings.", @@ -1522,9 +2343,13 @@ "Your account lacks the required RBAC permissions. Contact your cluster administrator.": "Your account lacks the required RBAC permissions. Contact your cluster administrator.", "Your clipboard contents will be saved as a kubeconfig source.": "Your clipboard contents will be saved as a kubeconfig source.", "Your Cluster": "Your Cluster", + "Your credential was successfully checked and saved, and is ready to use.": "Your credential was successfully checked and saved, and is ready to use.", "Your database stores documents with embedded fields, allowing for hierarchical data organization.": "Your database stores documents with embedded fields, allowing for hierarchical data organization.", "Your default kubeconfig ({0}) could not be loaded: {1}. Fix the kubeconfig and try again.": "Your default kubeconfig ({0}) could not be loaded: {1}. Fix the kubeconfig and try again.", + "Your Docker group change requires a new WSL session. Run this command in a Windows terminal. This VS Code window will disconnect. Reconnect to WSL, then open Quick Start again.": "Your Docker group change requires a new WSL session. Run this command in a Windows terminal. This VS Code window will disconnect. Reconnect to WSL, then open Quick Start again.", "Your feedback helps us improve Query Insights. Tell us what could be better:": "Your feedback helps us improve Query Insights. Tell us what could be better:", + "Your local connections have been moved to '{0}' in the Connections view.": "Your local connections have been moved to '{0}' in the Connections view.", + "Your own username and password": "Your own username and password", "Your positive feedback helps us understand what works well in Query Insights. Tell us more:": "Your positive feedback helps us understand what works well in Query Insights. Tell us more:", "Your query does not require sorting, which avoids additional processing overhead.": "Your query does not require sorting, which avoids additional processing overhead.", "Your query does not use an index.\n\nWhile not necessarily a problem for small collections, adding appropriate indexes can significantly improve query performance.": "Your query does not use an index.\n\nWhile not necessarily a problem for small collections, adding appropriate indexes can significantly improve query performance.", @@ -1537,5 +2362,10 @@ "Your query returns {0}% of the collection.\n\nWhen returning more than half the documents, a collection scan may actually be faster than an index lookup because sequential reads are more efficient than random index-pointer chasing.": "Your query returns {0}% of the collection.\n\nWhen returning more than half the documents, a collection scan may actually be faster than an index lookup because sequential reads are more efficient than random index-pointer chasing.", "Your query uses an index.\n\nThis allows the database to efficiently locate matching documents without scanning the entire collection.": "Your query uses an index.\n\nThis allows the database to efficiently locate matching documents without scanning the entire collection.", "Your query uses index-based sorting, which is efficient and avoids memory constraints.\n\nThis improves performance by leveraging the natural order of the index.": "Your query uses index-based sorting, which is efficient and avoids memory constraints.\n\nThis improves performance by leveraging the natural order of the index.", + "Your user cannot access the Docker socket on the machine where this extension is running.": "Your user cannot access the Docker socket on the machine where this extension is running.", + "Your user cannot access the Docker socket. Run this command, then restart the WSL session.": "Your user cannot access the Docker socket. Run this command, then restart the WSL session.", + "Your user cannot access the Docker socket. Run this command, then sign out and sign back in.": "Your user cannot access the Docker socket. Run this command, then sign out and sign back in.", + "Your user is in the docker group, but this session predates the change": "Your user is in the docker group, but this session predates the change", + "Your user is not a member of the docker group": "Your user is not a member of the docker group", "Your VS Code window must be reloaded to perform this action.": "Your VS Code window must be reloaded to perform this action." } diff --git a/package-lock.json b/package-lock.json index 7ae6514ea..2b337efa3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-documentdb", - "version": "0.9.2", + "version": "0.10.0-bug-bash-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vscode-documentdb", - "version": "0.9.2", + "version": "0.10.0-bug-bash-2", "license": "SEE LICENSE IN LICENSE.md", "workspaces": [ "packages/*" @@ -22,14 +22,16 @@ "@documentdb-js/operator-registry": "*", "@documentdb-js/schema-analyzer": "*", "@documentdb-js/shell-runtime": "*", - "@fluentui/react-components": "~9.73.3", + "@fluentui/react-components": "~9.74.4", "@fluentui/react-icons": "~2.0.320", "@kubernetes/client-node": "1.4.0", "@microsoft/vscode-azext-azureauth": "~4.1.1", "@microsoft/vscode-azext-azureutils": "~4.2.0", "@microsoft/vscode-azext-utils": "~4.1.0", "@microsoft/vscode-azureresources-api": "~2.5.0", + "@microsoft/vscode-container-client": "^0.5.4", "@microsoft/vscode-ext-webview": "*", + "@microsoft/vscode-processutils": "^0.2.2", "@monaco-editor/react": "~4.7.0", "@mongodb-js/explain-plan-helper": "1.4.24", "@mongodb-js/shell-bson-parser": "^1.5.6", @@ -2330,12 +2332,12 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.11" + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/devtools": { @@ -2348,19 +2350,19 @@ } }, "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, "node_modules/@fluentui/keyboard-keys": { @@ -2373,30 +2375,30 @@ } }, "node_modules/@fluentui/priority-overflow": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@fluentui/priority-overflow/-/priority-overflow-9.3.0.tgz", - "integrity": "sha512-yaBC0R4e+4ZlCWDulB5S+xBrlnLwfzdg68GaarCqQO8OHjLg7Ah05xTj7PsAYcoHeEg/9vYeBwGXBpRO8+Tjqw==", + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/@fluentui/priority-overflow/-/priority-overflow-9.4.1.tgz", + "integrity": "sha512-w/cO/mtqWd/ly4fhrbfCPLwdEAVmuifemnhl1SZm8IN7dMWv22pumsIuiH67T4YRLbhHtHXDAKp0HLXNR3z7lA==", "license": "MIT", "dependencies": { "@swc/helpers": "^0.5.1" } }, "node_modules/@fluentui/react-accordion": { - "version": "9.11.0", - "resolved": "https://registry.npmjs.org/@fluentui/react-accordion/-/react-accordion-9.11.0.tgz", - "integrity": "sha512-mEy73hbJM53tMj3MWqm3ajbBxj48uubnJjumVKI8Z/eXHS8L3GzUy5rf/gUH26xSR2Tl+edpFhYB8PFbJDIKKw==", + "version": "9.12.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-accordion/-/react-accordion-9.12.1.tgz", + "integrity": "sha512-F7xVaP0OR7JMCxrBwI3ryNhKof9Yi2c6RhYPsQy7rh2+KMGLGgYLsrDJyHmSejjxp4Bs11plXGdU38SN5j1hgw==", "license": "MIT", "dependencies": { - "@fluentui/react-aria": "^9.17.11", - "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-aria": "^9.17.13", + "@fluentui/react-context-selector": "^9.2.18", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-motion": "^9.15.0", - "@fluentui/react-motion-components-preview": "^0.15.4", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-motion": "^9.16.1", + "@fluentui/react-motion-components-preview": "^0.15.6", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -2408,18 +2410,18 @@ } }, "node_modules/@fluentui/react-alert": { - "version": "9.0.0-beta.139", - "resolved": "https://registry.npmjs.org/@fluentui/react-alert/-/react-alert-9.0.0-beta.139.tgz", - "integrity": "sha512-R9r4dwwpWpgFmB8wVeWqipjUh/e6lyacnerX39HtVdgcG/PE+kpdHjKGiy8MAD+BGYCzrUxKNhXTQDlpXasJ1Q==", + "version": "9.0.0-beta.142", + "resolved": "https://registry.npmjs.org/@fluentui/react-alert/-/react-alert-9.0.0-beta.142.tgz", + "integrity": "sha512-YrbMX1wF7huOByxP2J+2aUWatpODd1MXhqam95oeLUnoJUViBK6ZZuBYba7m0OTsRMUA4pB1WfjvuIGsnSQKtw==", "license": "MIT", "dependencies": { - "@fluentui/react-avatar": "^9.11.1", - "@fluentui/react-button": "^9.9.1", + "@fluentui/react-avatar": "^9.11.3", + "@fluentui/react-button": "^9.10.1", "@fluentui/react-icons": "^2.0.239", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -2431,16 +2433,16 @@ } }, "node_modules/@fluentui/react-aria": { - "version": "9.17.11", - "resolved": "https://registry.npmjs.org/@fluentui/react-aria/-/react-aria-9.17.11.tgz", - "integrity": "sha512-K9nz+Wn5JliCpG6bIYYPXvKmpOql+w9uyzmYNYkYQ6QHgoCpph7XUFx1HCtsJm2PPNi8WO8g0ZV9jojdGKl1Tg==", + "version": "9.17.13", + "resolved": "https://registry.npmjs.org/@fluentui/react-aria/-/react-aria-9.17.13.tgz", + "integrity": "sha512-f5qSP5aD2ZbYgQn4hCjQzqh8mHJNeN/vsC9Nwth5uJlGNdIAPbPO+dXVC18DkYqNU8O9A5Ae/uJPRbxoH23zbA==", "license": "MIT", "dependencies": { "@fluentui/keyboard-keys": "^9.0.8", - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-tabster": "^9.26.16", + "@fluentui/react-utilities": "^9.26.5", "@swc/helpers": "^0.5.1" }, "peerDependencies": { @@ -2451,21 +2453,21 @@ } }, "node_modules/@fluentui/react-avatar": { - "version": "9.11.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-avatar/-/react-avatar-9.11.1.tgz", - "integrity": "sha512-y1T67rVQQ/D4FAod8F4crXo9funaptscRIiW81LAsbN82fFVexMPQ9GmXooQQvn6ILvjJtf9IyvSJ195qDsyag==", + "version": "9.11.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-avatar/-/react-avatar-9.11.3.tgz", + "integrity": "sha512-O8PoDUf1OUXDviECFvxdxo88kCEJLlP4TtCXyE58PKuAywrVeqmOA7eNy6/xhaGVuyDO8l9YJh05sYkyLiNLIQ==", "license": "MIT", "dependencies": { - "@fluentui/react-badge": "^9.5.2", - "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-badge": "^9.5.4", + "@fluentui/react-context-selector": "^9.2.18", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-popover": "^9.14.2", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-popover": "^9.14.4", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-tooltip": "^9.10.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-tooltip": "^9.10.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -2477,16 +2479,16 @@ } }, "node_modules/@fluentui/react-badge": { - "version": "9.5.2", - "resolved": "https://registry.npmjs.org/@fluentui/react-badge/-/react-badge-9.5.2.tgz", - "integrity": "sha512-+UPAK9dCD6Gx+LWr6vqKMIbYOPf7oXX+GXRtCJ5fekCTHD0VgIWuIMuEtxVrHpJQdb2VNaZadY8/dMomk2JaXw==", + "version": "9.5.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-badge/-/react-badge-9.5.4.tgz", + "integrity": "sha512-jxS6H6+KCk62MeueCf7cT2fRbdnN6h/lCOIyXWJLpPf9Mx+LWWP3KAfKik3BuDY28oy1pDYIuvFucDL+OMAb/Q==", "license": "MIT", "dependencies": { "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -2498,20 +2500,20 @@ } }, "node_modules/@fluentui/react-breadcrumb": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-breadcrumb/-/react-breadcrumb-9.4.1.tgz", - "integrity": "sha512-XgUB1yv04GdcL/6kUo6kh+BaN4df1A/Ds/fL1QxNrm5E26Vmvvlc0LN0WV/qb5qhKx0NwhtIXgOZHjfzyt7iCA==", + "version": "9.4.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-breadcrumb/-/react-breadcrumb-9.4.4.tgz", + "integrity": "sha512-KcxyQAC+xTO/n2BMBj2lLKgQL2/eyTlkUhElHv9SKqP29Ks8EPYSovYpDtSfbtx8Dle+8NSUnhAvnO1kE/+fMQ==", "license": "MIT", "dependencies": { - "@fluentui/react-aria": "^9.17.11", - "@fluentui/react-button": "^9.9.1", + "@fluentui/react-aria": "^9.17.13", + "@fluentui/react-button": "^9.10.1", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-link": "^9.8.1", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-link": "^9.8.3", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -2523,19 +2525,19 @@ } }, "node_modules/@fluentui/react-button": { - "version": "9.9.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-button/-/react-button-9.9.1.tgz", - "integrity": "sha512-WNzpseiVbqEKKevTkAnyHNoK/8ktYPE6rvf31gGvSDnBBclqfrn4PSYG2ppi+Z7abmClnaNFxpp1OHuOoVQ8Bg==", + "version": "9.10.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-button/-/react-button-9.10.1.tgz", + "integrity": "sha512-8Ow/ck9a/RLh3cJ6ZPF4asmGnNfqXr/Kengk+zTkMuppk+pFL3X6WEMrJLSOUKKZtx0Tp1UBuUPA6RL6Ive5WQ==", "license": "MIT", "dependencies": { "@fluentui/keyboard-keys": "^9.0.8", - "@fluentui/react-aria": "^9.17.11", + "@fluentui/react-aria": "^9.17.13", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -2547,18 +2549,18 @@ } }, "node_modules/@fluentui/react-card": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-card/-/react-card-9.6.1.tgz", - "integrity": "sha512-KBijjAxi0mBDSgnA1OCglqAVWc+Q0L7A2wCokszX/53oqfJPSvWxWFma7esz9b5MF/kdRrAR0vmy7MiosepNLQ==", + "version": "9.7.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-card/-/react-card-9.7.1.tgz", + "integrity": "sha512-4t65Y9pRW9W7kf/Yyc7S796le2WFKfXFTCuzfkFS+AHUM7JlrmMUOnQLA0i24WDNS6ArA0vo6EbMDnUcjgV9Yg==", "license": "MIT", "dependencies": { "@fluentui/keyboard-keys": "^9.0.8", - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", - "@fluentui/react-text": "^9.6.16", + "@fluentui/react-tabster": "^9.26.16", + "@fluentui/react-text": "^9.6.18", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -2570,21 +2572,21 @@ } }, "node_modules/@fluentui/react-carousel": { - "version": "9.9.7", - "resolved": "https://registry.npmjs.org/@fluentui/react-carousel/-/react-carousel-9.9.7.tgz", - "integrity": "sha512-lummYk+tASL/rM/SXWruoqhUAyJjTiOMgiCz55ncE3q2pSZe/EbsV5WfRw5B3y7pHX8xLusN831TBgUthj/sUw==", + "version": "9.9.10", + "resolved": "https://registry.npmjs.org/@fluentui/react-carousel/-/react-carousel-9.9.10.tgz", + "integrity": "sha512-Ml3Vqi9KNA+mRG1FUiIjk/HCYqEjRZKSE8jQviMSQDLe4Rb6EO16FhtuFuIOz/ls47y/Sx6zTnb1t+HiFatpJg==", "license": "MIT", "dependencies": { - "@fluentui/react-aria": "^9.17.11", - "@fluentui/react-button": "^9.9.1", - "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-aria": "^9.17.13", + "@fluentui/react-button": "^9.10.1", + "@fluentui/react-context-selector": "^9.2.18", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-tooltip": "^9.10.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-tooltip": "^9.10.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1", "embla-carousel": "^8.5.1", @@ -2599,19 +2601,19 @@ } }, "node_modules/@fluentui/react-checkbox": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-checkbox/-/react-checkbox-9.6.1.tgz", - "integrity": "sha512-Rsf3TmcNrzLuHan9lyUFUmMZnNyvS7DV8C4Vc9lZnZTFRBo94GRMGzu0BcWKFbr3cCDT/r5RmIyQYz0kc7Jd2w==", + "version": "9.6.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-checkbox/-/react-checkbox-9.6.3.tgz", + "integrity": "sha512-VzePhN5Nz3D69Fu7SnPUCrMfkrbhfqGpNJDis85+W7dvOo9cyUou6yRreHsSzxVkRyE9swcA1LnsKJS6oVn9ew==", "license": "MIT", "dependencies": { - "@fluentui/react-field": "^9.5.1", + "@fluentui/react-field": "^9.5.3", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-label": "^9.4.1", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-label": "^9.4.3", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -2623,18 +2625,18 @@ } }, "node_modules/@fluentui/react-color-picker": { - "version": "9.2.16", - "resolved": "https://registry.npmjs.org/@fluentui/react-color-picker/-/react-color-picker-9.2.16.tgz", - "integrity": "sha512-+H8Ea8dwoSeUCTLRpUiGLrRsNvBnlHplnwJPU0isp8jdAfrIM/savZTLj6o4rqNFpNHQqAXxGwNuUV9YfHoJuQ==", + "version": "9.2.18", + "resolved": "https://registry.npmjs.org/@fluentui/react-color-picker/-/react-color-picker-9.2.18.tgz", + "integrity": "sha512-zbsQ+hVJeGwXVTjneA42i4UuHRACfcTnBs3BUcDT22lBDQjJxhYYZzmj5ksM92M2ZU0fMHV8K5OfSyCnZU5mqQ==", "license": "MIT", "dependencies": { "@ctrl/tinycolor": "^3.3.4", - "@fluentui/react-context-selector": "^9.2.16", - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-context-selector": "^9.2.18", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -2646,23 +2648,23 @@ } }, "node_modules/@fluentui/react-combobox": { - "version": "9.17.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-combobox/-/react-combobox-9.17.1.tgz", - "integrity": "sha512-ezgt6tfOKd3wlG6IHvWl0TPNPpfHRtnEwC2kuqHYH/r1nMNp9edFi8Ya3+1eM7oxai19XW0swt69GPwRu51FVQ==", + "version": "9.17.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-combobox/-/react-combobox-9.17.3.tgz", + "integrity": "sha512-QuWcM6fvqnfUzpkJApQbXfbIQ6iQkMGgrsdwmJHkB4Q/E6zT0aLZoEjEx2P5QkMz9m5D7LzeZWmFIOEFq8SzFQ==", "license": "MIT", "dependencies": { "@fluentui/keyboard-keys": "^9.0.8", - "@fluentui/react-aria": "^9.17.11", - "@fluentui/react-context-selector": "^9.2.16", - "@fluentui/react-field": "^9.5.1", + "@fluentui/react-aria": "^9.17.13", + "@fluentui/react-context-selector": "^9.2.18", + "@fluentui/react-field": "^9.5.3", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-portal": "^9.8.12", - "@fluentui/react-positioning": "^9.22.1", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-portal": "^9.8.14", + "@fluentui/react-positioning": "^9.22.3", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -2674,71 +2676,71 @@ } }, "node_modules/@fluentui/react-components": { - "version": "9.73.8", - "resolved": "https://registry.npmjs.org/@fluentui/react-components/-/react-components-9.73.8.tgz", - "integrity": "sha512-JG4KQjEvRRfPlh4yt6Rv1/k87ydM2y49r5XPNCnuYHahA7kEM+dY8JdOI7n7FW8bdcvZ7qt4smDrQ2XcPfmxlA==", - "license": "MIT", - "dependencies": { - "@fluentui/react-accordion": "^9.11.0", - "@fluentui/react-alert": "9.0.0-beta.139", - "@fluentui/react-aria": "^9.17.11", - "@fluentui/react-avatar": "^9.11.1", - "@fluentui/react-badge": "^9.5.2", - "@fluentui/react-breadcrumb": "^9.4.1", - "@fluentui/react-button": "^9.9.1", - "@fluentui/react-card": "^9.6.1", - "@fluentui/react-carousel": "^9.9.7", - "@fluentui/react-checkbox": "^9.6.1", - "@fluentui/react-color-picker": "^9.2.16", - "@fluentui/react-combobox": "^9.17.1", - "@fluentui/react-dialog": "^9.18.0", - "@fluentui/react-divider": "^9.7.1", - "@fluentui/react-drawer": "^9.12.0", - "@fluentui/react-field": "^9.5.1", - "@fluentui/react-image": "^9.4.1", - "@fluentui/react-infobutton": "9.0.0-beta.115", - "@fluentui/react-infolabel": "^9.4.20", - "@fluentui/react-input": "^9.8.2", - "@fluentui/react-label": "^9.4.1", - "@fluentui/react-link": "^9.8.1", - "@fluentui/react-list": "^9.6.14", - "@fluentui/react-menu": "^9.24.1", - "@fluentui/react-message-bar": "^9.7.0", - "@fluentui/react-motion": "^9.15.0", - "@fluentui/react-nav": "^9.3.24", - "@fluentui/react-overflow": "^9.7.2", - "@fluentui/react-persona": "^9.7.3", - "@fluentui/react-popover": "^9.14.2", - "@fluentui/react-portal": "^9.8.12", - "@fluentui/react-positioning": "^9.22.1", - "@fluentui/react-progress": "^9.5.1", - "@fluentui/react-provider": "^9.22.16", - "@fluentui/react-radio": "^9.6.2", - "@fluentui/react-rating": "^9.4.1", - "@fluentui/react-search": "^9.4.2", - "@fluentui/react-select": "^9.5.1", + "version": "9.74.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-components/-/react-components-9.74.4.tgz", + "integrity": "sha512-/8IxyJiQ7J0R3rF/T6InuVffT72dJjt506WaAFt1ndnpEAu2zpjYD6Vjhv4+Xvye20ix2j4dHDs6OUXS/vlrpg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-accordion": "^9.12.1", + "@fluentui/react-alert": "9.0.0-beta.142", + "@fluentui/react-aria": "^9.17.13", + "@fluentui/react-avatar": "^9.11.3", + "@fluentui/react-badge": "^9.5.4", + "@fluentui/react-breadcrumb": "^9.4.4", + "@fluentui/react-button": "^9.10.1", + "@fluentui/react-card": "^9.7.1", + "@fluentui/react-carousel": "^9.9.10", + "@fluentui/react-checkbox": "^9.6.3", + "@fluentui/react-color-picker": "^9.2.18", + "@fluentui/react-combobox": "^9.17.3", + "@fluentui/react-dialog": "^9.18.2", + "@fluentui/react-divider": "^9.7.3", + "@fluentui/react-drawer": "^9.13.1", + "@fluentui/react-field": "^9.5.3", + "@fluentui/react-image": "^9.4.3", + "@fluentui/react-infobutton": "9.0.0-beta.117", + "@fluentui/react-infolabel": "^9.4.22", + "@fluentui/react-input": "^9.8.4", + "@fluentui/react-label": "^9.4.3", + "@fluentui/react-link": "^9.8.3", + "@fluentui/react-list": "^9.6.16", + "@fluentui/react-menu": "^9.25.1", + "@fluentui/react-message-bar": "^9.7.3", + "@fluentui/react-motion": "^9.16.1", + "@fluentui/react-nav": "^9.4.2", + "@fluentui/react-overflow": "^9.9.1", + "@fluentui/react-persona": "^9.7.5", + "@fluentui/react-popover": "^9.14.4", + "@fluentui/react-portal": "^9.8.14", + "@fluentui/react-positioning": "^9.22.3", + "@fluentui/react-progress": "^9.5.3", + "@fluentui/react-provider": "^9.22.18", + "@fluentui/react-radio": "^9.6.4", + "@fluentui/react-rating": "^9.4.3", + "@fluentui/react-search": "^9.4.4", + "@fluentui/react-select": "^9.5.3", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-skeleton": "^9.7.2", - "@fluentui/react-slider": "^9.6.2", - "@fluentui/react-spinbutton": "^9.6.2", - "@fluentui/react-spinner": "^9.8.2", - "@fluentui/react-swatch-picker": "^9.5.2", - "@fluentui/react-switch": "^9.7.2", - "@fluentui/react-table": "^9.19.15", - "@fluentui/react-tabs": "^9.12.1", - "@fluentui/react-tabster": "^9.26.14", - "@fluentui/react-tag-picker": "^9.8.6", - "@fluentui/react-tags": "^9.8.1", - "@fluentui/react-teaching-popover": "^9.6.21", - "@fluentui/react-text": "^9.6.16", - "@fluentui/react-textarea": "^9.7.2", + "@fluentui/react-skeleton": "^9.7.4", + "@fluentui/react-slider": "^9.6.4", + "@fluentui/react-spinbutton": "^9.6.4", + "@fluentui/react-spinner": "^9.8.4", + "@fluentui/react-swatch-picker": "^9.5.4", + "@fluentui/react-switch": "^9.7.4", + "@fluentui/react-table": "^9.19.17", + "@fluentui/react-tabs": "^9.12.3", + "@fluentui/react-tabster": "^9.26.16", + "@fluentui/react-tag-picker": "^9.10.0", + "@fluentui/react-tags": "^9.9.2", + "@fluentui/react-teaching-popover": "^9.7.2", + "@fluentui/react-text": "^9.6.18", + "@fluentui/react-textarea": "^9.7.4", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-toast": "^9.7.17", - "@fluentui/react-toolbar": "^9.8.0", - "@fluentui/react-tooltip": "^9.10.1", - "@fluentui/react-tree": "^9.16.0", - "@fluentui/react-utilities": "^9.26.3", - "@fluentui/react-virtualizer": "9.0.0-alpha.112", + "@fluentui/react-toast": "^9.8.1", + "@fluentui/react-toolbar": "^9.8.3", + "@fluentui/react-tooltip": "^9.10.3", + "@fluentui/react-tree": "^9.16.3", + "@fluentui/react-utilities": "^9.26.5", + "@fluentui/react-virtualizer": "9.0.0-alpha.114", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -2750,12 +2752,12 @@ } }, "node_modules/@fluentui/react-context-selector": { - "version": "9.2.16", - "resolved": "https://registry.npmjs.org/@fluentui/react-context-selector/-/react-context-selector-9.2.16.tgz", - "integrity": "sha512-D+/X2liT+eZe0rzXbwddPH333ml2SXz71biR13aeyGJQr8+W+icMAIsYhpwk0CC3KtJ3f1/CLTm7vcIrvqsJ4g==", + "version": "9.2.18", + "resolved": "https://registry.npmjs.org/@fluentui/react-context-selector/-/react-context-selector-9.2.18.tgz", + "integrity": "sha512-A9YdkKonDlNSTnD8SCcSMhj0O6mAyMtXMERWH5mA1UqdtVxzJ4Y/muNd3Nk5rwAaLPUZ5HWMabtt2Ewa/BxARA==", "license": "MIT", "dependencies": { - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@swc/helpers": "^0.5.1" }, "peerDependencies": { @@ -2767,23 +2769,23 @@ } }, "node_modules/@fluentui/react-dialog": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/@fluentui/react-dialog/-/react-dialog-9.18.0.tgz", - "integrity": "sha512-i+V2o0NJ1itjVADJFov5AR/JetpD2hCMiLye0vfi3/XsFMgEPZnGzILVxPCO/ovULTiCyThcL1UvY0d/PYrZfA==", + "version": "9.18.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-dialog/-/react-dialog-9.18.2.tgz", + "integrity": "sha512-acW70/CxibJC19bQVbrJyxYiuIP9nX593O/Tya4x9wMEEcnTHZ6RW8liS6kbYYEL/AERYRuOYkLJ++TNniTxSA==", "license": "MIT", "dependencies": { "@fluentui/keyboard-keys": "^9.0.8", - "@fluentui/react-aria": "^9.17.11", - "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-aria": "^9.17.13", + "@fluentui/react-context-selector": "^9.2.18", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-motion": "^9.15.0", - "@fluentui/react-motion-components-preview": "^0.15.4", - "@fluentui/react-portal": "^9.8.12", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-motion": "^9.16.1", + "@fluentui/react-motion-components-preview": "^0.15.6", + "@fluentui/react-portal": "^9.8.14", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -2795,15 +2797,15 @@ } }, "node_modules/@fluentui/react-divider": { - "version": "9.7.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-divider/-/react-divider-9.7.1.tgz", - "integrity": "sha512-ptymE6iADb/ugezulaMeoAfGxKSwOjHEHBh8N1ydOR3AoOxsSUPkvoPC0mReO/yV5Nas7pz5s5VuJTspmFz0hA==", + "version": "9.7.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-divider/-/react-divider-9.7.3.tgz", + "integrity": "sha512-uhqpu+JfSaLEqFNtDQFYFo/gM1QoRV2I1iUYTMsO2iqEis4zZmMsQETti7lTv29SITWIky3e8pkRoC6xyQBLsg==", "license": "MIT", "dependencies": { - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -2815,20 +2817,20 @@ } }, "node_modules/@fluentui/react-drawer": { - "version": "9.12.0", - "resolved": "https://registry.npmjs.org/@fluentui/react-drawer/-/react-drawer-9.12.0.tgz", - "integrity": "sha512-PUXeXUH6JqwpjqYphHesHl75UAFSvxQJQqrevMFHE78ZF0Cqn59Xpa+8hGwRSuoRcYa90jjfHzJOOjN0iNM2iA==", + "version": "9.13.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-drawer/-/react-drawer-9.13.1.tgz", + "integrity": "sha512-nZBWG0290IcCshpt2wjORDD8fLOsccSPdp0EW45CxK09/JR+4hkGbbIj8x14GW7GYu4RsGzk18OYga0kY+4j2Q==", "license": "MIT", "dependencies": { - "@fluentui/react-dialog": "^9.18.0", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-motion": "^9.15.0", - "@fluentui/react-motion-components-preview": "^0.15.4", - "@fluentui/react-portal": "^9.8.12", + "@fluentui/react-dialog": "^9.18.2", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-motion": "^9.16.1", + "@fluentui/react-motion-components-preview": "^0.15.6", + "@fluentui/react-portal": "^9.8.14", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -2840,18 +2842,18 @@ } }, "node_modules/@fluentui/react-field": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-field/-/react-field-9.5.1.tgz", - "integrity": "sha512-u8J2d3AWb4yZXvy/mQd95y2lTon890RfybBTCbeBUzApGMI/77WqT5pRJ+zTM3lOMToPHVKylchNFusMpJaX9w==", + "version": "9.5.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-field/-/react-field-9.5.3.tgz", + "integrity": "sha512-5PJXFTGS9W4CBJW6Nh2Tqe5p8RxMMCPbsIyIcYUXIxCZTXDGrx1jZ+af8lWKJFlcfWOlFxyK+QE14UXl+lqzqw==", "license": "MIT", "dependencies": { - "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-context-selector": "^9.2.18", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-label": "^9.4.1", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-label": "^9.4.3", "@fluentui/react-shared-contexts": "^9.26.2", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -2876,15 +2878,15 @@ } }, "node_modules/@fluentui/react-image": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-image/-/react-image-9.4.1.tgz", - "integrity": "sha512-yNd2Wq2xq952UUEVBkWeEmM7bTKdWx6BnsHPYRf0kdTADox2PquApYXsI1xw2pnAh3GSjARrGi9Eto0qxouLqA==", + "version": "9.4.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-image/-/react-image-9.4.3.tgz", + "integrity": "sha512-BQSsT3kVdpR3s02Zq9zpqj0NjaijWOVKPLBchp9XqWlygO15dkykNt2LRoHAkZRgpnlh2D8zBCw9qQ4ubzYNaA==", "license": "MIT", "dependencies": { - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -2896,18 +2898,18 @@ } }, "node_modules/@fluentui/react-infobutton": { - "version": "9.0.0-beta.115", - "resolved": "https://registry.npmjs.org/@fluentui/react-infobutton/-/react-infobutton-9.0.0-beta.115.tgz", - "integrity": "sha512-b+4B0ODzPEb4jNaW9HdT6VVt3CL5FgPL2yuKzALBsYVl3udJdFpyxHsZEPf3JrVTBL/rgF2fRI1iAioX6Fl7DA==", + "version": "9.0.0-beta.117", + "resolved": "https://registry.npmjs.org/@fluentui/react-infobutton/-/react-infobutton-9.0.0-beta.117.tgz", + "integrity": "sha512-h01PQzH736I/7mhjNcYi8cFjspCqgSmQukXbzCsESzB1VnAkx8djqy5A8f/mV1HmHw7vBAIX8VdH+ddtx8WyXQ==", "license": "MIT", "dependencies": { "@fluentui/react-icons": "^2.0.237", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-label": "^9.4.1", - "@fluentui/react-popover": "^9.14.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-label": "^9.4.3", + "@fluentui/react-popover": "^9.14.4", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -2919,19 +2921,19 @@ } }, "node_modules/@fluentui/react-infolabel": { - "version": "9.4.20", - "resolved": "https://registry.npmjs.org/@fluentui/react-infolabel/-/react-infolabel-9.4.20.tgz", - "integrity": "sha512-w4FOnNP+CtbVdKBEO6wXAcmOuPZWvmB/BJj+7J/8cLAQm7+4kQgitFHncU6rtFhPdGbikVoBf707/0R1mA4aIg==", + "version": "9.4.22", + "resolved": "https://registry.npmjs.org/@fluentui/react-infolabel/-/react-infolabel-9.4.22.tgz", + "integrity": "sha512-K5W+g+HfGu5ltl5BGekZJB2z0ACLidIW7KFQ5Qj+UG1kpoB3G4q10HTm5gY74VjXP/GrM5RVx8IcnHRqyAfR1Q==", "license": "MIT", "dependencies": { "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-label": "^9.4.1", - "@fluentui/react-popover": "^9.14.2", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-label": "^9.4.3", + "@fluentui/react-popover": "^9.14.4", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -2943,16 +2945,16 @@ } }, "node_modules/@fluentui/react-input": { - "version": "9.8.2", - "resolved": "https://registry.npmjs.org/@fluentui/react-input/-/react-input-9.8.2.tgz", - "integrity": "sha512-t9zmqZR4bqeRjpWuCGfI4yrtPoCXFiK2XO4BoV5nNwAesglgz4+Vtso4YXst9QYEAazHtKI73YFJf1mn55hCuA==", + "version": "9.8.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-input/-/react-input-9.8.4.tgz", + "integrity": "sha512-Lpdu0TBBSbv3VasaCz3blNeEgQS7XhtLTmiYXZj5g8Hrk7gNDJORDPYRrXcRjOUPGkV/InB4JY+GeXy2cYfOaA==", "license": "MIT", "dependencies": { - "@fluentui/react-field": "^9.5.1", - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-field": "^9.5.3", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -2964,12 +2966,12 @@ } }, "node_modules/@fluentui/react-jsx-runtime": { - "version": "9.4.2", - "resolved": "https://registry.npmjs.org/@fluentui/react-jsx-runtime/-/react-jsx-runtime-9.4.2.tgz", - "integrity": "sha512-y3o0PBg2qzSdvgxDm7rH9BWq7E1h/eUWS+IhjQhd9dRpme6Py01+OLOglHojM5Tc9QjIp2Rjy2mFWBHXOR+8mw==", + "version": "9.4.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-jsx-runtime/-/react-jsx-runtime-9.4.4.tgz", + "integrity": "sha512-npqPWSJ2qciCRB4B/cyWyrTbf8V8Z2Kfr9HnZqrUBDgEvq72rRGb+gml6naxGNzhaT4NBLQLZmdPZqt+1wZ4ig==", "license": "MIT", "dependencies": { - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@swc/helpers": "^0.5.1" }, "peerDependencies": { @@ -2978,15 +2980,15 @@ } }, "node_modules/@fluentui/react-label": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-label/-/react-label-9.4.1.tgz", - "integrity": "sha512-4O3cPX6dSJVBKlIEbznjJ08utEc98lKbZz/6MZTTQfFgYl0TxAhxEDsIIIyNjj0Xy9eJpqubJsaswucWXTG/qg==", + "version": "9.4.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-label/-/react-label-9.4.3.tgz", + "integrity": "sha512-/tYFciaorFym7Q2yDCdRYeP3JzLtw5eYt2yCvRlmBsugtKmn2f/kOu+b2cB37kWizbXjSdOGhCrTL8J1EUlIZg==", "license": "MIT", "dependencies": { - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -2998,17 +3000,17 @@ } }, "node_modules/@fluentui/react-link": { - "version": "9.8.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-link/-/react-link-9.8.1.tgz", - "integrity": "sha512-ZxrCeX4pMWHujdmYV8b0QW0ztLtu0rHHvRNx67Y3WqSijVyij8QtNNiZ/nab+UDNlz9t8QIXKdWQgYj1uKDpMg==", + "version": "9.8.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-link/-/react-link-9.8.3.tgz", + "integrity": "sha512-3Cd+UWgLpP6E6/NZaomCqZd965gruWXz2+gqUDfP7nBTLaCgOIuFQe0HAgSYi0ys4xli3cDtKeytqZJ7ReA6gg==", "license": "MIT", "dependencies": { "@fluentui/keyboard-keys": "^9.0.8", - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3020,19 +3022,19 @@ } }, "node_modules/@fluentui/react-list": { - "version": "9.6.14", - "resolved": "https://registry.npmjs.org/@fluentui/react-list/-/react-list-9.6.14.tgz", - "integrity": "sha512-B1mUQFvJOUlZysSduVnATNZggrGpgEWnW9ZSJAZ17LM0+9nWEQRi40jpUGI/d3PGKHt5O2df78s+1nEPAk0L6A==", + "version": "9.6.16", + "resolved": "https://registry.npmjs.org/@fluentui/react-list/-/react-list-9.6.16.tgz", + "integrity": "sha512-ZWsLxr1ZDe6hmvGLGlHQIPt1E70xsWHaHn+QGxB0XUxjq64SnxCDm4HCSPZ40MS3Hqgd4eQdnmPUS9d6odcYUA==", "license": "MIT", "dependencies": { "@fluentui/keyboard-keys": "^9.0.8", - "@fluentui/react-checkbox": "^9.6.1", - "@fluentui/react-context-selector": "^9.2.16", - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-checkbox": "^9.6.3", + "@fluentui/react-context-selector": "^9.2.18", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3044,24 +3046,24 @@ } }, "node_modules/@fluentui/react-menu": { - "version": "9.24.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-menu/-/react-menu-9.24.1.tgz", - "integrity": "sha512-NLB5EhzKFiwax3O5JTRTtsqdEFDGEXzEuP/suyxNAaaQsIuXygo//Rmdq6dSn7GybTpEOZHKxYDyyG7dj+a4YA==", + "version": "9.25.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-menu/-/react-menu-9.25.1.tgz", + "integrity": "sha512-nP2bUB0Blrz5cqG6JnaHKznD2zGv134vuiCStk0op1/IErIPoU6wJj6H+unYVhFwyHCReyPZcp/pQZWkSWErJQ==", "license": "MIT", "dependencies": { "@fluentui/keyboard-keys": "^9.0.8", - "@fluentui/react-aria": "^9.17.11", - "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-aria": "^9.17.13", + "@fluentui/react-context-selector": "^9.2.18", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-motion": "^9.15.0", - "@fluentui/react-motion-components-preview": "^0.15.4", - "@fluentui/react-portal": "^9.8.12", - "@fluentui/react-positioning": "^9.22.1", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-motion": "^9.16.1", + "@fluentui/react-motion-components-preview": "^0.15.6", + "@fluentui/react-portal": "^9.8.14", + "@fluentui/react-positioning": "^9.22.3", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3073,20 +3075,20 @@ } }, "node_modules/@fluentui/react-message-bar": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/@fluentui/react-message-bar/-/react-message-bar-9.7.0.tgz", - "integrity": "sha512-ICFDxZ62r5OG97/FcfK1EfJPxGlyDNyFixLD/a3gOREvEcT/hyZgnlUM9Y30u92HjxChx2SwGWnv3iaQPsvToQ==", + "version": "9.7.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-message-bar/-/react-message-bar-9.7.3.tgz", + "integrity": "sha512-LxcoTatsPPYj8Y5fx9QeJYOlXs6C4HIgmXTUaqNTCnFquNrcBHU4RtzUEq/6ssJ8jP/dy8Z03Bk4dE31RCV5MA==", "license": "MIT", "dependencies": { - "@fluentui/react-button": "^9.9.1", + "@fluentui/react-button": "^9.10.1", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-link": "^9.8.1", - "@fluentui/react-motion": "^9.15.0", - "@fluentui/react-motion-components-preview": "^0.15.4", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-link": "^9.8.3", + "@fluentui/react-motion": "^9.16.1", + "@fluentui/react-motion-components-preview": "^0.15.6", "@fluentui/react-shared-contexts": "^9.26.2", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3098,13 +3100,13 @@ } }, "node_modules/@fluentui/react-motion": { - "version": "9.15.0", - "resolved": "https://registry.npmjs.org/@fluentui/react-motion/-/react-motion-9.15.0.tgz", - "integrity": "sha512-ZNQHYzE6MRbLQFT08/mrcqQ9k7F5niktRP93X1v/kmwKfPjvdDofySfbhQXQs3zQw600690C9rfJTKUd3h+zlg==", + "version": "9.16.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-motion/-/react-motion-9.16.1.tgz", + "integrity": "sha512-sbrNuauwI5uw20XOAqPjXBfgBqPreHc5AxU7bJ6yoLUHL2gDKo7KGAIvLEd216GX37mgPkb3dx9rarIVGx3uvA==", "license": "MIT", "dependencies": { "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@swc/helpers": "^0.5.1" }, "peerDependencies": { @@ -3115,9 +3117,9 @@ } }, "node_modules/@fluentui/react-motion-components-preview": { - "version": "0.15.4", - "resolved": "https://registry.npmjs.org/@fluentui/react-motion-components-preview/-/react-motion-components-preview-0.15.4.tgz", - "integrity": "sha512-gAHPlyEYylZzUSGwc68VaB+vO8CTX6tgIA3d2+jFrpcwvXZjsdCpF1w1zK1+hTuiipmEaZLZyBz0e0CKH2+3XQ==", + "version": "0.15.6", + "resolved": "https://registry.npmjs.org/@fluentui/react-motion-components-preview/-/react-motion-components-preview-0.15.6.tgz", + "integrity": "sha512-9aNzHAHNdfbH/8/mYGy5YVrUOGnpiru3YrqQ7KhCRWXvlce7yV3WF5CzN1g/uNh3MFmKGwQeW4ui6xJOfEaI4Q==", "license": "MIT", "dependencies": { "@fluentui/react-motion": "*", @@ -3132,25 +3134,25 @@ } }, "node_modules/@fluentui/react-nav": { - "version": "9.3.24", - "resolved": "https://registry.npmjs.org/@fluentui/react-nav/-/react-nav-9.3.24.tgz", - "integrity": "sha512-OlB5k5Zev5VNjSRfJvJLO09Hjcv2UHAjLpSVa6gKHx+1NqqSJWZeDLSF7r+/nyZ4CWP5jWZYq7whEu3WvzdVZw==", + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-nav/-/react-nav-9.4.2.tgz", + "integrity": "sha512-1nZgZwZHgJ1P73E0Aw4UrTqri9BMSShI7otuktdATB5iHolDHE+9JtQjr3OQJ0QR+YyXgD8bMWiKvSa6p/Pdlw==", "license": "MIT", "dependencies": { - "@fluentui/react-aria": "^9.17.11", - "@fluentui/react-button": "^9.9.1", - "@fluentui/react-context-selector": "^9.2.16", - "@fluentui/react-divider": "^9.7.1", - "@fluentui/react-drawer": "^9.12.0", + "@fluentui/react-aria": "^9.17.13", + "@fluentui/react-button": "^9.10.1", + "@fluentui/react-context-selector": "^9.2.18", + "@fluentui/react-divider": "^9.7.3", + "@fluentui/react-drawer": "^9.13.1", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-motion": "^9.15.0", - "@fluentui/react-motion-components-preview": "^0.15.4", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-motion": "^9.16.1", + "@fluentui/react-motion-components-preview": "^0.15.6", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-tooltip": "^9.10.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-tooltip": "^9.10.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3162,15 +3164,15 @@ } }, "node_modules/@fluentui/react-overflow": { - "version": "9.7.2", - "resolved": "https://registry.npmjs.org/@fluentui/react-overflow/-/react-overflow-9.7.2.tgz", - "integrity": "sha512-5PA67LgnVmbbOzBN2H5gH3OvSVy1373VJfsHq2+6TLCfm+LXAkWBoFwvBuFI7HsMYae9A0FVlgX7gTsKVfMddw==", + "version": "9.9.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-overflow/-/react-overflow-9.9.1.tgz", + "integrity": "sha512-C5JP1zZ71Z1qtDAZSQ+/dMYiEqhQOrNSz3qNloRLYb7G5ywaBUp+vOLbETEe7QUiaDNtViYtxR1TI9nX2pPOUA==", "license": "MIT", "dependencies": { - "@fluentui/priority-overflow": "^9.3.0", - "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/priority-overflow": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3182,17 +3184,17 @@ } }, "node_modules/@fluentui/react-persona": { - "version": "9.7.3", - "resolved": "https://registry.npmjs.org/@fluentui/react-persona/-/react-persona-9.7.3.tgz", - "integrity": "sha512-OY3xpSD6l4NDdeKihriC+H0q6P1CA2xyZ+pe/WwfKPnatxs2BALoRFtDQduMO7AK/j0w7UAxnaZrvEeftLen2g==", + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@fluentui/react-persona/-/react-persona-9.7.5.tgz", + "integrity": "sha512-5MlVpl3+l+UW7vTf/d1qKP6EANBkxoXjmxmbNZpiuXjIE2QJFcVRBplWXwEPgt3GAOGnix1wPVkUgHElUJOCEw==", "license": "MIT", "dependencies": { - "@fluentui/react-avatar": "^9.11.1", - "@fluentui/react-badge": "^9.5.2", - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-avatar": "^9.11.3", + "@fluentui/react-badge": "^9.5.4", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3204,23 +3206,23 @@ } }, "node_modules/@fluentui/react-popover": { - "version": "9.14.2", - "resolved": "https://registry.npmjs.org/@fluentui/react-popover/-/react-popover-9.14.2.tgz", - "integrity": "sha512-EDvzLkT98/vcCSGrcZWUACGsvLjrHin0Xf9eowMQKiiHFWbu8HNRmr7W2XB9Eja1W5HSIK6+mV8ro9zrLibG4w==", + "version": "9.14.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-popover/-/react-popover-9.14.4.tgz", + "integrity": "sha512-Ofn4kh+WfC647n9ap0mtoN47eP0FsP/ZIjoZf1GfW6Co+A3zAZN+V7z6AayCPvbvdv8vKFQ0diHeUBrIu9Pz3Q==", "license": "MIT", "dependencies": { "@fluentui/keyboard-keys": "^9.0.8", - "@fluentui/react-aria": "^9.17.11", - "@fluentui/react-context-selector": "^9.2.16", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-motion": "^9.15.0", - "@fluentui/react-motion-components-preview": "^0.15.4", - "@fluentui/react-portal": "^9.8.12", - "@fluentui/react-positioning": "^9.22.1", + "@fluentui/react-aria": "^9.17.13", + "@fluentui/react-context-selector": "^9.2.18", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-motion": "^9.16.1", + "@fluentui/react-motion-components-preview": "^0.15.6", + "@fluentui/react-portal": "^9.8.14", + "@fluentui/react-positioning": "^9.22.3", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3232,14 +3234,14 @@ } }, "node_modules/@fluentui/react-portal": { - "version": "9.8.12", - "resolved": "https://registry.npmjs.org/@fluentui/react-portal/-/react-portal-9.8.12.tgz", - "integrity": "sha512-+WH0wH/5lsodGP6Mam1alHXpkMCYA5uMcnF98RVOs7/GR69KiFcza1mCnvPJUaJ55AfwLuz/xLxuWdWgQnUdMQ==", + "version": "9.8.14", + "resolved": "https://registry.npmjs.org/@fluentui/react-portal/-/react-portal-9.8.14.tgz", + "integrity": "sha512-od8RN6dny6N/qGFm2uv5UV+ugGOKupFeCIF+R0rU5SimSvix6o+wv5rPrH9JHRHFqjDIi5kRh9hk/rZfgsnuaA==", "license": "MIT", "dependencies": { "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-tabster": "^9.26.16", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3251,16 +3253,16 @@ } }, "node_modules/@fluentui/react-positioning": { - "version": "9.22.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-positioning/-/react-positioning-9.22.1.tgz", - "integrity": "sha512-/r1BHQKr/WCjEM8UGloiq7bWWBSYB/Uqt7D1sAF9EHd968VH07cAN3RMVKmWWjeJO31rstOZHdgcz0WHhFF+2Q==", + "version": "9.22.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-positioning/-/react-positioning-9.22.3.tgz", + "integrity": "sha512-2j2k87k7yVX8LYd9q3SniXYVGqED6JFRdTNeyamDf4DTk9/ECxTl2nKTmKedkDodddR5RRXpAtJXv874Mi9eyw==", "license": "MIT", "dependencies": { "@floating-ui/devtools": "^0.2.3", "@floating-ui/dom": "^1.6.12", "@fluentui/react-shared-contexts": "^9.26.2", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1", "use-sync-external-store": "^1.2.0" @@ -3273,17 +3275,17 @@ } }, "node_modules/@fluentui/react-progress": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-progress/-/react-progress-9.5.1.tgz", - "integrity": "sha512-EXJ/Bp67d5+bXPNpPabxdtXUgCMTtvYrBoKtIS6wE5KeUzaek/rgQ3v5wnGfbuLnJ4J/kj+n7XQEc9fhoFPy9w==", + "version": "9.5.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-progress/-/react-progress-9.5.3.tgz", + "integrity": "sha512-GVrZzo9QCBOyMZC/K8Q4VTbD76hgG/Xuvhr8gyqOxnuHS9lTfnPJyEZ+T+F36LmpDb6d/XmDcwKjIcyg359ieg==", "license": "MIT", "dependencies": { - "@fluentui/react-field": "^9.5.1", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-motion": "^9.15.0", + "@fluentui/react-field": "^9.5.3", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-motion": "^9.16.1", "@fluentui/react-shared-contexts": "^9.26.2", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3295,17 +3297,17 @@ } }, "node_modules/@fluentui/react-provider": { - "version": "9.22.16", - "resolved": "https://registry.npmjs.org/@fluentui/react-provider/-/react-provider-9.22.16.tgz", - "integrity": "sha512-S77n5ASUWE/V1I6lX09CrHm4TAKSGENhIrKz9qMKDv2Vrq44/j3eGBLz12k8IW4TJVu9nwGwst9kBpCT+3WHpA==", + "version": "9.22.18", + "resolved": "https://registry.npmjs.org/@fluentui/react-provider/-/react-provider-9.22.18.tgz", + "integrity": "sha512-kLtBaw6WIzyJJCmzeublw1ifidVPnpzGS39lYjzWdO6vHwuizs5ARCgwlGXxkB+kAlG2Mma/cPFUdYOa0J2f2w==", "license": "MIT", "dependencies": { "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/core": "^1.16.0", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" @@ -3318,18 +3320,18 @@ } }, "node_modules/@fluentui/react-radio": { - "version": "9.6.2", - "resolved": "https://registry.npmjs.org/@fluentui/react-radio/-/react-radio-9.6.2.tgz", - "integrity": "sha512-Sp2us4eWRopUKOMCQw5/iks7euPKY6FeesBCCUIVGBg5VKZf2/CfEtbCa9hMjn4D4PCHGivnUTf23t238mvvnw==", + "version": "9.6.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-radio/-/react-radio-9.6.4.tgz", + "integrity": "sha512-punC09igeQT+3Fc67lteh4ibzi8kIAQyKJSh8gdYj9mA4WlAIAcdUIQ4cnqT57hRoz0zuUtZGXpP1y2Kbr8M+A==", "license": "MIT", "dependencies": { - "@fluentui/react-field": "^9.5.1", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-label": "^9.4.1", + "@fluentui/react-field": "^9.5.3", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-label": "^9.4.3", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3341,17 +3343,17 @@ } }, "node_modules/@fluentui/react-rating": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-rating/-/react-rating-9.4.1.tgz", - "integrity": "sha512-DfWipzrT44j+yaShtfHz96/vHEa5ut5IR1kobrO0bSqAcpetOn327gFeY+sG/W6xzork/STcy/T836yK8A2+DQ==", + "version": "9.4.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-rating/-/react-rating-9.4.3.tgz", + "integrity": "sha512-kolMzzTl9/fg54jY1iy8w54BktkKFKGLaEeiESXAbtSZiUyNIj1BADMbIG3kysbVnhkYK8Q5h0kxZPsblrL/ow==", "license": "MIT", "dependencies": { "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3363,17 +3365,17 @@ } }, "node_modules/@fluentui/react-search": { - "version": "9.4.2", - "resolved": "https://registry.npmjs.org/@fluentui/react-search/-/react-search-9.4.2.tgz", - "integrity": "sha512-PIb50euHoMsKWLqFymf8wo/+z1jrx1MB7uNuhjNT5DvwTP4VYAy5EtRCSwVRyxWNSaWSL6iy6dDy517EQE96mA==", + "version": "9.4.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-search/-/react-search-9.4.4.tgz", + "integrity": "sha512-mLDqzL00XkSfJrtIZN34tQO2eBrjlPr33HuD9m3zUUQTq5g7wvA0LomT7m3RQPCJfV6FcOcMDmVfotqDUmttzA==", "license": "MIT", "dependencies": { "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-input": "^9.8.2", - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-input": "^9.8.4", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3385,17 +3387,17 @@ } }, "node_modules/@fluentui/react-select": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-select/-/react-select-9.5.1.tgz", - "integrity": "sha512-8GocQKiUHEUlAks6zA0HbGGSF2lpjuSZuxPzIBqTyuWof8vFiK6eFAcSXb0hTYIVH3RsTihhfc6G3NRnHoBrzg==", + "version": "9.5.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-select/-/react-select-9.5.3.tgz", + "integrity": "sha512-Qt4ovfXQRx11ou7OaoD0O1CNawfIzTS+Q39fX+wwLwL2yfn15oWnQGGVuQD3hWh0dh7k9cmOErRIrOKeI2wmXg==", "license": "MIT", "dependencies": { - "@fluentui/react-field": "^9.5.1", + "@fluentui/react-field": "^9.5.3", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3421,16 +3423,16 @@ } }, "node_modules/@fluentui/react-skeleton": { - "version": "9.7.2", - "resolved": "https://registry.npmjs.org/@fluentui/react-skeleton/-/react-skeleton-9.7.2.tgz", - "integrity": "sha512-PrUgdSGDAZw9FIP5NyvPoPfHDe2N9VxMyBfyTwWfZVg03dzRfnE3vEqr7N5xyfv4JsRs6u1xSqVn/0jdS0IEMQ==", + "version": "9.7.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-skeleton/-/react-skeleton-9.7.4.tgz", + "integrity": "sha512-lIDvfFDldqOkmiwQU0ZEdnKnj/xxnNOGeuciuPz7ao+RyvDYf87Uo1RvTpMTveRCmpPC9z5rOX0EZZK+PhX7Dg==", "license": "MIT", "dependencies": { - "@fluentui/react-field": "^9.5.1", - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-field": "^9.5.3", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3442,17 +3444,17 @@ } }, "node_modules/@fluentui/react-slider": { - "version": "9.6.2", - "resolved": "https://registry.npmjs.org/@fluentui/react-slider/-/react-slider-9.6.2.tgz", - "integrity": "sha512-lVavtTg8eqovfRokeYDk4popwCi8nuicacJ/HZdF3ni5e3y/2WT/bVP0eErS/GvC7+90ACQQ8uxdr4sjjY/HWA==", + "version": "9.6.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-slider/-/react-slider-9.6.4.tgz", + "integrity": "sha512-hPpbAe6pT00FG717A7wV6QCx4+WizhvT9AI/IbEifjKTQ9uxKhodDQNk8WqEXA1ziFoSifDHGAKbcOn/kdr4Ww==", "license": "MIT", "dependencies": { - "@fluentui/react-field": "^9.5.1", - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-field": "^9.5.3", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3464,18 +3466,18 @@ } }, "node_modules/@fluentui/react-spinbutton": { - "version": "9.6.2", - "resolved": "https://registry.npmjs.org/@fluentui/react-spinbutton/-/react-spinbutton-9.6.2.tgz", - "integrity": "sha512-P4vvJH7P5yHPFAv6aSo3dZxtErN62DiRJN+nEKS+/XBoRGsOGQdqyyx5Q/PQKOmyQrtwuZdXNHUjcyv8b50T2w==", + "version": "9.6.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-spinbutton/-/react-spinbutton-9.6.4.tgz", + "integrity": "sha512-+t4B6anAWSXFJgmnNXOvC7S/ZWU23MOCDWrvRXKz3fki9fENN85HiRmlnx4fR8akYItVDscqQacRQRxAL8F0oA==", "license": "MIT", "dependencies": { "@fluentui/keyboard-keys": "^9.0.8", - "@fluentui/react-field": "^9.5.1", + "@fluentui/react-field": "^9.5.3", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3487,16 +3489,16 @@ } }, "node_modules/@fluentui/react-spinner": { - "version": "9.8.2", - "resolved": "https://registry.npmjs.org/@fluentui/react-spinner/-/react-spinner-9.8.2.tgz", - "integrity": "sha512-0LxykLJGUD/I3XEeIXAWznwdg9XRe0piaByR0nLFOOV3UPwkVc2w5UdPhy2Y0NZDvtPHbNaMCuQAq82+bxg/0w==", + "version": "9.8.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-spinner/-/react-spinner-9.8.4.tgz", + "integrity": "sha512-vgfsJosM6hMixFCR7mP856xKx0xXa5Vz4Y95t37Xne2yzJ47bTfqQ62kof7U1AyvaSEeDIp76cwKMyv3lQUD3A==", "license": "MIT", "dependencies": { - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-label": "^9.4.1", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-label": "^9.4.3", "@fluentui/react-shared-contexts": "^9.26.2", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3508,19 +3510,19 @@ } }, "node_modules/@fluentui/react-swatch-picker": { - "version": "9.5.2", - "resolved": "https://registry.npmjs.org/@fluentui/react-swatch-picker/-/react-swatch-picker-9.5.2.tgz", - "integrity": "sha512-DK6UU9OJY9XaGBPU2ROx+B5/7XdwVtHBdVthOAptyKSsYGOdQt5AQqg3ZOXH6r5WYbMRQDuP2OZ2iKtwidFCVQ==", + "version": "9.5.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-swatch-picker/-/react-swatch-picker-9.5.4.tgz", + "integrity": "sha512-UwrtK3vP2Ruo2nAI+bbu56XhtpEIwi1XvHFXpVyRWGNQI9362lMS3F4CjwDlyPR24GeF+kEgZeF7QaruTVmagQ==", "license": "MIT", "dependencies": { - "@fluentui/react-context-selector": "^9.2.16", - "@fluentui/react-field": "^9.5.1", + "@fluentui/react-context-selector": "^9.2.18", + "@fluentui/react-field": "^9.5.3", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3532,19 +3534,19 @@ } }, "node_modules/@fluentui/react-switch": { - "version": "9.7.2", - "resolved": "https://registry.npmjs.org/@fluentui/react-switch/-/react-switch-9.7.2.tgz", - "integrity": "sha512-j3e5se+3d+befV9MytkxxvJ9nHZOeZ7thKDTF4YVSYf6kcNx9eOlLvPgDjhGO08gzngO4B7aaprhDN7DJc3W1g==", + "version": "9.7.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-switch/-/react-switch-9.7.4.tgz", + "integrity": "sha512-P4Xh+zrEOXO7mUmHDPo2G/yfQYwXWArrBOBKCLKOw6qI7KjuzBJxmy1Zqm94/drRr1EKVpBaZckSiT8X8N0TNQ==", "license": "MIT", "dependencies": { - "@fluentui/react-field": "^9.5.1", + "@fluentui/react-field": "^9.5.3", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-label": "^9.4.1", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-label": "^9.4.3", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3556,23 +3558,23 @@ } }, "node_modules/@fluentui/react-table": { - "version": "9.19.15", - "resolved": "https://registry.npmjs.org/@fluentui/react-table/-/react-table-9.19.15.tgz", - "integrity": "sha512-OdQ2Nwx2nAlPMlJeyAFrKa3Zy5Ya/H87OU8MvtFJhabM/FkHiZoli/DO1mavVI+jqavOlJuQWmJ55D6jjuGa7g==", + "version": "9.19.17", + "resolved": "https://registry.npmjs.org/@fluentui/react-table/-/react-table-9.19.17.tgz", + "integrity": "sha512-SPhAlS6yQ/53GB/1XUzCum63ktzmNaZVWswshr6SCo7oUERxdszr6xHJ5bSvgNczlApuLzZVz6Vnxe2s7+bsCw==", "license": "MIT", "dependencies": { "@fluentui/keyboard-keys": "^9.0.8", - "@fluentui/react-aria": "^9.17.11", - "@fluentui/react-avatar": "^9.11.1", - "@fluentui/react-checkbox": "^9.6.1", - "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-aria": "^9.17.13", + "@fluentui/react-avatar": "^9.11.3", + "@fluentui/react-checkbox": "^9.6.3", + "@fluentui/react-context-selector": "^9.2.18", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-radio": "^9.6.2", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-radio": "^9.6.4", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3584,17 +3586,17 @@ } }, "node_modules/@fluentui/react-tabs": { - "version": "9.12.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-tabs/-/react-tabs-9.12.1.tgz", - "integrity": "sha512-WvzOtpC6C/7Mo5X+xmE+3stpCbx2iH9BqrEN5KuGrsHJ78DjMDeabYeL90vlrHBdP4VlTpwdORBui/jtWkxnmQ==", + "version": "9.12.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-tabs/-/react-tabs-9.12.3.tgz", + "integrity": "sha512-CkQmrErImxvGDgrNIh+v0GbXzTKx1YSiBXYuFIhjdFaTnTk5CE79xwZp3mIkZK4SUns0HcH5wchjuO1L5LfcRg==", "license": "MIT", "dependencies": { - "@fluentui/react-context-selector": "^9.2.16", - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-context-selector": "^9.2.18", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3606,18 +3608,18 @@ } }, "node_modules/@fluentui/react-tabster": { - "version": "9.26.14", - "resolved": "https://registry.npmjs.org/@fluentui/react-tabster/-/react-tabster-9.26.14.tgz", - "integrity": "sha512-WibgoF67hl6BXfmsY6RSIWSHadeMP/6EDG9gAacfHlwKvK0+FiHp5ernwuXTPAmu2kiHicn2qUZ8EteCFiFryg==", + "version": "9.26.16", + "resolved": "https://registry.npmjs.org/@fluentui/react-tabster/-/react-tabster-9.26.16.tgz", + "integrity": "sha512-napGx7dGdLoKoUpKlzc2Til43UMUTtr9J1GWrOFvCT6aZLTox5GjtoxQM/IPhcZ09jJOOUMbVF8ScA5u3/uyTA==", "license": "MIT", "dependencies": { "@fluentui/react-shared-contexts": "^9.26.2", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1", - "keyborg": "^2.6.0", - "tabster": "^8.5.5" + "keyborg": "^2.14.1", + "tabster": "^8.8.0" }, "peerDependencies": { "@types/react": ">=16.14.0 <20.0.0", @@ -3627,25 +3629,25 @@ } }, "node_modules/@fluentui/react-tag-picker": { - "version": "9.8.6", - "resolved": "https://registry.npmjs.org/@fluentui/react-tag-picker/-/react-tag-picker-9.8.6.tgz", - "integrity": "sha512-sOZ+wBA3hgGhKrOP7wbjB2yRvAxjcRXtcj1jDTrtSkaDPXb3K0nGmjiqp2mve995ps3wvCGnNKK4EurX842ZbA==", + "version": "9.10.0", + "resolved": "https://registry.npmjs.org/@fluentui/react-tag-picker/-/react-tag-picker-9.10.0.tgz", + "integrity": "sha512-GV358puRbq4W/2nR3g0AQ43VxGHSXrocQY5szJwrneO7JJqhqp9qcMrK4b3eV5Fwo9X1V0e4grWDKbBPwlUGVw==", "license": "MIT", "dependencies": { "@fluentui/keyboard-keys": "^9.0.8", - "@fluentui/react-aria": "^9.17.11", - "@fluentui/react-combobox": "^9.17.1", - "@fluentui/react-context-selector": "^9.2.16", - "@fluentui/react-field": "^9.5.1", + "@fluentui/react-aria": "^9.17.13", + "@fluentui/react-combobox": "^9.17.3", + "@fluentui/react-context-selector": "^9.2.18", + "@fluentui/react-field": "^9.5.3", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-portal": "^9.8.12", - "@fluentui/react-positioning": "^9.22.1", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-portal": "^9.8.14", + "@fluentui/react-positioning": "^9.22.3", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", - "@fluentui/react-tags": "^9.8.1", + "@fluentui/react-tabster": "^9.26.16", + "@fluentui/react-tags": "^9.9.2", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3657,20 +3659,20 @@ } }, "node_modules/@fluentui/react-tags": { - "version": "9.8.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-tags/-/react-tags-9.8.1.tgz", - "integrity": "sha512-6ZTW78fu5eWByKHIM3i+raDrX3hwfZ67ONfZ8wEUXfZHowskxqpMHI8Gw7IAMWkC1scgLqEnht8TnHvZgjo7Ug==", + "version": "9.9.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-tags/-/react-tags-9.9.2.tgz", + "integrity": "sha512-yJmUrx3b1m4OYoXGt1+hUgZzghoTuHGGHkXyTwmCUCKX7BKAXQBbOu0VHyxmG+VDkPhpO3773ObQCFXCdTavrw==", "license": "MIT", "dependencies": { "@fluentui/keyboard-keys": "^9.0.8", - "@fluentui/react-aria": "^9.17.11", - "@fluentui/react-avatar": "^9.11.1", + "@fluentui/react-aria": "^9.17.13", + "@fluentui/react-avatar": "^9.11.3", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3682,21 +3684,21 @@ } }, "node_modules/@fluentui/react-teaching-popover": { - "version": "9.6.21", - "resolved": "https://registry.npmjs.org/@fluentui/react-teaching-popover/-/react-teaching-popover-9.6.21.tgz", - "integrity": "sha512-V86zLB1B8xu3U/02FvvMdsJP+ZC9l3vT9bQ2Gr7hZHxJ4/0NLpVcrYSBFHpON9e/WK3p+A5b5V96p86b5Pavlg==", + "version": "9.7.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-teaching-popover/-/react-teaching-popover-9.7.2.tgz", + "integrity": "sha512-984lSUplfBiLTKpNSGvzPaBMmQ8pSM0u/Ucv4QoB2QB9U771pu8NtuNvtiuhQe5f6yC3wblIFHnTVrlZW66TWQ==", "license": "MIT", "dependencies": { - "@fluentui/react-aria": "^9.17.11", - "@fluentui/react-button": "^9.9.1", - "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-aria": "^9.17.13", + "@fluentui/react-button": "^9.10.1", + "@fluentui/react-context-selector": "^9.2.18", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-popover": "^9.14.2", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-popover": "^9.14.4", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1", "use-sync-external-store": "^1.2.0" @@ -3709,15 +3711,15 @@ } }, "node_modules/@fluentui/react-text": { - "version": "9.6.16", - "resolved": "https://registry.npmjs.org/@fluentui/react-text/-/react-text-9.6.16.tgz", - "integrity": "sha512-ZzCSJWQ6LrVuPqA6sqNEZaXbLvhi2NxBOtlMudWlqYzidLQp038d7mMGSzNnhyeblg+gj+bOVE2eOgWFuVHGYw==", + "version": "9.6.18", + "resolved": "https://registry.npmjs.org/@fluentui/react-text/-/react-text-9.6.18.tgz", + "integrity": "sha512-iED0KJPtU44M5kByfg93n/Zwwky0BhyRHaWFcIeB6jsCVAeCf8kUSS2Nr+7zQocf60DFHgMF7y4XEl0rRe+PHw==", "license": "MIT", "dependencies": { - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3729,16 +3731,16 @@ } }, "node_modules/@fluentui/react-textarea": { - "version": "9.7.2", - "resolved": "https://registry.npmjs.org/@fluentui/react-textarea/-/react-textarea-9.7.2.tgz", - "integrity": "sha512-awlkZoW81WaOqSoXTT9rZs3mTAzCCHnC9eAm6J8ZxI5+ASX07BTolBfZ82it5wxOHI5GMDfbFOl+xIy8uAMdzA==", + "version": "9.7.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-textarea/-/react-textarea-9.7.4.tgz", + "integrity": "sha512-Zq2D8u2ssneLVY4OuvMWYT5Er3yAo3OKmTWmPNsJ6F9dISIc7UullDwuBoKYES943EBzSfNyKZQwCY7Bo1BGuQ==", "license": "MIT", "dependencies": { - "@fluentui/react-field": "^9.5.1", - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-field": "^9.5.3", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3760,22 +3762,22 @@ } }, "node_modules/@fluentui/react-toast": { - "version": "9.7.17", - "resolved": "https://registry.npmjs.org/@fluentui/react-toast/-/react-toast-9.7.17.tgz", - "integrity": "sha512-DWA5EARWSo1k19iWAulLpKrcUHT+Dq/Bw9zfdpoQEWWybrAZwyN7WiYFkBjKCQRxSp10OjLASSQK91CXfb1wJA==", + "version": "9.8.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-toast/-/react-toast-9.8.1.tgz", + "integrity": "sha512-J9nKVwbwmBxDNrArgJ9GHypWH43WW0XJhNWvC6ED4dGrvgc8Rnw7bKxI7swG46OTMJNCkwrOnGNEu5YGkMigfw==", "license": "MIT", "dependencies": { "@fluentui/keyboard-keys": "^9.0.8", - "@fluentui/react-aria": "^9.17.11", + "@fluentui/react-aria": "^9.17.13", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-motion": "^9.15.0", - "@fluentui/react-motion-components-preview": "^0.15.4", - "@fluentui/react-portal": "^9.8.12", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-motion": "^9.16.1", + "@fluentui/react-motion-components-preview": "^0.15.6", + "@fluentui/react-portal": "^9.8.14", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3787,20 +3789,20 @@ } }, "node_modules/@fluentui/react-toolbar": { - "version": "9.8.0", - "resolved": "https://registry.npmjs.org/@fluentui/react-toolbar/-/react-toolbar-9.8.0.tgz", - "integrity": "sha512-EIe+QWOaFR1pZzENefsFTmjxGa2yJb4A/by3kGuGqSjx7isqPUllPq0/kFQzfoUYgPDJbJtQ+KyuRDekTL0QpQ==", + "version": "9.8.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-toolbar/-/react-toolbar-9.8.3.tgz", + "integrity": "sha512-/R12jBM1cllfvim9x+NxL4/5ObDdih42yHsswecVGhwK/rituz37UEWWr1MkapMCDnr/gVRVb4QEsbFXX3lpNA==", "license": "MIT", "dependencies": { - "@fluentui/react-button": "^9.9.1", - "@fluentui/react-context-selector": "^9.2.16", - "@fluentui/react-divider": "^9.7.1", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-radio": "^9.6.2", + "@fluentui/react-button": "^9.10.1", + "@fluentui/react-context-selector": "^9.2.18", + "@fluentui/react-divider": "^9.7.3", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-radio": "^9.6.4", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3812,19 +3814,19 @@ } }, "node_modules/@fluentui/react-tooltip": { - "version": "9.10.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-tooltip/-/react-tooltip-9.10.1.tgz", - "integrity": "sha512-IPHBFjqGhaaMDhLt5NSNOE9LEpDOpT7qgEqNz+Mlflo0A4qI2LW/EnkNop7IRmX/bC88A+wUtEONTjjR87dNBw==", + "version": "9.10.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-tooltip/-/react-tooltip-9.10.3.tgz", + "integrity": "sha512-QDc5XQyODm0BIQ5VCbM59n0pEXNCMk2a9OQ7B5eDWoqnvTxj++ul1XQb6EF0libbkjFl7zTsaaHnhIOzsVEq0w==", "license": "MIT", "dependencies": { "@fluentui/keyboard-keys": "^9.0.8", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-portal": "^9.8.12", - "@fluentui/react-positioning": "^9.22.1", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-portal": "^9.8.14", + "@fluentui/react-positioning": "^9.22.3", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3836,26 +3838,26 @@ } }, "node_modules/@fluentui/react-tree": { - "version": "9.16.0", - "resolved": "https://registry.npmjs.org/@fluentui/react-tree/-/react-tree-9.16.0.tgz", - "integrity": "sha512-c+Q4AVaYk9U69aGDgmJVNne+CtWKS75YIfGoxs6+9+wE2Wqz4T0E+gE1ng7ARCQQgI7E2NEJlot6DuI6nYYrRw==", + "version": "9.16.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-tree/-/react-tree-9.16.3.tgz", + "integrity": "sha512-Uf4ero9GhHoe8B6ZONKTIriPnd8cuyPFGXbPo+AG4t4vB5QO8qSZmwrR89btd2Xbr1zWQoMmJJFDn83S+3Te8w==", "license": "MIT", "dependencies": { "@fluentui/keyboard-keys": "^9.0.8", - "@fluentui/react-aria": "^9.17.11", - "@fluentui/react-avatar": "^9.11.1", - "@fluentui/react-button": "^9.9.1", - "@fluentui/react-checkbox": "^9.6.1", - "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-aria": "^9.17.13", + "@fluentui/react-avatar": "^9.11.3", + "@fluentui/react-button": "^9.10.1", + "@fluentui/react-checkbox": "^9.6.3", + "@fluentui/react-context-selector": "^9.2.18", "@fluentui/react-icons": "^2.0.245", - "@fluentui/react-jsx-runtime": "^9.4.2", - "@fluentui/react-motion": "^9.15.0", - "@fluentui/react-motion-components-preview": "^0.15.4", - "@fluentui/react-radio": "^9.6.2", + "@fluentui/react-jsx-runtime": "^9.4.4", + "@fluentui/react-motion": "^9.16.1", + "@fluentui/react-motion-components-preview": "^0.15.6", + "@fluentui/react-radio": "^9.6.4", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tabster": "^9.26.16", "@fluentui/react-theme": "^9.2.1", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -3867,9 +3869,9 @@ } }, "node_modules/@fluentui/react-utilities": { - "version": "9.26.3", - "resolved": "https://registry.npmjs.org/@fluentui/react-utilities/-/react-utilities-9.26.3.tgz", - "integrity": "sha512-bXB3jMm/RroT8c5eGZkijkPbLd4MqMI6biBHjavo0e7OkZHv9IPfH2nDkGhSn5Sh8e6kRcX0IjYhbM10WUK2iQ==", + "version": "9.26.5", + "resolved": "https://registry.npmjs.org/@fluentui/react-utilities/-/react-utilities-9.26.5.tgz", + "integrity": "sha512-lLWhnfMVEu+cWx3o9uTA5sDwo4U9PnpDJGVtheZ1bOCdh1YiQsJxeFM7n6nLE72UK2w+ATkGZQPYZA4QW1JLPQ==", "license": "MIT", "dependencies": { "@fluentui/keyboard-keys": "^9.0.8", @@ -3882,14 +3884,14 @@ } }, "node_modules/@fluentui/react-virtualizer": { - "version": "9.0.0-alpha.112", - "resolved": "https://registry.npmjs.org/@fluentui/react-virtualizer/-/react-virtualizer-9.0.0-alpha.112.tgz", - "integrity": "sha512-dao/mQssaPFxCXMx7K+G/DrRoZg28kXcE1NGbJ1RPtbkVCzJgwrEEeDhM5/wyOXO/Z5EZ31FIerDDVOyr6FAaw==", + "version": "9.0.0-alpha.114", + "resolved": "https://registry.npmjs.org/@fluentui/react-virtualizer/-/react-virtualizer-9.0.0-alpha.114.tgz", + "integrity": "sha512-hD44CrZh84P1Rsd+ACPdmre3j20OevkoMKxMRzMXWlSIYKbT+m5I5yM3XxxSvKb1Qr77LeBPTTJs6apvMcfRiA==", "license": "MIT", "dependencies": { - "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-jsx-runtime": "^9.4.4", "@fluentui/react-shared-contexts": "^9.26.2", - "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-utilities": "^9.26.5", "@griffel/react": "^1.5.32", "@swc/helpers": "^0.5.1" }, @@ -5156,6 +5158,37 @@ "@nevware21/ts-utils": ">= 0.12.6 < 2.x" } }, + "node_modules/@microsoft/applicationinsights-channel-js": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-channel-js/-/applicationinsights-channel-js-3.4.2.tgz", + "integrity": "sha512-Q7Q9gV45WSgSmOP2abu0y9tdbFh8sPELMgKUCDy62FsNd3xmE8X3zLtkzc7mcXPlpTeoDXwMCYjCRIAYd6d2lQ==", + "license": "MIT", + "dependencies": { + "@microsoft/applicationinsights-core-js": "3.4.2", + "@microsoft/applicationinsights-shims": "3.0.1", + "@microsoft/dynamicproto-js": "^2.0.3", + "@nevware21/ts-async": ">= 0.5.5 < 0.6.0", + "@nevware21/ts-utils": ">= 0.14.0 < 2.x" + }, + "peerDependencies": { + "tslib": ">= 1.0.0" + } + }, + "node_modules/@microsoft/applicationinsights-channel-js/node_modules/@microsoft/applicationinsights-core-js": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-core-js/-/applicationinsights-core-js-3.4.2.tgz", + "integrity": "sha512-Iw3gq1JREKLbXiVpr3F0+Ezuzphe+o25n9me9H5Kkjmck6tIkuGQea01SNfeemE4wf2mop2QQSvTcKxT8tNN1g==", + "license": "MIT", + "dependencies": { + "@microsoft/applicationinsights-shims": "3.0.1", + "@microsoft/dynamicproto-js": "^2.0.3", + "@nevware21/ts-async": ">= 0.5.5 < 0.6.0", + "@nevware21/ts-utils": ">= 0.14.0 < 2.x" + }, + "peerDependencies": { + "tslib": ">= 1.0.0" + } + }, "node_modules/@microsoft/applicationinsights-common": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-common/-/applicationinsights-common-3.4.1.tgz", @@ -5195,6 +5228,38 @@ "@nevware21/ts-utils": ">= 0.9.4 < 2.x" } }, + "node_modules/@microsoft/applicationinsights-web-basic": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-web-basic/-/applicationinsights-web-basic-3.4.2.tgz", + "integrity": "sha512-ONJbCSdJ/KMOVT7b8oVVPa2Etp99SHhehprpQn51QrHTiJtTehBLOmovBBIKQZuwWm0ZLKOaoUBWHCkRXUqNAw==", + "license": "MIT", + "dependencies": { + "@microsoft/applicationinsights-channel-js": "3.4.2", + "@microsoft/applicationinsights-core-js": "3.4.2", + "@microsoft/applicationinsights-shims": "3.0.1", + "@microsoft/dynamicproto-js": "^2.0.3", + "@nevware21/ts-async": ">= 0.5.5 < 0.6.0", + "@nevware21/ts-utils": ">= 0.14.0 < 2.x" + }, + "peerDependencies": { + "tslib": ">= 1.0.0" + } + }, + "node_modules/@microsoft/applicationinsights-web-basic/node_modules/@microsoft/applicationinsights-core-js": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-core-js/-/applicationinsights-core-js-3.4.2.tgz", + "integrity": "sha512-Iw3gq1JREKLbXiVpr3F0+Ezuzphe+o25n9me9H5Kkjmck6tIkuGQea01SNfeemE4wf2mop2QQSvTcKxT8tNN1g==", + "license": "MIT", + "dependencies": { + "@microsoft/applicationinsights-shims": "3.0.1", + "@microsoft/dynamicproto-js": "^2.0.3", + "@nevware21/ts-async": ">= 0.5.5 < 0.6.0", + "@nevware21/ts-utils": ">= 0.14.0 < 2.x" + }, + "peerDependencies": { + "tslib": ">= 1.0.0" + } + }, "node_modules/@microsoft/dynamicproto-js": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@microsoft/dynamicproto-js/-/dynamicproto-js-2.0.5.tgz", @@ -5350,6 +5415,17 @@ "@azure/ms-rest-azure-env": "^2.0.0" } }, + "node_modules/@microsoft/vscode-container-client": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@microsoft/vscode-container-client/-/vscode-container-client-0.5.4.tgz", + "integrity": "sha512-cFLiSNImnURifgmXkYxUvyYAs6qFJ7qegFAarUEUE5fCFgfU68Nj+e6W2WZaOienkgFOmUbACDXKTSNYL/XxbQ==", + "license": "See LICENSE in the project root for license information.", + "dependencies": { + "@microsoft/vscode-processutils": "^0.2.2", + "dayjs": "^1.11.2", + "zod": "^4.1.13" + } + }, "node_modules/@microsoft/vscode-ext-webview": { "resolved": "packages/vscode-ext-webview", "link": true @@ -6746,19 +6822,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -9833,39 +9896,6 @@ "vscode": "^1.75.0" } }, - "node_modules/@vscode/extension-telemetry/node_modules/@microsoft/applicationinsights-channel-js": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-channel-js/-/applicationinsights-channel-js-3.4.1.tgz", - "integrity": "sha512-QS1k6iwVwR1MznGAB1H0F9raqpevbFNbadLS5O1419pz9OEWBfF9wRQLnENCyo8QS9Q0IdiqnGAON/D8IywpWg==", - "license": "MIT", - "dependencies": { - "@microsoft/applicationinsights-core-js": "3.4.1", - "@microsoft/applicationinsights-shims": "3.0.1", - "@microsoft/dynamicproto-js": "^2.0.3", - "@nevware21/ts-async": ">= 0.5.5 < 2.x", - "@nevware21/ts-utils": ">= 0.12.6 < 2.x" - }, - "peerDependencies": { - "tslib": ">= 1.0.0" - } - }, - "node_modules/@vscode/extension-telemetry/node_modules/@microsoft/applicationinsights-web-basic": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-web-basic/-/applicationinsights-web-basic-3.4.1.tgz", - "integrity": "sha512-V/hSlauFp1thJa57+TMv5mAYinJAQUi4zOmDmpahnDgs8g1zrQ0D8QYDmu0Zfi+9GhoD80B4yJez2+ydJPJz2w==", - "license": "MIT", - "dependencies": { - "@microsoft/applicationinsights-channel-js": "3.4.1", - "@microsoft/applicationinsights-core-js": "3.4.1", - "@microsoft/applicationinsights-shims": "3.0.1", - "@microsoft/dynamicproto-js": "^2.0.3", - "@nevware21/ts-async": ">= 0.5.5 < 2.x", - "@nevware21/ts-utils": ">= 0.12.6 < 2.x" - }, - "peerDependencies": { - "tslib": ">= 1.0.0" - } - }, "node_modules/@vscode/l10n": { "version": "0.0.18", "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", @@ -17999,9 +18029,9 @@ } }, "node_modules/keyborg": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/keyborg/-/keyborg-2.6.0.tgz", - "integrity": "sha512-o5kvLbuTF+o326CMVYpjlaykxqYP9DphFQZ2ZpgrvBouyvOxyEB7oqe8nOLFpiV5VCtz0D3pt8gXQYWpLpBnmA==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/keyborg/-/keyborg-2.14.1.tgz", + "integrity": "sha512-/WmmVBa6Me3hIKAOIyIq1sql+6oydQZzGMBDLNfOcJ8710byMsq3KSLS8GQhBJHOMtvnXnUBrDAIbABcZVipcg==", "license": "MIT" }, "node_modules/keytar": { @@ -23795,16 +23825,13 @@ } }, "node_modules/tabster": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/tabster/-/tabster-8.7.0.tgz", - "integrity": "sha512-AKYquti8AdWzuqJdQo4LUMQDZrHoYQy6V+8yUq2PmgLZV10EaB+8BD0nWOfC/3TBp4mPNg4fbHkz6SFtkr0PpA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/tabster/-/tabster-8.8.0.tgz", + "integrity": "sha512-eGFXgtvKOQP5BywDI9Ngs+Atm6TRj45epAAqWKyVoi+HmOmdamEB//1H/FttLdNly/+Cz+GJ4RN8TnXTw0KwfA==", "license": "MIT", "dependencies": { - "keyborg": "2.6.0", + "keyborg": "^2.14.0", "tslib": "^2.8.1" - }, - "optionalDependencies": { - "@rollup/rollup-linux-x64-gnu": "4.53.3" } }, "node_modules/tapable": { diff --git a/package.json b/package.json index 4aa2e8c11..c42c58d32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vscode-documentdb", - "version": "0.9.2", + "version": "0.10.0-bug-bash-2", "releaseNotesUrl": "https://github.com/microsoft/vscode-documentdb/discussions/750", "aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255", "publisher": "ms-azuretools", @@ -164,13 +164,19 @@ "@azure/arm-resources": "~7.0.0", "@azure/cosmos": "~4.7.0", "@azure/identity": "~4.13.0", - "@fluentui/react-components": "~9.73.3", + "@documentdb-js/operator-registry": "*", + "@documentdb-js/schema-analyzer": "*", + "@documentdb-js/shell-runtime": "*", + "@fluentui/react-components": "~9.74.4", "@fluentui/react-icons": "~2.0.320", "@kubernetes/client-node": "1.4.0", "@microsoft/vscode-azext-azureauth": "~4.1.1", "@microsoft/vscode-azext-azureutils": "~4.2.0", "@microsoft/vscode-azext-utils": "~4.1.0", "@microsoft/vscode-azureresources-api": "~2.5.0", + "@microsoft/vscode-container-client": "^0.5.4", + "@microsoft/vscode-ext-webview": "*", + "@microsoft/vscode-processutils": "^0.2.2", "@monaco-editor/react": "~4.7.0", "@mongodb-js/explain-plan-helper": "1.4.24", "@mongodb-js/shell-bson-parser": "^1.5.6", @@ -181,10 +187,6 @@ "@mongosh/shell-evaluator": "^5.1.4", "@trpc/client": "~11.10.0", "@trpc/server": "~11.10.0", - "@documentdb-js/operator-registry": "*", - "@documentdb-js/shell-runtime": "*", - "@documentdb-js/schema-analyzer": "*", - "@microsoft/vscode-ext-webview": "*", "@vscode/l10n": "~0.0.18", "acorn": "^8.16.0", "acorn-walk": "^8.3.5", @@ -206,11 +208,10 @@ "vscode-uri": "~3.1.0", "zod": "~4.3.6" }, - "//overrides": "Pin transitive dependencies: glob 12.0.x for latest, test-exclude 7 for compatibility. applicationinsights-web-basic pinned to 3.4.1 to DEDUPE the App Insights stack (distinct from the earlier ~3.3.4 pin removed in v0.9.1 via #614, which existed only to restore applicationinsights-common before extension-telemetry@1.5.2 declared it directly). extension-telemetry resolves web-basic@^3.4.1 up to 3.4.2, which exact-pins core-js/channel-js@3.4.2 while common/1ds-* exact-pin core-js@3.4.1 - bundling two core-js copies and risking an unresolved @nevware21/ts-utils named import (isNullOrUndefined) at runtime. 3.4.1 is in-range (not a downgrade) and collapses the stack to one line. Remove once the AI stack resolves to a single core-js without this pin. See #626.", + "//overrides": "Pin transitive dependencies: glob 12.0.x for latest, test-exclude 7 for compatibility.", "overrides": { "glob": "~12.0.0", - "test-exclude": "~7.0.1", - "@microsoft/applicationinsights-web-basic": "3.4.1" + "test-exclude": "~7.0.1" }, "extensionDependencies": [], "contributes": { @@ -356,6 +357,62 @@ "title": "New Local Connection…", "icon": "$(add)" }, + { + "//": "[Local Quick Start] Open the Quick Start webview", + "category": "DocumentDB", + "command": "vscode-documentdb.command.localQuickStart.open", + "title": "Local Quick Start", + "icon": "$(rocket)" + }, + { + "//": "[Local Quick Start] Start the managed instance", + "category": "DocumentDB", + "command": "vscode-documentdb.command.localQuickStart.start", + "title": "Start", + "icon": "$(play)" + }, + { + "//": "[Local Quick Start] Stop the managed instance", + "category": "DocumentDB", + "command": "vscode-documentdb.command.localQuickStart.stop", + "title": "Stop", + "icon": "$(debug-stop)" + }, + { + "//": "[Local Quick Start] Restart the managed instance", + "category": "DocumentDB", + "command": "vscode-documentdb.command.localQuickStart.restart", + "title": "Restart", + "icon": "$(debug-restart)" + }, + { + "//": "[Local Quick Start] Delete the managed container", + "category": "DocumentDB", + "command": "vscode-documentdb.command.localQuickStart.delete", + "title": "Delete Container…", + "icon": "$(trash)" + }, + { + "//": "[Local Quick Start] Copy connection string", + "category": "DocumentDB", + "command": "vscode-documentdb.command.localQuickStart.copyConnectionString", + "title": "Copy Connection String", + "icon": "$(link)" + }, + { + "//": "[Local Quick Start] Copy password", + "category": "DocumentDB", + "command": "vscode-documentdb.command.localQuickStart.copyPassword", + "title": "Copy Password", + "icon": "$(key)" + }, + { + "//": "[Local Quick Start] View container logs", + "category": "DocumentDB", + "command": "vscode-documentdb.command.localQuickStart.viewLogs", + "title": "View Logs", + "icon": "$(output)" + }, { "//": "[ConnectionsView] Delete Connection", "category": "DocumentDB", @@ -440,6 +497,13 @@ "title": "Save To DocumentDB Connections", "icon": "$(save)" }, + { + "//": "[DiscoveryView] Open a discovered MongoDB Atlas cluster in the Atlas web UI", + "category": "DocumentDB", + "command": "vscode-documentdb.command.discoveryView.atlas.openCluster", + "title": "Open in MongoDB Atlas", + "icon": "$(link-external)" + }, { "//": "[DiscoveryView] Content Provider: Manage Credentials", "category": "DocumentDB", @@ -537,6 +601,20 @@ "title": "View as Tree", "icon": "$(list-selection)" }, + { + "//": "[DiscoveryView] Switch MongoDB Atlas discovery to the hierarchical tree view. Shown while the LIST view is active, so the icon reflects the CURRENT (list) mode and the title states the action.", + "category": "DocumentDB", + "command": "vscode-documentdb.command.discoveryView.atlas.switchToTreeView", + "title": "View as Tree", + "icon": "$(list-selection)" + }, + { + "//": "[DiscoveryView] Switch MongoDB Atlas discovery to the flat cluster list. Shown while the TREE view is active, so the icon reflects the CURRENT (tree) mode and the title states the action.", + "category": "DocumentDB", + "command": "vscode-documentdb.command.discoveryView.atlas.switchToFlatListView", + "title": "View as List", + "icon": "$(list-tree)" + }, { "//": "Create Database", "category": "DocumentDB", @@ -746,6 +824,51 @@ "editor/context": [], "editor/title": [], "view/item/context": [ + { + "command": "vscode-documentdb.command.localQuickStart.start", + "when": "view == connectionsView && viewItem =~ /\\btreeItem_quickStartInstance\\b/i && viewItem =~ /\\bstate_stopped\\b/i", + "group": "inline@1" + }, + { + "command": "vscode-documentdb.command.localQuickStart.stop", + "when": "view == connectionsView && viewItem =~ /\\btreeItem_quickStartInstance\\b/i && viewItem =~ /\\bstate_running\\b/i", + "group": "inline@1" + }, + { + "command": "vscode-documentdb.command.localQuickStart.start", + "when": "view == connectionsView && viewItem =~ /\\btreeItem_quickStartInstance\\b/i && viewItem =~ /\\bstate_stopped\\b/i", + "group": "1_quickstart@1" + }, + { + "command": "vscode-documentdb.command.localQuickStart.stop", + "when": "view == connectionsView && viewItem =~ /\\btreeItem_quickStartInstance\\b/i && viewItem =~ /\\bstate_running\\b/i", + "group": "1_quickstart@1" + }, + { + "command": "vscode-documentdb.command.localQuickStart.restart", + "when": "view == connectionsView && viewItem =~ /\\btreeItem_quickStartInstance\\b/i && viewItem =~ /\\bstate_(running|error)\\b/i", + "group": "1_quickstart@2" + }, + { + "command": "vscode-documentdb.command.localQuickStart.viewLogs", + "when": "view == connectionsView && viewItem =~ /\\btreeItem_quickStartInstance\\b/i && viewItem =~ /\\bstate_(running|stopped|starting|stopping|error|missing)\\b/i", + "group": "1_quickstart@3" + }, + { + "command": "vscode-documentdb.command.localQuickStart.copyConnectionString", + "when": "view == connectionsView && viewItem =~ /\\btreeItem_quickStartInstance\\b/i && viewItem =~ /\\bstate_(running|stopped)\\b/i", + "group": "2_quickstart@1" + }, + { + "command": "vscode-documentdb.command.localQuickStart.copyPassword", + "when": "view == connectionsView && viewItem =~ /\\btreeItem_quickStartInstance\\b/i && viewItem =~ /\\bstate_(running|stopped)\\b/i", + "group": "2_quickstart@2" + }, + { + "command": "vscode-documentdb.command.localQuickStart.delete", + "when": "view == connectionsView && viewItem =~ /\\btreeItem_quickStartInstance\\b/i && viewItem =~ /\\bstate_(running|stopped|error|missing)\\b/i", + "group": "3_quickstart@1" + }, { "command": "vscode-documentdb.command.connectionsView.updateConnectionString", "when": "view == connectionsView && viewItem =~ /\\btreeitem_documentdbcluster\\b/i && !listMultiSelection", @@ -808,6 +931,11 @@ "when": "view == discoveryView && viewItem =~ /\\btreeitem_documentdbcluster\\b/i", "group": "0@1" }, + { + "command": "vscode-documentdb.command.discoveryView.atlas.openCluster", + "when": "view == discoveryView && viewItem =~ /\\btreeitem_documentdbcluster\\b/i && viewItem =~ /\\bexperience_mongoDBAtlas\\b/i && !listMultiSelection", + "group": "0@2" + }, { "command": "vscode-documentdb.command.discoveryView.removeRegistry", "when": "view == discoveryView && viewItem =~ /\\brootItem\\b/i", @@ -899,6 +1027,27 @@ "when": "view == discoveryView && viewItem =~ /\\bdiscoveryKubernetesViewModeList\\b/i", "group": "yheAlmostLastGroup@1" }, + { + "//": "[DiscoveryView] View-mode toggle on the MongoDB Atlas root node. Same convention as the Kubernetes toggle: the icon reflects the CURRENT mode and the action switches to the other one.", + "command": "vscode-documentdb.command.discoveryView.atlas.switchToFlatListView", + "when": "view == discoveryView && viewItem =~ /\\bdiscoveryAtlasViewModeTree\\b/i", + "group": "inline@0" + }, + { + "command": "vscode-documentdb.command.discoveryView.atlas.switchToFlatListView", + "when": "view == discoveryView && viewItem =~ /\\bdiscoveryAtlasViewModeTree\\b/i", + "group": "yheAlmostLastGroup@1" + }, + { + "command": "vscode-documentdb.command.discoveryView.atlas.switchToTreeView", + "when": "view == discoveryView && viewItem =~ /\\bdiscoveryAtlasViewModeList\\b/i", + "group": "inline@0" + }, + { + "command": "vscode-documentdb.command.discoveryView.atlas.switchToTreeView", + "when": "view == discoveryView && viewItem =~ /\\bdiscoveryAtlasViewModeList\\b/i", + "group": "yheAlmostLastGroup@1" + }, { "command": "vscode-documentdb.command.discoveryView.learnMoreAboutProvider", "when": "view == discoveryView && viewItem =~ /\\benableLearnMoreCommand\\b/i", @@ -930,55 +1079,55 @@ { "//": "[Database] Create collection", "command": "vscode-documentdb.command.createCollection", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_database\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_database\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "1@1" }, { "//": "[Database] Delete database", "command": "vscode-documentdb.command.dropDatabase", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_database\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_database\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "2@1" }, { "//": "[Database] Paste Collection", "command": "vscode-documentdb.command.pasteCollection", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_database\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_database\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "1@2" }, { "//": "[Database] Open Interactive Shell", "command": "vscode-documentdb.command.shell.open", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_database\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_database\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "5@1" }, { "//": "[Database] Open Interactive Shell (inline)", "command": "vscode-documentdb.command.shell.open.inline", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_database\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_database\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "inline@1" }, { "//": "[Collection] Mongo DB|Cluster Open collection", "command": "vscode-documentdb.command.containerView.open", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "1@1" }, { "//": "[Collection] Create document", "command": "vscode-documentdb.command.createDocument", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "2@1" }, { "//": "[Collection] Import Documents", "command": "vscode-documentdb.command.importDocuments", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "3@1" }, { "//": "[Collection] Export documents", "command": "vscode-documentdb.command.exportDocuments", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "3@2" }, { @@ -990,67 +1139,67 @@ { "//": "[Collection] Drop collection", "command": "vscode-documentdb.command.dropCollection", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "4@1" }, { "//": "[Index] Hide Index", "command": "vscode-documentdb.command.hideIndex", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_index\\b/i && !(viewItem =~ /\\bstate_hidden\\b/i) && !(viewItem =~ /\\bstate_default\\b/i) && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_index\\b/i && !(viewItem =~ /\\bstate_hidden\\b/i) && !(viewItem =~ /\\bstate_default\\b/i) && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "3@1" }, { "//": "[Index] Unhide Index", "command": "vscode-documentdb.command.unhideIndex", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_index\\b/i && viewItem =~ /\\bstate_hidden\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_index\\b/i && viewItem =~ /\\bstate_hidden\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "3@2" }, { "//": "[Index] Delete Index", "command": "vscode-documentdb.command.dropIndex", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_index\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_index\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "4@1" }, { "//": "[Collection] Open Interactive Shell", "command": "vscode-documentdb.command.shell.open", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "5@1" }, { "//": "[Collection] Open Collection (inline)", "command": "vscode-documentdb.command.containerView.open.inline", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "inline@1" }, { "//": "[Collection] Open Interactive Shell (inline)", "command": "vscode-documentdb.command.shell.open.inline", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "inline@3" }, { "//": "[Collection] New Query Playground (inline)", "command": "vscode-documentdb.command.playground.new.inline", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "inline@2" }, { "//": "[Database/Collection] New Query Playground", "command": "vscode-documentdb.command.playground.new", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_(database|collection)\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_(database|collection)\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "5@2" }, { "//": "[Collection/Documents] Mongo DB|Cluster Open collection", "command": "vscode-documentdb.command.containerView.open", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_documents\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_documents\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "2@1" }, { "//": "[TreeItem] Refresh Item (cluster, database, collection, documents, indexes) -> but not in azure(ResourceGroups|FocusView) as it's done there by the Azure Resources host extension.", "command": "vscode-documentdb.command.refresh", - "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /\\btreeitem_(documentdbcluster|database|collection|documents|indexes|index)\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /\\btreeitem_(documentdbcluster|database|collection|documents|indexes|index)\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "zheLastGroup@1" }, { @@ -1062,31 +1211,31 @@ { "//": "[Collection] Copy Collection", "command": "vscode-documentdb.command.copyCollection", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "3@3" }, { "//": "[Collection] Paste Collection", "command": "vscode-documentdb.command.pasteCollection", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "3@4" }, { "//": "[Database] Copy Reference", "command": "vscode-documentdb.command.copyReference", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_database\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_database\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "yheAlmostLastGroup@1" }, { "//": "[Collection] Copy Reference", "command": "vscode-documentdb.command.copyReference", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "yheAlmostLastGroup@1" }, { "//": "[Index] Copy Reference", "command": "vscode-documentdb.command.copyReference", - "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_index\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i && !listMultiSelection", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_index\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU|mongoDBAtlas)\\b/i && !listMultiSelection", "group": "yheAlmostLastGroup@1" } ], @@ -1096,6 +1245,35 @@ "command": "vscode-documentdb.command.refresh", "when": "never" }, + { + "//": "[Local Quick Start] Lifecycle commands act on the managed instance selected in the Connections view. Invoked from the palette there is no instance in context, so they would silently do nothing — only `localQuickStart.open` belongs in the palette.", + "command": "vscode-documentdb.command.localQuickStart.start", + "when": "never" + }, + { + "command": "vscode-documentdb.command.localQuickStart.stop", + "when": "never" + }, + { + "command": "vscode-documentdb.command.localQuickStart.restart", + "when": "never" + }, + { + "command": "vscode-documentdb.command.localQuickStart.delete", + "when": "never" + }, + { + "command": "vscode-documentdb.command.localQuickStart.copyConnectionString", + "when": "never" + }, + { + "command": "vscode-documentdb.command.localQuickStart.copyPassword", + "when": "never" + }, + { + "command": "vscode-documentdb.command.localQuickStart.viewLogs", + "when": "never" + }, { "command": "vscode-documentdb.command.connectionsView.renameConnection", "when": "never" @@ -1136,6 +1314,10 @@ "command": "vscode-documentdb.command.discoveryView.addConnectionToConnectionsView", "when": "never" }, + { + "command": "vscode-documentdb.command.discoveryView.atlas.openCluster", + "when": "never" + }, { "command": "vscode-documentdb.command.discoveryView.manageCredentials", "when": "never" @@ -1176,6 +1358,14 @@ "command": "vscode-documentdb.command.discoveryView.kubernetes.switchToTreeView", "when": "never" }, + { + "command": "vscode-documentdb.command.discoveryView.atlas.switchToFlatListView", + "when": "never" + }, + { + "command": "vscode-documentdb.command.discoveryView.atlas.switchToTreeView", + "when": "never" + }, { "command": "vscode-documentdb.command.discoveryView.learnMoreAboutProvider", "when": "never" diff --git a/packages/documentdb-js-operator-registry/README.md b/packages/documentdb-js-operator-registry/README.md index 923ef06d0..a058aa175 100644 --- a/packages/documentdb-js-operator-registry/README.md +++ b/packages/documentdb-js-operator-registry/README.md @@ -49,8 +49,8 @@ const stages = getFilteredCompletions({ meta: STAGE_COMPLETION_META }); All operator data is derived from the official DocumentDB documentation: - **Compatibility reference:** [DocumentDB Query Language Compatibility](https://learn.microsoft.com/en-us/azure/documentdb/compatibility-query-language) — lists every operator with its support status across DocumentDB versions 5.0–8.0. -- **Per-operator docs:** [DocumentDB Operators](https://learn.microsoft.com/en-us/azure/documentdb/operators/) — individual pages with descriptions and syntax for each operator. -- **Source repository:** [MicrosoftDocs/azure-databases-docs](https://github.com/MicrosoftDocs/azure-databases-docs) — the GitHub repo containing the raw Markdown source for all documentation pages above (under `articles/documentdb/`). +- **Per-operator docs:** [DocumentDB Operators](https://learn.microsoft.com/en-us/documentdb/query/operators/) — individual pages with descriptions and syntax for each operator. +- **Source repository:** [MicrosoftDocs/nosql-docs](https://github.com/MicrosoftDocs/nosql-docs) — the public GitHub repo containing the raw Markdown source for all documentation pages above. The compatibility page lives under `azure/documentdb/`, and the per-operator pages under `documentdb/query/operators/`. ### Scraper diff --git a/packages/documentdb-js-operator-registry/package.json b/packages/documentdb-js-operator-registry/package.json index 2ab945793..e5f04f507 100644 --- a/packages/documentdb-js-operator-registry/package.json +++ b/packages/documentdb-js-operator-registry/package.json @@ -12,7 +12,7 @@ "clean": "rimraf dist tsconfig.tsbuildinfo", "test": "jest --config jest.config.js", "prettier-fix": "prettier -w \"(scripts|src)/**/*.@(js|ts|jsx|tsx|json|md)\" \"./*.@(js|ts|jsx|tsx|json|md)\"", - "scrape": "ts-node scripts/scrape-operator-docs.ts && prettier --write resources/scraped/operator-reference.md", + "scrape": "ts-node scripts/scrape-operator-docs.ts && prettier --write resources/scraped/operator-reference.md resources/scraped/index-reference.md", "generate": "ts-node scripts/generate-from-reference.ts", "evaluate": "ts-node scripts/evaluate-overrides.ts" }, diff --git a/packages/documentdb-js-operator-registry/resources/overrides/operator-overrides.md b/packages/documentdb-js-operator-registry/resources/overrides/operator-overrides.md index d4618d657..b1f448d5f 100644 --- a/packages/documentdb-js-operator-registry/resources/overrides/operator-overrides.md +++ b/packages/documentdb-js-operator-registry/resources/overrides/operator-overrides.md @@ -226,6 +226,10 @@ ## Array Expression Operators +### $maxN + +- **Description:** The $maxN operator retrieves the top N values based on specified filtering criteria. + ### $objectToArray - **Description:** Converts an object into an array of key-value pair documents. @@ -264,19 +268,25 @@ ### $minN -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$minn +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$minn ## Comparison Expression Operators ### $cmp -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/comparison-query/$cmp +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/comparison-query/$cmp + +## Accumulators ($group, $bucket, $bucketAuto, $setWindowFields) + +### $maxN + +- **Description:** The $maxN operator retrieves the top N values based on specified filtering criteria. ## Window Operators ### $minN -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$minn +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$minn ## Geospatial Operators diff --git a/packages/documentdb-js-operator-registry/resources/scraped/index-reference.md b/packages/documentdb-js-operator-registry/resources/scraped/index-reference.md new file mode 100644 index 000000000..5e0a88c0c --- /dev/null +++ b/packages/documentdb-js-operator-registry/resources/scraped/index-reference.md @@ -0,0 +1,29 @@ +# DocumentDB Index Reference + + + + + +## Index Types + +| Name | Description | Supported | +| ------------ | ------------------------------------------------ | --------- | +| Single Field | Indexes a single field for faster lookups. | Yes | +| Compound | Indexes multiple fields in one index. | Yes | +| Multikey | Indexes array fields by indexing each element. | Yes | +| Text | Supports text search on string fields. | Yes | +| Wildcard | Dynamically indexes all or selected fields. | Yes | +| Geospatial | Supports spatial queries on GeoJSON data. | Yes | +| Hashed | Indexes hashed field values, often for sharding. | Yes | +| Vector | Enables similarity search on vector data. | Yes | + +## Index Properties + +| Name | Description | Supported | +| ---------------- | ----------------------------------------------------------------------------- | --------- | +| TTL | Automatically deletes documents after a specified time-to-live period. | Yes | +| Unique | Ensures that all values in the indexed field are unique. | Yes | +| Partial | Indexes only documents that match a specified filter condition. | Yes | +| Case Insensitive | Supports case-insensitive indexing for string fields. | Yes | +| Sparse | Indexes only documents that contain the indexed field. | Yes | +| Background | Allows the index to be created in the background without blocking operations. | Yes | diff --git a/packages/documentdb-js-operator-registry/resources/scraped/operator-reference.md b/packages/documentdb-js-operator-registry/resources/scraped/operator-reference.md index 6ba385cfb..b46cf9147 100644 --- a/packages/documentdb-js-operator-registry/resources/scraped/operator-reference.md +++ b/packages/documentdb-js-operator-registry/resources/scraped/operator-reference.md @@ -1,8 +1,8 @@ # DocumentDB Operator Reference - - + + ## Summary @@ -15,7 +15,7 @@ | Geospatial Operators | 11 | 11 | | Array Query Operators | 3 | 3 | | Bitwise Query Operators | 4 | 4 | -| Projection Operators | 3 | 4 | +| Projection Operators | 4 | 4 | | Miscellaneous Query Operators | 3 | 3 | | Field Update Operators | 9 | 9 | | Array Update Operators | 12 | 12 | @@ -33,7 +33,7 @@ | Object Expression Operators | 3 | 3 | | Set Expression Operators | 7 | 7 | | String Expression Operators | 23 | 23 | -| Text Expression Operator | 0 | 1 | +| Text Expression Operator | 1 | 1 | | Timestamp Expression Operators | 2 | 2 | | Trigonometry Expression Operators | 15 | 15 | | Type Expression Operators | 11 | 11 | @@ -44,7 +44,7 @@ | Conditional Expression Operators | 3 | 3 | | Aggregation Pipeline Stages | 35 | 42 | | Variables in Aggregation Expressions | 7 | 10 | -| **Total** | **308** | **324** | +| **Total** | **310** | **324** | ## Comparison Query Operators @@ -61,7 +61,7 @@ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/comparison-query/$eq +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/comparison-query/$eq ### $gt @@ -76,7 +76,7 @@ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/comparison-query/$gt +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/comparison-query/$gt ### $gte @@ -91,7 +91,7 @@ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/comparison-query/$gte +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/comparison-query/$gte ### $in @@ -106,7 +106,7 @@ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/comparison-query/$in +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/comparison-query/$in ### $lt @@ -121,7 +121,7 @@ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/comparison-query/$lt +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/comparison-query/$lt ### $lte @@ -136,7 +136,7 @@ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/comparison-query/$lte +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/comparison-query/$lte ### $ne @@ -151,7 +151,7 @@ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/comparison-query/$ne +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/comparison-query/$ne ### $nin @@ -166,7 +166,7 @@ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/comparison-query/$nin +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/comparison-query/$nin ## Logical Query Operators @@ -187,7 +187,7 @@ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/logical-query/$and +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/logical-query/$and ### $not @@ -204,7 +204,7 @@ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/logical-query/$not +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/logical-query/$not ### $nor @@ -223,7 +223,7 @@ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/logical-query/$nor +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/logical-query/$nor ### $or @@ -242,7 +242,7 @@ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/logical-query/$or +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/logical-query/$or ## Element Query Operators @@ -257,7 +257,7 @@ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/element-query/$exists +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/element-query/$exists ### $type @@ -270,7 +270,7 @@ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/element-query/$type +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/element-query/$type ## Evaluation Query Operators @@ -285,7 +285,7 @@ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/evaluation-query/$expr +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/evaluation-query/$expr ### $jsonSchema @@ -301,6 +301,7 @@ db.createCollection('collectionName', { properties: { field1: { bsonType: 'string', + description: 'Description of field1 requirements', }, field2: { bsonType: 'int', @@ -315,7 +316,7 @@ db.createCollection('collectionName', { }); ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/evaluation-query/$jsonschema +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/evaluation-query/$jsonschema ### $mod @@ -328,7 +329,7 @@ db.createCollection('collectionName', { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/evaluation-query/$mod +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/evaluation-query/$mod ### $regex @@ -341,7 +342,7 @@ db.createCollection('collectionName', { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/evaluation-query/$regex +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/evaluation-query/$regex ### $text @@ -359,7 +360,7 @@ db.createCollection('collectionName', { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/evaluation-query/$text +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/evaluation-query/$text ## Geospatial Operators @@ -381,7 +382,7 @@ db.createCollection('collectionName', { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/geospatial/$geointersects +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/geospatial/$geointersects ### $geoWithin @@ -420,7 +421,7 @@ db.createCollection('collectionName', { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/geospatial/$geowithin +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/geospatial/$geowithin ### $box @@ -440,7 +441,7 @@ db.createCollection('collectionName', { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/geospatial/$box +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/geospatial/$box ### $center @@ -455,7 +456,7 @@ db.createCollection('collectionName', { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/geospatial/$center +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/geospatial/$center ### $centerSphere @@ -470,7 +471,7 @@ db.createCollection('collectionName', { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/geospatial/$centersphere +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/geospatial/$centersphere ### $geometry @@ -486,7 +487,7 @@ db.createCollection('collectionName', { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/geospatial/$geometry +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/geospatial/$geometry ### $maxDistance @@ -507,7 +508,7 @@ db.createCollection('collectionName', { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/geospatial/$maxdistance +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/geospatial/$maxdistance ### $minDistance @@ -528,7 +529,7 @@ db.createCollection('collectionName', { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/geospatial/$mindistance +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/geospatial/$mindistance ### $polygon @@ -550,7 +551,7 @@ db.createCollection('collectionName', { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/geospatial/$polygon +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/geospatial/$polygon ### $near @@ -572,7 +573,7 @@ db.createCollection('collectionName', { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/geospatial/$near +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/geospatial/$near ### $nearSphere @@ -594,7 +595,7 @@ db.createCollection('collectionName', { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/geospatial/$nearsphere +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/geospatial/$nearsphere ## Array Query Operators @@ -611,18 +612,18 @@ db.collection.find({ }) ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-query/$all +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-query/$all ### $elemMatch -- **Description:** The $elemmatch operator returns complete array, qualifying criteria with at least one matching array element. +- **Description:** The $elemMatch operator returns complete array, qualifying criteria with at least one matching array element. - **Syntax:** ```javascript db.collection.find({ : { $elemMatch: { , , ... } } }) ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-query/$elemmatch +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-query/$elemmatch ### $size @@ -633,7 +634,7 @@ db.collection.find({ : { $elemMatch: { , , ... } } }) db.collection.find({ : { $size: } }) ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-query/$size +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-query/$size ## Bitwise Query Operators @@ -648,7 +649,7 @@ db.collection.find({ : { $size: } }) } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/bitwise-query/$bitsallclear +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/bitwise-query/$bitsallclear ### $bitsAllSet @@ -661,7 +662,7 @@ db.collection.find({ : { $size: } }) } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/bitwise-query/$bitsallset +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/bitwise-query/$bitsallset ### $bitsAnyClear @@ -674,7 +675,7 @@ db.collection.find({ : { $size: } }) } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/bitwise-query/$bitsanyclear +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/bitwise-query/$bitsanyclear ### $bitsAnySet @@ -687,7 +688,7 @@ db.collection.find({ : { $size: } }) } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/bitwise-query/$bitsanyset +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/bitwise-query/$bitsanyset ## Projection Operators @@ -708,7 +709,7 @@ db.collection.updateOne( ### $elemMatch -- **Description:** The $elemmatch operator returns complete array, qualifying criteria with at least one matching array element. +- **Description:** The $elemMatch operator returns complete array, qualifying criteria with at least one matching array element. - **Syntax:** ```javascript @@ -718,6 +719,25 @@ db.collection.find({ : { $elemMatch: { , , ... } } }) - **Doc Link:** none - **Scraper Comment:** Doc page not found in expected directory 'projection/'. Content scraped from 'array-query/'. +### $meta + +- **Description:** The $meta operator returns a calculated metadata column with returned dataset. +- **Syntax:** + +```javascript +db.collection.find({ + $text: { + $search: < string > + } +}, { + field: { + $meta: < metaDataKeyword > + } +}) +``` + +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/projection/$meta + ### $slice - **Description:** The $slice operator returns a subset of an array from any element onwards in the array. @@ -745,7 +765,7 @@ db.collection.find({ : { $elemMatch: { , , ... } } }) } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/miscellaneous-query/$comment +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/miscellaneous-query/$comment ### $rand @@ -759,7 +779,7 @@ db.collection.find({ : { $elemMatch: { , , ... } } }) } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/miscellaneous-query/$rand +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/miscellaneous-query/$rand ### $natural @@ -772,7 +792,7 @@ db.collection.find({ : { $elemMatch: { , , ... } } }) } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/miscellaneous-query/$natural +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/miscellaneous-query/$natural ## Field Update Operators @@ -791,7 +811,7 @@ db.collection.find({ : { $elemMatch: { , , ... } } }) } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/field-update/$currentdate +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/field-update/$currentdate ### $inc @@ -808,11 +828,11 @@ db.collection.find({ : { $elemMatch: { , , ... } } }) } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/field-update/$inc +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/field-update/$inc ### $min -- **Description:** Retrieves the minimum value for a specified field +- **Description:** The $min operator retrieves the minimum value for a specified field - **Syntax:** ```javascript @@ -849,7 +869,7 @@ $max: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/field-update/$mul +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/field-update/$mul ### $rename @@ -866,11 +886,11 @@ $max: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/field-update/$rename +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/field-update/$rename ### $set -- **Description:** The $set operator in Azure DocumentDB updates or creates a new field with a specified value +- **Description:** The $set operator in DocumentDB updates or creates a new field with a specified value - **Syntax:** ```javascript @@ -899,7 +919,7 @@ $max: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/field-update/$setoninsert +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/field-update/$setoninsert ### $unset @@ -929,7 +949,7 @@ db.collection.updateOne( ) ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-update/$ +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-update/$ ### $[] @@ -946,11 +966,11 @@ db.collection.updateOne( } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-update/$addtoset +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-update/$addtoset ### $pop -- **Description:** Removes the first or last element of an array. +- **Description:** The $pop operator removes the first or last element of an array. - **Syntax:** ```javascript @@ -961,7 +981,7 @@ db.collection.updateOne( } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-update/$pop +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-update/$pop ### $pull @@ -974,7 +994,7 @@ db.collection.updateOne( } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-update/$pull +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-update/$pull ### $push @@ -993,7 +1013,7 @@ db.collection.update({ }) ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-update/$push +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-update/$push ### $pullAll @@ -1006,7 +1026,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-update/$pullall +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-update/$pullall ### $each @@ -1025,7 +1045,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-update/$each +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-update/$each ### $position @@ -1077,7 +1097,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/bitwise-update/$bit +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/bitwise-update/$bit ## Arithmetic Expression Operators @@ -1092,7 +1112,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/arithmetic-expression/$abs +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/arithmetic-expression/$abs ### $add @@ -1105,7 +1125,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/arithmetic-expression/$add +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/arithmetic-expression/$add ### $ceil @@ -1118,7 +1138,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/arithmetic-expression/$ceil +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/arithmetic-expression/$ceil ### $divide @@ -1131,7 +1151,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/arithmetic-expression/$divide +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/arithmetic-expression/$divide ### $exp @@ -1144,7 +1164,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/arithmetic-expression/$exp +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/arithmetic-expression/$exp ### $floor @@ -1157,7 +1177,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/arithmetic-expression/$floor +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/arithmetic-expression/$floor ### $ln @@ -1170,7 +1190,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/arithmetic-expression/$ln +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/arithmetic-expression/$ln ### $log @@ -1183,7 +1203,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/arithmetic-expression/$log +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/arithmetic-expression/$log ### $log10 @@ -1196,7 +1216,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/arithmetic-expression/$log10 +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/arithmetic-expression/$log10 ### $mod @@ -1223,7 +1243,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/arithmetic-expression/$multiply +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/arithmetic-expression/$multiply ### $pow @@ -1236,7 +1256,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/arithmetic-expression/$pow +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/arithmetic-expression/$pow ### $round @@ -1249,7 +1269,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/arithmetic-expression/$round +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/arithmetic-expression/$round ### $sqrt @@ -1262,7 +1282,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/arithmetic-expression/$sqrt +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/arithmetic-expression/$sqrt ### $subtract @@ -1275,7 +1295,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/arithmetic-expression/$subtract +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/arithmetic-expression/$subtract ### $trunc @@ -1288,7 +1308,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/arithmetic-expression/$trunc +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/arithmetic-expression/$trunc ## Array Expression Operators @@ -1303,7 +1323,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-expression/$arrayelemat +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-expression/$arrayelemat ### $arrayToObject @@ -1316,7 +1336,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-expression/$arraytoobject +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-expression/$arraytoobject ### $concatArrays @@ -1329,7 +1349,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-expression/$concatarrays +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-expression/$concatarrays ### $filter @@ -1346,7 +1366,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-expression/$filter +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-expression/$filter ### $firstN @@ -1395,7 +1415,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-expression/$indexofarray +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-expression/$indexofarray ### $isArray @@ -1408,7 +1428,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-expression/$isarray +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-expression/$isarray ### $lastN @@ -1447,11 +1467,11 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-expression/$map +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-expression/$map ### $maxN -- **Description:** Retrieves the top N values based on a specified filtering criteria +- **Description:** The $maxN opertor retrieves the top N values based on a specified filtering criteria - **Syntax:** ```javascript @@ -1466,7 +1486,7 @@ $maxN: { ### $minN -- **Description:** Retrieves the bottom N values based on a specified filtering criteria +- **Description:** The $minN operator retrieves the bottom N values based on a specified filtering criteria - **Syntax:** ```javascript @@ -1492,7 +1512,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-expression/$range +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-expression/$range ### $reduce @@ -1507,7 +1527,7 @@ $reduce: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-expression/$reduce +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-expression/$reduce ### $reverseArray @@ -1520,7 +1540,7 @@ $reduce: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-expression/$reversearray +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-expression/$reversearray ### $size @@ -1545,7 +1565,7 @@ db.collection.find({ : { $size: } }) } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-expression/$slice +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-expression/$slice ### $sortArray @@ -1561,7 +1581,7 @@ db.collection.find({ : { $size: } }) } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-expression/$sortarray +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-expression/$sortarray ### $zip @@ -1578,7 +1598,7 @@ db.collection.find({ : { $size: } }) } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/array-expression/$zip +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/array-expression/$zip ## Bitwise Operators @@ -1593,7 +1613,7 @@ db.collection.find({ : { $size: } }) } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/bitwise/$bitand +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/bitwise/$bitand ### $bitNot @@ -1606,7 +1626,7 @@ db.collection.find({ : { $size: } }) } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/bitwise/$bitnot +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/bitwise/$bitnot ### $bitOr @@ -1619,7 +1639,7 @@ db.collection.find({ : { $size: } }) } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/bitwise/$bitor +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/bitwise/$bitor ### $bitXor @@ -1632,7 +1652,7 @@ db.collection.find({ : { $size: } }) } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/bitwise/$bitxor +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/bitwise/$bitxor ## Boolean Expression Operators @@ -1819,7 +1839,7 @@ db.collection.find({ : { $size: } }) } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/data-size/$bsonsize +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/data-size/$bsonsize ### $binarySize @@ -1832,7 +1852,7 @@ db.collection.find({ : { $size: } }) } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/data-size/$binarysize +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/data-size/$binarysize ## Date Expression Operators @@ -1850,7 +1870,7 @@ $dateAdd: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$dateadd +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$dateadd ### $dateDiff @@ -1867,7 +1887,7 @@ $dateDiff: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$datediff +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$datediff ### $dateFromParts @@ -1889,7 +1909,7 @@ $dateDiff: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$datefromparts +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$datefromparts ### $dateFromString @@ -1908,7 +1928,7 @@ $dateDiff: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$datefromstring +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$datefromstring ### $dateSubtract @@ -1926,7 +1946,7 @@ $dateDiff: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$datesubtract +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$datesubtract ### $dateToParts @@ -1941,7 +1961,7 @@ $dateToParts: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$datetoparts +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$datetoparts ### $dateToString @@ -1959,7 +1979,7 @@ $dateToParts: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$datetostring +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$datetostring ### $dateTrunc @@ -1976,7 +1996,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$datetrunc +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$datetrunc ### $dayOfMonth @@ -1989,7 +2009,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$dayofmonth +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$dayofmonth ### $dayOfWeek @@ -2002,7 +2022,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$dayofweek +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$dayofweek ### $dayOfYear @@ -2015,7 +2035,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$dayofyear +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$dayofyear ### $hour @@ -2028,7 +2048,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$hour +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$hour ### $isoDayOfWeek @@ -2041,7 +2061,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$isodayofweek +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$isodayofweek ### $isoWeek @@ -2054,7 +2074,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$isoweek +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$isoweek ### $isoWeekYear @@ -2067,7 +2087,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$isoweekyear +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$isoweekyear ### $millisecond @@ -2080,7 +2100,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$millisecond +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$millisecond ### $minute @@ -2093,7 +2113,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$minute +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$minute ### $month @@ -2106,7 +2126,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$month +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$month ### $second @@ -2119,7 +2139,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$second +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$second ### $toDate @@ -2133,7 +2153,7 @@ $dateTrunc: { ``` - **Doc Link:** none -- **Scraper Comment:** Doc page not found in expected directory 'date-expression/'. Content scraped from 'aggregation/type-expression/'. +- **Scraper Comment:** Doc page not found in expected directory 'date-expression/'. Content scraped from 'aggregation/'. ### $week @@ -2146,7 +2166,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$week +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$week ### $year @@ -2159,7 +2179,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$year +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$year ## Literal Expression Operator @@ -2174,7 +2194,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/literal-expression/$literal +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/literal-expression/$literal ## Miscellaneous Operators @@ -2192,7 +2212,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/miscellaneous/$getfield +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/miscellaneous/$getfield ### $rand @@ -2222,7 +2242,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/miscellaneous/$samplerate +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/miscellaneous/$samplerate ## Object Expression Operators @@ -2237,7 +2257,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/object-expression/$mergeobjects +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/object-expression/$mergeobjects ### $objectToArray @@ -2250,7 +2270,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/object-expression/$objecttoarray +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/object-expression/$objecttoarray ### $setField @@ -2267,7 +2287,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/object-expression/$setfield +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/object-expression/$setfield ## Set Expression Operators @@ -2282,7 +2302,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/set-expression/$allelementstrue +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/set-expression/$allelementstrue ### $anyElementTrue @@ -2295,7 +2315,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/set-expression/$anyelementtrue +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/set-expression/$anyelementtrue ### $setDifference @@ -2308,7 +2328,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/set-expression/$setdifference +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/set-expression/$setdifference ### $setEquals @@ -2321,7 +2341,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/set-expression/$setequals +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/set-expression/$setequals ### $setIntersection @@ -2334,7 +2354,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/set-expression/$setintersection +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/set-expression/$setintersection ### $setIsSubset @@ -2347,7 +2367,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/set-expression/$setissubset +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/set-expression/$setissubset ### $setUnion @@ -2360,7 +2380,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/set-expression/$setunion +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/set-expression/$setunion ## String Expression Operators @@ -2451,12 +2471,34 @@ $dateTrunc: { ``` - **Doc Link:** none -- **Scraper Comment:** Doc page not found in expected directory 'string-expression/'. Content scraped from 'aggregation/type-expression/'. +- **Scraper Comment:** Doc page not found in expected directory 'string-expression/'. Content scraped from 'aggregation/'. ### $trim ### $toUpper +## Text Expression Operator + +### $meta + +- **Description:** The $meta operator returns a calculated metadata column with returned dataset. +- **Syntax:** + +```javascript +db.collection.find({ + $text: { + $search: < string > + } +}, { + field: { + $meta: < metaDataKeyword > + } +}) +``` + +- **Doc Link:** none +- **Scraper Comment:** Doc page not found in expected directory 'miscellaneous/'. Content scraped from 'projection/'. + ## Timestamp Expression Operators ### $tsIncrement @@ -2470,7 +2512,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/timestamp-expression/$tsincrement +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/timestamp-expression/$tsincrement ### $tsSecond @@ -2483,7 +2525,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/timestamp-expression/$tssecond +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/timestamp-expression/$tssecond ## Trigonometry Expression Operators @@ -2536,7 +2578,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/type-expression/$convert +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$convert ### $isNumber @@ -2549,7 +2591,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/type-expression/$isnumber +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$isnumber ### $toBool @@ -2562,7 +2604,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/type-expression/$tobool +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$tobool ### $toDate @@ -2575,7 +2617,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/type-expression/$todate +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$todate ### $toDecimal @@ -2588,7 +2630,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/type-expression/$todecimal +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$todecimal ### $toDouble @@ -2601,7 +2643,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/type-expression/$todouble +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$todouble ### $toInt @@ -2614,7 +2656,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/type-expression/$toint +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$toint ### $toLong @@ -2627,7 +2669,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/type-expression/$tolong +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$tolong ### $toObjectId @@ -2640,7 +2682,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/type-expression/$toobjectid +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$toobjectid ### $toString @@ -2653,7 +2695,7 @@ $dateTrunc: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/type-expression/$tostring +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$tostring ### $type @@ -2667,7 +2709,7 @@ $dateTrunc: { ``` - **Doc Link:** none -- **Scraper Comment:** Doc page not found in expected directory 'aggregation/type-expression/'. Content scraped from 'element-query/'. +- **Scraper Comment:** Doc page not found in expected directory 'aggregation/'. Content scraped from 'element-query/'. ## Accumulators ($group, $bucket, $bucketAuto, $setWindowFields) @@ -2687,14 +2729,14 @@ $dateTrunc: { ### $avg -- **Description:** Computes the average of numeric values for documents in a group, bucket, or window. +- **Description:** The $avg operator computes the average of numeric values for documents in a group, bucket, or window. - **Syntax:** ```javascript $avg: ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$avg +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$avg ### $bottom @@ -2712,7 +2754,7 @@ $avg: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$bottom +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$bottom ### $bottomN @@ -2731,7 +2773,7 @@ $avg: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$bottomn +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$bottomn ### $count @@ -2744,7 +2786,7 @@ $avg: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$count +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$count ### $first @@ -2757,7 +2799,7 @@ $avg: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$first +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$first ### $firstN @@ -2776,7 +2818,7 @@ $avg: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$firstn +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$firstn ### $last @@ -2789,7 +2831,7 @@ $avg: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$last +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$last ### $lastN @@ -2810,7 +2852,7 @@ $avg: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$lastn +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$lastn ### $max @@ -2821,11 +2863,11 @@ $avg: $max: ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$max +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$max ### $maxN -- **Description:** Retrieves the top N values based on a specified filtering criteria +- **Description:** The $maxN opertor retrieves the top N values based on a specified filtering criteria - **Syntax:** ```javascript @@ -2835,7 +2877,7 @@ $maxN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$maxn +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$maxn ### $median @@ -2856,7 +2898,7 @@ $maxN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$median +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$median ### $mergeObjects @@ -2874,14 +2916,14 @@ $maxN: { ### $min -- **Description:** Retrieves the minimum value for a specified field +- **Description:** The $min operator retrieves the minimum value for a specified field - **Syntax:** ```javascript $min: ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$min +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$min ### $percentile @@ -2896,7 +2938,7 @@ $percentile: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$percentile +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$percentile ### $push @@ -2931,7 +2973,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$stddevpop +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$stddevpop ### $stdDevSamp @@ -2946,7 +2988,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$stddevsamp +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$stddevsamp ### $sum @@ -2959,7 +3001,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$sum +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$sum ### $top @@ -2977,7 +3019,7 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$top +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$top ### $topN @@ -2996,20 +3038,20 @@ db.collection.update({ } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$topn +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$topn ## Accumulators (in Other Stages) ### $avg -- **Description:** Computes the average of numeric values for documents in a group, bucket, or window. +- **Description:** The $avg operator computes the average of numeric values for documents in a group, bucket, or window. - **Syntax:** ```javascript $avg: ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$avg +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$avg ### $first @@ -3022,7 +3064,7 @@ $avg: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$first +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$first ### $last @@ -3035,7 +3077,7 @@ $avg: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$last +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$last ### $max @@ -3046,7 +3088,7 @@ $avg: $max: ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$max +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$max ### $median @@ -3067,18 +3109,18 @@ $max: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$median +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$median ### $min -- **Description:** Retrieves the minimum value for a specified field +- **Description:** The $min operator retrieves the minimum value for a specified field - **Syntax:** ```javascript $min: ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$min +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$min ### $percentile @@ -3093,7 +3135,7 @@ $percentile: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$percentile +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$percentile ### $stdDevPop @@ -3108,7 +3150,7 @@ $percentile: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$stddevpop +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$stddevpop ### $stdDevSamp @@ -3123,7 +3165,7 @@ $percentile: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$stddevsamp +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$stddevsamp ### $sum @@ -3136,7 +3178,7 @@ $percentile: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$sum +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$sum ## Variable Expression Operators @@ -3158,7 +3200,7 @@ $percentile: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/variable-expression/$let +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/variable-expression/$let ## Window Operators @@ -3238,7 +3280,7 @@ $max: ### $min -- **Description:** Retrieves the minimum value for a specified field +- **Description:** The $min operator retrieves the minimum value for a specified field - **Syntax:** ```javascript @@ -3250,7 +3292,7 @@ $min: ### $avg -- **Description:** Computes the average of numeric values for documents in a group, bucket, or window. +- **Description:** The $avg operator computes the average of numeric values for documents in a group, bucket, or window. - **Syntax:** ```javascript @@ -3326,7 +3368,7 @@ $avg: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/window-operators/$covariancepop +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/window-operators/$covariancepop ### $covarianceSamp @@ -3339,7 +3381,7 @@ $avg: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/window-operators/$covariancesamp +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/window-operators/$covariancesamp ### $denseRank @@ -3353,7 +3395,7 @@ $avg: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/window-operators/$denserank +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/window-operators/$denserank ### $derivative @@ -3369,7 +3411,7 @@ $avg: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/window-operators/$derivative +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/window-operators/$derivative ### $documentNumber @@ -3383,7 +3425,7 @@ $avg: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/window-operators/$documentnumber +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/window-operators/$documentnumber ### $expMovingAvg @@ -3399,7 +3441,7 @@ $avg: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/window-operators/$expmovingavg +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/window-operators/$expmovingavg ### $first @@ -3429,7 +3471,7 @@ $avg: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/window-operators/$integral +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/window-operators/$integral ### $last @@ -3461,7 +3503,7 @@ $avg: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/window-operators/$linearfill +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/window-operators/$linearfill ### $locf @@ -3477,11 +3519,11 @@ $avg: } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/window-operators/$locf +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/window-operators/$locf ### $minN -- **Description:** Retrieves the bottom N values based on a specified filtering criteria +- **Description:** The $minN operator retrieves the bottom N values based on a specified filtering criteria - **Syntax:** ```javascript @@ -3515,7 +3557,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/window-operators/$rank +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/window-operators/$rank ### $shift @@ -3532,7 +3574,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/window-operators/$shift +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/window-operators/$shift ### $stdDevSamp @@ -3606,7 +3648,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/conditional-expression/$cond +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/conditional-expression/$cond ### $ifNull @@ -3619,7 +3661,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/conditional-expression/$ifnull +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/conditional-expression/$ifnull ### $switch @@ -3638,7 +3680,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/conditional-expression/$switch +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/conditional-expression/$switch ## Aggregation Pipeline Stages @@ -3657,11 +3699,11 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$addfields +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$addfields ### $bucket -- **Description:** Groups input documents into buckets based on specified boundaries. +- **Description:** The $bucket operator groups input documents into buckets based on specified boundaries. - **Syntax:** ```javascript @@ -3678,7 +3720,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$bucket +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$bucket ### $bucketAuto @@ -3701,7 +3743,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$changestream +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$changestream ### $collStats @@ -3718,7 +3760,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$collstats +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$collstats ### $count @@ -3736,7 +3778,7 @@ $minN: { ### $densify -- **Description:** Adds missing data points in a sequence of values within an array or collection. +- **Description:** The $densify operator adds missing data points in a sequence of values within an array or collection. - **Syntax:** ```javascript @@ -3753,7 +3795,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$densify +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$densify ### $documents @@ -3770,7 +3812,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$documents +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$documents ### $facet @@ -3786,7 +3828,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$facet +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$facet ### $fill @@ -3807,7 +3849,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$fill +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$fill ### $geoNear @@ -3833,7 +3875,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$geonear +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$geonear ### $graphLookup @@ -3852,7 +3894,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$group +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$group ### $indexStats @@ -3866,7 +3908,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$indexstats +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$indexstats ### $limit @@ -3886,7 +3928,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$lookup +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$lookup ### $match @@ -3901,7 +3943,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$match +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$match ### $merge @@ -3919,7 +3961,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$merge +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$merge ### $out @@ -3932,13 +3974,13 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$out +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$out ### $project ### $redact -- **Description:** Filters the content of the documents based on access rights. +- **Description:** The $redact operator filters the content of the documents based on access rights. - **Syntax:** ```javascript @@ -3947,13 +3989,13 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$redact +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$redact ### $replaceRoot ### $replaceWith -- **Description:** The $replaceWith operator in Azure DocumentDB returns a document after replacing a document with the specified document +- **Description:** The $replaceWith operator in DocumentDB returns a document after replacing a document with the specified document - **Syntax:** ```javascript @@ -3962,11 +4004,11 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$replacewith +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$replacewith ### $sample -- **Description:** The $sample operator in Azure DocumentDB returns a randomly selected number of documents +- **Description:** The $sample operator in DocumentDB returns a randomly selected number of documents - **Syntax:** ```javascript @@ -3975,7 +4017,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$sample +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$sample ### $search @@ -3983,7 +4025,7 @@ $minN: { ### $set -- **Description:** The $set operator in Azure DocumentDB updates or creates a new field with a specified value +- **Description:** The $set operator in DocumentDB updates or creates a new field with a specified value - **Syntax:** ```javascript @@ -3994,7 +4036,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$set +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$set ### $setWindowFields @@ -4009,7 +4051,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$skip +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$skip ### $sort @@ -4025,7 +4067,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$sort +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$sort ### $sortByCount @@ -4038,7 +4080,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$sortbycount +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$sortbycount ### $unionWith @@ -4053,7 +4095,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$unset +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$unset ### $unwind @@ -4070,7 +4112,7 @@ $minN: { } ``` -- **Doc Link:** https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$unwind +- **Doc Link:** https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$unwind ### $currentOp @@ -4096,10 +4138,8 @@ Operators below are present on the compatibility page but are not in scope for this package (deprecated or not available in DocumentDB). - **$where** (Evaluation Query Operators) — Deprecated in Mongo version 8.0 -- **$meta** (Projection Operators) — Not in scope - **$accumulator** (Custom Aggregation Expression Operators) — Deprecated in Mongo version 8.0 - **$function** (Custom Aggregation Expression Operators) — Deprecated in Mongo version 8.0 -- **$meta** (Text Expression Operator) — Not in scope - **$accumulator** (Accumulators ($group, $bucket, $bucketAuto, $setWindowFields)) — Deprecated in Mongo version 8.0 - **$changeStreamSplitLargeEvent** (Aggregation Pipeline Stages) — Not in scope - **$listSampledQueries** (Aggregation Pipeline Stages) — Not in scope diff --git a/packages/documentdb-js-operator-registry/scripts/README.md b/packages/documentdb-js-operator-registry/scripts/README.md index 5e575db31..a9d0ea2e4 100644 --- a/packages/documentdb-js-operator-registry/scripts/README.md +++ b/packages/documentdb-js-operator-registry/scripts/README.md @@ -4,7 +4,7 @@ Helper scripts for maintaining the `@documentdb-js/operator-registry` package. ## scrape-operator-docs.ts -Scrapes the DocumentDB compatibility page and per-operator documentation to produce `resources/scraped/operator-reference.md`. +Scrapes the DocumentDB compatibility page and per-operator documentation to produce `resources/scraped/operator-reference.md` and `resources/scraped/index-reference.md`. ```bash npm run scrape @@ -12,7 +12,12 @@ npm run scrape **When to run:** When the upstream DocumentDB documentation changes (new operators, updated descriptions, etc.). This is infrequent — typically once per DocumentDB release. -**Output:** `resources/scraped/operator-reference.md` — a machine-generated Markdown dump of all supported operators, their descriptions, syntax blocks, and doc links. +**Source:** the public [MicrosoftDocs/nosql-docs](https://github.com/MicrosoftDocs/nosql-docs) repository (`azure/documentdb/compatibility-query-language.md` for the compatibility tables and `documentdb/query/operators/**` for per-operator pages). + +**Output:** + +- `resources/scraped/operator-reference.md` — a machine-generated Markdown dump of all supported operators, their descriptions, syntax blocks, and doc links. +- `resources/scraped/index-reference.md` — the supported index types and index properties (name, description, support status), parsed from the compatibility page's `## Index types` and `## Index properties` tables. ## generate-from-reference.ts @@ -33,10 +38,11 @@ npm run generate | File | Purpose | | ------------------------------------------- | ---------------------------------- | | `resources/scraped/operator-reference.md` | Primary data (machine-generated) | +| `resources/scraped/index-reference.md` | Index types/properties (generated) | | `resources/overrides/operator-overrides.md` | Manual overrides (hand-maintained) | | `resources/overrides/operator-snippets.md` | Snippet templates per category | -**Outputs:** Seven TypeScript files in `src/`: +**Outputs:** Eight TypeScript files in `src/`: - `queryOperators.ts` — comparison, logical, element, evaluation, geospatial, array, bitwise, projection, misc query operators - `updateOperators.ts` — field, array, and bitwise update operators @@ -45,6 +51,7 @@ npm run generate - `windowOperators.ts` — window function operators - `stages.ts` — aggregation pipeline stages - `systemVariables.ts` — system variables (`$$NOW`, `$$ROOT`, etc.) +- `indexReference.ts` — supported index types (`INDEX_TYPES`) and index properties (`INDEX_PROPERTIES`), from `resources/scraped/index-reference.md` > **Do not edit the generated `src/` files by hand.** Put corrections in the overrides or snippets files instead. The generated files contain a header warning to this effect. diff --git a/packages/documentdb-js-operator-registry/scripts/evaluate-overrides.ts b/packages/documentdb-js-operator-registry/scripts/evaluate-overrides.ts index 366bfc608..439d573c2 100644 --- a/packages/documentdb-js-operator-registry/scripts/evaluate-overrides.ts +++ b/packages/documentdb-js-operator-registry/scripts/evaluate-overrides.ts @@ -268,6 +268,7 @@ const CATEGORY_TO_META: Record = { 'Bitwise Query Operators': 'META_QUERY_BITWISE', 'Projection Operators': 'META_QUERY_PROJECTION', 'Miscellaneous Query Operators': 'META_QUERY_MISC', + 'Text Expression Operator': 'META_QUERY_PROJECTION', 'Field Update Operators': 'META_UPDATE_FIELD', 'Array Update Operators': 'META_UPDATE_ARRAY', 'Bitwise Update Operators': 'META_UPDATE_BITWISE', diff --git a/packages/documentdb-js-operator-registry/scripts/generate-from-reference.ts b/packages/documentdb-js-operator-registry/scripts/generate-from-reference.ts index 0e198b548..7023f8580 100644 --- a/packages/documentdb-js-operator-registry/scripts/generate-from-reference.ts +++ b/packages/documentdb-js-operator-registry/scripts/generate-from-reference.ts @@ -75,6 +75,10 @@ const CATEGORY_TO_META: Record = { 'Bitwise Query Operators': 'META_QUERY_BITWISE', 'Projection Operators': 'META_QUERY_PROJECTION', 'Miscellaneous Query Operators': 'META_QUERY_MISC', + // $meta appears in the compat table under both "Projection Operators" and + // "Text Expression Operator"; it is the same operator, so both map to the + // projection meta and the generator deduplicates by value + meta. + 'Text Expression Operator': 'META_QUERY_PROJECTION', 'Field Update Operators': 'META_UPDATE_FIELD', 'Array Update Operators': 'META_UPDATE_ARRAY', 'Bitwise Update Operators': 'META_UPDATE_BITWISE', @@ -125,6 +129,7 @@ const CATEGORY_TO_FILE: Record = { 'Bitwise Query Operators': 'queryOperators', 'Projection Operators': 'queryOperators', 'Miscellaneous Query Operators': 'queryOperators', + 'Text Expression Operator': 'queryOperators', 'Field Update Operators': 'updateOperators', 'Array Update Operators': 'updateOperators', 'Bitwise Update Operators': 'updateOperators', @@ -788,6 +793,95 @@ function categoryToVarName(category: string): string { .join(''); } +// --------------------------------------------------------------------------- +// Index reference generation +// --------------------------------------------------------------------------- + +interface ParsedIndexEntry { + name: string; + description: string; + supported: boolean; +} + +/** + * Parses the scraped index-reference.md dump into its "Index Types" and + * "Index Properties" tables (Name | Description | Supported). + */ +function parseIndexReference(content: string): { types: ParsedIndexEntry[]; properties: ParsedIndexEntry[] } { + const sections = new Map(); + let current = ''; + let separatorSeen = false; + + for (const raw of content.split('\n')) { + const h2 = raw.match(/^##\s+(.+)$/); + if (h2) { + current = h2[1].trim().toLowerCase(); + sections.set(current, []); + separatorSeen = false; + continue; + } + + const line = raw.trim(); + if (!current || !line.startsWith('|')) continue; + + const cells = line + .split('|') + .map((c) => c.trim()) + .filter((c) => c.length > 0); + if (cells.length < 3) continue; + if (cells.every((c) => /^:?-+:?$/.test(c))) { + separatorSeen = true; + continue; + } + if (!separatorSeen) continue; // header row + + sections.get(current)!.push({ + name: cells[0], + description: cells[1], + supported: /^yes$/i.test(cells[2]), + }); + } + + return { + types: sections.get('index types') ?? [], + properties: sections.get('index properties') ?? [], + }; +} + +/** + * Generates the src/indexReference.ts file content from the parsed index dump. + */ +function generateIndexReferenceContent(index: { types: ParsedIndexEntry[]; properties: ParsedIndexEntry[] }): string { + const copyright = `/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED — DO NOT EDIT BY HAND +// +// Generated by: npm run generate (scripts/generate-from-reference.ts) +// Source: resources/scraped/index-reference.md +// +// To change index data, re-run the scraper (npm run scrape) then the generator. +`; + + const emitArray = (constName: string, entries: ParsedIndexEntry[]): string => { + const rows = entries + .map( + (e) => + ` { name: '${escapeString(e.name)}', description: '${escapeString(e.description)}', supported: ${e.supported} },`, + ) + .join('\n'); + return `export const ${constName}: readonly IndexReferenceEntry[] = [\n${rows}\n];\n`; + }; + + return `${copyright} +import { type IndexReferenceEntry } from './types'; + +${emitArray('INDEX_TYPES', index.types)} +${emitArray('INDEX_PROPERTIES', index.properties)}`; +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -854,8 +948,22 @@ function main(): void { fs.writeFileSync(filePath, fileContent, 'utf-8'); } + // Generate the index types/properties reference from its scraped dump. + const indexDumpPath = path.join(__dirname, '..', 'resources', 'scraped', 'index-reference.md'); + const generatedIndexFiles: string[] = []; + if (fs.existsSync(indexDumpPath)) { + console.log('\n📇 Generating indexReference.ts...'); + const index = parseIndexReference(fs.readFileSync(indexDumpPath, 'utf-8')); + console.log(` ${index.types.length} index types, ${index.properties.length} index properties`); + const indexFilePath = path.join(srcDir, 'indexReference.ts'); + fs.writeFileSync(indexFilePath, generateIndexReferenceContent(index), 'utf-8'); + generatedIndexFiles.push(indexFilePath); + } else { + console.log('\nℹ️ No index-reference.md dump found, skipping indexReference.ts.'); + } + // Format generated files with Prettier - const generatedFiles = [...fileGroups.keys()].map((f) => path.join(srcDir, `${f}.ts`)); + const generatedFiles = [...[...fileGroups.keys()].map((f) => path.join(srcDir, `${f}.ts`)), ...generatedIndexFiles]; console.log('\n🎨 Formatting generated files with Prettier...'); execSync(`npx prettier --write ${generatedFiles.map((f) => `"${f}"`).join(' ')}`, { stdio: 'inherit', @@ -866,6 +974,9 @@ function main(): void { const count = specs.reduce((n, s) => n + s.operators.length, 0); console.log(` src/${fileName}.ts — ${count} operators`); } + for (const f of generatedIndexFiles) { + console.log(` src/${path.basename(f)}`); + } } main(); diff --git a/packages/documentdb-js-operator-registry/scripts/scrape-operator-docs.ts b/packages/documentdb-js-operator-registry/scripts/scrape-operator-docs.ts index 79d88e262..482d8a9ca 100644 --- a/packages/documentdb-js-operator-registry/scripts/scrape-operator-docs.ts +++ b/packages/documentdb-js-operator-registry/scripts/scrape-operator-docs.ts @@ -49,23 +49,35 @@ interface OperatorInfo { scraperComment?: string; } +/** + * A single row from the compatibility page's "Index types" or + * "Index properties" tables. + */ +interface IndexEntry { + /** Cleaned display name, e.g. "Single Field", "Wildcard", "TTL". */ + name: string; + /** Description from the table's second column. */ + description: string; + /** Whether DocumentDB supports it (derived from the "Supported" column). */ + supported: boolean; +} + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const COMPAT_PAGE_URL = - 'https://raw.githubusercontent.com/MicrosoftDocs/azure-databases-docs/main/articles/documentdb/compatibility-query-language.md'; + 'https://raw.githubusercontent.com/MicrosoftDocs/nosql-docs/main/azure/documentdb/compatibility-query-language.md'; -const OPERATOR_DOC_BASE = - 'https://raw.githubusercontent.com/MicrosoftDocs/azure-databases-docs/main/articles/documentdb/operators'; +const OPERATOR_DOC_BASE = 'https://raw.githubusercontent.com/MicrosoftDocs/nosql-docs/main/documentdb/query/operators'; -const DOC_LINK_BASE = 'https://learn.microsoft.com/en-us/azure/documentdb/operators'; +const DOC_LINK_BASE = 'https://learn.microsoft.com/en-us/documentdb/query/operators'; /** * Maps category names (as they appear in column 1 of the compat page table) * to the docs directory used for per-operator doc pages. * - * This mapping is derived from the operators TOC.yml in the azure-databases-docs repo. + * This mapping is derived from the operators TOC.yml in the nosql-docs repo. * Category names are trimmed before lookup, so leading/trailing spaces are OK. */ const CATEGORY_TO_DIR: Record = { @@ -102,7 +114,7 @@ const CATEGORY_TO_DIR: Record = { 'Set Expression Operators': 'set-expression', 'String Expression Operators': 'string-expression', 'Trigonometry Expression Operators': 'trigonometry-expression', - 'Type Expression Operators': 'aggregation/type-expression', + 'Type Expression Operators': 'aggregation', 'Timestamp Expression Operators': 'timestamp-expression', 'Variable Expression Operators': 'variable-expression', 'Text Expression Operator': 'miscellaneous', @@ -587,6 +599,160 @@ function parseCompatibilityTables(markdown: string): OperatorInfo[] { return operators; } +// --------------------------------------------------------------------------- +// Phase 1b: Index types & properties extraction +// --------------------------------------------------------------------------- + +/** + * Extracts the Markdown text of a `## ` section, up to the next + * `## ` heading (or end of document). Returns undefined if not found. + */ +function extractSection(markdown: string, heading: string): string | undefined { + const lines = markdown.replace(/\r\n/g, '\n').split('\n'); + const start = lines.findIndex((l) => l.trim().toLowerCase() === `## ${heading.toLowerCase()}`); + if (start === -1) return undefined; + + const body: string[] = []; + for (let i = start + 1; i < lines.length; i++) { + if (/^##\s/.test(lines[i])) break; + body.push(lines[i]); + } + return body.join('\n'); +} + +/** + * Parses a simple 3-column Markdown table (Name | Description | Supported) + * into rows of trimmed cells. Handles rows that upstream wrapped across two + * lines (e.g. the Vector index row) by accumulating physical lines until a + * full 3-column row (4 pipes) has been collected. + */ +function parseThreeColumnTable(sectionText: string): string[][] { + const rows: string[][] = []; + let buffer = ''; + + for (const raw of sectionText.split('\n')) { + const line = raw.trim(); + if (!line.startsWith('|')) { + buffer = ''; + continue; + } + + // Direct concatenation preserves the cell separator: a wrapped row's + // continuation line begins with the `|` that terminates the prior cell. + buffer += line; + if ((buffer.match(/\|/g)?.length ?? 0) < 4) { + // Not a complete 3-column row yet — wait for the continuation line. + continue; + } + + const cells = buffer + .split('|') + .map((c) => c.trim()) + .filter((c) => c.length > 0); + buffer = ''; + + if (cells.length < 3) continue; + // Skip the header separator row (| --- | --- | --- |). + if (cells.every((c) => /^:?-+:?$/.test(c))) continue; + rows.push(cells); + } + + return rows; +} + +/** Strips markdown links, keeping the link text: `[text](url)` -> `text`. */ +function stripMarkdownLinks(text: string): string { + return text.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1'); +} + +/** Cleans an index-type display name: drops the trailing "Index" and any parenthetical. */ +function cleanIndexTypeName(raw: string): string { + return raw + .replace(/\s*\([^)]*\)/g, '') + .replace(/\s+Index(es)?$/i, '') + .trim(); +} + +/** + * Cleans an index-property display name. Prefers a parenthetical acronym when + * present (e.g. "time-to-live (TTL)" -> "TTL"), otherwise returns the trimmed + * text with any parenthetical removed. + */ +function cleanIndexPropertyName(raw: string): string { + const acronym = raw.match(/\(([^)]+)\)/); + if (acronym) return acronym[1].trim(); + return raw.replace(/\s*\([^)]*\)/g, '').trim(); +} + +/** Returns true when a "Supported" cell indicates support (✅ / "Yes"). */ +function isSupportedCell(cell: string): boolean { + const normalized = cell.toLowerCase(); + return cell.includes('✅') || (normalized.includes('yes') && !normalized.includes('no')); +} + +/** + * Parses the "Index types" and "Index properties" sections of the + * compatibility page into structured entries. + */ +function parseIndexTables(markdown: string): { types: IndexEntry[]; properties: IndexEntry[] } { + const parseSection = (heading: string, cleanName: (raw: string) => string): IndexEntry[] => { + const section = extractSection(markdown, heading); + if (!section) return []; + + const entries: IndexEntry[] = []; + for (const cells of parseThreeColumnTable(section)) { + const name = cleanName(stripMarkdownLinks(cells[0])); + // Skip the header row (first column literally "Index" / "Index Property"). + if (!name || /^Index( Property)?$/i.test(name)) continue; + entries.push({ + name, + description: stripMarkdownLinks(cells[1]).trim(), + supported: isSupportedCell(cells[2]), + }); + } + return entries; + }; + + return { + types: parseSection('Index types', cleanIndexTypeName), + properties: parseSection('Index properties', cleanIndexPropertyName), + }; +} + +/** + * Generates the resources/scraped/index-reference.md dump for index types and + * properties. + */ +function generateIndexDump(index: { types: IndexEntry[]; properties: IndexEntry[] }): string { + const now = new Date().toISOString().split('T')[0]; + const lines: string[] = []; + + lines.push('# DocumentDB Index Reference'); + lines.push(''); + lines.push(''); + lines.push(``); + lines.push(''); + lines.push(''); + + const emitTable = (heading: string, entries: IndexEntry[]): void => { + lines.push(`## ${heading}`); + lines.push(''); + lines.push('| Name | Description | Supported |'); + lines.push('| --- | --- | --- |'); + for (const e of entries) { + lines.push( + `| ${escapeTableCell(e.name)} | ${escapeTableCell(e.description)} | ${e.supported ? 'Yes' : 'No'} |`, + ); + } + lines.push(''); + }; + + emitTable('Index Types', index.types); + emitTable('Index Properties', index.properties); + + return lines.join('\n'); +} + // --------------------------------------------------------------------------- // Phase 2: Per-operator doc fetching // --------------------------------------------------------------------------- @@ -601,8 +767,7 @@ function parseCompatibilityTables(markdown: string): OperatorInfo[] { * but lives in comparison-query/). */ async function buildGlobalFileIndex(): Promise> { - const GITHUB_API_BASE = - 'https://api.github.com/repos/MicrosoftDocs/azure-databases-docs/contents/articles/documentdb/operators'; + const GITHUB_API_BASE = 'https://api.github.com/repos/MicrosoftDocs/nosql-docs/contents/documentdb/query/operators'; type GithubEntry = { name: string; type: string }; const index = new Map(); @@ -630,7 +795,8 @@ async function buildGlobalFileIndex(): Promise> { index.set(file.name.toLowerCase(), dir.name); } - // Also check subdirectories (e.g., aggregation/type-expression/) + // Also check any subdirectories (defensive — the operators tree is + // currently flat in nosql-docs, but this keeps the crawl future-proof). for (const sub of subdirs) { await sleep(300); @@ -810,7 +976,7 @@ function generateDump(operators: OperatorInfo[]): string { lines.push(''); lines.push(''); lines.push(``); - lines.push(''); + lines.push(''); lines.push(''); // Summary table (compact — stays as a table) @@ -955,7 +1121,18 @@ async function main(): Promise { console.log(` Written to: ${outputPath}`); console.log(` File size: ${(dump.length / 1024).toFixed(1)} KB`); console.log(''); - console.log('Done! Review the generated file and commit it to the repo.'); + + // Phase 3b: Parse & generate the index types/properties dump from the same + // compatibility page content (no extra fetch needed). + console.log(' Phase 3b: Generating scraped/index-reference.md...'); + const index = parseIndexTables(compatResult.content); + console.log(` Parsed ${index.types.length} index types, ${index.properties.length} index properties`); + const indexDump = generateIndexDump(index); + const indexOutputPath = path.join(outputDir, 'index-reference.md'); + fs.writeFileSync(indexOutputPath, indexDump, 'utf-8'); + console.log(` Written to: ${indexOutputPath}`); + console.log(''); + console.log('Done! Review the generated files and commit them to the repo.'); } main().catch((err) => { diff --git a/packages/documentdb-js-operator-registry/src/accumulators.ts b/packages/documentdb-js-operator-registry/src/accumulators.ts index c2d4d97d0..9e23c95f7 100644 --- a/packages/documentdb-js-operator-registry/src/accumulators.ts +++ b/packages/documentdb-js-operator-registry/src/accumulators.ts @@ -28,12 +28,13 @@ const groupAccumulators: readonly OperatorEntry[] = [ description: "The addToSet operator adds elements to an array if they don't already exist, while ensuring uniqueness of elements within the set.", snippet: '{ $addToSet: "${1:\\$field}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/array-update/$addtoset', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/array-update/$addtoset', // inferred from another category }, { value: '$avg', meta: META_ACCUMULATOR, - description: 'Computes the average of numeric values for documents in a group, bucket, or window.', + description: + 'The $avg operator computes the average of numeric values for documents in a group, bucket, or window.', snippet: '{ $avg: "${1:\\$field}" }', link: getDocLink('$avg', META_ACCUMULATOR), }, @@ -99,7 +100,7 @@ const groupAccumulators: readonly OperatorEntry[] = [ { value: '$maxN', meta: META_ACCUMULATOR, - description: 'Retrieves the top N values based on a specified filtering criteria', + description: 'The $maxN operator retrieves the top N values based on specified filtering criteria.', snippet: '{ $maxN: { input: "${1:\\$field}", n: ${2:number} } }', link: getDocLink('$maxN', META_ACCUMULATOR), }, @@ -115,12 +116,12 @@ const groupAccumulators: readonly OperatorEntry[] = [ meta: META_ACCUMULATOR, description: 'The $mergeObjects operator merges multiple documents into a single document', snippet: '{ $mergeObjects: "${1:\\$field}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/object-expression/$mergeobjects', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/object-expression/$mergeobjects', // inferred from another category }, { value: '$min', meta: META_ACCUMULATOR, - description: 'Retrieves the minimum value for a specified field', + description: 'The $min operator retrieves the minimum value for a specified field', snippet: '{ $min: "${1:\\$field}" }', link: getDocLink('$min', META_ACCUMULATOR), }, @@ -137,7 +138,7 @@ const groupAccumulators: readonly OperatorEntry[] = [ meta: META_ACCUMULATOR, description: 'The $push operator adds a specified value to an array within a document.', snippet: '{ $push: "${1:\\$field}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/array-update/$push', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/array-update/$push', // inferred from another category }, { value: '$stdDevPop', diff --git a/packages/documentdb-js-operator-registry/src/docLinks.test.ts b/packages/documentdb-js-operator-registry/src/docLinks.test.ts index c79a53da9..a95759fb4 100644 --- a/packages/documentdb-js-operator-registry/src/docLinks.test.ts +++ b/packages/documentdb-js-operator-registry/src/docLinks.test.ts @@ -11,50 +11,48 @@ import { getDocBase, getDocLink } from './index'; describe('docLinks', () => { test('getDocBase returns the expected base URL', () => { - expect(getDocBase()).toBe('https://learn.microsoft.com/en-us/azure/documentdb/operators'); + expect(getDocBase()).toBe('https://learn.microsoft.com/en-us/documentdb/query/operators'); }); describe('getDocLink', () => { test('generates correct URL for comparison query operator', () => { const link = getDocLink('$eq', 'query:comparison'); - expect(link).toBe('https://learn.microsoft.com/en-us/azure/documentdb/operators/comparison-query/$eq'); + expect(link).toBe('https://learn.microsoft.com/en-us/documentdb/query/operators/comparison-query/$eq'); }); test('generates correct URL for aggregation stage', () => { const link = getDocLink('$match', 'stage'); - expect(link).toBe('https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$match'); + expect(link).toBe('https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$match'); }); test('generates correct URL for accumulator', () => { const link = getDocLink('$sum', 'accumulator'); - expect(link).toBe('https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$sum'); + expect(link).toBe('https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$sum'); }); test('generates correct URL for field update operator', () => { const link = getDocLink('$set', 'update:field'); - expect(link).toBe('https://learn.microsoft.com/en-us/azure/documentdb/operators/field-update/$set'); + expect(link).toBe('https://learn.microsoft.com/en-us/documentdb/query/operators/field-update/$set'); }); test('generates correct URL for array expression operator', () => { const link = getDocLink('$filter', 'expr:array'); - expect(link).toBe('https://learn.microsoft.com/en-us/azure/documentdb/operators/array-expression/$filter'); + expect(link).toBe('https://learn.microsoft.com/en-us/documentdb/query/operators/array-expression/$filter'); }); - test('generates correct URL for type expression operator (nested dir)', () => { + test('generates correct URL for type expression operator', () => { const link = getDocLink('$convert', 'expr:type'); - expect(link).toBe( - 'https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/type-expression/$convert', - ); + expect(link).toBe('https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$convert'); }); test('generates correct URL for window operator', () => { const link = getDocLink('$rank', 'window'); - expect(link).toBe('https://learn.microsoft.com/en-us/azure/documentdb/operators/window-operators/$rank'); + expect(link).toBe('https://learn.microsoft.com/en-us/documentdb/query/operators/window-operators/$rank'); }); test('lowercases operator names in URLs', () => { const link = getDocLink('$AddFields', 'stage'); - expect(link).toBe('https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$addfields'); + expect(link).toBe('https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$addfields'); }); test('returns undefined for unknown meta tag', () => { @@ -71,12 +69,12 @@ describe('docLinks', () => { test('generates correct URL for boolean expression operator', () => { const link = getDocLink('$and', 'expr:bool'); - expect(link).toBe('https://learn.microsoft.com/en-us/azure/documentdb/operators/boolean-expression/$and'); + expect(link).toBe('https://learn.microsoft.com/en-us/documentdb/query/operators/boolean-expression/$and'); }); test('generates correct URL for comparison expression operator', () => { const link = getDocLink('$eq', 'expr:comparison'); - expect(link).toBe('https://learn.microsoft.com/en-us/azure/documentdb/operators/comparison-expression/$eq'); + expect(link).toBe('https://learn.microsoft.com/en-us/documentdb/query/operators/comparison-expression/$eq'); }); }); }); diff --git a/packages/documentdb-js-operator-registry/src/docLinks.ts b/packages/documentdb-js-operator-registry/src/docLinks.ts index 460112548..8b8ae2cd3 100644 --- a/packages/documentdb-js-operator-registry/src/docLinks.ts +++ b/packages/documentdb-js-operator-registry/src/docLinks.ts @@ -7,10 +7,10 @@ * URL generation helpers for DocumentDB documentation pages. * * Each operator has a documentation page at: - * https://learn.microsoft.com/en-us/azure/documentdb/operators/{category}/{operatorName} + * https://learn.microsoft.com/en-us/documentdb/query/operators/{category}/{operatorName} */ -const DOC_BASE = 'https://learn.microsoft.com/en-us/azure/documentdb/operators'; +const DOC_BASE = 'https://learn.microsoft.com/en-us/documentdb/query/operators'; /** * Maps meta tag prefixes to the docs directory name used in the @@ -41,7 +41,7 @@ const META_TO_DOC_DIR: Record = { 'expr:set': 'set-expression', 'expr:string': 'string-expression', 'expr:trig': 'trigonometry-expression', - 'expr:type': 'aggregation/type-expression', + 'expr:type': 'aggregation', 'expr:datasize': 'data-size', 'expr:timestamp': 'timestamp-expression', 'expr:bitwise': 'bitwise', diff --git a/packages/documentdb-js-operator-registry/src/expressionOperators.ts b/packages/documentdb-js-operator-registry/src/expressionOperators.ts index a75905738..52ed4720a 100644 --- a/packages/documentdb-js-operator-registry/src/expressionOperators.ts +++ b/packages/documentdb-js-operator-registry/src/expressionOperators.ts @@ -109,7 +109,7 @@ const arithmeticExpressionOperators: readonly OperatorEntry[] = [ description: 'The $mod operator performs a modulo operation on the value of a field and selects documents with a specified result.', snippet: '{ $mod: ["${1:\\$field1}", "${2:\\$field2}"] }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/evaluation-query/$mod', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/evaluation-query/$mod', // inferred from another category }, { value: '$multiply', @@ -195,14 +195,14 @@ const arrayExpressionOperators: readonly OperatorEntry[] = [ description: 'The $firstN operator sorts documents on one or more fields specified by the query and returns the first N document matching the filtering criteria', snippet: '{ $firstN: { input: "${1:\\$array}", n: ${2:number} } }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$firstn', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$firstn', // inferred from another category }, { value: '$in', meta: META_EXPR_ARRAY, description: 'The $in operator matches value of a field against an array of specified values', snippet: '{ $in: ["${1:\\$field}", "${2:\\$array}"] }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/comparison-query/$in', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/comparison-query/$in', // inferred from another category }, { value: '$indexOfArray', @@ -224,7 +224,7 @@ const arrayExpressionOperators: readonly OperatorEntry[] = [ meta: META_EXPR_ARRAY, description: 'The $lastN accumulator operator returns the last N values in a group of documents.', snippet: '{ $lastN: { input: "${1:\\$array}", n: ${2:number} } }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$lastn', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$lastn', // inferred from another category }, { value: '$map', @@ -236,23 +236,23 @@ const arrayExpressionOperators: readonly OperatorEntry[] = [ { value: '$maxN', meta: META_EXPR_ARRAY, - description: 'Retrieves the top N values based on a specified filtering criteria', + description: 'The $maxN operator retrieves the top N values based on specified filtering criteria.', snippet: '{ $maxN: { input: "${1:\\$array}", n: ${2:number} } }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$maxn', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$maxn', // inferred from another category }, { value: '$minN', meta: META_EXPR_ARRAY, - description: 'Retrieves the bottom N values based on a specified filtering criteria', + description: 'The $minN operator retrieves the bottom N values based on a specified filtering criteria', snippet: '{ $minN: { input: "${1:\\$array}", n: ${2:number} } }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$minn', + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$minn', }, { value: '$objectToArray', meta: META_EXPR_ARRAY, description: 'Converts an object into an array of key-value pair documents.', snippet: '{ $objectToArray: "${1:\\$object}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/object-expression/$objecttoarray', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/object-expression/$objecttoarray', // inferred from another category }, { value: '$range', @@ -282,7 +282,7 @@ const arrayExpressionOperators: readonly OperatorEntry[] = [ description: 'The $size operator is used to query documents where an array field has a specified number of elements.', snippet: '{ $size: "${1:\\$array}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/array-query/$size', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/array-query/$size', // inferred from another category }, { value: '$slice', @@ -356,7 +356,7 @@ const booleanExpressionOperators: readonly OperatorEntry[] = [ description: 'The $and operator joins multiple query clauses and returns documents that match all specified conditions.', snippet: '{ $and: ["${1:expression1}", "${2:expression2}"] }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/logical-query/$and', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/logical-query/$and', // inferred from another category }, { value: '$not', @@ -364,7 +364,7 @@ const booleanExpressionOperators: readonly OperatorEntry[] = [ description: "The $not operator performs a logical NOT operation on a specified expression, selecting documents that don't match the expression.", snippet: '{ $not: ["${1:expression}"] }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/logical-query/$not', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/logical-query/$not', // inferred from another category }, { value: '$or', @@ -372,7 +372,7 @@ const booleanExpressionOperators: readonly OperatorEntry[] = [ description: 'The $or operator joins query clauses with a logical OR and returns documents that match at least one of the specified conditions.', snippet: '{ $or: ["${1:expression1}", "${2:expression2}"] }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/logical-query/$or', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/logical-query/$or', // inferred from another category }, ]; @@ -386,14 +386,14 @@ const comparisonExpressionOperators: readonly OperatorEntry[] = [ meta: META_EXPR_COMPARISON, description: 'The $cmp operator compares two values', snippet: '{ $cmp: ["${1:\\$field1}", "${2:\\$field2}"] }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/comparison-query/$cmp', + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/comparison-query/$cmp', }, { value: '$eq', meta: META_EXPR_COMPARISON, description: 'The $eq query operator compares the value of a field to a specified value', snippet: '{ $eq: ["${1:\\$field1}", "${2:\\$field2}"] }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/comparison-query/$eq', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/comparison-query/$eq', // inferred from another category }, { value: '$gt', @@ -401,7 +401,7 @@ const comparisonExpressionOperators: readonly OperatorEntry[] = [ description: 'The $gt query operator retrieves documents where the value of a field is greater than a specified value', snippet: '{ $gt: ["${1:\\$field1}", "${2:\\$field2}"] }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/comparison-query/$gt', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/comparison-query/$gt', // inferred from another category }, { value: '$gte', @@ -409,14 +409,14 @@ const comparisonExpressionOperators: readonly OperatorEntry[] = [ description: 'The $gte operator retrieves documents where the value of a field is greater than or equal to a specified value', snippet: '{ $gte: ["${1:\\$field1}", "${2:\\$field2}"] }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/comparison-query/$gte', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/comparison-query/$gte', // inferred from another category }, { value: '$lt', meta: META_EXPR_COMPARISON, description: 'The $lt operator retrieves documents where the value of field is less than a specified value', snippet: '{ $lt: ["${1:\\$field1}", "${2:\\$field2}"] }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/comparison-query/$lt', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/comparison-query/$lt', // inferred from another category }, { value: '$lte', @@ -424,14 +424,14 @@ const comparisonExpressionOperators: readonly OperatorEntry[] = [ description: 'The $lte operator retrieves documents where the value of a field is less than or equal to a specified value', snippet: '{ $lte: ["${1:\\$field1}", "${2:\\$field2}"] }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/comparison-query/$lte', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/comparison-query/$lte', // inferred from another category }, { value: '$ne', meta: META_EXPR_COMPARISON, description: "The $ne operator retrieves documents where the value of a field doesn't equal a specified value", snippet: '{ $ne: ["${1:\\$field1}", "${2:\\$field2}"] }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/comparison-query/$ne', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/comparison-query/$ne', // inferred from another category }, ]; @@ -604,7 +604,7 @@ const dateExpressionOperators: readonly OperatorEntry[] = [ meta: META_EXPR_DATE, description: 'The $toDate operator converts supported types to a proper Date object.', snippet: '{ $toDate: "${1:\\$field}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/type-expression/$todate', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$todate', // inferred from another category }, { value: '$week', @@ -654,7 +654,7 @@ const miscellaneousOperators: readonly OperatorEntry[] = [ meta: META_EXPR_MISC, description: 'The $rand operator generates a random float value between 0 and 1.', snippet: '{ $rand: {} }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/miscellaneous-query/$rand', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/miscellaneous-query/$rand', // inferred from another category }, { value: '$sampleRate', @@ -770,14 +770,14 @@ const stringExpressionOperators: readonly OperatorEntry[] = [ meta: META_EXPR_STRING, description: 'The $dateDiff operator converts a date/time string to a date object.', snippet: '{ $dateFromString: "${1:\\$string}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$datefromstring', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$datefromstring', // inferred from another category }, { value: '$dateToString', meta: META_EXPR_STRING, description: 'The $dateToString operator converts a date object into a formatted string.', snippet: '{ $dateToString: "${1:\\$string}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/date-expression/$datetostring', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/date-expression/$datetostring', // inferred from another category }, { value: '$indexOfBytes', @@ -889,7 +889,7 @@ const stringExpressionOperators: readonly OperatorEntry[] = [ meta: META_EXPR_STRING, description: 'The $toString operator converts an expression into a String', snippet: '{ $toString: "${1:\\$string}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/type-expression/$tostring', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$tostring', // inferred from another category }, { value: '$trim', @@ -1103,7 +1103,7 @@ const typeExpressionOperators: readonly OperatorEntry[] = [ meta: META_EXPR_TYPE, description: 'The $type operator retrieves documents if the chosen field is of the specified type.', snippet: '{ $type: "${1:\\$field}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/element-query/$type', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/element-query/$type', // inferred from another category }, ]; diff --git a/packages/documentdb-js-operator-registry/src/index.ts b/packages/documentdb-js-operator-registry/src/index.ts index 770ef3aa8..72def535a 100644 --- a/packages/documentdb-js-operator-registry/src/index.ts +++ b/packages/documentdb-js-operator-registry/src/index.ts @@ -11,7 +11,7 @@ */ // -- Core types -- -export type { CompletionFilter, MetaTag, OperatorEntry } from './types'; +export type { CompletionFilter, IndexReferenceEntry, MetaTag, OperatorEntry } from './types'; // -- Meta tag constants and presets -- export { @@ -70,6 +70,9 @@ export { getAllCompletions, getFilteredCompletions } from './getFilteredCompleti // -- Documentation URL helpers -- export { getDocBase, getDocLink } from './docLinks'; +// -- Index types & properties (scraped from the compatibility page) -- +export { INDEX_PROPERTIES, INDEX_TYPES } from './indexReference'; + // -- Operator data modules -- import { loadAccumulators } from './accumulators'; import { loadBsonConstructors } from './bsonConstructors'; diff --git a/packages/documentdb-js-operator-registry/src/indexReference.test.ts b/packages/documentdb-js-operator-registry/src/indexReference.test.ts new file mode 100644 index 000000000..82fb68e0e --- /dev/null +++ b/packages/documentdb-js-operator-registry/src/indexReference.test.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Verifies the generated index reference data (scraped from the compatibility + * page's "Index types" and "Index properties" tables). + */ + +import { INDEX_PROPERTIES, INDEX_TYPES } from './index'; + +describe('index reference', () => { + test('exposes the documented index types', () => { + const names = INDEX_TYPES.map((t) => t.name); + expect(names).toEqual( + expect.arrayContaining([ + 'Single Field', + 'Compound', + 'Multikey', + 'Text', + 'Wildcard', + 'Geospatial', + 'Hashed', + 'Vector', + ]), + ); + }); + + test('exposes the documented index properties', () => { + const names = INDEX_PROPERTIES.map((p) => p.name); + expect(names).toEqual( + expect.arrayContaining(['TTL', 'Unique', 'Partial', 'Case Insensitive', 'Sparse', 'Background']), + ); + }); + + test('every entry has a non-empty name and description', () => { + for (const entry of [...INDEX_TYPES, ...INDEX_PROPERTIES]) { + expect(entry.name.length).toBeGreaterThan(0); + expect(entry.description.length).toBeGreaterThan(0); + } + }); + + test('all listed types and properties are marked supported', () => { + for (const entry of [...INDEX_TYPES, ...INDEX_PROPERTIES]) { + expect(entry.supported).toBe(true); + } + }); +}); diff --git a/packages/documentdb-js-operator-registry/src/indexReference.ts b/packages/documentdb-js-operator-registry/src/indexReference.ts new file mode 100644 index 000000000..5b15f7142 --- /dev/null +++ b/packages/documentdb-js-operator-registry/src/indexReference.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED — DO NOT EDIT BY HAND +// +// Generated by: npm run generate (scripts/generate-from-reference.ts) +// Source: resources/scraped/index-reference.md +// +// To change index data, re-run the scraper (npm run scrape) then the generator. + +import { type IndexReferenceEntry } from './types'; + +export const INDEX_TYPES: readonly IndexReferenceEntry[] = [ + { name: 'Single Field', description: 'Indexes a single field for faster lookups.', supported: true }, + { name: 'Compound', description: 'Indexes multiple fields in one index.', supported: true }, + { name: 'Multikey', description: 'Indexes array fields by indexing each element.', supported: true }, + { name: 'Text', description: 'Supports text search on string fields.', supported: true }, + { name: 'Wildcard', description: 'Dynamically indexes all or selected fields.', supported: true }, + { name: 'Geospatial', description: 'Supports spatial queries on GeoJSON data.', supported: true }, + { name: 'Hashed', description: 'Indexes hashed field values, often for sharding.', supported: true }, + { name: 'Vector', description: 'Enables similarity search on vector data.', supported: true }, +]; + +export const INDEX_PROPERTIES: readonly IndexReferenceEntry[] = [ + { + name: 'TTL', + description: 'Automatically deletes documents after a specified time-to-live period.', + supported: true, + }, + { name: 'Unique', description: 'Ensures that all values in the indexed field are unique.', supported: true }, + { + name: 'Partial', + description: 'Indexes only documents that match a specified filter condition.', + supported: true, + }, + { name: 'Case Insensitive', description: 'Supports case-insensitive indexing for string fields.', supported: true }, + { name: 'Sparse', description: 'Indexes only documents that contain the indexed field.', supported: true }, + { + name: 'Background', + description: 'Allows the index to be created in the background without blocking operations.', + supported: true, + }, +]; diff --git a/packages/documentdb-js-operator-registry/src/operatorReference.test.ts b/packages/documentdb-js-operator-registry/src/operatorReference.test.ts index 7df303f81..d813b234b 100644 --- a/packages/documentdb-js-operator-registry/src/operatorReference.test.ts +++ b/packages/documentdb-js-operator-registry/src/operatorReference.test.ts @@ -41,6 +41,7 @@ const CATEGORY_TO_META: Record = { 'Bitwise Query Operators': 'query:bitwise', 'Projection Operators': 'query:projection', 'Miscellaneous Query Operators': 'query:misc', + 'Text Expression Operator': 'query:projection', 'Field Update Operators': 'update:field', 'Array Update Operators': 'update:array', 'Bitwise Update Operators': 'update:bitwise', diff --git a/packages/documentdb-js-operator-registry/src/queryOperators.ts b/packages/documentdb-js-operator-registry/src/queryOperators.ts index 8390356a6..43c3722c8 100644 --- a/packages/documentdb-js-operator-registry/src/queryOperators.ts +++ b/packages/documentdb-js-operator-registry/src/queryOperators.ts @@ -320,7 +320,7 @@ const arrayQueryOperators: readonly OperatorEntry[] = [ value: '$elemMatch', meta: META_QUERY_ARRAY, description: - 'The $elemmatch operator returns complete array, qualifying criteria with at least one matching array element.', + 'The $elemMatch operator returns complete array, qualifying criteria with at least one matching array element.', snippet: '{ $elemMatch: { ${1:query} } }', link: getDocLink('$elemMatch', META_QUERY_ARRAY), applicableBsonTypes: ['array'], @@ -388,23 +388,29 @@ const projectionOperators: readonly OperatorEntry[] = [ meta: META_QUERY_PROJECTION, description: 'The $ positional operator identifies an element in an array to update without explicitly specifying the position of the element in the array.', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/array-update/$', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/array-update/$', // inferred from another category standalone: false, }, { value: '$elemMatch', meta: META_QUERY_PROJECTION, description: - 'The $elemmatch operator returns complete array, qualifying criteria with at least one matching array element.', + 'The $elemMatch operator returns complete array, qualifying criteria with at least one matching array element.', snippet: '{ $elemMatch: { ${1:query} } }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/array-query/$elemmatch', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/array-query/$elemmatch', // inferred from another category + }, + { + value: '$meta', + meta: META_QUERY_PROJECTION, + description: 'The $meta operator returns a calculated metadata column with returned dataset.', + link: getDocLink('$meta', META_QUERY_PROJECTION), }, { value: '$slice', meta: META_QUERY_PROJECTION, description: 'The $slice operator returns a subset of an array from any element onwards in the array.', snippet: '{ $slice: ${1:number} }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/array-expression/$slice', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/array-expression/$slice', // inferred from another category }, ]; diff --git a/packages/documentdb-js-operator-registry/src/stages.ts b/packages/documentdb-js-operator-registry/src/stages.ts index 0752d7734..bfa8d0a75 100644 --- a/packages/documentdb-js-operator-registry/src/stages.ts +++ b/packages/documentdb-js-operator-registry/src/stages.ts @@ -32,7 +32,7 @@ const aggregationPipelineStages: readonly OperatorEntry[] = [ { value: '$bucket', meta: META_STAGE, - description: 'Groups input documents into buckets based on specified boundaries.', + description: 'The $bucket operator groups input documents into buckets based on specified boundaries.', snippet: '{ $bucket: { groupBy: "${1:\\$field}", boundaries: [${2:values}], default: "${3:Other}" } }', link: getDocLink('$bucket', META_STAGE), }, @@ -64,12 +64,13 @@ const aggregationPipelineStages: readonly OperatorEntry[] = [ description: 'The `$count` operator is used to count the number of documents that match a query filtering criteria.', snippet: '{ $count: "${1:countField}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$count', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$count', // inferred from another category }, { value: '$densify', meta: META_STAGE, - description: 'Adds missing data points in a sequence of values within an array or collection.', + description: + 'The $densify operator adds missing data points in a sequence of values within an array or collection.', snippet: '{ $densify: { field: "${1:field}", range: { step: ${2:1}, bounds: "full" } } }', link: getDocLink('$densify', META_STAGE), }, @@ -176,7 +177,7 @@ const aggregationPipelineStages: readonly OperatorEntry[] = [ { value: '$redact', meta: META_STAGE, - description: 'Filters the content of the documents based on access rights.', + description: 'The $redact operator filters the content of the documents based on access rights.', snippet: '{ $redact: { \\$cond: { if: { ${1:expression} }, then: "${2:\\$\\$DESCEND}", else: "${3:\\$\\$PRUNE}" } } }', link: getDocLink('$redact', META_STAGE), @@ -191,14 +192,14 @@ const aggregationPipelineStages: readonly OperatorEntry[] = [ value: '$replaceWith', meta: META_STAGE, description: - 'The $replaceWith operator in Azure DocumentDB returns a document after replacing a document with the specified document', + 'The $replaceWith operator in DocumentDB returns a document after replacing a document with the specified document', snippet: '{ $replaceWith: "${1:\\$field}" }', link: getDocLink('$replaceWith', META_STAGE), }, { value: '$sample', meta: META_STAGE, - description: 'The $sample operator in Azure DocumentDB returns a randomly selected number of documents', + description: 'The $sample operator in DocumentDB returns a randomly selected number of documents', snippet: '{ $sample: { size: ${1:number} } }', link: getDocLink('$sample', META_STAGE), }, @@ -217,7 +218,7 @@ const aggregationPipelineStages: readonly OperatorEntry[] = [ { value: '$set', meta: META_STAGE, - description: 'The $set operator in Azure DocumentDB updates or creates a new field with a specified value', + description: 'The $set operator in DocumentDB updates or creates a new field with a specified value', snippet: '{ $set: { ${1:field}: ${2:expression} } }', link: getDocLink('$set', META_STAGE), }, diff --git a/packages/documentdb-js-operator-registry/src/structuralInvariants.test.ts b/packages/documentdb-js-operator-registry/src/structuralInvariants.test.ts index 953fc7831..d9fe904a4 100644 --- a/packages/documentdb-js-operator-registry/src/structuralInvariants.test.ts +++ b/packages/documentdb-js-operator-registry/src/structuralInvariants.test.ts @@ -228,7 +228,7 @@ describe('meta tag coverage', () => { countByPrefix[prefix] = (countByPrefix[prefix] || 0) + 1; } - expect(countByPrefix['query']).toBe(43); + expect(countByPrefix['query']).toBe(44); expect(countByPrefix['update']).toBe(22); expect(countByPrefix['stage']).toBe(35); expect(countByPrefix['accumulator']).toBe(21); diff --git a/packages/documentdb-js-operator-registry/src/types.ts b/packages/documentdb-js-operator-registry/src/types.ts index d08cac711..704be17ee 100644 --- a/packages/documentdb-js-operator-registry/src/types.ts +++ b/packages/documentdb-js-operator-registry/src/types.ts @@ -95,6 +95,23 @@ export interface OperatorEntry { readonly returnType?: string; } +/** + * A single index type or index property supported by DocumentDB, scraped from + * the "Index types" / "Index properties" tables on the compatibility page. + * + * Generated into `indexReference.ts` — see `scripts/scrape-operator-docs.ts`. + */ +export interface IndexReferenceEntry { + /** Display name, e.g. "Single Field", "Wildcard", "TTL". */ + readonly name: string; + + /** Human-readable one-line description from the docs. */ + readonly description: string; + + /** Whether DocumentDB supports this index type / property. */ + readonly supported: boolean; +} + /** * Filter configuration for {@link getFilteredCompletions}. */ diff --git a/packages/documentdb-js-operator-registry/src/updateOperators.ts b/packages/documentdb-js-operator-registry/src/updateOperators.ts index 2936c69c0..62cf4f012 100644 --- a/packages/documentdb-js-operator-registry/src/updateOperators.ts +++ b/packages/documentdb-js-operator-registry/src/updateOperators.ts @@ -42,14 +42,14 @@ const fieldUpdateOperators: readonly OperatorEntry[] = [ meta: META_UPDATE_FIELD, description: 'Updates the field only if the specified value is less than the existing field value.', snippet: '{ $min: { "${1:field}": ${2:value} } }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$min', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$min', // inferred from another category }, { value: '$max', meta: META_UPDATE_FIELD, description: 'Updates the field only if the specified value is greater than the existing field value.', snippet: '{ $max: { "${1:field}": ${2:value} } }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$max', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$max', // inferred from another category }, { value: '$mul', @@ -68,9 +68,9 @@ const fieldUpdateOperators: readonly OperatorEntry[] = [ { value: '$set', meta: META_UPDATE_FIELD, - description: 'The $set operator in Azure DocumentDB updates or creates a new field with a specified value', + description: 'The $set operator in DocumentDB updates or creates a new field with a specified value', snippet: '{ $set: { "${1:field}": ${2:value} } }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$set', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$set', // inferred from another category }, { value: '$setOnInsert', @@ -85,7 +85,7 @@ const fieldUpdateOperators: readonly OperatorEntry[] = [ meta: META_UPDATE_FIELD, description: 'Removes the specified field from a document.', snippet: '{ $unset: { "${1:field}": ${2:value} } }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$unset', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$unset', // inferred from another category }, ]; @@ -123,7 +123,7 @@ const arrayUpdateOperators: readonly OperatorEntry[] = [ { value: '$pop', meta: META_UPDATE_ARRAY, - description: 'Removes the first or last element of an array.', + description: 'The $pop operator removes the first or last element of an array.', snippet: '{ $pop: { "${1:field}": ${2:1} } }', link: getDocLink('$pop', META_UPDATE_ARRAY), }, @@ -168,14 +168,14 @@ const arrayUpdateOperators: readonly OperatorEntry[] = [ meta: META_UPDATE_ARRAY, description: 'Limits the number of elements in an array during a `$push` operation.', snippet: '{ $slice: ${1:number} }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/array-expression/$slice', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/array-expression/$slice', // inferred from another category }, { value: '$sort', meta: META_UPDATE_ARRAY, description: 'Sorts the elements of an array during a `$push` operation.', snippet: '{ $sort: { "${1:field}": ${2:1} } }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/aggregation/$sort', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/aggregation/$sort', // inferred from another category }, ]; diff --git a/packages/documentdb-js-operator-registry/src/windowOperators.ts b/packages/documentdb-js-operator-registry/src/windowOperators.ts index f15b412e1..90743883c 100644 --- a/packages/documentdb-js-operator-registry/src/windowOperators.ts +++ b/packages/documentdb-js-operator-registry/src/windowOperators.ts @@ -27,14 +27,14 @@ const windowOperators: readonly OperatorEntry[] = [ meta: META_WINDOW, description: 'The $sum operator calculates the sum of the values of a field based on a filtering criteria', snippet: '{ $sum: "${1:\\$field}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$sum', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$sum', // inferred from another category }, { value: '$push', meta: META_WINDOW, description: 'The $push operator adds a specified value to an array within a document.', snippet: '{ $push: "${1:\\$field}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/array-update/$push', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/array-update/$push', // inferred from another category }, { value: '$addToSet', @@ -42,7 +42,7 @@ const windowOperators: readonly OperatorEntry[] = [ description: "The addToSet operator adds elements to an array if they don't already exist, while ensuring uniqueness of elements within the set.", snippet: '{ $addToSet: "${1:\\$field}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/array-update/$addtoset', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/array-update/$addtoset', // inferred from another category }, { value: '$count', @@ -50,35 +50,36 @@ const windowOperators: readonly OperatorEntry[] = [ description: 'The `$count` operator is used to count the number of documents that match a query filtering criteria.', snippet: '{ $count: "${1:\\$field}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$count', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$count', // inferred from another category }, { value: '$max', meta: META_WINDOW, description: 'The $max operator returns the maximum value from a set of input values.', snippet: '{ $max: "${1:\\$field}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$max', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$max', // inferred from another category }, { value: '$min', meta: META_WINDOW, - description: 'Retrieves the minimum value for a specified field', + description: 'The $min operator retrieves the minimum value for a specified field', snippet: '{ $min: "${1:\\$field}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$min', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$min', // inferred from another category }, { value: '$avg', meta: META_WINDOW, - description: 'Computes the average of numeric values for documents in a group, bucket, or window.', + description: + 'The $avg operator computes the average of numeric values for documents in a group, bucket, or window.', snippet: '{ $avg: "${1:\\$field}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$avg', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$avg', // inferred from another category }, { value: '$stdDevPop', meta: META_WINDOW, description: 'The $stddevpop operator calculates the standard deviation of the specified values', snippet: '{ $stdDevPop: "${1:\\$field}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$stddevpop', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$stddevpop', // inferred from another category }, { value: '$bottom', @@ -86,14 +87,14 @@ const windowOperators: readonly OperatorEntry[] = [ description: "The $bottom operator returns the last document from the query's result set sorted by one or more fields", snippet: '{ $bottom: "${1:\\$field}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$bottom', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$bottom', // inferred from another category }, { value: '$bottomN', meta: META_WINDOW, description: 'The $bottomN operator returns the last N documents from the result sorted by one or more fields', snippet: '{ $bottomN: "${1:\\$field}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$bottomn', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$bottomn', // inferred from another category }, { value: '$covariancePop', @@ -146,7 +147,7 @@ const windowOperators: readonly OperatorEntry[] = [ meta: META_WINDOW, description: "The $first operator returns the first value in a group according to the group's sorting order.", snippet: '{ $first: "${1:\\$field}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$first', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$first', // inferred from another category }, { value: '$integral', @@ -161,7 +162,7 @@ const windowOperators: readonly OperatorEntry[] = [ meta: META_WINDOW, description: 'The $last operator returns the last document from the result sorted by one or more fields', snippet: '{ $last: "${1:\\$field}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$last', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$last', // inferred from another category }, { value: '$linearFill', @@ -182,9 +183,9 @@ const windowOperators: readonly OperatorEntry[] = [ { value: '$minN', meta: META_WINDOW, - description: 'Retrieves the bottom N values based on a specified filtering criteria', + description: 'The $minN operator retrieves the bottom N values based on a specified filtering criteria', snippet: '{ $minN: "${1:\\$field}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$minn', + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$minn', }, { value: '$rank', @@ -206,21 +207,21 @@ const windowOperators: readonly OperatorEntry[] = [ description: 'The $stddevsamp operator calculates the standard deviation of a specified sample of values and not the entire population', snippet: '{ $stdDevSamp: "${1:\\$field}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$stddevsamp', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$stddevsamp', // inferred from another category }, { value: '$top', meta: META_WINDOW, description: 'The $top operator returns the first document from the result set sorted by one or more fields', snippet: '{ $top: "${1:\\$field}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$top', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$top', // inferred from another category }, { value: '$topN', meta: META_WINDOW, description: 'The $topN operator returns the first N documents from the result sorted by one or more fields', snippet: '{ $topN: "${1:\\$field}" }', - link: 'https://learn.microsoft.com/en-us/azure/documentdb/operators/accumulators/$topn', // inferred from another category + link: 'https://learn.microsoft.com/en-us/documentdb/query/operators/accumulators/$topn', // inferred from another category }, ]; diff --git a/scripts/k8s-test-setup-wsl.sh b/scripts/k8s-test-setup-wsl.sh index 6c5243987..a59d435ed 100644 --- a/scripts/k8s-test-setup-wsl.sh +++ b/scripts/k8s-test-setup-wsl.sh @@ -172,6 +172,6 @@ echo " 8. Credentials should auto-resolve — no username prompt!" echo "" echo "Manual port-forward test:" echo " kubectl port-forward svc/documentdb-service-my-documentdb 10260:10260 -n documentdb-ns" -echo " mongosh 'mongodb://dev_user:DevPassword123@127.0.0.1:10260/?directConnection=true&authMechanism=SCRAM-SHA-256&tls=true&tlsAllowInvalidCertificates=true'" +echo " mongosh 'mongodb://dev_user:DevPassword123@127.0.0.1:10260/?directConnection=true&authMechanism=SCRAM-SHA-256&tls=true&tlsAllowInvalidCertificates=true&replicaSet=rs0'" echo "" echo "To tear down: ./scripts/k8s-test-teardown-wsl.sh" diff --git a/scripts/k8s-test-setup.sh b/scripts/k8s-test-setup.sh index fd9a9fc8e..0f8121834 100755 --- a/scripts/k8s-test-setup.sh +++ b/scripts/k8s-test-setup.sh @@ -119,6 +119,6 @@ echo " 7. Credentials should auto-resolve — no username prompt!" echo "" echo "Manual port-forward test:" echo " kubectl port-forward pod/my-documentdb-1 10260:10260 -n documentdb-ns" -echo " mongosh 'mongodb://dev_user:DevPassword123@127.0.0.1:10260/?directConnection=true&authMechanism=SCRAM-SHA-256&tls=true&tlsAllowInvalidCertificates=true'" +echo " mongosh 'mongodb://dev_user:DevPassword123@127.0.0.1:10260/?directConnection=true&authMechanism=SCRAM-SHA-256&tls=true&tlsAllowInvalidCertificates=true&replicaSet=rs0'" echo "" echo "To tear down: ./scripts/k8s-test-teardown.sh" diff --git a/src/DocumentDBExperiences.ts b/src/DocumentDBExperiences.ts index 812b2753a..5594ae06b 100644 --- a/src/DocumentDBExperiences.ts +++ b/src/DocumentDBExperiences.ts @@ -6,6 +6,7 @@ export enum API { CosmosDBMongoRU = 'mongoRU', DocumentDB = 'documentDB', + Atlas = 'mongoDBAtlas', } export function getExperienceFromApi(api: API): Experience { @@ -49,7 +50,15 @@ export const DocumentDBExperience: Experience = { tag: 'DocumentDB', } as const; -const experiencesArray: Experience[] = [DocumentDBExperience, CosmosDBMongoRUExperience]; +export const AtlasExperience: Experience = { + api: API.Atlas, + longName: 'MongoDB Atlas', + shortName: 'Atlas', + telemetryName: 'atlas', + tag: 'MongoDB Atlas', +} as const; + +const experiencesArray: Experience[] = [DocumentDBExperience, CosmosDBMongoRUExperience, AtlasExperience]; const experiencesMap = new Map( experiencesArray.map((info: Experience): [API, Experience] => [info.api, info]), ); diff --git a/src/commands/connections-view/moveItems/moveItems.ts b/src/commands/connections-view/moveItems/moveItems.ts index 033d40e69..e3caad5a8 100644 --- a/src/commands/connections-view/moveItems/moveItems.ts +++ b/src/commands/connections-view/moveItems/moveItems.ts @@ -13,6 +13,7 @@ import { } from '../../../services/connectionStorageService'; import { DocumentDBClusterItem } from '../../../tree/connections-view/DocumentDBClusterItem'; import { FolderItem } from '../../../tree/connections-view/FolderItem'; +import { resolveStorageZone } from '../../../tree/connections-view/models/ConnectionClusterModel'; import { type TreeElement } from '../../../tree/TreeElement'; import { ConfirmMoveStep } from './ConfirmMoveStep'; import { ExecuteStep } from './ExecuteStep'; @@ -126,7 +127,7 @@ function getConnectionType(item: MovableTreeElement): ConnectionType { } if (item instanceof DocumentDBClusterItem) { - return item.cluster.emulatorConfiguration?.isEmulator ? ConnectionType.Emulators : ConnectionType.Clusters; + return resolveStorageZone(item.cluster); } // Default fallback diff --git a/src/commands/connections-view/renameConnection/ExecuteStep.ts b/src/commands/connections-view/renameConnection/ExecuteStep.ts index d9e5fe2ec..9bd1a1624 100644 --- a/src/commands/connections-view/renameConnection/ExecuteStep.ts +++ b/src/commands/connections-view/renameConnection/ExecuteStep.ts @@ -19,13 +19,12 @@ export class ExecuteStep extends AzureWizardExecuteStep { + const resourceType = + context.storageZone ?? (context.isEmulator ? ConnectionType.Emulators : ConnectionType.Clusters); // Set telemetry properties - context.telemetry.properties.connectionType = context.isEmulator - ? ConnectionType.Emulators - : ConnectionType.Clusters; + context.telemetry.properties.connectionType = resourceType; await withConnectionsViewProgress(async () => { - const resourceType = context.isEmulator ? ConnectionType.Emulators : ConnectionType.Clusters; const connection = await ConnectionStorageService.get(context.storageId, resourceType); if (connection) { diff --git a/src/commands/connections-view/renameConnection/PromptNewConnectionNameStep.ts b/src/commands/connections-view/renameConnection/PromptNewConnectionNameStep.ts index e9654e2df..d634725a3 100644 --- a/src/commands/connections-view/renameConnection/PromptNewConnectionNameStep.ts +++ b/src/commands/connections-view/renameConnection/PromptNewConnectionNameStep.ts @@ -39,7 +39,8 @@ export class PromptNewConnectionNameStep extends AzureWizardPromptStep { expect(ctx.telemetry.properties.passwordIncluded).toBe('false'); }); + it('T-02b embedded-password base + WITHOUT password -> strips the inherited password (no leak)', async () => { + // Regression: some callers (e.g. the in-memory Local Quick Start instance) can pass a base + // connectionString that already embeds the password. buildParsedConnectionString must not + // inherit it, so "copy without password" is genuinely password-free. + mockShowQuickPick.mockResolvedValue({ includePassword: false }); + const ctx = makeContext(); + const node = makeNode('connectionsView;treeitem_documentdbcluster', { + connectionString: 'mongodb://alice:s3cr3t@127.0.0.1:27017/?directConnection=true', + availableAuthMethods: [AuthMethodId.NativeAuth], + selectedAuthMethod: AuthMethodId.NativeAuth, + nativeAuthConfig: { connectionUser: 'alice', connectionPassword: 's3cr3t' }, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await copyConnectionString(ctx as any, node as any); + + const written = mockClipboardWriteText.mock.calls[0][0] as string; + expect(written).not.toContain('s3cr3t'); + expect(written).toContain('alice@127.0.0.1'); + expect(ctx.telemetry.properties.passwordIncluded).toBe('false'); + }); + + it('T-02c embedded-password base + WITH password -> includes the password exactly once', async () => { + mockShowQuickPick.mockResolvedValue({ includePassword: true }); + const ctx = makeContext(); + const node = makeNode('connectionsView;treeitem_documentdbcluster', { + connectionString: 'mongodb://alice:s3cr3t@127.0.0.1:27017/?directConnection=true', + availableAuthMethods: [AuthMethodId.NativeAuth], + selectedAuthMethod: AuthMethodId.NativeAuth, + nativeAuthConfig: { connectionUser: 'alice', connectionPassword: 's3cr3t' }, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await copyConnectionString(ctx as any, node as any); + + const written = mockClipboardWriteText.mock.calls[0][0] as string; + expect(written).toContain('alice:s3cr3t@127.0.0.1'); + expect(written.match(/s3cr3t/g)?.length).toBe(1); + expect(ctx.valuesToMask).toContain('s3cr3t'); + }); + it('T-03 K8s discovery + native + password, picks WITH password -> includes password', async () => { mockShowQuickPick.mockResolvedValue({ includePassword: true }); const ctx = makeContext(); diff --git a/src/commands/copyConnectionString/copyConnectionString.ts b/src/commands/copyConnectionString/copyConnectionString.ts index 906e28613..b615bc79a 100644 --- a/src/commands/copyConnectionString/copyConnectionString.ts +++ b/src/commands/copyConnectionString/copyConnectionString.ts @@ -133,6 +133,11 @@ export async function copyConnectionString(context: IActionContext, node: Cluste function buildParsedConnectionString(credentials: EphemeralClusterCredentials): DocumentDBConnectionString { const parsedConnectionString = new DocumentDBConnectionString(credentials.connectionString); parsedConnectionString.username = credentials.nativeAuthConfig?.connectionUser ?? ''; + // Never inherit a password embedded in `credentials.connectionString`: the base string is treated + // as password-free and the password is added back ONLY in the with-password branch. Callers are + // expected to pass a stripped base, but clearing here makes "copy without password" structurally + // safe even if a caller (e.g. an in-memory instance) passes a credential-bearing string. + parsedConnectionString.password = ''; if (credentials.selectedAuthMethod === AuthMethodId.MicrosoftEntraID) { parsedConnectionString.searchParams.set('authMechanism', 'MONGODB-OIDC'); @@ -171,8 +176,12 @@ function buildKubectlPortForwardCommand(metadata: KubernetesPortForwardMetadata) /** * Standard copy flow for non-Kubernetes targets (and Kubernetes targets that are not reached * through a port-forward tunnel). Preserves the original with/without-password prompt. + * + * Exported so callers with in-memory (non storage-backed) credentials — e.g. the Local Quick Start + * managed instance — can reuse the exact with/without-password QuickPick instead of copying the + * password silently (UX review #7). */ -async function copyStandardConnectionString( +export async function copyStandardConnectionString( context: IActionContext, credentials: EphemeralClusterCredentials, isConnectionsView: boolean, diff --git a/src/commands/createCollection/InitialCollectionNameStep.ts b/src/commands/createCollection/InitialCollectionNameStep.ts new file mode 100644 index 000000000..2b4c0442d --- /dev/null +++ b/src/commands/createCollection/InitialCollectionNameStep.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import { type CreateDatabaseWizardContext } from '../createDatabase/CreateDatabaseWizardContext'; +import { CollectionNameStep } from './CollectionNameStep'; + +export class InitialCollectionNameStep extends AzureWizardPromptStep { + public hideStepCount: boolean = true; + + private readonly baseStep = new CollectionNameStep(); + + public async prompt(context: CreateDatabaseWizardContext): Promise { + const prompt: string = l10n.t('Enter an initial collection name for the new database.'); + context.collectionName = ( + await context.ui.showInputBox({ + prompt, + validateInput: (name?: string) => { + const trimmed = name?.trim() ?? ''; + return trimmed.length === 0 + ? l10n.t('Collection name is required.') + : this.baseStep.validateInput(trimmed); + }, + }) + ).trim(); + + context.valuesToMask.push(context.collectionName); + } + + public shouldPrompt(context: CreateDatabaseWizardContext): boolean { + return !!context.requiresInitialCollection && !context.collectionName; + } +} diff --git a/src/commands/createDatabase/CreateDatabaseWizardContext.ts b/src/commands/createDatabase/CreateDatabaseWizardContext.ts index 142b62b91..e531b280b 100644 --- a/src/commands/createDatabase/CreateDatabaseWizardContext.ts +++ b/src/commands/createDatabase/CreateDatabaseWizardContext.ts @@ -14,5 +14,13 @@ export interface CreateDatabaseWizardContext extends IActionContext { clusterName: string; nodeId: string; + /** + * When true, the wizard prompts for an initial collection name. + * Required where dropping the last collection also removes the database, as with the MongoDB API + * wire protocol as implemented by Atlas. Azure DocumentDB vCore does not need this. + */ + requiresInitialCollection?: boolean; + databaseName?: string; + collectionName?: string; } diff --git a/src/commands/createDatabase/ExecuteStep.ts b/src/commands/createDatabase/ExecuteStep.ts index 885766973..98dfb66bb 100644 --- a/src/commands/createDatabase/ExecuteStep.ts +++ b/src/commands/createDatabase/ExecuteStep.ts @@ -15,6 +15,7 @@ export class ExecuteStep extends AzureWizardExecuteStep { const credentialsId = context.credentialsId; const databaseName = context.databaseName!; + const collectionName = context.collectionName; const nodeId = context.nodeId; const client = await ClustersClient.getClient(credentialsId); @@ -30,7 +31,7 @@ export class ExecuteStep extends AzureWizardExecuteStep setTimeout(resolve, 250)); - await client.createDatabase(databaseName); + await client.createDatabase(databaseName, collectionName); }, ); } diff --git a/src/commands/createDatabase/createDatabase.ts b/src/commands/createDatabase/createDatabase.ts index 0b3fcd87e..ac56fb97d 100644 --- a/src/commands/createDatabase/createDatabase.ts +++ b/src/commands/createDatabase/createDatabase.ts @@ -5,10 +5,12 @@ import { AzureWizard, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; +import { AtlasExperience } from '../../DocumentDBExperiences'; import { CredentialCache } from '../../documentdb/CredentialCache'; import { type ClusterItemBase } from '../../tree/documentdb/ClusterItemBase'; import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; import { nonNullValue } from '../../utils/nonNull'; +import { InitialCollectionNameStep } from '../createCollection/InitialCollectionNameStep'; import { type CreateDatabaseWizardContext } from './CreateDatabaseWizardContext'; import { DatabaseNameStep } from './DatabaseNameStep'; import { ExecuteStep } from './ExecuteStep'; @@ -42,11 +44,12 @@ async function createMongoDatabase(context: IActionContext, node: ClusterItemBas credentialsId: node.cluster.clusterId, clusterName: node.cluster.name, nodeId: node.id, + requiresInitialCollection: node.experience.api === AtlasExperience.api, }; const wizard = new AzureWizard(wizardContext, { title: l10n.t('Create database'), - promptSteps: [new DatabaseNameStep()], + promptSteps: [new DatabaseNameStep(), new InitialCollectionNameStep()], executeSteps: [new ExecuteStep()], showLoadingPrompt: true, }); diff --git a/src/commands/index.dropIndex/dropIndex.ts b/src/commands/index.dropIndex/dropIndex.ts index e67cadc70..1cc0f376e 100644 --- a/src/commands/index.dropIndex/dropIndex.ts +++ b/src/commands/index.dropIndex/dropIndex.ts @@ -5,11 +5,13 @@ import { type IActionContext } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; import { ClustersClient } from '../../documentdb/ClustersClient'; import { ext } from '../../extensionVariables'; import { type IndexItem } from '../../tree/documentdb/IndexItem'; -import { getConfirmationAsInSettings } from '../../utils/dialogs/getConfirmation'; +import { confirmIndexAction } from '../../utils/dialogs/confirmIndexAction'; import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; +import { getIndexConfirmationStats } from '../index.shared/getIndexConfirmationStats'; export async function dropIndex(context: IActionContext, node: IndexItem): Promise { if (!node) { @@ -27,13 +29,13 @@ export async function dropIndex(context: IActionContext, node: IndexItem): Promi const indexName = node.indexInfo.name; const collectionName = node.collectionInfo.name; - const confirmed = await getConfirmationAsInSettings( - l10n.t('Delete index?'), - l10n.t('Delete index "{indexName}" from collection "{collectionName}"?', { indexName, collectionName }) + - '\n' + - l10n.t('This cannot be undone.'), - 'delete', - ); + const { sizeBytes, usageOps } = await getIndexConfirmationStats(node); + const confirmed = await confirmIndexAction('delete', { + indexName, + collectionName, + sizeBytes, + usageOps, + }); if (!confirmed) { return; @@ -62,6 +64,18 @@ export async function dropIndex(context: IActionContext, node: IndexItem): Promi if (success) { showConfirmationAsInSettings(l10n.t('Index "{indexName}" has been deleted.', { indexName })); } + } catch (error) { + // A failed user action is surfaced modally, matching the webview matrix + // (failure of a user-triggered action -> modal; completion -> non-modal + // toast). We show it ourselves and suppress azext's default non-modal + // error, then rethrow so telemetry still records the failure. + const detail = error instanceof Error ? error.message : String(error); + context.errorHandling.suppressDisplay = true; + void vscode.window.showErrorMessage(l10n.t('Failed to delete index "{indexName}".', { indexName }), { + modal: true, + detail, + }); + throw error; } finally { // Refresh parent (collection's indexes folder) const lastSlashIndex = node.id.lastIndexOf('/'); diff --git a/src/commands/index.hideIndex/hideIndex.ts b/src/commands/index.hideIndex/hideIndex.ts index 5d68e5120..e4162c2c1 100644 --- a/src/commands/index.hideIndex/hideIndex.ts +++ b/src/commands/index.hideIndex/hideIndex.ts @@ -5,11 +5,13 @@ import { type IActionContext } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; import { ClustersClient } from '../../documentdb/ClustersClient'; import { ext } from '../../extensionVariables'; import { type IndexItem } from '../../tree/documentdb/IndexItem'; -import { getConfirmationWithClick } from '../../utils/dialogs/getConfirmation'; +import { confirmIndexAction } from '../../utils/dialogs/confirmIndexAction'; import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; +import { getIndexConfirmationStats } from '../index.shared/getIndexConfirmationStats'; export async function hideIndex(context: IActionContext, node: IndexItem): Promise { if (!node) { @@ -32,12 +34,13 @@ export async function hideIndex(context: IActionContext, node: IndexItem): Promi const indexName = node.indexInfo.name; const collectionName = node.collectionInfo.name; - const confirmed = await getConfirmationWithClick( - l10n.t('Hide index?'), - l10n.t('Hide index "{indexName}" from collection "{collectionName}"?', { indexName, collectionName }) + - '\n' + - l10n.t('This will prevent the query planner from using this index.'), - ); + const { sizeBytes, usageOps } = await getIndexConfirmationStats(node); + const confirmed = await confirmIndexAction('hide', { + indexName, + collectionName, + sizeBytes, + usageOps, + }); if (!confirmed) { return; @@ -67,6 +70,16 @@ export async function hideIndex(context: IActionContext, node: IndexItem): Promi if (success) { showConfirmationAsInSettings(l10n.t('Index "{indexName}" has been hidden.', { indexName })); } + } catch (error) { + // Failed user action -> modal (matches the webview matrix); suppress + // azext's default non-modal error and rethrow for telemetry. + const detail = error instanceof Error ? error.message : String(error); + context.errorHandling.suppressDisplay = true; + void vscode.window.showErrorMessage(l10n.t('Failed to hide index "{indexName}".', { indexName }), { + modal: true, + detail, + }); + throw error; } finally { // Refresh parent (collection's indexes folder) const lastSlashIndex = node.id.lastIndexOf('/'); diff --git a/src/commands/index.shared/getIndexConfirmationStats.ts b/src/commands/index.shared/getIndexConfirmationStats.ts new file mode 100644 index 000000000..f0fc9b155 --- /dev/null +++ b/src/commands/index.shared/getIndexConfirmationStats.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ClustersClient } from '../../documentdb/ClustersClient'; +import { type IndexItem } from '../../tree/documentdb/IndexItem'; + +/** + * Fetch the size + usage of a single index for a confirmation dialog, so the + * tree-view delete / hide / unhide prompts match the level of detail shown in + * the Index Management webview. + * + * Both statistics come from optional server commands (`collStats` for the index + * size, `$indexStats` for the usage counter) that some cluster tiers do not + * support. Any failure — or a missing entry — yields `undefined`, which + * `confirmIndexAction` renders as "Not available". + */ +export async function getIndexConfirmationStats(node: IndexItem): Promise<{ sizeBytes?: number; usageOps?: number }> { + const client = await ClustersClient.getClient(node.cluster.clusterId); + const dbName = node.databaseInfo.name; + const collName = node.collectionInfo.name; + const indexName = node.indexInfo.name; + + let sizeBytes: number | undefined; + try { + const stats = await client.getCollectionStats(dbName, collName); + const bytes = stats.indexSizes?.[indexName]; + if (typeof bytes === 'number') { + sizeBytes = bytes; + } + } catch { + // Ignore — size stays unknown. + } + + let usageOps: number | undefined; + try { + const indexStats = await client.getIndexStats(dbName, collName); + const stat = indexStats.find((s) => s.name === indexName); + if (stat && stat.accesses !== 'N/A') { + usageOps = stat.accesses.ops; + } + } catch { + // Ignore — usage stays unknown. + } + + return { sizeBytes, usageOps }; +} diff --git a/src/commands/index.unhideIndex/unhideIndex.ts b/src/commands/index.unhideIndex/unhideIndex.ts index 3901e1a11..c77734e89 100644 --- a/src/commands/index.unhideIndex/unhideIndex.ts +++ b/src/commands/index.unhideIndex/unhideIndex.ts @@ -5,11 +5,13 @@ import { type IActionContext } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; import { ClustersClient } from '../../documentdb/ClustersClient'; import { ext } from '../../extensionVariables'; import { type IndexItem } from '../../tree/documentdb/IndexItem'; -import { getConfirmationWithClick } from '../../utils/dialogs/getConfirmation'; +import { confirmIndexAction } from '../../utils/dialogs/confirmIndexAction'; import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; +import { getIndexConfirmationStats } from '../index.shared/getIndexConfirmationStats'; export async function unhideIndex(context: IActionContext, node: IndexItem): Promise { if (!node) { @@ -27,12 +29,13 @@ export async function unhideIndex(context: IActionContext, node: IndexItem): Pro const indexName = node.indexInfo.name; const collectionName = node.collectionInfo.name; - const confirmed = await getConfirmationWithClick( - l10n.t('Unhide index?'), - l10n.t('Unhide index "{indexName}" from collection "{collectionName}"?', { indexName, collectionName }) + - '\n' + - l10n.t('This will allow the query planner to use this index again.'), - ); + const { sizeBytes, usageOps } = await getIndexConfirmationStats(node); + const confirmed = await confirmIndexAction('unhide', { + indexName, + collectionName, + sizeBytes, + usageOps, + }); if (!confirmed) { return; @@ -62,6 +65,16 @@ export async function unhideIndex(context: IActionContext, node: IndexItem): Pro if (success) { showConfirmationAsInSettings(l10n.t('Index "{indexName}" has been unhidden.', { indexName })); } + } catch (error) { + // Failed user action -> modal (matches the webview matrix); suppress + // azext's default non-modal error and rethrow for telemetry. + const detail = error instanceof Error ? error.message : String(error); + context.errorHandling.suppressDisplay = true; + void vscode.window.showErrorMessage(l10n.t('Failed to unhide index "{indexName}".', { indexName }), { + modal: true, + detail, + }); + throw error; } finally { // Refresh parent (collection's indexes folder) const lastSlashIndex = node.id.lastIndexOf('/'); diff --git a/src/commands/localQuickStart/contributions.test.ts b/src/commands/localQuickStart/contributions.test.ts new file mode 100644 index 000000000..1ba9c6f0b --- /dev/null +++ b/src/commands/localQuickStart/contributions.test.ts @@ -0,0 +1,116 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Contribution-manifest regression tests for Local Quick Start (#851, #852). + * + * These two defects live in data, not in code paths, so no amount of service-level testing catches + * them: #851 was a MISSING `contributes.menus.commandPalette` entry, and #852 was a user-facing + * string that never reached `l10n/bundle.l10n.json` because it bypassed `l10n.t()`. Both are + * asserted against the shipped manifests directly. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +const REPO_ROOT = path.join(__dirname, '..', '..', '..'); + +function readJson(relativePath: string): T { + return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, relativePath), 'utf8')) as T; +} + +interface PackageManifest { + contributes: { + commands: Array<{ command: string }>; + menus: { commandPalette: Array<{ command: string; when?: string }> }; + }; +} + +describe('Local Quick Start command contributions (#851)', () => { + const manifest = readJson('package.json'); + const quickStartCommands = manifest.contributes.commands + .map((entry) => entry.command) + .filter((command) => command.includes('localQuickStart')); + const paletteEntries = new Map( + manifest.contributes.menus.commandPalette.map((entry) => [entry.command, entry.when]), + ); + + /** Guards the list itself: a new command added without a decision here should fail. */ + it('contributes exactly the commands this test reasons about', () => { + expect(quickStartCommands.sort()).toEqual( + [ + 'vscode-documentdb.command.localQuickStart.copyConnectionString', + 'vscode-documentdb.command.localQuickStart.copyPassword', + 'vscode-documentdb.command.localQuickStart.delete', + 'vscode-documentdb.command.localQuickStart.open', + 'vscode-documentdb.command.localQuickStart.restart', + 'vscode-documentdb.command.localQuickStart.start', + 'vscode-documentdb.command.localQuickStart.stop', + 'vscode-documentdb.command.localQuickStart.viewLogs', + ].sort(), + ); + }); + + // Every one of these acts on the instance selected in the Connections view. Run from the + // palette there is nothing in context, so they used to run their pipeline, find nothing, and + // return in silence — no notification, no error, no log line. + it.each([ + 'vscode-documentdb.command.localQuickStart.start', + 'vscode-documentdb.command.localQuickStart.stop', + 'vscode-documentdb.command.localQuickStart.restart', + 'vscode-documentdb.command.localQuickStart.delete', + 'vscode-documentdb.command.localQuickStart.copyConnectionString', + 'vscode-documentdb.command.localQuickStart.copyPassword', + 'vscode-documentdb.command.localQuickStart.viewLogs', + ])('hides %s from the Command Palette', (command) => { + expect(paletteEntries.get(command)).toBe('never'); + }); + + /** The entry point must STAY reachable — hiding everything would be its own bug. */ + it('keeps the Quick Start entry point in the Command Palette', () => { + expect(paletteEntries.has('vscode-documentdb.command.localQuickStart.open')).toBe(false); + }); + + /** + * Presence alone is not enough: two branches adding the same gating block at different offsets + * merge into duplicates that every lookup here would still find, so only uniqueness catches it. + */ + it('contributes each palette entry exactly once', () => { + const counts = new Map(); + for (const entry of manifest.contributes.menus.commandPalette) { + counts.set(entry.command, (counts.get(entry.command) ?? 0) + 1); + } + const duplicated = [...counts.entries()].filter(([, count]) => count > 1).map(([command]) => command); + expect(duplicated).toEqual([]); + }); +}); + +describe('Local Quick Start localized strings (#852)', () => { + const bundle = readJson>('l10n/bundle.l10n.json'); + + // The extractor only picks up strings wrapped in `l10n.t()`, so presence in the bundle is + // proof the call goes through localization rather than a raw template literal. + it.each([ + 'Port {0} is already in use. Go back to Configure to pick a different port, or free it, then try again.', + 'Docker CLI was not found on your PATH. Install Docker and retry.', + 'Docker is installed but the daemon is not reachable. Start Docker and retry.', + 'DocumentDB Local is running on localhost:{0}.', + 'Setup is already in progress.', + 'Setup was cancelled.', + ])('extracts %s for translation', (message) => { + expect(Object.keys(bundle)).toContain(message); + }); + + /** + * The host port is always the one the user saw in Configure (review L3, "no magic after + * execute"). Setup never substitutes it, so no string may tell the user that it did. + */ + it('never announces a silently substituted port', () => { + const offenders = Object.keys(bundle).filter( + (key) => /\bports?\b/i.test(key) && /are all in use|was busy|using .* instead/i.test(key), + ); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/commands/localQuickStart/localQuickStartCommands.test.ts b/src/commands/localQuickStart/localQuickStartCommands.test.ts new file mode 100644 index 000000000..71295f508 --- /dev/null +++ b/src/commands/localQuickStart/localQuickStartCommands.test.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AuthMethodId } from '../../documentdb/auth/AuthMethod'; +import { QuickStartService } from '../../services/localQuickStart/QuickStartService'; +import { InstanceState } from '../../services/localQuickStart/quickStartTypes'; +import { getConfirmationAsInSettings } from '../../utils/dialogs/getConfirmation'; +import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; +import { buildQuickStartCopyCredentials, deleteQuickStartInstance } from './localQuickStartCommands'; + +jest.mock('../../services/localQuickStart/QuickStartService', () => ({ + QuickStartService: { getStatus: jest.fn(), deleteContainer: jest.fn() }, +})); +jest.mock('../../utils/dialogs/getConfirmation', () => ({ getConfirmationAsInSettings: jest.fn() })); +jest.mock('../../utils/dialogs/showConfirmation', () => ({ showConfirmationAsInSettings: jest.fn() })); + +// UX review #7: the Quick Start "Copy Connection String" reuses the shared copy flow, which treats +// credentials.connectionString as a PASSWORD-FREE base (the password lives only in nativeAuthConfig). +// The Quick Start metadata string embeds the password, so the helper must strip it — otherwise +// "copy without password" would leak the password. +describe('buildQuickStartCopyCredentials (UX review #7)', () => { + it('strips the embedded password from the base and carries it in nativeAuthConfig', () => { + const credentials = buildQuickStartCopyCredentials( + 'mongodb://admin:s3cr3tPass@localhost:10260/?tls=true&tlsAllowInvalidCertificates=true', + 'admin', + ); + + expect(credentials).toBeDefined(); + // The base string handed to the shared copy flow must not contain the password. + expect(credentials?.connectionString).not.toContain('s3cr3tPass'); + // The password is carried separately so the with-password branch can add it back. + expect(credentials?.nativeAuthConfig?.connectionPassword).toBe('s3cr3tPass'); + expect(credentials?.nativeAuthConfig?.connectionUser).toBe('admin'); + expect(credentials?.selectedAuthMethod).toBe(AuthMethodId.NativeAuth); + }); + + it('handles a password-free connection string (no prompt path)', () => { + const credentials = buildQuickStartCopyCredentials('mongodb://localhost:10260/?tls=true', 'admin'); + + expect(credentials?.connectionString).not.toContain('@'); // no userinfo embedded + expect(credentials?.nativeAuthConfig?.connectionPassword).toBe(''); + }); + + it('fails closed (returns undefined) when the connection string cannot be parsed', () => { + expect(buildQuickStartCopyCredentials('not a valid connection string', 'admin')).toBeUndefined(); + }); +}); + +// GPT-5.6 review #1: the "DocumentDB Local container deleted." toast must be gated on the ACTUAL +// delete outcome. deleteContainer() refuses to touch a foreign container (returns 'refused') and +// no-ops when the alias is busy (returns 'busy'); in both cases nothing was removed, so a success +// toast would be contradictory and the instance would still be in the tree. +describe('deleteQuickStartInstance — success toast gated on the delete outcome (GPT-5.6 review #1)', () => { + const getStatus = QuickStartService.getStatus as jest.Mock; + const deleteContainer = QuickStartService.deleteContainer as jest.Mock; + const confirm = getConfirmationAsInSettings as unknown as jest.Mock; + const showToast = showConfirmationAsInSettings as unknown as jest.Mock; + + const makeContext = () => + ({ telemetry: { properties: {} as Record, measurements: {} } }) as unknown as Parameters< + typeof deleteQuickStartInstance + >[0]; + + beforeEach(() => { + jest.clearAllMocks(); + getStatus.mockReturnValue({ state: InstanceState.Stopped }); + confirm.mockResolvedValue(true); + }); + + it('shows the confirmation only when the instance was actually removed', async () => { + deleteContainer.mockResolvedValue('deleted'); + await deleteQuickStartInstance(makeContext()); + expect(deleteContainer).toHaveBeenCalledTimes(1); + expect(showToast).toHaveBeenCalledTimes(1); + }); + + it('stays silent (no false success) when a foreign container is refused', async () => { + deleteContainer.mockResolvedValue('refused'); + const context = makeContext(); + await deleteQuickStartInstance(context); + expect(showToast).not.toHaveBeenCalled(); + expect(context.telemetry.properties.deleteOutcome).toBe('refused'); + }); + + it('stays silent when the alias is busy (another lifecycle op is running)', async () => { + deleteContainer.mockResolvedValue('busy'); + await deleteQuickStartInstance(makeContext()); + expect(showToast).not.toHaveBeenCalled(); + }); + + it('stays silent when removing our container fails (the service already showed the error)', async () => { + deleteContainer.mockResolvedValue('error'); + const context = makeContext(); + await deleteQuickStartInstance(context); + expect(showToast).not.toHaveBeenCalled(); + expect(context.telemetry.properties.deleteOutcome).toBe('error'); + }); + + it('does not delete or toast when the user cancels the confirmation', async () => { + confirm.mockResolvedValue(false); + await deleteQuickStartInstance(makeContext()); + expect(deleteContainer).not.toHaveBeenCalled(); + expect(showToast).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/localQuickStart/localQuickStartCommands.ts b/src/commands/localQuickStart/localQuickStartCommands.ts new file mode 100644 index 000000000..6d6d687e5 --- /dev/null +++ b/src/commands/localQuickStart/localQuickStartCommands.ts @@ -0,0 +1,256 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { AuthMethodId } from '../../documentdb/auth/AuthMethod'; +import { DocumentDBConnectionString } from '../../documentdb/utils/DocumentDBConnectionString'; +import { ContainerRuntime, getQuickStartOutputChannel } from '../../services/localQuickStart/ContainerRuntime'; +import { secretVariants } from '../../services/localQuickStart/quickStartCredentials'; +import { QuickStartService } from '../../services/localQuickStart/QuickStartService'; +import { InstanceState } from '../../services/localQuickStart/quickStartTypes'; +import { type EphemeralClusterCredentials } from '../../tree/documentdb/ClusterItemBase'; +import { getConfirmationAsInSettings } from '../../utils/dialogs/getConfirmation'; +import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; +import { copyStandardConnectionString } from '../copyConnectionString/copyConnectionString'; + +/** + * Quick Start managed-instance lifecycle commands (design §6.2 / §11). They act + * on the single service-owned instance, so the (optional) tree node argument is + * ignored. The tree refreshes via `QuickStartService.onDidChangeStatus`. + */ + +/** + * Every command below acts on the single service-owned instance. When there is no instance to act + * on, returning quietly leaves the user with no feedback at all — the bug behind #851, where the + * commands were also listed in the Command Palette (now gated to `when: never` in package.json). + * + * The palette entries are the primary fix; this is the second line of defense, so that ANY route + * into a command with nothing to act on explains itself instead of doing nothing. Two states are + * distinguished: + * + * - `CredentialsMissing` — a labelled container (or durable `ready` record) exists but its saved + * credentials are gone. The service already detects and logs this; surface it and offer Delete, + * which is the only way forward. + * - anything else without metadata — no instance yet; offer the Quick Start wizard. + * + * Returns `true` when the caller may proceed (metadata is present). + */ +function ensureInstanceOrExplain(context: IActionContext): boolean { + const status = QuickStartService.getStatus(); + if (status.metadata) { + return true; + } + context.telemetry.properties.noInstanceState = status.state; + + if (status.state === InstanceState.CredentialsMissing) { + const deleteAction = l10n.t('Delete Container…'); + void vscode.window + .showWarningMessage( + l10n.t( + 'Saved credentials for DocumentDB Local are missing, so this instance cannot be opened. Delete it and set it up again to start fresh (this erases its data).', + ), + deleteAction, + ) + .then((choice) => { + if (choice === deleteAction) { + return vscode.commands.executeCommand('vscode-documentdb.command.localQuickStart.delete'); + } + return undefined; + }); + return false; + } + + const setUpAction = l10n.t('Set up DocumentDB Local'); + void vscode.window + .showInformationMessage( + l10n.t('DocumentDB Local is not set up yet. Run Quick Start to create a local instance first.'), + setUpAction, + ) + .then((choice) => { + if (choice === setUpAction) { + return vscode.commands.executeCommand('vscode-documentdb.command.localQuickStart.open'); + } + return undefined; + }); + return false; +} + +export async function startQuickStartInstance(context: IActionContext): Promise { + context.telemetry.properties.action = 'start'; + if (!ensureInstanceOrExplain(context)) { + return; + } + await QuickStartService.start(); +} + +export async function stopQuickStartInstance(context: IActionContext): Promise { + context.telemetry.properties.action = 'stop'; + if (!ensureInstanceOrExplain(context)) { + return; + } + await QuickStartService.stop(); +} + +export async function restartQuickStartInstance(context: IActionContext): Promise { + context.telemetry.properties.action = 'restart'; + if (!ensureInstanceOrExplain(context)) { + return; + } + await QuickStartService.restart(); +} + +export async function deleteQuickStartInstance(context: IActionContext): Promise { + context.telemetry.properties.action = 'delete'; + + // Delete is now offered while Running too, so the container is force-stopped before removal + // (ContainerRuntime.removeContainer uses force). Warn accordingly and make the data-loss + // consequences explicit — Delete drops the data volume, so this is a permanent clean slate. + const wasRunning = QuickStartService.getStatus().state === InstanceState.Running; + context.telemetry.properties.wasRunning = String(wasRunning); + + const detail = wasRunning + ? l10n.t( + 'The container is currently running. It will be stopped and permanently removed. All data, logs, and the auto-generated credentials will be lost. This cannot be undone. You can recreate a fresh instance any time with Quick Start.', + ) + : l10n.t( + 'The container and its data volume will be permanently removed. All data, logs, and the auto-generated credentials will be lost. This cannot be undone. You can recreate a fresh instance any time with Quick Start.', + ); + + const confirmed = await getConfirmationAsInSettings(l10n.t('Delete DocumentDB Local container?'), detail, 'delete'); + if (!confirmed) { + return; + } + const outcome = await QuickStartService.deleteContainer(); + context.telemetry.properties.deleteOutcome = outcome; + // Only claim success when the instance was actually removed. On 'refused' (a container created + // outside the extension) or 'error' (Docker refused to remove our container) the service already + // showed the relevant message; on 'busy' another lifecycle op is running. In all three cases stay + // silent rather than show a contradictory "deleted" toast (GPT-5.6 review). + if (outcome === 'deleted') { + showConfirmationAsInSettings(l10n.t('DocumentDB Local container deleted.')); + } +} + +/** + * Build password-free ephemeral credentials for the shared copy flow from a Quick Start instance's + * (credential-bearing) connection string. The shared flow treats `connectionString` as a password- + * free base and carries the password only in `nativeAuthConfig`, so we strip the embedded username + + * password here. Returns `undefined` (fail closed — copy nothing rather than leak) when the string + * can't be parsed. + */ +export function buildQuickStartCopyCredentials( + connectionString: string, + username: string, +): EphemeralClusterCredentials | undefined { + let parsed: DocumentDBConnectionString; + try { + parsed = new DocumentDBConnectionString(connectionString); + } catch { + return undefined; + } + const password = parsed.password; + parsed.username = ''; + parsed.password = ''; + return { + connectionString: parsed.toString(), + availableAuthMethods: [AuthMethodId.NativeAuth], + selectedAuthMethod: AuthMethodId.NativeAuth, + nativeAuthConfig: { connectionUser: username, connectionPassword: password }, + }; +} + +export async function copyQuickStartConnectionString(context: IActionContext): Promise { + if (!ensureInstanceOrExplain(context)) { + return; + } + const metadata = QuickStartService.getStatus().metadata; + if (!metadata) { + return; + } + // Reuse the shared copy flow so the user gets the same with/without-password QuickPick as every + // other connection instead of silently copying the password (UX review #7). The Quick Start + // instance is in-memory (not a stored connection), so we build password-free ephemeral + // credentials from its metadata rather than going through the storage-backed node.getCredentials(). + const credentials = buildQuickStartCopyCredentials(metadata.connectionString, metadata.username); + if (!credentials) { + return; + } + context.telemetry.properties.copyOrigin = 'quickStart'; + await copyStandardConnectionString(context, credentials, true, false); +} + +export function copyQuickStartPassword(context: IActionContext): void { + if (!ensureInstanceOrExplain(context)) { + return; + } + const metadata = QuickStartService.getStatus().metadata; + if (!metadata) { + return; + } + let password = ''; + try { + password = new DocumentDBConnectionString(metadata.connectionString).password; + } catch { + password = ''; + } + if (!password) { + // The instance exists but its stored connection string carries no password (e.g. it was + // rewritten outside the extension). Say so rather than leaving the clipboard untouched + // with no explanation (#851). + void vscode.window.showWarningMessage( + l10n.t('No password is stored for the DocumentDB Local instance, so there is nothing to copy.'), + ); + return; + } + void vscode.env.clipboard.writeText(password); + showConfirmationAsInSettings(l10n.t('Password copied to clipboard.')); +} + +/** + * The single active `docker logs -f` follow. Reused across "View Logs" clicks so + * repeated invocations don't stack concurrent follows — each would duplicate the + * channel output and leak an orphaned child process until the container stops. + */ +let activeLogFollow: vscode.CancellationTokenSource | undefined; + +/** + * Stop the active `docker logs -f` follow. Registered as a subscription at command-registration + * time: without it the last follow's child process outlives extension deactivation until the + * container stops, since nothing else cancels the token. + */ +export function disposeQuickStartLogFollow(): void { + activeLogFollow?.cancel(); + activeLogFollow?.dispose(); + activeLogFollow = undefined; +} + +export function viewQuickStartLogs(_context: IActionContext): void { + const channel = getQuickStartOutputChannel(); + channel.show(true); + // Best-effort: stream the running container's current logs into the channel, + // masking the password (D14) in case the image ever echoes it. + const metadata = QuickStartService.getStatus().metadata; + if (!metadata) { + // The channel is now in front of the user, so state there why no container logs follow + // rather than leaving them staring at unrelated output (#851). A notification would be + // redundant on top of the surface we just revealed. + channel.appendLine(l10n.t('There is no DocumentDB Local container to follow. Run Quick Start to create one.')); + return; + } + // Cancel any prior follow before starting a new one (see activeLogFollow). + activeLogFollow?.cancel(); + activeLogFollow?.dispose(); + activeLogFollow = new vscode.CancellationTokenSource(); + const token = activeLogFollow.token; + let password = ''; + try { + password = new DocumentDBConnectionString(metadata.connectionString).password; + } catch { + password = ''; + } + void ContainerRuntime.followLogs(metadata.containerId, secretVariants(password), token); +} diff --git a/src/commands/localQuickStart/openLocalQuickStart.ts b/src/commands/localQuickStart/openLocalQuickStart.ts new file mode 100644 index 000000000..be035f6f7 --- /dev/null +++ b/src/commands/localQuickStart/openLocalQuickStart.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as vscode from 'vscode'; +import { openLocalQuickStartWebview } from '../../webviews/documentdb/localQuickStart/localQuickStartController'; + +/** + * Opens the Local Quick Start webview. Primary entry point is the tree rocket + * row (WI-6); this command is the command-palette / fallback launch (D10). + */ +export function openLocalQuickStart(_context: IActionContext): void { + const view = openLocalQuickStartWebview({ id: 'localQuickStart' }); + // Reveal in the panel's own column when it already has one (so reopening the create-or-reveal + // singleton doesn't move a panel the user parked in another group), falling back to the active + // column instead of the framework default (ViewColumn.One), which would yank the tab to column 1 + // (GPT-5.6 review + panel follow-up). + view.revealToForeground(view.panel.viewColumn ?? vscode.ViewColumn.Active); +} diff --git a/src/commands/newConnection/ExecuteStep.ts b/src/commands/newConnection/ExecuteStep.ts index 70cd81660..509d94d0a 100644 --- a/src/commands/newConnection/ExecuteStep.ts +++ b/src/commands/newConnection/ExecuteStep.ts @@ -8,6 +8,7 @@ import * as l10n from '@vscode/l10n'; import { AuthMethodId } from '../../documentdb/auth/AuthMethod'; import { redactCredentialsFromConnectionString } from '../../documentdb/utils/connectionStringHelpers'; import { DocumentDBConnectionString } from '../../documentdb/utils/DocumentDBConnectionString'; +import { areAllHostsLocal, canonicalizeTlsException } from '../../documentdb/utils/tlsException'; import { API } from '../../DocumentDBExperiences'; import { ext } from '../../extensionVariables'; // FIXME (discovery plugin API coupling): this generic command imports directly from the @@ -53,7 +54,16 @@ export class ExecuteStep extends AzureWizardExecuteStep { @@ -64,6 +65,8 @@ export class PromptConnectionModeStep extends AzureWizardPromptStep { @@ -45,6 +46,18 @@ export class PromptConnectionStringStep extends AzureWizardPromptStep { + public async prompt(context: NewConnectionWizardContext): Promise { + const enableTls = { + id: 'enable', + label: l10n.t('Enable TLS (default)'), + detail: l10n.t('Validate the server certificate. Recommended.'), + alwaysShow: true, + }; + const allowInvalid = { + id: 'allow', + label: l10n.t('Allow invalid certificates'), + detail: l10n.t( + 'Accept a self-signed or untrusted certificate. Only choose this for a host you trust — a “.local” or single-word name can also be managed corporate infrastructure.', + ), + alwaysShow: true, + }; + + const selected = await context.ui.showQuickPick([enableTls, allowInvalid], { + placeHolder: l10n.t('This connection targets a local or private network host. TLS certificate validation:'), + stepName: 'tlsException', + suppressPersistence: true, + }); + + context.disableEmulatorSecurity = selected.id === 'allow'; + context.telemetry.properties.tlsException = context.disableEmulatorSecurity ? 'allowInvalid' : 'enabled'; + } + + public shouldPrompt(context: NewConnectionWizardContext): boolean { + // Already decided (e.g. the connection string already opted in) — don't ask again. + if (context.disableEmulatorSecurity !== undefined) { + return false; + } + if (!context.connectionString) { + return false; + } + try { + const hosts = new DocumentDBConnectionString(context.connectionString).hosts; + // Allow-invalid is client-wide, so only offer the exception when EVERY seed host is + // local/private — a mixed list (e.g. localhost + a public host) must NOT be able to + // disable certificate validation for the public host. + return hosts.length > 0 && hosts.every((host) => isLocalOrPrivateHost(host)); + } catch { + // A connection string we can't parse won't reach the gate — let later steps handle it. + return false; + } + } +} diff --git a/src/commands/newLocalConnection/ExecuteStep.ts b/src/commands/newLocalConnection/ExecuteStep.ts index 958da1025..c942a1583 100644 --- a/src/commands/newLocalConnection/ExecuteStep.ts +++ b/src/commands/newLocalConnection/ExecuteStep.ts @@ -3,8 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; +import { AzureWizardExecuteStep, UserCancelledError } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; import { redactCredentialsFromConnectionString } from '../../documentdb/utils/connectionStringHelpers'; import { DocumentDBConnectionString } from '../../documentdb/utils/DocumentDBConnectionString'; import { API } from '../../DocumentDBExperiences'; @@ -15,17 +16,20 @@ import { ConnectionType, ItemType, } from '../../services/connectionStorageService'; +import { QuickStartService } from '../../services/localQuickStart/QuickStartService'; import { buildFullTreePath, focusAndRevealInConnectionsView, refreshParentInConnectionsView, withConnectionsViewProgress, } from '../../tree/connections-view/connectionsViewHelpers'; +import { revealQuickStartInstance } from '../../tree/connections-view/LocalQuickStart/revealQuickStartInstance'; import { UserFacingError } from '../../utils/commandErrorHandling'; import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; import { type EmulatorConfiguration } from '../../utils/emulatorConfiguration'; import { nonNullValue } from '../../utils/nonNull'; import { generateDocumentDBStorageId } from '../../utils/storageUtils'; +import { findQuickStartInstanceForHosts, normalizeEndpointList } from './localEndpoint'; import { NewEmulatorConnectionMode, type NewLocalConnectionWizardContext } from './NewLocalConnectionWizardContext'; export class ExecuteStep extends AzureWizardExecuteStep { @@ -62,6 +66,51 @@ export class ExecuteStep extends AzureWizardExecuteStep { + it.each(['localhost:10260', '127.0.0.1:10260', '[::1]:10260', 'LOCALHOST:10260', '0:0:0:0:0:0:0:1'])( + 'collapses the loopback spelling %s', + (host) => { + // The last case carries no port, so it normalizes to the wire-protocol default instead. + const expected = host === '0:0:0:0:0:0:0:1' ? 'localhost:27017' : 'localhost:10260'; + expect(normalizeEndpoint(host)).toBe(expected); + }, + ); + + it('fills in the wire-protocol default port when the host carries none', () => { + expect(normalizeEndpoint('localhost')).toBe('localhost:27017'); + expect(normalizeEndpoint('localhost', 10260)).toBe('localhost:10260'); + expect(normalizeEndpoint('::1')).toBe('localhost:27017'); + }); + + it('keeps a bare IPv6 address whole instead of reading its last group as a port', () => { + expect(normalizeEndpoint('fe80::1')).toBe('fe80::1:27017'); + expect(normalizeEndpoint('[fe80::1]:10260')).toBe('fe80::1:10260'); + }); + + it('leaves non-loopback hosts alone (lowercased)', () => { + expect(normalizeEndpoint('Example.COM:27017')).toBe('example.com:27017'); + // Loopback at the IP layer, but a service bound to 127.0.0.1 is not reachable here, so + // treating the two as one endpoint would produce false duplicates. + expect(normalizeEndpoint('127.0.0.2:10260')).toBe('127.0.0.2:10260'); + }); +}); + +describe('normalizeEndpointList', () => { + it('is order-independent and spelling-independent', () => { + expect(normalizeEndpointList(['127.0.0.1:10260', 'example.com:27017'])).toBe( + normalizeEndpointList(['example.com:27017', 'localhost:10260']), + ); + }); + + it('distinguishes different ports on the same host', () => { + expect(normalizeEndpointList(['localhost:10260'])).not.toBe(normalizeEndpointList(['localhost:10261'])); + }); +}); + +describe('hasLoopbackHost', () => { + it('detects every loopback spelling', () => { + expect(hasLoopbackHost(['example.com:27017', '[::1]:10260'])).toBe(true); + expect(hasLoopbackHost(['example.com:27017'])).toBe(false); + }); +}); + +describe('findQuickStartInstanceForHosts', () => { + function instance(overrides: Partial = {}): InstanceStatus { + return { + alias: 'vscode-documentdb-local', + displayName: 'DocumentDB Local', + state: InstanceState.Running, + missing: false, + port: 10260, + canResumeReadiness: false, + ...overrides, + }; + } + + it('matches a managed instance across loopback spellings', () => { + expect(findQuickStartInstanceForHosts(['127.0.0.1:10260'], [instance()])).toMatchObject({ port: 10260 }); + expect(findQuickStartInstanceForHosts(['[::1]:10260'], [instance()])).toMatchObject({ port: 10260 }); + }); + + it('does not match a different port or a remote host on the same port', () => { + expect(findQuickStartInstanceForHosts(['localhost:10261'], [instance()])).toBeUndefined(); + expect(findQuickStartInstanceForHosts(['example.com:10260'], [instance()])).toBeUndefined(); + }); + + it('ignores instances that have no port yet', () => { + expect(findQuickStartInstanceForHosts(['localhost:10260'], [instance({ port: undefined })])).toBeUndefined(); + }); + + it('matches a stopped instance too — it still owns that port on this machine', () => { + expect( + findQuickStartInstanceForHosts(['localhost:10260'], [instance({ state: InstanceState.Stopped })]), + ).toMatchObject({ port: 10260 }); + }); +}); diff --git a/src/commands/newLocalConnection/localEndpoint.ts b/src/commands/newLocalConnection/localEndpoint.ts new file mode 100644 index 000000000..dd9ed7424 --- /dev/null +++ b/src/commands/newLocalConnection/localEndpoint.ts @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Endpoint normalization for the "is this connection already here?" checks in the New Local + * Connection wizard (#858). + * + * `localhost`, `127.0.0.1`, and `::1` all address the same machine, so comparing host strings + * verbatim let the same local service be added twice under different spellings — including + * alongside the Quick Start managed instance, which is not a stored connection and so was not + * compared against at all. + */ + +import { type InstanceStatus } from '../../services/localQuickStart/quickStartTypes'; + +/** Default MongoDB/DocumentDB wire-protocol port, used when a host carries no explicit port. */ +export const DEFAULT_WIRE_PROTOCOL_PORT = 27017; + +/** + * Host spellings that all mean "this machine". `0:0:0:0:0:0:0:1` is the expanded form of `::1`; + * anything else in `127.0.0.0/8` (e.g. `127.0.0.2`) is deliberately NOT here — it is loopback at + * the IP layer, but a service bound to one such address is not reachable on another, so treating + * them as the same endpoint would produce false duplicates. + */ +const LOOPBACK_HOSTNAMES: ReadonlySet = new Set(['localhost', '127.0.0.1', '::1', '0:0:0:0:0:0:0:1']); + +/** Canonical spelling every loopback form collapses to. */ +const CANONICAL_LOOPBACK = 'localhost'; + +/** + * Split `host[:port]`, honouring the bracket form IPv6 requires (`[::1]:10260`) and the bare form + * that carries no port (`::1`). A bare address with more than one colon cannot also carry a port — + * that is exactly why the bracket form exists — so it is returned whole. + */ +function splitHostPort(host: string): { readonly hostname: string; readonly port?: string } { + const trimmed = host.trim(); + + if (trimmed.startsWith('[')) { + const closing = trimmed.indexOf(']'); + if (closing > 0) { + const rest = trimmed.slice(closing + 1); + return { + hostname: trimmed.slice(1, closing), + port: rest.startsWith(':') ? rest.slice(1) : undefined, + }; + } + return { hostname: trimmed }; + } + + const colons = (trimmed.match(/:/g) ?? []).length; + if (colons !== 1) { + // Zero colons: a plain hostname. More than one: a bare IPv6 address. + return { hostname: trimmed }; + } + const separator = trimmed.indexOf(':'); + return { hostname: trimmed.slice(0, separator), port: trimmed.slice(separator + 1) }; +} + +/** + * Canonical `host:port` for comparison. Loopback spellings collapse to `localhost` and a missing + * port is filled in with the wire-protocol default, so `localhost`, `127.0.0.1:27017`, and + * `[::1]:27017` all normalize to the same string. + */ +export function normalizeEndpoint(host: string, defaultPort: number = DEFAULT_WIRE_PROTOCOL_PORT): string { + const { hostname, port } = splitHostPort(host); + const lowered = hostname.toLowerCase(); + const canonical = LOOPBACK_HOSTNAMES.has(lowered) ? CANONICAL_LOOPBACK : lowered; + const resolvedPort = port?.trim() ? port.trim() : String(defaultPort); + return `${canonical}:${resolvedPort}`; +} + +/** + * Canonical, order-independent key for a whole host list, so a seed list written in a different + * order (or with different loopback spellings) still compares equal. + */ +export function normalizeEndpointList(hosts: readonly string[], defaultPort?: number): string { + return hosts + .map((host) => normalizeEndpoint(host, defaultPort)) + .sort() + .join(','); +} + +/** True when any host in the list addresses the local machine. */ +export function hasLoopbackHost(hosts: readonly string[]): boolean { + return hosts.some((host) => normalizeEndpoint(host).startsWith(`${CANONICAL_LOOPBACK}:`)); +} + +/** + * The Quick Start managed instance serving one of `hosts`, if any. + * + * Matching is by endpoint alone — the instance owns that port on this machine, so any connection + * pointed at it reaches the same server regardless of the credentials used. Instances with no known + * port yet (never provisioned) can't collide with anything and are skipped. + */ +export function findQuickStartInstanceForHosts( + hosts: readonly string[], + instances: readonly InstanceStatus[], +): InstanceStatus | undefined { + const normalized = new Set(hosts.map((host) => normalizeEndpoint(host))); + return instances.find( + (instance) => instance.port !== undefined && normalized.has(`${CANONICAL_LOOPBACK}:${instance.port}`), + ); +} diff --git a/src/commands/openCollectionView/openCollectionView.ts b/src/commands/openCollectionView/openCollectionView.ts index 6a469d96f..4cb15b114 100644 --- a/src/commands/openCollectionView/openCollectionView.ts +++ b/src/commands/openCollectionView/openCollectionView.ts @@ -51,6 +51,12 @@ export async function openCollectionViewInternal( skip?: number; limit?: number; }; + /** + * Optional tab to land on when the view opens. When invoked from the + * "Indexes" tree node we pass `'tab_indexes'` so the user lands + * directly on the Index Management tab instead of Documents. + */ + initialTab?: 'tab_result' | 'tab_indexes' | 'tab_queryInsights'; }, ): Promise { /** @@ -79,6 +85,7 @@ export async function openCollectionViewInternal( collectionName: props.collectionName, feedbackSignalsEnabled: feedbackSignalsEnabled, initialQuery: props.initialQuery, + initialTab: props.initialTab, }); // Clean up the ClusterSession when the tab is closed diff --git a/src/commands/openInteractiveShell/openInteractiveShell.test.ts b/src/commands/openInteractiveShell/openInteractiveShell.test.ts index 905dc0510..3bbf8e428 100644 --- a/src/commands/openInteractiveShell/openInteractiveShell.test.ts +++ b/src/commands/openInteractiveShell/openInteractiveShell.test.ts @@ -39,7 +39,6 @@ describe('openInteractiveShell', () => { let mockShowTerminal: jest.Mock; let mockShowInformationMessage: jest.SpyInstance; let mockShowErrorMessage: jest.SpyInstance; - const mockEnsureConnectionReady = jest.fn(); const mockContext = { telemetry: { @@ -61,7 +60,6 @@ describe('openInteractiveShell', () => { mockShowErrorMessage = jest.spyOn(vscode.window, 'showErrorMessage').mockResolvedValue(undefined); mockContext.telemetry.properties = {}; (CredentialCache.hasCredentials as jest.Mock).mockReturnValue(true); - mockEnsureConnectionReady.mockResolvedValue(true); }); afterEach(() => { @@ -105,7 +103,6 @@ describe('openInteractiveShell', () => { dbExperience: { api: 'documentDB' }, }, experience: { api: 'documentDB' }, - ensureConnectionReady: mockEnsureConnectionReady, }; } @@ -180,23 +177,6 @@ describe('openInteractiveShell', () => { await openInteractiveShell(mockContext as never, makeClusterNode() as never); expect(mockContext.telemetry.properties.nodeType).toBe('cluster'); }); - - it('should prepare authentication and reachability before opening the terminal', async () => { - await openInteractiveShell(mockContext as never, makeClusterNode() as never); - - expect(mockEnsureConnectionReady).toHaveBeenCalledTimes(1); - expect(mockEnsureConnectionReady.mock.invocationCallOrder[0]).toBeLessThan( - mockCreateTerminal.mock.invocationCallOrder[0], - ); - }); - - it('should not open a terminal when connection preparation is cancelled or fails', async () => { - mockEnsureConnectionReady.mockResolvedValue(false); - - await openInteractiveShell(mockContext as never, makeClusterNode() as never); - - expect(mockCreateTerminal).not.toHaveBeenCalled(); - }); }); describe('when invoked from a collection node', () => { diff --git a/src/commands/openInteractiveShell/openInteractiveShell.ts b/src/commands/openInteractiveShell/openInteractiveShell.ts index 21157e1e0..45133b4c5 100644 --- a/src/commands/openInteractiveShell/openInteractiveShell.ts +++ b/src/commands/openInteractiveShell/openInteractiveShell.ts @@ -51,14 +51,6 @@ export async function openInteractiveShell( const connectionInfo = extractConnectionInfo(node); - // Cluster-level shell actions can be invoked before the node is expanded. - // Reuse the cluster's normal authentication and reachability hooks so - // source-specific infrastructure (such as a Kubernetes ClusterIP tunnel) - // is ready before the shell worker connects. - if (isClusterNode(node) && !(await node.ensureConnectionReady())) { - return; - } - // Verify credentials are available before opening the terminal if (!CredentialCache.hasCredentials(connectionInfo.clusterId)) { void vscode.window.showErrorMessage( @@ -166,7 +158,3 @@ function getNodeType(node: ClusterItemBase | DatabaseItem | CollectionItem): str } return 'cluster'; } - -function isClusterNode(node: ClusterItemBase | DatabaseItem | CollectionItem): node is ClusterItemBase { - return !('databaseInfo' in node); -} diff --git a/src/commands/removeConnection/removeConnection.ts b/src/commands/removeConnection/removeConnection.ts index ec153178c..c889a2d78 100644 --- a/src/commands/removeConnection/removeConnection.ts +++ b/src/commands/removeConnection/removeConnection.ts @@ -9,13 +9,14 @@ import * as vscode from 'vscode'; import { CredentialCache } from '../../documentdb/CredentialCache'; import { SchemaStore } from '../../documentdb/SchemaStore'; import { ext } from '../../extensionVariables'; -import { ConnectionStorageService, ConnectionType } from '../../services/connectionStorageService'; +import { ConnectionStorageService } from '../../services/connectionStorageService'; import { checkCanProceedAndInformUser } from '../../services/taskService/resourceUsageHelper'; import { refreshParentInConnectionsView, withConnectionsViewProgress, } from '../../tree/connections-view/connectionsViewHelpers'; import { DocumentDBClusterItem } from '../../tree/connections-view/DocumentDBClusterItem'; +import { resolveStorageZone } from '../../tree/connections-view/models/ConnectionClusterModel'; import { type TreeElement } from '../../tree/TreeElement'; import { getConfirmationAsInSettings } from '../../utils/dialogs/getConfirmation'; import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; @@ -78,11 +79,7 @@ export async function removeConnection( for (const connection of connectionsToDelete) { try { await ext.state.showDeleting(connection.id, async () => { - if (connection.cluster.emulatorConfiguration?.isEmulator) { - await ConnectionStorageService.delete(ConnectionType.Emulators, connection.storageId); - } else { - await ConnectionStorageService.delete(ConnectionType.Clusters, connection.storageId); - } + await ConnectionStorageService.delete(resolveStorageZone(connection.cluster), connection.storageId); }); // delete cached credentials from memory using stable clusterId (not treeId) diff --git a/src/commands/updateConnectionString/ExecuteStep.ts b/src/commands/updateConnectionString/ExecuteStep.ts index 2dada7245..65783199a 100644 --- a/src/commands/updateConnectionString/ExecuteStep.ts +++ b/src/commands/updateConnectionString/ExecuteStep.ts @@ -5,8 +5,9 @@ import { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; import { l10n, window } from 'vscode'; +import { areAllHostsLocal, canonicalizeTlsException } from '../../documentdb/utils/tlsException'; import { ext } from '../../extensionVariables'; -import { ConnectionStorageService, ConnectionType } from '../../services/connectionStorageService'; +import { ConnectionStorageService, ConnectionType, isConnection } from '../../services/connectionStorageService'; import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; import { nonNullValue } from '../../utils/nonNull'; import { type UpdateCSWizardContext } from './UpdateCSWizardContext'; @@ -15,7 +16,8 @@ export class ExecuteStep extends AzureWizardExecuteStep { public priority: number = 100; public async execute(context: UpdateCSWizardContext): Promise { - const resourceType = context.isEmulator ? ConnectionType.Emulators : ConnectionType.Clusters; + const resourceType = + context.storageZone ?? (context.isEmulator ? ConnectionType.Emulators : ConnectionType.Clusters); const connection = await ConnectionStorageService.get(context.storageId, resourceType); if (!connection || !connection.secrets?.connectionString) { @@ -27,15 +29,41 @@ export class ExecuteStep extends AzureWizardExecuteStep { } try { + // Canonicalize the edited connection string: when every host is local/private the + // TLS-bypass params are folded into `emulatorConfiguration.disableEmulatorSecurity`, + // the single source of truth (§7). Recompute the exception host-gated against the EDITED + // hosts: honor a freshly-requested bypass OR preserve an existing exception, but ONLY + // while every host stays local/private. Editing to a public/mixed host therefore CLEARS + // allow-invalid so the public host validates certificates (it never stays latched from + // the old value) — while leaving any bypass param the user wrote in the string itself + // untouched, since the stored flag would never be honored for that host anyway. + const canonicalTls = canonicalizeTlsException( + nonNullValue(context.newConnectionString?.trim(), 'context.newConnectionString', 'ExecuteStep.ts'), + ); + connection.secrets = { ...connection.secrets, - connectionString: nonNullValue( - context.newConnectionString?.trim(), - 'context.newConnectionString', - 'ExecuteStep.ts', - ), + connectionString: canonicalTls.connectionString, }; + if (isConnection(connection)) { + // Host-gate BOTH emulator flags against the EDITED hosts. Editing a local emulator or + // TLS-exception connection to a public/mixed host therefore CLEARS allow-invalid (so + // the public host validates certificates) AND clears `isEmulator` in storage — neither + // flag stays latched from the old local value. A still-local edit preserves both. + // (Note: a legacy connection still rendered under the Emulators tree node is forced to + // `isEmulator:true` at runtime by LocalEmulatorsItem regardless of the stored value, so + // the "(Emulator)" label/timeout there only fully clears once the §4 migration retires + // that node; the security-relevant `disableEmulatorSecurity` is honored from storage.) + const existing = connection.properties.emulatorConfiguration; + const allLocal = areAllHostsLocal(canonicalTls.connectionString); + const disableEmulatorSecurity = + allLocal && (canonicalTls.disableEmulatorSecurity || !!existing?.disableEmulatorSecurity); + const isEmulator = allLocal && !!existing?.isEmulator; + connection.properties.emulatorConfiguration = + isEmulator || disableEmulatorSecurity ? { isEmulator, disableEmulatorSecurity } : undefined; + } + await ConnectionStorageService.save(resourceType, connection, true); showConfirmationAsInSettings(l10n.t('Connection updated successfully.')); diff --git a/src/commands/updateConnectionString/UpdateCSWizardContext.ts b/src/commands/updateConnectionString/UpdateCSWizardContext.ts index 3f748568f..5779a8678 100644 --- a/src/commands/updateConnectionString/UpdateCSWizardContext.ts +++ b/src/commands/updateConnectionString/UpdateCSWizardContext.ts @@ -4,10 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type StorageZone } from '../../services/connectionStorageService'; export interface UpdateCSWizardContext extends IActionContext { // target item details isEmulator: boolean; + /** Explicit storage zone of the target connection (preferred over isEmulator inference). */ + storageZone?: StorageZone; storageId: string; originalConnectionString: string; diff --git a/src/commands/updateConnectionString/updateConnectionString.ts b/src/commands/updateConnectionString/updateConnectionString.ts index adffb1967..d9c5335db 100644 --- a/src/commands/updateConnectionString/updateConnectionString.ts +++ b/src/commands/updateConnectionString/updateConnectionString.ts @@ -8,8 +8,9 @@ import * as l10n from '@vscode/l10n'; import { maskSensitiveValuesInTelemetry } from '../../documentdb/utils/connectionStringHelpers'; import { DocumentDBConnectionString } from '../../documentdb/utils/DocumentDBConnectionString'; import { Views } from '../../documentdb/Views'; -import { ConnectionStorageService, ConnectionType } from '../../services/connectionStorageService'; +import { ConnectionStorageService } from '../../services/connectionStorageService'; import { type DocumentDBClusterItem } from '../../tree/connections-view/DocumentDBClusterItem'; +import { resolveStorageZone } from '../../tree/connections-view/models/ConnectionClusterModel'; import { refreshView } from '../refreshView/refreshView'; import { ConnectionStringStep } from './ConnectionStringStep'; import { ExecuteStep } from './ExecuteStep'; @@ -39,9 +40,7 @@ export async function updateConnectionString(context: IActionContext, node: Docu // as the object is cached in the tree view, and in the 'retry/error' nodes // that's why we need to get the fresh one each time. - const resourceType = node.cluster.emulatorConfiguration?.isEmulator - ? ConnectionType.Emulators - : ConnectionType.Clusters; + const resourceType = resolveStorageZone(node.cluster); const connection = await ConnectionStorageService.get(node.storageId, resourceType); const connectionString = connection?.secrets?.connectionString || ''; @@ -57,6 +56,7 @@ export async function updateConnectionString(context: IActionContext, node: Docu ...context, originalConnectionString: parsedCS.toString(), isEmulator: Boolean(node.cluster.emulatorConfiguration?.isEmulator), + storageZone: resolveStorageZone(node.cluster), storageId: node.storageId, }; diff --git a/src/commands/updateCredentials/ExecuteStep.ts b/src/commands/updateCredentials/ExecuteStep.ts index 7f8deba76..2e7a87365 100644 --- a/src/commands/updateCredentials/ExecuteStep.ts +++ b/src/commands/updateCredentials/ExecuteStep.ts @@ -27,7 +27,8 @@ export class ExecuteStep extends AzureWizardExecuteStep { - const resourceType = context.isEmulator ? ConnectionType.Emulators : ConnectionType.Clusters; + const resourceType = + context.storageZone ?? (context.isEmulator ? ConnectionType.Emulators : ConnectionType.Clusters); const connectionCredentials = await ConnectionStorageService.get(context.storageId, resourceType); if (!connectionCredentials) { diff --git a/src/commands/updateCredentials/UpdateCredentialsWizardContext.ts b/src/commands/updateCredentials/UpdateCredentialsWizardContext.ts index 05d7621e5..2ec9336dc 100644 --- a/src/commands/updateCredentials/UpdateCredentialsWizardContext.ts +++ b/src/commands/updateCredentials/UpdateCredentialsWizardContext.ts @@ -6,10 +6,13 @@ import { type IActionContext } from '@microsoft/vscode-azext-utils'; import { type EntraIdAuthConfig, type NativeAuthConfig } from '../../documentdb/auth/AuthConfig'; import { type AuthMethodId } from '../../documentdb/auth/AuthMethod'; +import { type StorageZone } from '../../services/connectionStorageService'; export interface UpdateCredentialsWizardContext extends IActionContext { // target item details isEmulator: boolean; + /** Explicit storage zone of the target connection (preferred over isEmulator inference). */ + storageZone?: StorageZone; storageId: string; availableAuthenticationMethods: AuthMethodId[]; diff --git a/src/commands/updateCredentials/updateCredentials.ts b/src/commands/updateCredentials/updateCredentials.ts index 1021e9f5a..cfbe00324 100644 --- a/src/commands/updateCredentials/updateCredentials.ts +++ b/src/commands/updateCredentials/updateCredentials.ts @@ -12,8 +12,9 @@ import { AzureDomains, hasDomainSuffix } from '../../documentdb/utils/connection import { DocumentDBConnectionString } from '../../documentdb/utils/DocumentDBConnectionString'; import { Views } from '../../documentdb/Views'; import { ext } from '../../extensionVariables'; -import { ConnectionStorageService, ConnectionType, isConnection } from '../../services/connectionStorageService'; +import { ConnectionStorageService, isConnection } from '../../services/connectionStorageService'; import { type DocumentDBClusterItem } from '../../tree/connections-view/DocumentDBClusterItem'; +import { resolveStorageZone } from '../../tree/connections-view/models/ConnectionClusterModel'; import { refreshView } from '../refreshView/refreshView'; import { PromptAuthMethodStep } from '../updateCredentials/PromptAuthMethodStep'; import { ExecuteStep } from './ExecuteStep'; @@ -50,9 +51,7 @@ export async function updateCredentials(context: IActionContext, node: DocumentD // Note to future maintainers: the node.cluster might be out of date // as the object is cached in the tree view, and in the 'retry/error' nodes // that's why we need to get the fresh one each time. - const resourceType = node.cluster.emulatorConfiguration?.isEmulator - ? ConnectionType.Emulators - : ConnectionType.Clusters; + const resourceType = resolveStorageZone(node.cluster); const storedItem = await ConnectionStorageService.get(node.storageId, resourceType); // Type guard ensures we have connection properties (not a folder) @@ -83,6 +82,7 @@ export async function updateCredentials(context: IActionContext, node: DocumentD availableAuthenticationMethods: authMethodsFromString(supportedAuthMethods), selectedAuthenticationMethod: authMethodFromString(connectionCredentials?.properties.selectedAuthMethod), isEmulator: Boolean(node.cluster.emulatorConfiguration?.isEmulator), + storageZone: resolveStorageZone(node.cluster), storageId: node.storageId, isErrorState, reconnectAfterError: false, diff --git a/src/documentdb/ClustersClient.ts b/src/documentdb/ClustersClient.ts index 29db8cdb8..9be3ed3e8 100644 --- a/src/documentdb/ClustersClient.ts +++ b/src/documentdb/ClustersClient.ts @@ -61,6 +61,7 @@ import { getHostsFromConnectionString, hasAzureDomain } from './utils/connection import { fixupDocumentDbExplain } from './utils/fixupDocumentDbExplain'; import { getClusterMetadata, type ClusterMetadata } from './utils/getClusterMetadata'; import { parseDocumentId } from './utils/parseDocumentId'; +import { resolveAllowInvalidCertificates } from './utils/tlsException'; import { toFilterQueryObj } from './utils/toFilterQuery'; export interface DatabaseItemModel { @@ -124,6 +125,7 @@ export interface IndexItemModel { hidden?: boolean; expireAfterSeconds?: number; partialFilterExpression?: Document; + cosmosSearchOptions?: Document; status?: string; queryable?: boolean; fields?: unknown[]; @@ -341,13 +343,20 @@ export class ClustersClient { } const message = parseError(error).message; - if (emulatorConfiguration?.isEmulator && message.includes('ECONNREFUSED')) { + // Surface the friendly local-connection tips only for a genuinely local-ish connection + // (emulator, OR a local connection that opted into the TLS exception — host-gated the + // same way as the TLS option). An orphaned flag on a PUBLIC host must NOT make a public + // ECONNREFUSED/self-signed failure show "local instance" troubleshooting copy. + const isLocalish = + !!emulatorConfiguration?.isEmulator || + !!resolveAllowInvalidCertificates(emulatorConfiguration?.disableEmulatorSecurity, connectionString); + if (isLocalish && message.includes('ECONNREFUSED')) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access error.message = l10n.t( 'Unable to connect to the local instance. Make sure it is started correctly. See {link} for tips.', { link: Links.LocalConnectionDebuggingTips }, ); - } else if (emulatorConfiguration?.isEmulator && message.includes('self-signed certificate')) { + } else if (isLocalish && message.includes('self-signed certificate')) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access error.message = l10n.t( 'The local instance is using a self-signed certificate. To connect, you must import the appropriate TLS/SSL certificate. See {link} for tips.', @@ -1059,12 +1068,17 @@ export class ClustersClient { return result; } - async createDatabase(databaseName: string): Promise { + async createDatabase(databaseName: string, collectionName?: string): Promise { // TODO: add logging of failures to the telemetry somewhere in the call chain - const newCollection = await this._mongoClient - .db(databaseName) - .createCollection('_dummy_collection_creation_forces_db_creation'); - await newCollection.drop({ writeConcern: { w: 'majority', wtimeoutMS: 5000 } }); + // In MongoDB, databases are created implicitly when their first collection is created. + if (collectionName) { + await this._mongoClient.db(databaseName).createCollection(collectionName); + } else { + const newCollection = await this._mongoClient + .db(databaseName) + .createCollection('_dummy_collection_creation_forces_db_creation'); + await newCollection.drop({ writeConcern: { w: 'majority', wtimeoutMS: 5000 } }); + } this._databasesCache = null; } diff --git a/src/documentdb/ClustersExtension.ts b/src/documentdb/ClustersExtension.ts index 67fc0e100..c6ac0421b 100644 --- a/src/documentdb/ClustersExtension.ts +++ b/src/documentdb/ClustersExtension.ts @@ -47,6 +47,17 @@ import { dropIndex } from '../commands/index.dropIndex/dropIndex'; import { hideIndex } from '../commands/index.hideIndex/hideIndex'; import { unhideIndex } from '../commands/index.unhideIndex/unhideIndex'; import { learnMoreAboutServiceProvider } from '../commands/learnMoreAboutServiceProvider/learnMoreAboutServiceProvider'; +import { + copyQuickStartConnectionString, + copyQuickStartPassword, + deleteQuickStartInstance, + disposeQuickStartLogFollow, + restartQuickStartInstance, + startQuickStartInstance, + stopQuickStartInstance, + viewQuickStartLogs, +} from '../commands/localQuickStart/localQuickStartCommands'; +import { openLocalQuickStart } from '../commands/localQuickStart/openLocalQuickStart'; import { newConnection } from '../commands/newConnection/newConnection'; import { newLocalConnection } from '../commands/newLocalConnection/newLocalConnection'; import { openCollectionView, openCollectionViewInternal } from '../commands/openCollectionView/openCollectionView'; @@ -78,14 +89,22 @@ import { updateCredentials } from '../commands/updateCredentials/updateCredentia import { doubleClickDebounceDelay } from '../constants'; import { isVCoreAndRURolloutEnabled } from '../extension'; import { ext } from '../extensionVariables'; +import { AtlasDiscoveryProvider } from '../plugins/service-atlas-mongodb/AtlasDiscoveryProvider'; +import { + OPEN_ATLAS_CLUSTER_COMMAND_ID, + openAtlasCluster, +} from '../plugins/service-atlas-mongodb/commands/openAtlasCluster'; +import { ADD_ATLAS_CREDENTIAL_COMMAND_ID } from '../plugins/service-atlas-mongodb/credentialsManagement/addAtlasCredential'; import { AzureMongoRUDiscoveryProvider } from '../plugins/service-azure-mongo-ru/AzureMongoRUDiscoveryProvider'; import { AzureDiscoveryProvider } from '../plugins/service-azure-mongo-vcore/AzureDiscoveryProvider'; import { AzureVMDiscoveryProvider } from '../plugins/service-azure-vm/AzureVMDiscoveryProvider'; import { KubernetesDiscoveryProvider } from '../plugins/service-kubernetes/KubernetesDiscoveryProvider'; import { KubernetesReachabilityProvider } from '../plugins/service-kubernetes/KubernetesReachabilityProvider'; import { ConnectionReachabilityService } from '../services/connectionReachabilityService'; -import { removeLegacyActiveDiscoveryProviderIds } from '../services/discoveryProviderVisibility'; import { DiscoveryService } from '../services/discoveryServices'; +import { migrateLegacyEmulatorConnections } from '../services/legacyEmulatorMigration'; +import { disposeQuickStartOutputChannel } from '../services/localQuickStart/ContainerRuntime'; +import { QuickStartService, sweepStaleQuickStartEnvFiles } from '../services/localQuickStart/QuickStartService'; import { maybeShowReleaseNotesNotification } from '../services/releaseNotesNotification'; import { DemoTask } from '../services/taskService/tasks/DemoTask'; import { TaskService } from '../services/taskService/taskService'; @@ -106,6 +125,7 @@ import { type ClusterItemBase } from '../tree/documentdb/ClusterItemBase'; import { type CollectionItem } from '../tree/documentdb/CollectionItem'; import { type DatabaseItem } from '../tree/documentdb/DatabaseItem'; import { HelpAndFeedbackBranchDataProvider } from '../tree/help-and-feedback-view/HelpAndFeedbackBranchDataProvider'; +import { type TreeElement } from '../tree/TreeElement'; import { accumulateTelemetry } from '../utils/accumulatingTelemetry'; import { registerCommandWithModalErrors, @@ -126,6 +146,8 @@ import { ShellTerminalLinkProvider } from './shell/ShellTerminalLinkProvider'; import { Views } from './Views'; export class ClustersExtension implements vscode.Disposable { + private readonly atlasDiscoveryProvider = new AtlasDiscoveryProvider(); + async dispose(): Promise { // Clean up any active port-forward tunnels const { PortForwardTunnelManager } = await import('../plugins/service-kubernetes/portForwardTunnel'); @@ -136,11 +158,9 @@ export class ClustersExtension implements vscode.Disposable { DiscoveryService.registerProvider(new AzureDiscoveryProvider()); DiscoveryService.registerProvider(new AzureMongoRUDiscoveryProvider()); DiscoveryService.registerProvider(new AzureVMDiscoveryProvider()); + DiscoveryService.registerProvider(this.atlasDiscoveryProvider); DiscoveryService.registerProvider(new KubernetesDiscoveryProvider()); - // One-time cleanup of the pre-0.9.0 opt-in visibility key; see discoveryProviderVisibility.ts (TODO #831). - void removeLegacyActiveDiscoveryProviderIds(); - // Connection-reachability providers: source-specific steps that make a saved connection // reachable before connecting (e.g. re-establishing a Kubernetes port-forward tunnel). // The generic Connections-view cluster node delegates to these via ConnectionReachabilityService. @@ -252,6 +272,28 @@ export class ClustersExtension implements vscode.Disposable { const playgroundService = PlaygroundService.getInstance(); ext.context.subscriptions.push(playgroundService); + // Initialize Local Quick Start (managed local DocumentDB container). + // Reconcile detects a still-running container after a window reload. + ext.context.subscriptions.push(QuickStartService); + ext.context.subscriptions.push({ dispose: disposeQuickStartOutputChannel }); + ext.context.subscriptions.push({ dispose: disposeQuickStartLogFollow }); + ext.context.subscriptions.push( + QuickStartService.onDidChangeStatus(() => { + // Reset BEFORE refreshing (I2-17): a failure the user fixed in the Quick Start + // webview would otherwise keep rendering its cached error node, because the + // provider returns those children without re-fetching. + ext.connectionsBranchDataProvider?.resetLocalQuickStartErrorState(); + ext.connectionsBranchDataProvider?.refresh(); + }), + ); + void QuickStartService.reconcile(); + // Self-heal after a crash that skipped provision()'s env-file cleanup (L9). + void sweepStaleQuickStartEnvFiles(); + + // One-time migration of legacy emulator connections into a regular + // "Local Connections (Legacy)" folder (design §4). Non-blocking. + void migrateLegacyEmulatorConnections(); + // Register evaluator disposal for clean worker shutdown on deactivation ext.context.subscriptions.push({ dispose: disposeEvaluators }); @@ -604,6 +646,39 @@ export class ClustersExtension implements vscode.Disposable { withTreeNodeCommandCorrelation(newLocalConnection), ); + registerCommand( + 'vscode-documentdb.command.localQuickStart.open', + withCommandCorrelation(openLocalQuickStart), + ); + registerCommand( + 'vscode-documentdb.command.localQuickStart.start', + withCommandCorrelation(startQuickStartInstance), + ); + registerCommand( + 'vscode-documentdb.command.localQuickStart.stop', + withCommandCorrelation(stopQuickStartInstance), + ); + registerCommand( + 'vscode-documentdb.command.localQuickStart.restart', + withCommandCorrelation(restartQuickStartInstance), + ); + registerCommand( + 'vscode-documentdb.command.localQuickStart.delete', + withCommandCorrelation(deleteQuickStartInstance), + ); + registerCommand( + 'vscode-documentdb.command.localQuickStart.copyConnectionString', + withCommandCorrelation(copyQuickStartConnectionString), + ); + registerCommand( + 'vscode-documentdb.command.localQuickStart.copyPassword', + withCommandCorrelation(copyQuickStartPassword), + ); + registerCommand( + 'vscode-documentdb.command.localQuickStart.viewLogs', + withCommandCorrelation(viewQuickStartLogs), + ); + registerCommand( 'vscode-documentdb.command.connectionsView.refresh', withCommandCorrelation((context: IActionContext) => { @@ -638,6 +713,18 @@ export class ClustersExtension implements vscode.Disposable { withTreeNodeCommandCorrelation(manageCredentials), ); + registerCommandWithTreeNodeUnwrapping( + ADD_ATLAS_CREDENTIAL_COMMAND_ID, + withTreeNodeCommandCorrelation((context, node: TreeElement) => + this.atlasDiscoveryProvider.addCredential(context, node), + ), + ); + + registerCommandWithTreeNodeUnwrapping( + OPEN_ATLAS_CLUSTER_COMMAND_ID, + withTreeNodeCommandCorrelation(openAtlasCluster), + ); + registerCommandWithTreeNodeUnwrapping( 'vscode-documentdb.command.discoveryView.learnMoreAboutProvider', withTreeNodeCommandCorrelation(learnMoreAboutServiceProvider), @@ -744,6 +831,24 @@ export class ClustersExtension implements vscode.Disposable { }), ); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.discoveryView.atlas.switchToTreeView', + withTreeNodeCommandCorrelation(async (context) => { + const { switchToAtlasTreeView } = + await import('../plugins/service-atlas-mongodb/commands/switchAtlasViewMode'); + await switchToAtlasTreeView(context); + }), + ); + + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.discoveryView.atlas.switchToFlatListView', + withTreeNodeCommandCorrelation(async (context) => { + const { switchToAtlasFlatListView } = + await import('../plugins/service-atlas-mongodb/commands/switchAtlasViewMode'); + await switchToAtlasFlatListView(context); + }), + ); + registerCommandWithTreeNodeUnwrappingAndModalErrors( 'vscode-documentdb.command.discoveryView.addConnectionToConnectionsView', withTreeNodeCommandCorrelation(addConnectionFromRegistry), diff --git a/src/documentdb/LlmEnhancedFeatureApis.ts b/src/documentdb/LlmEnhancedFeatureApis.ts index 890626086..7ede56f82 100644 --- a/src/documentdb/LlmEnhancedFeatureApis.ts +++ b/src/documentdb/LlmEnhancedFeatureApis.ts @@ -47,6 +47,10 @@ export interface IndexSpecification { expireAfterSeconds?: number; // Partial index filter expression partialFilterExpression?: Document; + // Wildcard index field inclusion/exclusion document + wildcardProjection?: Document; + // DocumentDB vector index options (used with a `cosmosSearch` key value) + cosmosSearchOptions?: Document; // Additional index options [key: string]: unknown; } @@ -112,6 +116,11 @@ export interface IndexStats { // Host information host: string; + // Whether the index is currently being built. `$indexStats` only includes + // this field (as `true`) while a build is in progress, so it is absent for + // ready indexes. + building?: boolean; + // Access statistics accesses: | { @@ -197,6 +206,7 @@ export class llmEnhancedFeatureApis { name: stat.name as string, key: stat.key as Record, host: stat.host as string, + building: stat.building === true ? true : undefined, accesses: { ops: accesses?.ops ?? 0, since: accesses?.since ?? new Date(), diff --git a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts index 395734468..cf4755082 100644 --- a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts +++ b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts @@ -9,6 +9,7 @@ import * as l10n from '@vscode/l10n'; import { type MongoClientOptions, type OIDCCallbackParams, type OIDCResponse } from 'mongodb'; import { type CachedClusterCredentials } from '../CredentialCache'; import { DocumentDBConnectionString } from '../utils/DocumentDBConnectionString'; +import { resolveAllowInvalidCertificates } from '../utils/tlsException'; import { type AuthHandler, type AuthHandlerResponse } from './AuthHandler'; import { getOidcAllowedHosts } from './oidcAllowedHosts'; @@ -53,6 +54,19 @@ export class MicrosoftEntraIDAuthHandler implements AuthHandler { }, }; + // Honor the TLS exception (design §7) consistently with the other auth paths, with the same + // "hybrid" runtime policy (flag honored only for local/private hosts). Entra ID is only + // offered for Azure (public) hosts, so this is effectively defensive, but it keeps the policy + // uniform across all builders. + if ( + resolveAllowInvalidCertificates( + this.clusterCredentials.emulatorConfiguration?.disableEmulatorSecurity, + this.clusterCredentials.connectionString, + ) + ) { + options.tlsAllowInvalidCertificates = true; + } + return { connectionString: dbConnectionString.toString(), options, diff --git a/src/documentdb/auth/NativeAuthHandler.ts b/src/documentdb/auth/NativeAuthHandler.ts index ddab5e92a..24c048b93 100644 --- a/src/documentdb/auth/NativeAuthHandler.ts +++ b/src/documentdb/auth/NativeAuthHandler.ts @@ -6,6 +6,7 @@ import { type MongoClientOptions } from 'mongodb'; import { nonNullValue } from '../../utils/nonNull'; import { type CachedClusterCredentials } from '../CredentialCache'; +import { resolveAllowInvalidCertificates } from '../utils/tlsException'; import { type AuthHandler, type AuthHandlerResponse } from './AuthHandler'; /** @@ -17,22 +18,41 @@ export class NativeAuthHandler implements AuthHandler { public configureAuth(): Promise { const options: MongoClientOptions = {}; - // Apply emulator-specific configuration if needed - if (this.clusterCredentials.emulatorConfiguration?.isEmulator) { + const connectionString = nonNullValue( + this.clusterCredentials.connectionStringWithPassword, + 'clusterCredentials.connectionStringWithPassword', + 'NativeAuthHandler.ts', + ); + + // Emulator-specific tuning: a shorter server-selection timeout fails fast against a + // local instance that isn't up yet — also applied to a regular LOCAL connection that opted + // into the TLS exception (§7). Host-gated the same way as the TLS option, so an orphaned + // flag on a public host does NOT trigger the aggressive 4s fail-fast. + if ( + this.clusterCredentials.emulatorConfiguration?.isEmulator || + resolveAllowInvalidCertificates( + this.clusterCredentials.emulatorConfiguration?.disableEmulatorSecurity, + connectionString, + ) + ) { options.serverSelectionTimeoutMS = 4000; + } - if (this.clusterCredentials.emulatorConfiguration?.disableEmulatorSecurity) { - // Prevents self signed certificate error for emulator - options.tlsAllowInvalidCertificates = true; - } + // TLS-allow-invalid is driven by `disableEmulatorSecurity` (design §7), honored ONLY for + // local/private hosts ("hybrid" runtime policy): an orphaned flag on a public host is not + // activated, while an explicit `tlsAllowInvalidCertificates` URL param is still honored by + // the driver (we never force the option to `false`). + if ( + resolveAllowInvalidCertificates( + this.clusterCredentials.emulatorConfiguration?.disableEmulatorSecurity, + connectionString, + ) + ) { + options.tlsAllowInvalidCertificates = true; } return Promise.resolve({ - connectionString: nonNullValue( - this.clusterCredentials.connectionStringWithPassword, - 'clusterCredentials.connectionStringWithPassword', - 'NativeAuthHandler.ts', - ), + connectionString, options, }); } diff --git a/src/documentdb/client/QueryInsightsApis.test.ts b/src/documentdb/client/QueryInsightsApis.test.ts deleted file mode 100644 index 51f93bab0..000000000 --- a/src/documentdb/client/QueryInsightsApis.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { type Document, type MongoClient } from 'mongodb'; -import { QueryInsightsApis } from './QueryInsightsApis'; - -describe('QueryInsightsApis', () => { - it('limits explain execution to 30 seconds', async () => { - const explain = jest.fn().mockResolvedValue({}); - const maxTimeMS = jest.fn(); - const cursor = { - maxTimeMS, - explain, - }; - const client = { - db: jest.fn().mockReturnValue({ - collection: jest.fn().mockReturnValue({ - find: jest.fn().mockReturnValue(cursor), - }), - }), - } as unknown as MongoClient; - - const result = await new QueryInsightsApis(client).explainFind('database', 'collection', {} as Document, { - verbosity: 'executionStats', - }); - - expect(maxTimeMS).toHaveBeenCalledWith(30_000); - expect(explain).toHaveBeenCalledWith('executionStats'); - expect(result).toEqual({}); - }); -}); diff --git a/src/documentdb/client/QueryInsightsApis.ts b/src/documentdb/client/QueryInsightsApis.ts index 9c60eb141..b20581246 100644 --- a/src/documentdb/client/QueryInsightsApis.ts +++ b/src/documentdb/client/QueryInsightsApis.ts @@ -10,8 +10,6 @@ import { type Document, type MongoClient } from 'mongodb'; -const QUERY_INSIGHTS_TIMEOUT_MS = 30_000; - /** * Options for explain operations on find queries */ @@ -68,7 +66,6 @@ export class QueryInsightsApis { const collection = db.collection(collectionName); const cursor = collection.find(filter); - cursor.maxTimeMS(QUERY_INSIGHTS_TIMEOUT_MS); if (options.sort) { cursor.sort(options.sort); diff --git a/src/documentdb/connectToClient.ts b/src/documentdb/connectToClient.ts index 2f20a9d6f..56afc3743 100644 --- a/src/documentdb/connectToClient.ts +++ b/src/documentdb/connectToClient.ts @@ -7,6 +7,7 @@ import * as l10n from '@vscode/l10n'; import { MongoClient, type MongoClientOptions } from 'mongodb'; import { Links, wellKnownEmulatorPassword } from '../constants'; import { type EmulatorConfiguration } from '../utils/emulatorConfiguration'; +import { resolveAllowInvalidCertificates } from './utils/tlsException'; export async function connectToClient( connectionString: string, @@ -22,8 +23,12 @@ export async function connectToClient( useUnifiedTopology: true, }; - if (emulatorConfiguration && emulatorConfiguration.isEmulator && emulatorConfiguration.disableEmulatorSecurity) { - // Prevents self signed certificate error for emulator https://github.com/microsoft/vscode-cosmosdb/issues/1241#issuecomment-614446198 + // TLS-allow-invalid is driven by `disableEmulatorSecurity` (design §7), but the stored flag is + // honored ONLY for local/private hosts ("hybrid" runtime policy): an orphaned flag on a public + // host is not activated, while an explicit `tlsAllowInvalidCertificates` URL param a user put in + // the connection string is still honored by the driver (we never force the option to `false`). + if (resolveAllowInvalidCertificates(emulatorConfiguration?.disableEmulatorSecurity, connectionString)) { + // Prevents self signed certificate error https://github.com/microsoft/vscode-cosmosdb/issues/1241#issuecomment-614446198 options.tlsAllowInvalidCertificates = true; } diff --git a/src/documentdb/playground/PlaygroundEvaluator.ts b/src/documentdb/playground/PlaygroundEvaluator.ts index 8e5391a17..0953334ee 100644 --- a/src/documentdb/playground/PlaygroundEvaluator.ts +++ b/src/documentdb/playground/PlaygroundEvaluator.ts @@ -11,6 +11,7 @@ import { ext } from '../../extensionVariables'; import { meterSilentCatch } from '../../utils/accumulatingTelemetry'; import { getBatchSizeSetting } from '../../utils/workspacUtils'; import { CredentialCache } from '../CredentialCache'; +import { resolveAllowInvalidCertificates } from '../utils/tlsException'; import { type ExecutionResult, type PlaygroundConnection } from './types'; import { WorkerSessionManager } from './WorkerSessionManager'; import { type MainToWorkerMessage, type SerializableMongoClientOptions, type WorkerToMainMessage } from './workerTypes'; @@ -258,12 +259,23 @@ export class PlaygroundEvaluator implements vscode.Disposable { // Build serializable MongoClientOptions const clientOptions: SerializableMongoClientOptions = { - serverSelectionTimeoutMS: credentials.emulatorConfiguration?.isEmulator ? 4000 : undefined, - tlsAllowInvalidCertificates: - credentials.emulatorConfiguration?.isEmulator && - credentials.emulatorConfiguration?.disableEmulatorSecurity - ? true + // Fail-fast 4s timeout for emulators / local TLS-exception connections — host-gated the + // same way as the TLS option so an orphaned flag on a public host doesn't trigger it. + serverSelectionTimeoutMS: + credentials.emulatorConfiguration?.isEmulator || + resolveAllowInvalidCertificates( + credentials.emulatorConfiguration?.disableEmulatorSecurity, + connectionString, + ) + ? 4000 : undefined, + // TLS-allow-invalid is keyed off `disableEmulatorSecurity` (design §7), honored ONLY for + // local/private hosts ("hybrid" runtime policy): an orphaned flag on a public host is not + // activated; an explicit URL param is still honored by the driver (we never force `false`). + tlsAllowInvalidCertificates: resolveAllowInvalidCertificates( + credentials.emulatorConfiguration?.disableEmulatorSecurity, + connectionString, + ), }; return { diff --git a/src/documentdb/shell/ShellSessionManager.ts b/src/documentdb/shell/ShellSessionManager.ts index 2ea95a1c1..a77260c03 100644 --- a/src/documentdb/shell/ShellSessionManager.ts +++ b/src/documentdb/shell/ShellSessionManager.ts @@ -15,6 +15,7 @@ import { type SerializableMongoClientOptions, type WorkerToMainMessage, } from '../playground/workerTypes'; +import { resolveAllowInvalidCertificates } from '../utils/tlsException'; /** * Connection parameters for a shell session. @@ -172,7 +173,14 @@ export class ShellSessionManager implements vscode.Disposable { return { host: this.extractHost(initMsg.connectionString), authMechanism: initMsg.authMechanism, - isEmulator: initMsg.clientOptions.serverSelectionTimeoutMS === 4000, + // Derive emulator-ness from the authoritative credential flag, NOT from the + // fail-fast `serverSelectionTimeoutMS === 4000` proxy: that timeout now also fires + // for a regular local connection that opted into the TLS exception + // (`disableEmulatorSecurity` without `isEmulator`, design §7), which must not be + // mislabeled "(Emulator)" in the banner or inflate the emulator telemetry metric. + isEmulator: + CredentialCache.getCredentials(this._connectionInfo.clusterId)?.emulatorConfiguration?.isEmulator ?? + false, username, }; } @@ -254,12 +262,23 @@ export class ShellSessionManager implements vscode.Disposable { } const clientOptions: SerializableMongoClientOptions = { - serverSelectionTimeoutMS: credentials.emulatorConfiguration?.isEmulator ? 4000 : undefined, - tlsAllowInvalidCertificates: - credentials.emulatorConfiguration?.isEmulator && - credentials.emulatorConfiguration?.disableEmulatorSecurity - ? true + // Fail-fast 4s timeout for emulators / local TLS-exception connections — host-gated the + // same way as the TLS option so an orphaned flag on a public host doesn't trigger it. + serverSelectionTimeoutMS: + credentials.emulatorConfiguration?.isEmulator || + resolveAllowInvalidCertificates( + credentials.emulatorConfiguration?.disableEmulatorSecurity, + connectionString, + ) + ? 4000 : undefined, + // TLS-allow-invalid is keyed off `disableEmulatorSecurity` (design §7), honored ONLY for + // local/private hosts ("hybrid" runtime policy): an orphaned flag on a public host is not + // activated; an explicit URL param is still honored by the driver (we never force `false`). + tlsAllowInvalidCertificates: resolveAllowInvalidCertificates( + credentials.emulatorConfiguration?.disableEmulatorSecurity, + connectionString, + ), }; return { diff --git a/src/documentdb/utils/hostClassification.test.ts b/src/documentdb/utils/hostClassification.test.ts new file mode 100644 index 000000000..4fceb13f7 --- /dev/null +++ b/src/documentdb/utils/hostClassification.test.ts @@ -0,0 +1,111 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { extractHostname, isLocalOrPrivateHost } from './hostClassification'; + +describe('hostClassification (TLS-exception gating, design §7.1)', () => { + describe('extractHostname', () => { + it('strips an optional port', () => { + expect(extractHostname('localhost:10260')).toBe('localhost'); + expect(extractHostname('10.0.0.5:27017')).toBe('10.0.0.5'); + }); + + it('strips IPv6 brackets and port', () => { + expect(extractHostname('[fe80::1]:10260')).toBe('fe80::1'); + expect(extractHostname('[::1]')).toBe('::1'); + }); + + it('returns bare IPv6 unchanged', () => { + expect(extractHostname('fe80::1')).toBe('fe80::1'); + }); + + it('lowercases the host', () => { + expect(extractHostname('MyDevBox')).toBe('mydevbox'); + }); + }); + + describe('isLocalOrPrivateHost — should OFFER the TLS exception (true)', () => { + it.each([ + 'localhost', + 'localhost:10260', + 'db.localhost', + '127.0.0.1', + '127.5.6.7', + '::1', + '[::1]:10260', + '10.0.0.1', + '10.255.255.255', + '172.16.0.0', + '172.16.0.1', + '172.31.255.255', + '192.168.1.1', + '192.168.255.255', + '169.254.0.1', + 'devbox', // single-word hostname + 'home', + 'my-server.local', // mDNS + 'fc00::', // IPv6 ULA lower boundary + 'fc00::1', + 'fd12:3456::1', + 'fdff::1', // IPv6 ULA upper boundary + 'fe80::1', // IPv6 link-local + 'febf::1', // IPv6 link-local upper boundary + '[fe80::abcd]:10260', + '0:0:0:0:0:0:0:1', // fully-expanded ::1 + '0000:0000:0000:0000:0000:0000:0000:0001', // zero-padded ::1 + '[0:0:0:0:0:0:0:1]:10260', + '::', // unspecified address — targets the local machine + '0.0.0.0', + '::ffff:127.0.0.1', // IPv4-mapped loopback + '[::ffff:127.0.0.1]:10260', + '::ffff:10.0.0.5', // IPv4-mapped RFC1918 + '::ffff:192.168.1.1', + '::127.0.0.1', // deprecated IPv4-compatible form + 'fe80::1%eth0', // zone index + '0:0:0:0:0:0:0:0', // fully-expanded :: + ])('%s → true', (host) => { + expect(isLocalOrPrivateHost(host)).toBe(true); + }); + }); + + describe('isLocalOrPrivateHost — should NOT offer the TLS exception (false)', () => { + it.each([ + 'example.com', + 'cluster0.mongodb.net', + 'my-cluster.documents.azure.com', + '8.8.8.8', + '172.15.0.1', // just below the 172.16/12 range + '172.32.0.1', // just above the 172.16/12 range + '192.169.0.1', // not 192.168 + '169.255.0.1', // not 169.254 + '11.0.0.1', // not 10/8 + 'fec0::1', // just above fe80::/10 (not link-local) + '2001:db8::1', // public IPv6 + '2001:0db8:0000:0000:0000:0000:0000:0001', // fully-expanded public IPv6 + '::ffff:8.8.8.8', // IPv4-mapped PUBLIC address must stay public + '::ffff:11.0.0.1', + 'fe80:::1', // malformed — must not be classified as link-local + '::1::2', // malformed — two '::' groups + '', // empty + 'example\u3002com', // U+3002 ideographic full stop — DNS resolves as public example.com + 'example\uFF0Ecom', // U+FF0E fullwidth full stop + 'example\uFF61com', // U+FF61 halfwidth ideographic full stop + '8\u30028\u30028\u30028', // public IPv4 written with Unicode dots → 8.8.8.8 + 'cluster0\u3002mongodb\u3002net', // public host with mixed Unicode separators + ])('%s → false', (host) => { + expect(isLocalOrPrivateHost(host)).toBe(false); + }); + }); + + describe('isLocalOrPrivateHost — IDNA/homograph normalization keeps genuinely-local hosts local', () => { + it.each([ + '127\u30020\u30020\u30021', // loopback IPv4 with Unicode dots → 127.0.0.1 + '10\u30020\u30020\u30021', // private IPv4 with Unicode dots → 10.0.0.1 + 'devbox', // legitimate single-label local host (no dots at all) + ])('%s → true', (host) => { + expect(isLocalOrPrivateHost(host)).toBe(true); + }); + }); +}); diff --git a/src/documentdb/utils/hostClassification.ts b/src/documentdb/utils/hostClassification.ts new file mode 100644 index 000000000..81fc0ea10 --- /dev/null +++ b/src/documentdb/utils/hostClassification.ts @@ -0,0 +1,215 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { domainToASCII } from 'node:url'; + +/** + * Host classification for the TLS-exception gating rules (Local Quick Start design §7.1). + * + * Decides whether a connection target is a local / private-network host for which a + * self-signed or untrusted certificate is plausibly expected — i.e. whether to *offer* + * the "Allow invalid TLS certificates" step in the new-connection wizard. The step itself + * always defaults to **Enable TLS**; this gate only decides whether the step is shown. + * + * Caveat (design §7.1): `.local` suffixes and single-word names can also be corporate + * infrastructure (AD domains, DNS search domains). That is why the gate only controls + * whether the step is offered — the step defaults to keeping TLS on. + * + * Security note: classification is done on the IDNA/punycode-normalized hostname (see + * `normalizeHostForClassification`). A public domain must never be able to masquerade as a + * single-word local host by using a Unicode label separator (e.g. `example。com`, U+3002), + * which DNS resolves as `example.com` but a naive ASCII-dot check would treat as one word. + */ + +/** + * Extract the bare hostname/IP from a connection-string host entry, stripping an optional + * port and IPv6 brackets, lowercased. Handles `host`, `host:port`, `[ipv6]`, `[ipv6]:port`, + * and bare IPv6 (`fe80::1`). + */ +export function extractHostname(host: string): string { + const trimmed = host.trim(); + if (trimmed.startsWith('[')) { + // [ipv6] or [ipv6]:port + const end = trimmed.indexOf(']'); + if (end !== -1) { + return trimmed.slice(1, end).toLowerCase(); + } + } + // `host:port` has exactly one colon; bare IPv6 has several (and no port without brackets). + const colonCount = (trimmed.match(/:/g) ?? []).length; + if (colonCount === 1) { + return trimmed.slice(0, trimmed.indexOf(':')).toLowerCase(); + } + return trimmed.toLowerCase(); +} + +/** + * Normalize a bare hostname for classification so Unicode/IDNA homographs can't disguise a + * public multi-label domain as a single-word local host. IPv6 literals (containing `:`) are + * returned unchanged because `domainToASCII` rejects them. For everything else we first map the + * Unicode full-stop variants that IDNA treats as label separators (U+3002 `。`, U+FF0E `.`, + * U+FF61 `。`) to ASCII `.` (defense-in-depth), then apply IDNA via `domainToASCII` (which also + * punycodes other confusables). Falls back to the dot-normalized input if `domainToASCII` returns + * empty (a malformed domain), so the downstream IP checks still run. + */ +function normalizeHostForClassification(hostname: string): string { + if (hostname.includes(':')) { + return hostname; + } + const dotNormalized = hostname.replace(/[\u3002\uFF0E\uFF61]/g, '.'); + return domainToASCII(dotNormalized) || dotNormalized; +} + +/** Parse a dotted-quad IPv4 string into octets, or undefined if it is not a valid IPv4. */ +function ipv4Octets(value: string): number[] | undefined { + if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(value)) { + return undefined; + } + const octets = value.split('.').map((part) => Number(part)); + return octets.every((octet) => octet >= 0 && octet <= 255) ? octets : undefined; +} + +/** + * Fully expand an IPv6 literal into its 8 hextets, or undefined if it is not a valid IPv6 literal. + * Handles the compressed `::` form, a zone index (`fe80::1%eth0`) and the dotted-quad tail of + * IPv4-mapped / IPv4-compatible addresses (`::ffff:127.0.0.1`), which occupies the last two hextets. + * + * Expanding first is what lets the caller classify every spelling of the same address alike: + * matching on the literal text made `0:0:0:0:0:0:0:1` and `::ffff:127.0.0.1` look public. + */ +function expandIpv6(value: string): number[] | undefined { + const literal = value.split('%')[0]; + if (!literal.includes(':')) { + return undefined; + } + const sides = literal.split('::'); + if (sides.length > 2) { + return undefined; + } + + const expandSide = (side: string): number[] | undefined => { + if (side === '') { + return []; + } + const groups = side.split(':'); + const hextets: number[] = []; + for (let index = 0; index < groups.length; index++) { + const group = groups[index]; + if (group.includes('.')) { + // A dotted-quad is legal only as the final element, and fills two hextets. + const octets = ipv4Octets(group); + if (!octets || index !== groups.length - 1) { + return undefined; + } + hextets.push((octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]); + continue; + } + if (!/^[0-9a-f]{1,4}$/.test(group)) { + return undefined; + } + hextets.push(parseInt(group, 16)); + } + return hextets; + }; + + const head = expandSide(sides[0]); + const tail = sides.length === 2 ? expandSide(sides[1]) : []; + if (!head || !tail) { + return undefined; + } + if (sides.length === 1) { + return head.length === 8 ? head : undefined; + } + const fill = 8 - head.length - tail.length; + return fill < 1 ? undefined : [...head, ...new Array(fill).fill(0), ...tail]; +} + +/** + * Whether a host is a loopback / private-network / local-discovery target for which a + * TLS-exception step should be offered (design §7.1): + * - Loopback: `localhost`, `*.localhost`, `127.0.0.0/8`, `::1` (in any spelling) + * - Unspecified/wildcard: `0.0.0.0`, `::` — these address the local machine + * - IPv4 private (RFC 1918): `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` + * - IPv4 link-local: `169.254.0.0/16` + * - IPv6 unique-local (`fc00::/7`) and link-local (`fe80::/10`) + * - IPv4-mapped / IPv4-compatible IPv6 (`::ffff:127.0.0.1`) — classified by the embedded IPv4 + * - Single-word hostnames (no dots), e.g. `home`, `devbox` + * - `*.local` mDNS names + */ +export function isLocalOrPrivateHost(host: string): boolean { + const extracted = extractHostname(host); + if (!extracted) { + return false; + } + // Normalize away IDNA/Unicode homographs so a public domain can't pose as a single-word host. + const hostname = normalizeHostForClassification(extracted); + if (!hostname) { + return false; + } + + // Loopback / mDNS / single-word names. + if (hostname === 'localhost' || hostname.endsWith('.localhost')) { + return true; + } + if (hostname.endsWith('.local')) { + return true; + } + // A single-word hostname has no dots and is not IPv6 (no colons). + if (!hostname.includes('.') && !hostname.includes(':')) { + return true; + } + + // IPv4 ranges. + const octets = ipv4Octets(hostname); + if (octets) { + return isLocalOrPrivateIpv4(octets); + } + + // IPv6: expand first, so every spelling of the same address classifies alike. + const hextets = hostname.includes(':') ? expandIpv6(hostname) : undefined; + if (hextets) { + // ::1 loopback and :: unspecified, in any spelling (`0:0:0:0:0:0:0:1`, `::1`, `::`). + if (hextets.slice(0, 7).every((hextet) => hextet === 0) && hextets[7] <= 1) { + return true; + } + // IPv4-mapped (`::ffff:a.b.c.d`) and IPv4-compatible (`::a.b.c.d`): the address IS that + // IPv4, so classify it with the IPv4 rules rather than as an opaque public IPv6. + if (hextets.slice(0, 5).every((hextet) => hextet === 0) && (hextets[5] === 0xffff || hextets[5] === 0)) { + return isLocalOrPrivateIpv4([hextets[6] >> 8, hextets[6] & 0xff, hextets[7] >> 8, hextets[7] & 0xff]); + } + if ((hextets[0] & 0xfe00) === 0xfc00) { + return true; // fc00::/7 unique-local + } + if ((hextets[0] & 0xffc0) === 0xfe80) { + return true; // fe80::/10 link-local + } + } + + return false; +} + +/** Classify parsed IPv4 octets against the loopback / private / link-local ranges. */ +function isLocalOrPrivateIpv4(octets: number[]): boolean { + const [a, b] = octets; + if (a === 127) { + return true; // 127.0.0.0/8 loopback + } + if (a === 0) { + return true; // 0.0.0.0/8 — the unspecified address targets the local machine + } + if (a === 10) { + return true; // 10.0.0.0/8 + } + if (a === 172 && b >= 16 && b <= 31) { + return true; // 172.16.0.0/12 + } + if (a === 192 && b === 168) { + return true; // 192.168.0.0/16 + } + if (a === 169 && b === 254) { + return true; // 169.254.0.0/16 link-local + } + return false; +} diff --git a/src/documentdb/utils/tlsException.test.ts b/src/documentdb/utils/tlsException.test.ts new file mode 100644 index 000000000..5f047c42b --- /dev/null +++ b/src/documentdb/utils/tlsException.test.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { areAllHostsLocal, canonicalizeTlsException, resolveAllowInvalidCertificates } from './tlsException'; + +describe('canonicalizeTlsException (TLS exception single-source-of-truth, design §7)', () => { + it('honors allow-invalid for a local host and strips the param', () => { + const result = canonicalizeTlsException('mongodb://localhost:10260/?tls=true&tlsAllowInvalidCertificates=true'); + expect(result.disableEmulatorSecurity).toBe(true); + expect(result.connectionString).not.toContain('tlsAllowInvalidCertificates'); + expect(result.connectionString).toContain('tls=true'); + }); + + it('honors allow-invalid for a private (RFC1918) host', () => { + const result = canonicalizeTlsException('mongodb://192.168.1.5:27017/?tlsAllowInvalidCertificates=true'); + expect(result.disableEmulatorSecurity).toBe(true); + expect(result.connectionString).not.toContain('tlsAllowInvalidCertificates'); + }); + + it("does NOT adopt the exception for a public host, and leaves the user's param untouched", () => { + const input = 'mongodb://prod.example.com/?tlsAllowInvalidCertificates=true'; + const result = canonicalizeTlsException(input); + expect(result.disableEmulatorSecurity).toBe(false); + // The stored flag is host-gated and would never be honored here, so stripping the param + // would leave a self-hosted server on a public DNS name with no way to express the + // exception at all. `resolveAllowInvalidCertificates` stays silent so the driver honors it. + expect(result.connectionString).toBe(input); + }); + + it('does NOT adopt the exception for a Unicode-dot homograph of a public host', () => { + // `example。com` (U+3002) has no ASCII dot but DNS resolves it as the public example.com, + // so it must NOT be treated as a single-word local host. + const input = 'mongodb://example\u3002com/?tlsAllowInvalidCertificates=true'; + const result = canonicalizeTlsException(input); + expect(result.disableEmulatorSecurity).toBe(false); + expect(result.connectionString).toBe(input); + }); + + it('does NOT adopt the exception for a mixed seed list (one public host)', () => { + const input = 'mongodb://localhost:27017,prod.example.com:27017/?tlsAllowInvalidCertificates=true'; + const result = canonicalizeTlsException(input); + expect(result.disableEmulatorSecurity).toBe(false); + expect(result.connectionString).toBe(input); + }); + + it('strips alias bypass params (sslAllowInvalidCertificates, tlsInsecure)', () => { + const a = canonicalizeTlsException('mongodb://localhost/?sslAllowInvalidCertificates=true'); + expect(a.disableEmulatorSecurity).toBe(true); + expect(a.connectionString.toLowerCase()).not.toContain('sslallowinvalidcertificates'); + + const b = canonicalizeTlsException('mongodb://localhost/?tlsInsecure=true'); + expect(b.disableEmulatorSecurity).toBe(true); + expect(b.connectionString.toLowerCase()).not.toContain('tlsinsecure'); + }); + + it('strips hostname-validation bypass params (tlsAllowInvalidHostnames / ssl alias) for a local host', () => { + const a = canonicalizeTlsException('mongodb://localhost/?tlsAllowInvalidHostnames=true'); + expect(a.disableEmulatorSecurity).toBe(true); + expect(a.connectionString.toLowerCase()).not.toContain('allowinvalidhostnames'); + + const b = canonicalizeTlsException('mongodb://localhost/?sslAllowInvalidHostnames=true'); + expect(b.disableEmulatorSecurity).toBe(true); + expect(b.connectionString.toLowerCase()).not.toContain('allowinvalidhostnames'); + }); + + it('does NOT adopt a hostname-validation bypass for a public host, and keeps the param', () => { + const input = 'mongodb://prod.example.com/?tlsAllowInvalidHostnames=true'; + const result = canonicalizeTlsException(input); + expect(result.disableEmulatorSecurity).toBe(false); + expect(result.connectionString).toBe(input); + }); + + it('does NOT adopt a hostname-validation bypass for a mixed seed list, and keeps the param', () => { + const input = 'mongodb://localhost:27017,prod.example.com:27017/?tlsAllowInvalidHostnames=true'; + const result = canonicalizeTlsException(input); + expect(result.disableEmulatorSecurity).toBe(false); + expect(result.connectionString).toBe(input); + }); + + it('is case-insensitive on the hostname-validation bypass key', () => { + const result = canonicalizeTlsException('mongodb://localhost/?TLSAllowInvalidHostnames=true'); + expect(result.disableEmulatorSecurity).toBe(true); + expect(result.connectionString.toLowerCase()).not.toContain('allowinvalidhostnames'); + }); + + it('honors rejectUnauthorized=false (inverse semantics) for a local host and strips it', () => { + const result = canonicalizeTlsException('mongodb://localhost/?rejectUnauthorized=false'); + expect(result.disableEmulatorSecurity).toBe(true); + expect(result.connectionString.toLowerCase()).not.toContain('rejectunauthorized'); + }); + + it('does NOT adopt rejectUnauthorized=false for a public host, and keeps the param', () => { + const input = 'mongodb://prod.example.com/?rejectUnauthorized=false'; + const result = canonicalizeTlsException(input); + expect(result.disableEmulatorSecurity).toBe(false); + expect(result.connectionString).toBe(input); + }); + + it('strips rejectUnauthorized=true without requesting a bypass (and validates)', () => { + const result = canonicalizeTlsException('mongodb://localhost/?rejectUnauthorized=true'); + expect(result.disableEmulatorSecurity).toBe(false); + expect(result.connectionString.toLowerCase()).not.toContain('rejectunauthorized'); + }); + + it('returns false (no exception) when no bypass param is present', () => { + const result = canonicalizeTlsException('mongodb://localhost:10260/?tls=true'); + expect(result.disableEmulatorSecurity).toBe(false); + // Nothing to strip → connection string returned unchanged. + expect(result.connectionString).toBe('mongodb://localhost:10260/?tls=true'); + }); + + it('treats a bypass param set to false as no exception (and strips it)', () => { + const result = canonicalizeTlsException('mongodb://localhost/?tlsAllowInvalidCertificates=false'); + expect(result.disableEmulatorSecurity).toBe(false); + expect(result.connectionString).not.toContain('tlsAllowInvalidCertificates'); + }); + + it('is case-insensitive on the param key', () => { + const result = canonicalizeTlsException('mongodb://localhost/?TLSAllowInvalidCertificates=true'); + expect(result.disableEmulatorSecurity).toBe(true); + expect(result.connectionString.toLowerCase()).not.toContain('allowinvalidcertificates'); + }); + + it('returns the input unchanged and no exception for an unparseable string', () => { + const result = canonicalizeTlsException('not-a-connection-string'); + expect(result.disableEmulatorSecurity).toBe(false); + expect(result.connectionString).toBe('not-a-connection-string'); + }); +}); + +describe('areAllHostsLocal (host-gating a TLS exception decided elsewhere)', () => { + it('is true when every host is local/private', () => { + expect(areAllHostsLocal('mongodb://localhost:10260/')).toBe(true); + expect(areAllHostsLocal('mongodb://192.168.1.5:27017,10.0.0.1:27017/')).toBe(true); + }); + + it('is false when any host is public (mixed seed list)', () => { + expect(areAllHostsLocal('mongodb://localhost:27017,prod.example.com:27017/')).toBe(false); + }); + + it('is false for a public host', () => { + expect(areAllHostsLocal('mongodb://cluster0.mongodb.net/')).toBe(false); + }); + + it('is false for a Unicode-dot homograph of a public host', () => { + expect(areAllHostsLocal('mongodb://example\u3002com/')).toBe(false); + }); + + it('is false for an unparseable string', () => { + expect(areAllHostsLocal('not-a-connection-string')).toBe(false); + }); +}); + +describe('resolveAllowInvalidCertificates (hybrid runtime policy: honor the flag only for local hosts)', () => { + it('returns true for a local/private host with the exception flag set', () => { + expect(resolveAllowInvalidCertificates(true, 'mongodb://localhost:10260/')).toBe(true); + expect(resolveAllowInvalidCertificates(true, 'mongodb://192.168.1.5:27017/')).toBe(true); + }); + + it('returns undefined for a local host without the flag', () => { + expect(resolveAllowInvalidCertificates(false, 'mongodb://localhost:10260/')).toBeUndefined(); + expect(resolveAllowInvalidCertificates(undefined, 'mongodb://localhost:10260/')).toBeUndefined(); + }); + + it('returns undefined (NOT false) for a public host even when the orphaned flag is set', () => { + // Staying silent (undefined) — never forcing `false` — lets the driver still honor an + // explicit `tlsAllowInvalidCertificates=true` URL param, while a BARE orphaned flag on a + // public host is not activated. + expect(resolveAllowInvalidCertificates(true, 'mongodb://cluster0.mongodb.net/')).toBeUndefined(); + expect(resolveAllowInvalidCertificates(true, 'mongodb://prod.example.com/')).toBeUndefined(); + }); + + it('returns undefined for a mixed seed list with a public host even when the flag is set', () => { + expect( + resolveAllowInvalidCertificates(true, 'mongodb://localhost:27017,prod.example.com:27017/'), + ).toBeUndefined(); + }); + + it('returns undefined for a Unicode-dot homograph of a public host with the flag set', () => { + expect(resolveAllowInvalidCertificates(true, 'mongodb://example\u3002com/')).toBeUndefined(); + }); + + it('returns undefined for an unparseable connection string (fail closed: no allow-invalid)', () => { + expect(resolveAllowInvalidCertificates(true, 'not-a-connection-string')).toBeUndefined(); + }); +}); diff --git a/src/documentdb/utils/tlsException.ts b/src/documentdb/utils/tlsException.ts new file mode 100644 index 000000000..ed3b174d6 --- /dev/null +++ b/src/documentdb/utils/tlsException.ts @@ -0,0 +1,165 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DocumentDBConnectionString } from './DocumentDBConnectionString'; +import { isLocalOrPrivateHost } from './hostClassification'; + +/** + * Canonicalize the TLS exception carried by a connection string (Local Quick Start design §7). + * + * Why this exists: TLS-allow-invalid is keyed off `emulatorConfiguration.disableEmulatorSecurity` + * (a stored flag), but a user-supplied connection string can ALSO request a TLS bypass via URL + * params: certificate-validation bypass (`tlsAllowInvalidCertificates`, the legacy alias + * `sslAllowInvalidCertificates`, or `tlsInsecure`), hostname-validation bypass + * (`tlsAllowInvalidHostnames` / `sslAllowInvalidHostnames`), and the low-level Node socket toggle + * `rejectUnauthorized` (inverse semantics: `false` = skip validation). + * Two sources of truth are dangerous: the UI could show "TLS enabled" while the URL silently + * disables certificate or hostname validation. And because the option is connection-wide, a public + * host must never be able to disable validation through a pasted/deep-linked URL. + * + * This helper makes `emulatorConfiguration.disableEmulatorSecurity` the SINGLE source of truth + * **for the local/private case, which is the only case it governs**: + * - It returns `disableEmulatorSecurity: true` ONLY when a bypass was requested AND **every** host + * is local/private (loopback/RFC1918/etc., per §7.1). A public host (or a mixed seed list) can + * never obtain the stored flag, so a pasted/deep-linked public URL cannot turn it on. + * - It strips the TLS-bypass params from the connection string ONLY when that exception is + * actually adopted. The stored flag then re-derives the relaxed posture (the option builders set + * `tlsAllowInvalidCertificates`), so no bypass param needs to persist in the string. Note a + * hostname-only bypass request is intentionally promoted to the broader certificate bypass for + * local hosts: `tlsAllowInvalidCertificates` (⇒ `rejectUnauthorized: false`) is a superset that + * also relaxes hostname checking, which keeps the single-source-of-truth model to one knob. + * - For a public or mixed host the connection string is returned **unchanged**. Stripping there + * would silently break every self-hosted server on a public DNS name with a self-signed / + * internal-CA certificate: the param is the user's only way to express that intent, and + * `resolveAllowInvalidCertificates` deliberately stays silent (never returns `false`) precisely + * so the driver keeps honoring it. Removing it from storage while refusing to replace it with + * the flag left those users with no working configuration at all. + */ + +/** TLS-bypass URL params (lower-cased keys) whose value `true` disables certificate/hostname validation. */ +const TLS_BYPASS_KEYS = new Set([ + 'tlsallowinvalidcertificates', + 'sslallowinvalidcertificates', + 'tlsinsecure', + 'tlsallowinvalidhostnames', + 'sslallowinvalidhostnames', +]); + +/** + * The low-level Node TLS socket toggle `rejectUnauthorized`, which the MongoDB driver also accepts + * as a URL param. It has INVERSE semantics (`false` = skip validation) and is the one with the + * subtlest footgun: via a URL it is parsed as the *string* `"false"`, and Node's `tls.connect` + * only treats the *boolean* `false` as "skip validation" (`rejectUnauthorized !== false`), so a + * URL `?rejectUnauthorized=false` does NOT actually disable validation today. We still strip it + * from the stored string (it is a socket-level toggle that should never persist in a user-facing + * connection string) and, for hygiene + consistency, treat `=false` as a bypass *request* so a + * local user's intent is honored through the single source of truth and a public host is gated. + */ +const REJECT_UNAUTHORIZED_KEY = 'rejectunauthorized'; + +export interface CanonicalTls { + /** + * The connection string with the TLS-bypass params removed when the exception was adopted + * (all hosts local/private); otherwise the original string, unchanged. + */ + readonly connectionString: string; + /** Whether allow-invalid certificates should be enabled (bypass requested AND all hosts local). */ + readonly disableEmulatorSecurity: boolean; +} + +/** + * Delete every TLS-bypass param (case-insensitive) from a parsed connection string in place. + * Returns `stripped` (any bypass param was present and removed) and `bypassRequested` (the string + * asked to skip certificate/hostname validation: a `TLS_BYPASS_KEYS` param set to `true`, or + * `rejectUnauthorized` set to `false`). + */ +export function stripTlsBypassParams(parsed: DocumentDBConnectionString): { + stripped: boolean; + bypassRequested: boolean; +} { + let stripped = false; + let bypassRequested = false; + for (const key of [...parsed.searchParams.keys()]) { + const lowerKey = key.toLowerCase(); + const value = (parsed.searchParams.get(key) ?? '').toLowerCase(); + if (TLS_BYPASS_KEYS.has(lowerKey)) { + if (value === 'true') { + bypassRequested = true; + } + parsed.searchParams.delete(key); + stripped = true; + } else if (lowerKey === REJECT_UNAUTHORIZED_KEY) { + // Inverse semantics: `rejectUnauthorized=false` is the bypass request. + if (value === 'false') { + bypassRequested = true; + } + parsed.searchParams.delete(key); + stripped = true; + } + } + return { stripped, bypassRequested }; +} + +export function canonicalizeTlsException(connectionString: string): CanonicalTls { + let parsed: DocumentDBConnectionString; + try { + parsed = new DocumentDBConnectionString(connectionString); + } catch { + // Unparseable — leave it for the regular validators; never claim an exception. + return { connectionString, disableEmulatorSecurity: false }; + } + + // Allow-invalid is connection-wide, so only honor it when EVERY seed host is local/private. + const allHostsLocal = parsed.hosts.length > 0 && parsed.hosts.every((host) => isLocalOrPrivateHost(host)); + if (!allHostsLocal) { + // Public or mixed: the exception is NOT adopted, so leave the user's string untouched — + // stripping it here would delete the only expression of their intent without replacing it. + return { connectionString, disableEmulatorSecurity: false }; + } + + const { stripped, bypassRequested } = stripTlsBypassParams(parsed); + + return { + connectionString: stripped ? parsed.toString() : connectionString, + disableEmulatorSecurity: bypassRequested, + }; +} + +/** + * Whether EVERY host in a connection string is local/private (§7.1). Use this to host-gate a + * TLS exception that was decided elsewhere (e.g. a wizard choice or a previously-stored flag), + * so a connection string later changed to a public/mixed host can never keep allow-invalid. + * Returns false for an unparseable or host-less string. + */ +export function areAllHostsLocal(connectionString: string): boolean { + try { + const parsed = new DocumentDBConnectionString(connectionString); + return parsed.hosts.length > 0 && parsed.hosts.every((host) => isLocalOrPrivateHost(host)); + } catch { + return false; + } +} + +/** + * Resolve the runtime `tlsAllowInvalidCertificates` MongoClient option from the stored + * `emulatorConfiguration.disableEmulatorSecurity` flag (design §7 "hybrid" runtime policy). + * + * The stored flag is honored ONLY when every host is local/private — the case where a self-signed + * certificate is expected. For a public host a bare stored flag is deliberately NOT activated, so an + * orphaned flag left on a connection (e.g. an old shared deep link that was later edited to drop its + * `tlsAllowInvalidCertificates` URL param) can't silently disable certificate validation after the + * flag was decoupled from `isEmulator`. + * + * Returns `true` to enable allow-invalid, or `undefined` to stay silent. It NEVER returns `false`: + * staying silent (rather than forcing `tlsAllowInvalidCertificates: false`) lets the MongoDB driver + * still honor an explicit `tlsAllowInvalidCertificates=true` that a user deliberately put in their + * connection string, so self-hosted databases on public hostnames keep working. + */ +export function resolveAllowInvalidCertificates( + disableEmulatorSecurity: boolean | undefined, + connectionString: string, +): true | undefined { + return disableEmulatorSecurity && areAllHostsLocal(connectionString) ? true : undefined; +} diff --git a/src/plugins/service-atlas-mongodb/AtlasDiscoveryProvider.ts b/src/plugins/service-atlas-mongodb/AtlasDiscoveryProvider.ts new file mode 100644 index 000000000..010155f6d --- /dev/null +++ b/src/plugins/service-atlas-mongodb/AtlasDiscoveryProvider.ts @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext, type IWizardOptions, UserCancelledError } from '@microsoft/vscode-azext-utils'; +import { Disposable } from 'vscode'; +import { type NewConnectionWizardContext } from '../../commands/newConnection/NewConnectionWizardContext'; +import { Views } from '../../documentdb/Views'; +import { ext } from '../../extensionVariables'; +import { type DiscoveryProvider } from '../../services/discoveryServices'; +import { type TreeElement } from '../../tree/TreeElement'; +import { DESCRIPTION, DISCOVERY_PROVIDER_ID, ICON_PATH, LABEL, WIZARD_TITLE } from './config'; +import { readAtlasCredentials } from './credentials/atlasCredentialStore'; +import { addAtlasCredential } from './credentialsManagement/addAtlasCredential'; +import { configureAtlasCredentials } from './credentialsManagement/configureAtlasCredentials'; +import { AtlasServiceRootItem } from './discovery-tree/AtlasServiceRootItem'; +import { AtlasExecuteStep } from './discovery-wizard/AtlasExecuteStep'; +import { SelectAtlasClusterStep, SelectAtlasProjectStep } from './discovery-wizard/SelectAtlasSteps'; +import { AtlasDiscoveryService } from './discovery/AtlasDiscoveryService'; + +/** + * Discovery provider for MongoDB Atlas. + * Registers as a plugin in the Service Discovery tree view, enabling users + * to browse their Atlas Projects → Clusters hierarchy. + */ +export class AtlasDiscoveryProvider extends Disposable implements DiscoveryProvider { + id = DISCOVERY_PROVIDER_ID; + label = LABEL; + description = DESCRIPTION; + iconPath = ICON_PATH; + + private readonly discoveryService = new AtlasDiscoveryService(); + + constructor() { + super(() => { + // Nothing to tear down: credential secrets live in storage and sessions are recreated + // on demand, so disposing the provider must not sign the user out. + }); + } + + getDiscoveryTreeRootItem(parentId: string): TreeElement { + return new AtlasServiceRootItem(this.discoveryService, parentId); + } + + async getDiscoveryWizard(context: NewConnectionWizardContext): Promise> { + const credentials = await readAtlasCredentials(); + if (credentials.length === 0) { + // Nothing stored yet: run the credential-management flow first so the wizard has + // something to enumerate. A cancelled sign-in must cancel the wizard rather than + // dropping the user into an empty project list. + const changed = await configureAtlasCredentials(context, this.discoveryService); + if (!changed) { + throw new UserCancelledError(); + } + } + + return { + title: WIZARD_TITLE, + promptSteps: [ + new SelectAtlasProjectStep(this.discoveryService), + new SelectAtlasClusterStep(this.discoveryService), + ], + executeSteps: [new AtlasExecuteStep()], + showLoadingPrompt: true, + }; + } + + getLearnMoreUrl(): string | undefined { + return 'https://www.mongodb.com/docs/atlas/api/'; + } + + async configureCredentials(context: IActionContext, node?: TreeElement): Promise { + context.telemetry.properties.credentialConfigActivated = 'true'; + context.telemetry.properties.discoveryProviderId = DISCOVERY_PROVIDER_ID; + + const changed = await configureAtlasCredentials(context, this.discoveryService, node); + + if (changed) { + // Reveal and expand the root so projects appear without a manual expand. + void this.revealAtlasRoot(); + } + } + + async addCredential(context: IActionContext, node: TreeElement): Promise { + const changed = await addAtlasCredential(context, this.discoveryService, node); + + if (changed) { + void this.revealAtlasRoot(); + } + } + + /** + * Reveals and expands the Atlas root node in the discovery tree after a successful sign-in. + * Non-critical: failures are logged but do not affect the sign-in outcome. + */ + private async revealAtlasRoot(): Promise { + try { + const rootId = `${Views.DiscoveryView}/${DISCOVERY_PROVIDER_ID}`; + const rootItems = await ext.discoveryBranchDataProvider.getChildren(undefined as never); + const atlasRoot = rootItems?.find((item) => item.id === rootId); + if (!atlasRoot) { + ext.outputChannel.warn('[AtlasDiscovery] Could not reveal Atlas root: root node not found.'); + return; + } + await ext.discoveryTreeView.reveal(atlasRoot, { select: false, focus: false, expand: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ext.outputChannel.warn(`[AtlasDiscovery] Could not reveal Atlas root: ${message}`); + } + } +} diff --git a/src/plugins/service-atlas-mongodb/api/AtlasApiClient.test.ts b/src/plugins/service-atlas-mongodb/api/AtlasApiClient.test.ts new file mode 100644 index 000000000..aaa356230 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/api/AtlasApiClient.test.ts @@ -0,0 +1,335 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +jest.mock('vscode', () => ({ + ThemeIcon: class ThemeIcon { + constructor(public readonly id: string) {} + }, + l10n: { + t: jest.fn((message: string, ...args: string[]) => + args.reduce((m, value, index) => m.replace(`{${String(index)}}`, value), message), + ), + }, +})); + +jest.mock('../../../extensionVariables', () => ({ + ext: { + outputChannel: { trace: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), appendLine: jest.fn() }, + }, +})); + +jest.mock('./AtlasDigestAuth', () => ({ + parseDigestChallenge: jest.fn(() => ({ realm: 'realm', nonce: 'nonce', qop: 'auth' })), + computeDigestHeader: jest.fn(() => 'Digest computed-value'), +})); + +import { ext } from '../../../extensionVariables'; +import { type AtlasProject } from '../models/AtlasProjectModel'; +import { AtlasApiClient, AtlasApiError } from './AtlasApiClient'; +import { computeDigestHeader } from './AtlasDigestAuth'; + +const session = { type: 'serviceaccount', accessToken: 'token-1' } as const; + +const fetchMock = jest.fn(); + +function jsonResponse(body: unknown, status = 200, headers: Record = {}): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: (name: string): string | null => headers[name.toLowerCase()] ?? null }, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + } as unknown as Response; +} + +function page(count: number, offset: number, totalCount: number): { results: AtlasProject[]; totalCount: number } { + return { + results: Array.from({ length: count }, (_unused, index) => ({ + id: `p${String(offset + index)}`, + name: `Project ${String(offset + index)}`, + orgId: 'org-1', + clusterCount: 0, + created: '2026-01-01T00:00:00Z', + })), + totalCount, + }; +} + +function requestedUrls(): string[] { + return fetchMock.mock.calls.map((call) => String(call[0])); +} + +beforeEach(() => { + fetchMock.mockReset(); + (ext.outputChannel.warn as jest.Mock).mockClear(); + (ext.outputChannel.trace as jest.Mock).mockClear(); + global.fetch = fetchMock as unknown as typeof fetch; +}); + +describe('AtlasApiClient error reporting', () => { + it('keeps the whole Atlas error envelope instead of reducing it to detail', async () => { + // errorCode is the only stable, machine-readable part, and it is what separates an IP + // access list rejection from any other 403. + fetchMock.mockResolvedValueOnce( + jsonResponse( + { + error: 403, + errorCode: 'IP_ADDRESS_NOT_ON_ACCESS_LIST', + reason: 'Forbidden', + detail: 'IP address 203.0.113.9 is not allowed to access this resource.', + parameters: ['203.0.113.9'], + }, + 403, + ), + ); + + const error = await new AtlasApiClient(session).listProjects().catch((e: unknown) => e); + + expect(error).toBeInstanceOf(AtlasApiError); + expect((error as AtlasApiError).errorCode).toBe('IP_ADDRESS_NOT_ON_ACCESS_LIST'); + expect((error as AtlasApiError).detail).toContain('203.0.113.9'); + expect((error as AtlasApiError).parameters).toEqual(['203.0.113.9']); + + const warning = String((ext.outputChannel.warn as jest.Mock).mock.calls[0][0]); + expect(warning).toContain('errorCode=IP_ADDRESS_NOT_ON_ACCESS_LIST'); + expect(warning).toContain('reason=Forbidden'); + expect(warning).toContain('detail=IP address 203.0.113.9'); + expect(warning).toContain('parameters=["203.0.113.9"]'); + }); + + it('traces the rate-limit headers, which are the only way to spot throttling', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ errorCode: 'RATE_LIMITED', detail: 'Too many requests.' }, 429, { + 'retry-after': '30', + 'x-ratelimit-remaining': '0', + 'x-request-id': 'req-abc', + }), + ); + + await expect(new AtlasApiClient(session).listProjects()).rejects.toBeInstanceOf(AtlasApiError); + + const traced = (ext.outputChannel.trace as jest.Mock).mock.calls.map((call) => String(call[0])).join('\n'); + expect(traced).toContain('retry-after=30'); + expect(traced).toContain('x-ratelimit-remaining=0'); + expect(traced).toContain('x-request-id=req-abc'); + }); + + it('keeps a bounded slice of a non-JSON error body rather than dropping it', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 502, + headers: { get: (): string | null => null }, + json: () => Promise.reject(new Error('not json')), + text: () => Promise.resolve('Bad Gateway'), + } as unknown as Response); + + await expect(new AtlasApiClient(session).listProjects()).rejects.toBeInstanceOf(AtlasApiError); + + expect(String((ext.outputChannel.warn as jest.Mock).mock.calls[0][0])).toContain('body=Bad Gateway'); + }); +}); + +describe('AtlasApiClient pagination', () => { + it('issues a single request when the first page is short', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(page(3, 0, 3))); + + const projects = await new AtlasApiClient(session).listProjects(); + + expect(projects).toHaveLength(3); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(requestedUrls()[0]).toContain('itemsPerPage=500&pageNum=1'); + }); + + it('walks every page until a short page arrives', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(page(500, 0, 1200))) + .mockResolvedValueOnce(jsonResponse(page(500, 500, 1200))) + .mockResolvedValueOnce(jsonResponse(page(200, 1000, 1200))); + + const projects = await new AtlasApiClient(session).listProjects(); + + expect(projects).toHaveLength(1200); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(requestedUrls().map((url) => url.split('pageNum=')[1])).toEqual(['1', '2', '3']); + }); + + it('stops as soon as the reported total is reached, even on a full last page', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(page(500, 0, 1000))) + .mockResolvedValueOnce(jsonResponse(page(500, 500, 1000))); + + const projects = await new AtlasApiClient(session).listProjects(); + + expect(projects).toHaveLength(1000); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('paginates cluster lists with the project id encoded in the path', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], totalCount: 0 })); + + await new AtlasApiClient(session).listClusters('group/1'); + + expect(requestedUrls()[0]).toContain('/groups/group%2F1/clusters?itemsPerPage=500&pageNum=1'); + }); + + it('traces secret-free diagnostics for every discovered cluster', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + results: [ + { + id: 'c1', + name: 'PausedCluster', + groupId: 'g1', + mongoDBVersion: '8.0.0', + paused: true, + stateName: 'IDLE', + clusterType: 'REPLICASET', + providerSettings: { + providerName: 'AWS', + regionName: 'US_EAST_1', + instanceSizeName: 'M10', + }, + connectionStrings: { standardSrv: 'mongodb+srv://must-not-appear.example.invalid' }, + }, + ], + totalCount: 1, + }), + ); + + const clusters = await new AtlasApiClient(session).listClusters('g1'); + + expect(clusters[0].paused).toBe(true); + const traced = (ext.outputChannel.trace as jest.Mock).mock.calls.map((call) => String(call[0])).join('\n'); + expect(traced).toContain( + 'cluster "PausedCluster": state=IDLE, paused=true, type=REPLICASET, provider=AWS, region=US_EAST_1, tier=M10, connectionString=available', + ); + expect(traced).not.toContain('must-not-appear.example.invalid'); + }); + + it('does not paginate single-resource requests', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'u1', emailAddress: 'a@b.invalid' })); + + await new AtlasApiClient(session).getCurrentUser(); + + expect(requestedUrls()[0]).not.toContain('pageNum'); + }); + + it('surfaces API errors with their status code', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ detail: 'IP not allowed' }, 403)); + + await expect(new AtlasApiClient(session).listProjects()).rejects.toBeInstanceOf(AtlasApiError); + }); + + it('uses a credential-neutral message for a 403 with no detail body', async () => { + // The shared client serves both API Keys and Service Accounts, so a fallback that names an + // "API key" would be wrong for one of them. + fetchMock.mockResolvedValueOnce(jsonResponse({}, 403)); + + const error = await new AtlasApiClient(session).listProjects().catch((e: unknown) => e); + + expect(error).toBeInstanceOf(AtlasApiError); + expect((error as AtlasApiError).message).toBe('Access denied. Verify you have the required permissions.'); + }); + + it('refreshes the session once and retries when the token is rejected', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ detail: 'token expired' }, 401)) + .mockResolvedValueOnce(jsonResponse(page(1, 0, 1))); + + const refresher = { + tryRefreshIfPossible: jest + .fn() + .mockResolvedValue({ type: 'serviceaccount', accessToken: 'token-2' } as const), + }; + + const projects = await new AtlasApiClient(session, refresher).listProjects(); + + expect(projects).toHaveLength(1); + expect(refresher.tryRefreshIfPossible).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('does not mint a new token on 403, because a new token carries the same roles', async () => { + // 403 means authenticated but not permitted: an enforced IP access list, or roles that are + // too narrow. Re-minting cannot change the outcome, it only doubles the requests and makes + // the failure take twice as long to surface. + fetchMock.mockResolvedValueOnce(jsonResponse({ detail: 'IP address is not allowed' }, 403)); + + const refresher = { tryRefreshIfPossible: jest.fn() }; + + await expect(new AtlasApiClient(session, refresher).listProjects()).rejects.toBeInstanceOf(AtlasApiError); + expect(refresher.tryRefreshIfPossible).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe('AtlasApiClient API Key Digest authentication', () => { + const apiKeySession = { type: 'apikey', publicKey: 'pub', privateKey: 'priv' } as const; + + function challengeResponse(): Response { + return { + ok: false, + status: 401, + headers: { + get: (name: string): string | null => + name.toLowerCase() === 'www-authenticate' ? 'Digest realm="atlas", nonce="abc", qop="auth"' : null, + }, + json: () => Promise.resolve({}), + text: () => Promise.resolve(''), + } as unknown as Response; + } + + beforeEach(() => { + (computeDigestHeader as jest.Mock).mockClear(); + }); + + it('signs the full request-target including the query string, not just the path', async () => { + // RFC 7616 section 3.4.6: the Digest `uri` must match the request target that fetch() sends. + // The paginated list URL always carries `?itemsPerPage=...&pageNum=...`, so the signed + // request-target must include that query string. + fetchMock.mockResolvedValueOnce(challengeResponse()).mockResolvedValueOnce(jsonResponse(page(1, 0, 1))); + + await new AtlasApiClient(apiKeySession).listProjects(); + + const digestUri = (computeDigestHeader as jest.Mock).mock.calls[0][1] as string; + expect(digestUri).toBe('/api/atlas/v2/groups?itemsPerPage=500&pageNum=1'); + }); + + it('reuses the cached challenge pre-emptively on later requests with an incrementing nonce-count', async () => { + // First call answers a challenge (2 fetches); the second call sends the Digest header + // straight away using the cached challenge (1 fetch), and `nc` advances 1 -> 2. + const client = new AtlasApiClient(apiKeySession); + fetchMock + .mockResolvedValueOnce(challengeResponse()) + .mockResolvedValueOnce(jsonResponse(page(1, 0, 1))) + .mockResolvedValueOnce(jsonResponse(page(1, 0, 1))); + + await client.listProjects(); + await client.listProjects(); + + expect(fetchMock).toHaveBeenCalledTimes(3); + const nonceCounts = (computeDigestHeader as jest.Mock).mock.calls.map((call) => call[5] as number); + expect(nonceCounts).toEqual([1, 2]); + }); + + it('re-challenges once and resets the nonce-count when the cached nonce is rejected', async () => { + const client = new AtlasApiClient(apiKeySession); + fetchMock + // First call: establish the cached challenge. + .mockResolvedValueOnce(challengeResponse()) + .mockResolvedValueOnce(jsonResponse(page(1, 0, 1))) + // Second call: the pre-emptive request is rejected (stale nonce), triggering a re-challenge. + .mockResolvedValueOnce(challengeResponse()) + .mockResolvedValueOnce(jsonResponse(page(1, 0, 1))); + + await client.listProjects(); + await client.listProjects(); + + const nonceCounts = (computeDigestHeader as jest.Mock).mock.calls.map((call) => call[5] as number); + // 1 = first challenge answered; 2 = pre-emptive attempt on the second call; 1 = counter reset + // after the re-challenge adopts the fresh nonce. + expect(nonceCounts).toEqual([1, 2, 1]); + }); +}); diff --git a/src/plugins/service-atlas-mongodb/api/AtlasApiClient.ts b/src/plugins/service-atlas-mongodb/api/AtlasApiClient.ts new file mode 100644 index 000000000..620f120c3 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/api/AtlasApiClient.ts @@ -0,0 +1,529 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { atlasTrace, atlasWarn, formatMs, monotonicNow } from '../atlasTrace'; +import { type AtlasSessionRefresher } from '../auth/AtlasCredentialSessionRegistry'; +import { type AtlasSession } from '../auth/AtlasSession'; +import { ATLAS_API_BASE_URL } from '../config'; +import { + type AtlasCluster, + type AtlasDatabaseUser, + type AtlasOrganization, + type AtlasProject, + type AtlasUserInfo, +} from '../models/AtlasProjectModel'; +import { computeDigestHeader, type DigestChallenge, parseDigestChallenge } from './AtlasDigestAuth'; + +/** Atlas API response envelope for paginated results */ +interface AtlasPaginatedResponse { + results: T[]; + totalCount: number; + links?: { rel: string; href: string }[]; +} + +/** + * Page size requested from the Atlas Admin API. Atlas caps `itemsPerPage` at 500; asking for the + * maximum keeps the common single-page case to exactly one request. + */ +const ATLAS_PAGE_SIZE = 500; + +/** + * Defensive ceiling on the number of pages fetched for one list call. At 500 items per page this + * covers 50,000 resources, far beyond any realistic Atlas organization, and guarantees that a + * malformed `totalCount` can never spin an unbounded request loop. + */ +const ATLAS_MAX_PAGES = 100; + +/** + * Resource version sent in `Accept` when an endpoint does not declare a newer one. + * + * Atlas versions each resource independently, so the header is not global: asking for a version + * a resource never published is rejected rather than silently downgraded. + */ +const ATLAS_DEFAULT_API_VERSION = '2023-02-01'; + +/** + * Client for the MongoDB Atlas Admin API v2. + * Supports Service Account (Bearer token) and API Key (HTTP Digest) authentication. + */ +export class AtlasApiClient { + private digestNonceCount = 0; + /** + * The most recent Digest challenge parsed from a `401`, cached per client so subsequent + * requests can answer pre-emptively with an incrementing nonce-count instead of paying an + * unauthenticated challenge round-trip every call. Reset (and the counter with it) whenever the + * server re-challenges with a stale nonce. + */ + private digestChallenge?: DigestChallenge; + private session: AtlasSession; + + /** + * @param session The active Atlas session used to authenticate requests. + * @param sessionManager Optional session refresher. When provided, token-based sessions + * (Service Account) are transparently refreshed and the request retried once if + * the access token is rejected (401). The user is only signed out - and therefore + * prompted to sign in again - when the credentials themselves are completely rejected. + * @param owner Optional secret-free credential description used to correlate trace output + * when several credentials are querying Atlas at the same time. + */ + constructor( + session: AtlasSession, + private readonly sessionManager?: AtlasSessionRefresher, + private readonly owner?: string, + ) { + this.session = session; + } + + /** + * Lists all projects (groups) accessible by the authenticated user. + */ + async listProjects(signal?: AbortSignal): Promise { + return this.requestAllPages('/groups', signal); + } + + /** + * Lists all clusters in a given project. + */ + async listClusters(projectId: string, signal?: AbortSignal): Promise { + const clusters = await this.requestAllPages( + `/groups/${encodeURIComponent(projectId)}/clusters`, + signal, + ); + + for (const cluster of clusters) { + const regionConfig = cluster.replicationSpecs?.[0]?.regionConfigs?.[0]; + const provider = cluster.providerSettings ?? regionConfig; + const tier = cluster.providerSettings?.instanceSizeName ?? regionConfig?.electableSpecs?.instanceSize; + const hasConnectionString = !!( + cluster.connectionStrings?.standardSrv ?? cluster.connectionStrings?.standard + ); + const paused = cluster.paused === undefined ? 'missing' : String(cluster.paused); + atlasTrace( + `${this.describeClient()} cluster "${cluster.name}": state=${cluster.stateName}, paused=${paused}, type=${cluster.clusterType}, provider=${provider?.providerName ?? 'unknown'}, region=${provider?.regionName ?? 'unknown'}, tier=${tier ?? 'unknown'}, connectionString=${hasConnectionString ? 'available' : 'missing'}`, + ); + } + + return clusters; + } + + /** + * Gets details for a specific cluster. + */ + async getCluster(projectId: string, clusterName: string, signal?: AbortSignal): Promise { + return this.request( + `/groups/${encodeURIComponent(projectId)}/clusters/${encodeURIComponent(clusterName)}`, + signal, + ); + } + + /** + * Lists all organizations accessible by the authenticated user. + */ + async listOrganizations(signal?: AbortSignal): Promise { + return this.requestAllPages('/orgs', signal); + } + + /** + * Gets the currently authenticated user's info. + */ + async getCurrentUser(signal?: AbortSignal): Promise { + return this.request('/users/me', signal); + } + + /** + * Lists the database users defined in a project. + * + * Requires only the `Project Read Only` role, the same level the cluster listing already + * needs, so any credential that can see a cluster can also see its users. Passwords are never + * returned. This resource is still published at `2023-01-01`, hence the version override. + */ + async listDatabaseUsers(projectId: string, signal?: AbortSignal): Promise { + return this.requestAllPages( + `/groups/${encodeURIComponent(projectId)}/databaseUsers`, + signal, + '2023-01-01', + ); + } + + /** + * Walks every page of a paginated Atlas list endpoint and returns the concatenated results. + * + * Atlas paginates with `pageNum` (1-based) + `itemsPerPage` and reports `totalCount`. The loop + * stops as soon as a short page arrives, the reported total is reached, or the defensive page + * ceiling is hit, so a missing or wrong `totalCount` cannot cause an unbounded fetch. + */ + private async requestAllPages(path: string, signal?: AbortSignal, apiVersion?: string): Promise { + const separator = path.includes('?') ? '&' : '?'; + const collected: T[] = []; + const startedAt = monotonicNow(); + let pagesFetched = 0; + + for (let pageNum = 1; pageNum <= ATLAS_MAX_PAGES; pageNum++) { + const pagePath = `${path}${separator}itemsPerPage=${String(ATLAS_PAGE_SIZE)}&pageNum=${String(pageNum)}`; + const response = await this.request>(pagePath, signal, apiVersion); + const results = Array.isArray(response.results) ? response.results : []; + collected.push(...results); + pagesFetched = pageNum; + + if (results.length < ATLAS_PAGE_SIZE) { + break; + } + + const totalCount = response.totalCount; + if (typeof totalCount === 'number' && Number.isFinite(totalCount) && collected.length >= totalCount) { + break; + } + } + + if (pagesFetched === ATLAS_MAX_PAGES) { + atlasWarn( + `${this.describeClient()} GET ${path} hit the ${String(ATLAS_MAX_PAGES)}-page ceiling; results may be truncated`, + ); + } + + atlasTrace( + `${this.describeClient()} GET ${path} -> ${String(collected.length)} item(s) across ${String(pagesFetched)} page(s) in ${formatMs(startedAt)}`, + ); + + return collected; + } + + /** Short, secret-free description of this client for log correlation. */ + private describeClient(): string { + const auth = this.session.type === 'serviceaccount' ? 'service account' : 'api key'; + return this.owner ? `[${this.owner} · ${auth}]` : `[${auth}]`; + } + + /** + * Makes an authenticated request to the Atlas Admin API. + * + * For token-based sessions (Service Account) backed by a session manager, a single silent + * token refresh is attempted when the access token is rejected with `401`, and the request is + * retried with the freshly minted token. + * + * `403` deliberately does **not** trigger a refresh. Atlas returns it when the caller is + * authenticated but not permitted: an enforced IP access list, or roles that are too narrow. + * A new token carries exactly the same identity and the same roles, so re-minting cannot + * change the outcome; it only doubles the requests, mints a throwaway token, and makes the + * failure take twice as long to surface. + */ + private async request(path: string, signal?: AbortSignal, apiVersion?: string): Promise { + try { + return await this.requestOnce(path, signal, apiVersion); + } catch (error) { + const isExpiredToken = error instanceof AtlasApiError && error.statusCode === 401; + const canRefresh = this.sessionManager !== undefined && this.session.type === 'serviceaccount'; + + if (isExpiredToken && canRefresh) { + atlasTrace( + `${this.describeClient()} access token rejected on ${path}; minting a fresh token and retrying once`, + ); + const refreshedSession = await this.sessionManager!.tryRefreshIfPossible(); + if (refreshedSession) { + this.session = refreshedSession; + return await this.requestOnce(path, signal, apiVersion); + } + atlasWarn(`${this.describeClient()} could not mint a fresh token; surfacing the original failure`); + } + + throw error; + } + } + + /** + * Performs a single authenticated request to the Atlas Admin API. + * Handles Service Account Bearer and API Key Digest authentication transparently. + */ + private async requestOnce(path: string, signal?: AbortSignal, apiVersion?: string): Promise { + // Derive the transmitted URL and the Digest request-target from one parsed value so a future + // query parameter cannot be added to the request without also appearing in the signed target + // (RFC 7616 section 3.4.6). `ATLAS_API_BASE_URL` carries a `/api/atlas/v2` path prefix, so the + // base+path string is parsed directly rather than resolving `path` against the base. + const parsedUrl = new URL(`${ATLAS_API_BASE_URL}${path}`); + const url = parsedUrl.toString(); + const startedAt = monotonicNow(); + const headers: Record = { + Accept: `application/vnd.atlas.${apiVersion ?? ATLAS_DEFAULT_API_VERSION}+json`, + }; + + if (this.session.type === 'serviceaccount') { + headers['Authorization'] = `Bearer ${this.session.accessToken}`; + + const response = await fetch(url, { method: 'GET', headers, signal }); + atlasTrace(`${this.describeClient()} GET ${path} -> ${String(response.status)} in ${formatMs(startedAt)}`); + + if (!response.ok) { + await this.handleErrorResponse(response); + } + + return (await response.json()) as T; + } + + // API Key: HTTP Digest Authentication. + // + // Cache the parsed challenge on the client and reuse it with an incrementing nonce-count + // (`nc`) - the RFC 7616 mechanism for reusing a server nonce across requests. This keeps the + // steady-state path to a single request per call; only the first request from this client + // (no cached challenge) or a stale nonce (a `401` re-challenge) pays the extra + // unauthenticated round-trip. Previously the challenge was discarded after every call, so a + // fresh nonce was fetched for each request and `digestNonceCount` never served its purpose, + // doubling Atlas Admin API traffic for every API Key credential. + const session = this.session; + const digestUri = `${parsedUrl.pathname}${parsedUrl.search}`; + + // Answers the given challenge, advancing `nc` for each request that reuses the same nonce. + const sendAuthenticated = (challenge: DigestChallenge): Promise => { + const authHeader = computeDigestHeader( + 'GET', + digestUri, + session.publicKey, + session.privateKey, + challenge, + ++this.digestNonceCount, + ); + return fetch(url, { method: 'GET', headers: { ...headers, Authorization: authHeader }, signal }); + }; + + // Parses the `WWW-Authenticate` header and resets the per-nonce counter for the new nonce. + const adoptChallenge = (response: Response): DigestChallenge => { + const wwwAuth = response.headers.get('www-authenticate'); + if (!wwwAuth || !wwwAuth.toLowerCase().startsWith('digest')) { + throw new Error(vscode.l10n.t('Atlas API did not return a valid Digest challenge')); + } + this.digestChallenge = parseDigestChallenge(wwwAuth); + this.digestNonceCount = 0; + return this.digestChallenge; + }; + + let response: Response; + if (this.digestChallenge) { + response = await sendAuthenticated(this.digestChallenge); + if (response.status === 401) { + // Cached nonce was rejected (stale, or the server rotated it): re-challenge once. + response = await sendAuthenticated(adoptChallenge(response)); + } + } else { + const initialResponse = await fetch(url, { method: 'GET', headers, signal }); + response = + initialResponse.status === 401 + ? await sendAuthenticated(adoptChallenge(initialResponse)) + : initialResponse; + } + + atlasTrace( + `${this.describeClient()} GET ${path} -> ${String(response.status)} in ${formatMs(startedAt)} (digest)`, + ); + + if (!response.ok) { + await this.handleErrorResponse(response); + } + + return (await response.json()) as T; + } + + /** + * Handles API error responses with user-friendly messages. + * + * The whole Atlas error envelope is traced, not just `detail`. Atlas answers with + * `{ error, errorCode, reason, detail, parameters }`, and `errorCode` is the only part that is + * stable and machine-readable: it is what separates `IP_ADDRESS_NOT_ON_ACCESS_LIST` from any + * other `403`, and a throttled request from a genuinely forbidden one. Reducing all of that to + * `detail` made real diagnosis guesswork. + */ + private async handleErrorResponse(response: Response): Promise { + const body = await readAtlasErrorBody(response); + const detail = body.detail ?? body.reason ?? body.raw ?? ''; + + atlasWarn( + `${this.describeClient()} request failed with ${String(response.status)}${describeAtlasErrorBody(body)}`, + ); + + const diagnostics = describeDiagnosticHeaders(response); + if (diagnostics) { + atlasTrace(`${this.describeClient()} response diagnostics: ${diagnostics}`); + } + + switch (response.status) { + case 401: + throw new AtlasApiError( + detail + ? vscode.l10n.t('Authentication failed: {0}', detail) + : vscode.l10n.t('Authentication failed. Please sign in again.'), + response.status, + detail, + body.errorCode, + body.parameters, + ); + case 403: + throw new AtlasApiError( + detail + ? vscode.l10n.t('Access denied: {0}', detail) + : vscode.l10n.t('Access denied. Verify you have the required permissions.'), + response.status, + detail, + body.errorCode, + body.parameters, + ); + case 404: + throw new AtlasApiError( + vscode.l10n.t('Resource not found.'), + response.status, + detail, + body.errorCode, + body.parameters, + ); + case 429: + throw new AtlasApiError( + vscode.l10n.t('Rate limited by Atlas API. Please try again shortly.'), + response.status, + detail, + body.errorCode, + body.parameters, + ); + default: + throw new AtlasApiError( + vscode.l10n.t('Atlas API error ({0}): {1}', String(response.status), detail), + response.status, + detail, + body.errorCode, + body.parameters, + ); + } + } +} + +/** The error envelope the Atlas Admin API returns, plus the raw text when it is not JSON. */ +interface AtlasErrorBody { + /** Stable machine-readable code, for example `IP_ADDRESS_NOT_ON_ACCESS_LIST`. */ + errorCode?: string; + /** Short reason phrase, for example `Forbidden`. */ + reason?: string; + /** Human-readable explanation, often the most useful part. */ + detail?: string; + /** Values Atlas substituted into `detail`. */ + parameters?: unknown[]; + /** Response text, kept when the body was not JSON at all. */ + raw?: string; +} + +/** Longest response text kept when the error body is not JSON. */ +const MAX_RAW_ERROR_LENGTH = 500; + +async function readAtlasErrorBody(response: Response): Promise { + let text: string; + try { + text = await response.text(); + } catch { + return {}; + } + + try { + const parsed = JSON.parse(text) as AtlasErrorBody; + return { + errorCode: typeof parsed.errorCode === 'string' ? parsed.errorCode : undefined, + reason: typeof parsed.reason === 'string' ? parsed.reason : undefined, + detail: typeof parsed.detail === 'string' ? parsed.detail : undefined, + parameters: Array.isArray(parsed.parameters) ? parsed.parameters : undefined, + }; + } catch { + // Not JSON: keep a bounded slice of whatever came back rather than dropping it silently. + return { raw: text.slice(0, MAX_RAW_ERROR_LENGTH) }; + } +} + +/** Renders every populated part of the error envelope for the log. */ +function describeAtlasErrorBody(body: AtlasErrorBody): string { + const parts: string[] = []; + if (body.errorCode) { + parts.push(`errorCode=${body.errorCode}`); + } + if (body.reason) { + parts.push(`reason=${body.reason}`); + } + if (body.detail) { + parts.push(`detail=${body.detail}`); + } + if (body.parameters && body.parameters.length > 0) { + parts.push(`parameters=${JSON.stringify(body.parameters)}`); + } + if (body.raw) { + parts.push(`body=${body.raw}`); + } + return parts.length > 0 ? `: ${parts.join(' ')}` : ''; +} + +/** + * Response headers worth logging when a request fails. + * + * Throttling is the case this exists for: Atlas can answer a throttled request with a status that + * does not obviously say "slow down", and the rate-limit headers are the only way to tell. The + * request id makes a report to MongoDB support actionable. Nothing here can carry credentials. + */ +const DIAGNOSTIC_HEADERS = [ + 'retry-after', + 'x-ratelimit-limit', + 'x-ratelimit-remaining', + 'x-ratelimit-reset', + 'x-envoy-upstream-service-time', + 'x-request-id', + 'request-id', + 'date', +]; + +function describeDiagnosticHeaders(response: Response): string { + const parts: string[] = []; + for (const name of DIAGNOSTIC_HEADERS) { + const value = response.headers.get(name); + if (value) { + parts.push(`${name}=${value}`); + } + } + return parts.join(' '); +} + +/** + * Custom error class for Atlas API errors. + */ +export class AtlasApiError extends Error { + constructor( + message: string, + public readonly statusCode: number, + public readonly detail?: string, + /** + * Atlas's stable machine-readable code, for example `IP_ADDRESS_NOT_ON_ACCESS_LIST`. + * The status alone is ambiguous: several very different problems share `403`. + */ + public readonly errorCode?: string, + /** Values Atlas substituted into `detail`, such as a rejected IP address. */ + public readonly parameters?: readonly unknown[], + ) { + super(message); + this.name = 'AtlasApiError'; + } +} + +/** + * Recognises the several distinct 403 error codes Atlas uses for IP access-list problems: the + * caller's IP is not on the API access list (`IP_ADDRESS_NOT_ON_ACCESS_LIST`), or the organization + * mandates an access list the caller is not on (`ORG_REQUIRES_ACCESS_LIST`). They are all fixed the + * same way - add the current IP to the relevant access list in Atlas - so every path that tailors a + * message or a deep link for an IP problem must treat them alike, rather than mistaking one for a + * missing-role "permissions" failure. + * + * Shared as the single source of truth so the webview, discovery, and credential manager cannot + * drift apart on which codes count. Accepts `unknown` and self-guards on type/status, so callers + * can hand it a raw caught error. + */ +export function isAtlasIpAccessListError(error: unknown): boolean { + if (!(error instanceof AtlasApiError) || error.statusCode !== 403) { + return false; + } + if (error.errorCode && /ACCESS_LIST/i.test(error.errorCode)) { + return true; + } + // Some responses carry no machine-readable code; fall back to the human-readable text. + return `${error.detail ?? ''} ${error.message}`.toLowerCase().includes('access list'); +} diff --git a/src/plugins/service-atlas-mongodb/api/AtlasDigestAuth.ts b/src/plugins/service-atlas-mongodb/api/AtlasDigestAuth.ts new file mode 100644 index 000000000..7407b842f --- /dev/null +++ b/src/plugins/service-atlas-mongodb/api/AtlasDigestAuth.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as crypto from 'crypto'; + +/** + * Parameters parsed from a WWW-Authenticate: Digest header. + */ +export interface DigestChallenge { + realm: string; + nonce: string; + qop?: string; + opaque?: string; + algorithm?: string; +} + +/** + * Parses a WWW-Authenticate: Digest challenge header. + */ +export function parseDigestChallenge(header: string): DigestChallenge { + const params: Record = {}; + const regex = /(\w+)=(?:"([^"]*)"|([^\s,]+))/g; + let match: RegExpExecArray | null; + + while ((match = regex.exec(header)) !== null) { + params[match[1]] = match[2] ?? match[3]; + } + + return { + realm: params['realm'] ?? '', + nonce: params['nonce'] ?? '', + qop: params['qop'], + opaque: params['opaque'], + algorithm: params['algorithm'], + }; +} + +/** + * Computes an HTTP Digest Authentication header value. + */ +export function computeDigestHeader( + method: string, + uri: string, + username: string, + password: string, + challenge: DigestChallenge, + nc: number, +): string { + const algorithm = challenge.algorithm ?? 'MD5'; + const cnonce = crypto.randomBytes(8).toString('hex'); + const ncHex = nc.toString(16).padStart(8, '0'); + + function resolveHashAlgorithm(digestAlgorithm: string): string { + const normalized = digestAlgorithm.trim().toUpperCase(); + if (normalized === 'SHA-256' || normalized === 'SHA-256-SESS') { + return 'sha256'; + } + if (normalized === 'SHA-512-256' || normalized === 'SHA-512-256-SESS') { + return 'sha512-256'; + } + return 'md5'; + } + + const hashAlgorithm = resolveHashAlgorithm(algorithm); + + function digest(data: string): string { + return crypto.createHash(hashAlgorithm).update(data).digest('hex'); + } + + const ha1 = digest(`${username}:${challenge.realm}:${password}`); + const ha2 = digest(`${method}:${uri}`); + + let response: string; + if (challenge.qop === 'auth' || challenge.qop?.includes('auth')) { + response = digest(`${ha1}:${challenge.nonce}:${ncHex}:${cnonce}:auth:${ha2}`); + } else { + response = digest(`${ha1}:${challenge.nonce}:${ha2}`); + } + + let header = `Digest username="${username}", realm="${challenge.realm}", nonce="${challenge.nonce}", uri="${uri}", response="${response}", algorithm=${algorithm}`; + + if (challenge.qop) { + header += `, qop=auth, nc=${ncHex}, cnonce="${cnonce}"`; + } + if (challenge.opaque) { + header += `, opaque="${challenge.opaque}"`; + } + + return header; +} diff --git a/src/plugins/service-atlas-mongodb/atlasClusterAvailability.test.ts b/src/plugins/service-atlas-mongodb/atlasClusterAvailability.test.ts new file mode 100644 index 000000000..444f4a718 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/atlasClusterAvailability.test.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + getAtlasClusterStateLabel, + getAtlasPausedExplanation, + isAtlasClusterConnectable, + isAtlasClusterPaused, + type AtlasClusterAvailability, +} from './atlasClusterAvailability'; + +jest.mock('@vscode/l10n', () => ({ + t: jest.fn((message: string) => message), +})); + +function availability(overrides: Partial = {}): AtlasClusterAvailability { + return { + stateName: 'IDLE', + connectionString: 'mongodb+srv://cluster.example.invalid', + ...overrides, + }; +} + +describe('isAtlasClusterPaused', () => { + it('only treats an explicit true as paused', () => { + expect(isAtlasClusterPaused(availability({ paused: true }))).toBe(true); + expect(isAtlasClusterPaused(availability({ paused: false }))).toBe(false); + expect(isAtlasClusterPaused(availability())).toBe(false); + }); +}); + +describe('isAtlasClusterConnectable', () => { + it('accepts a running IDLE cluster that published a connection string', () => { + expect(isAtlasClusterConnectable(availability())).toBe(true); + }); + + it('rejects a paused cluster even though Atlas still reports it as IDLE', () => { + expect(isAtlasClusterConnectable(availability({ paused: true }))).toBe(false); + }); + + it('rejects a cluster that has not published a connection string yet', () => { + expect(isAtlasClusterConnectable(availability({ connectionString: undefined }))).toBe(false); + }); + + it('rejects every non-IDLE state', () => { + for (const stateName of ['CREATING', 'UPDATING', 'REPAIRING', 'DELETING', 'UNKNOWN'] as const) { + expect(isAtlasClusterConnectable(availability({ stateName }))).toBe(false); + } + }); +}); + +describe('getAtlasClusterStateLabel', () => { + it('annotates a paused cluster instead of its control-plane state', () => { + expect(getAtlasClusterStateLabel(availability({ paused: true }))).toBe('Paused'); + }); + + it('leaves a plain IDLE cluster unannotated', () => { + expect(getAtlasClusterStateLabel(availability())).toBeUndefined(); + }); + + it('labels the transient states', () => { + expect(getAtlasClusterStateLabel(availability({ stateName: 'CREATING' }))).toBe('Creating…'); + expect(getAtlasClusterStateLabel(availability({ stateName: 'UNKNOWN' }))).toBe('Unknown state'); + }); +}); + +describe('getAtlasPausedExplanation', () => { + it('tells the user where to resume the cluster', () => { + expect(getAtlasPausedExplanation()).toContain('Resume it in MongoDB Atlas before connecting'); + }); +}); diff --git a/src/plugins/service-atlas-mongodb/atlasClusterAvailability.ts b/src/plugins/service-atlas-mongodb/atlasClusterAvailability.ts new file mode 100644 index 000000000..36e9786bb --- /dev/null +++ b/src/plugins/service-atlas-mongodb/atlasClusterAvailability.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { type AtlasClusterState } from './models/AtlasProjectModel'; + +/** + * The subset of a cluster that decides whether it can be connected to. Both the raw Atlas API + * payload (`AtlasCluster`) and the normalized tree model (`AtlasClusterModel`) can be reduced to + * this shape, which is what lets the discovery tree and the connection wizard share one verdict. + */ +export interface AtlasClusterAvailability { + /** Atlas reports a paused cluster as IDLE, so this has to be carried separately. */ + readonly paused?: boolean; + readonly stateName: AtlasClusterState; + /** Absent while Atlas is still provisioning the cluster. */ + readonly connectionString?: string; +} + +/** Whether Atlas has paused the cluster, including automatic inactivity pauses. */ +export function isAtlasClusterPaused(cluster: AtlasClusterAvailability): boolean { + return cluster.paused === true; +} + +/** + * A running, IDLE cluster with a published connection string is the only thing that can be + * opened. Every surface that offers a connect affordance must agree on this, otherwise the tree + * and the wizard disagree about the same cluster. + */ +export function isAtlasClusterConnectable(cluster: AtlasClusterAvailability): boolean { + return !isAtlasClusterPaused(cluster) && cluster.stateName === 'IDLE' && !!cluster.connectionString; +} + +/** + * Short, localized annotation for a cluster that is not simply running, or `undefined` when it + * needs none. Shown next to the cluster name in the tree description and in the quick pick. + */ +export function getAtlasClusterStateLabel(cluster: AtlasClusterAvailability): string | undefined { + if (isAtlasClusterPaused(cluster)) { + return l10n.t('Paused'); + } + + const labels: Record = { + IDLE: undefined, + CREATING: l10n.t('Creating…'), + UPDATING: l10n.t('Updating…'), + REPAIRING: l10n.t('Repairing…'), + DELETING: l10n.t('Deleting…'), + UNKNOWN: l10n.t('Unknown state'), + }; + + return labels[cluster.stateName]; +} + +/** + * Why a paused cluster cannot be opened, and what to do about it. Shared verbatim so the tree + * tooltip and the wizard's modal give the same instruction. + * + * The remaining state explanations stay with their surface on purpose: the wizard says "from the + * wizard" and points at the quick pick, while the tree tooltip is phrased for the tree. + */ +export function getAtlasPausedExplanation(): string { + return l10n.t('This cluster is paused. Resume it in MongoDB Atlas before connecting.'); +} diff --git a/src/plugins/service-atlas-mongodb/atlasConnectionErrors.test.ts b/src/plugins/service-atlas-mongodb/atlasConnectionErrors.test.ts new file mode 100644 index 000000000..82b2734cc --- /dev/null +++ b/src/plugins/service-atlas-mongodb/atlasConnectionErrors.test.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isAtlasTlsHandshakeRejection } from './atlasConnectionErrors'; +import { buildAtlasNetworkAccessUrl } from './atlasDeepLinks'; + +describe('isAtlasTlsHandshakeRejection', () => { + it('recognises the OpenSSL text seen when an Atlas connection dies at the TLS layer', () => { + // Verbatim from a live run. All this signature establishes is that the failure was + // transport-level; it is not the shape of an authentication rejection. + const error = new Error( + '00B92AFAC07A0000:error:0A000438:SSL routines:ssl3_read_bytes:tlsv1 alert internal error:' + + '../deps/openssl/openssl/ssl/record/rec_layer_s3.c:918:SSL alert number 80', + ); + + expect(isAtlasTlsHandshakeRejection(error)).toBe(true); + }); + + it('accepts a non-Error value', () => { + expect(isAtlasTlsHandshakeRejection('tlsv1 alert internal error')).toBe(true); + }); + + it('leaves a genuine authentication failure alone', () => { + expect(isAtlasTlsHandshakeRejection(new Error('bad auth : Authentication failed.'))).toBe(false); + }); + + it('leaves an unrelated TLS problem alone', () => { + expect(isAtlasTlsHandshakeRejection(new Error('self-signed certificate in certificate chain'))).toBe(false); + }); +}); + +describe('buildAtlasNetworkAccessUrl', () => { + it('points at the project IP access list', () => { + expect(buildAtlasNetworkAccessUrl('64b1f0c9e4b0a12345678901')).toBe( + 'https://cloud.mongodb.com/v2/64b1f0c9e4b0a12345678901#/security/network/accessList', + ); + }); +}); diff --git a/src/plugins/service-atlas-mongodb/atlasConnectionErrors.ts b/src/plugins/service-atlas-mongodb/atlasConnectionErrors.ts new file mode 100644 index 000000000..70b410f7d --- /dev/null +++ b/src/plugins/service-atlas-mongodb/atlasConnectionErrors.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Recognises MongoDB Atlas connection failures that the raw driver error describes badly. + */ + +/** + * Matches a TLS-level failure reported by OpenSSL, of which `internal_error` (alert 80) is the + * one seen against Atlas: + * `ssl3_read_bytes:tlsv1 alert internal error ... SSL alert number 80`. + * + * What this justifies saying, and nothing more: the connection died at the transport layer. That + * is not the shape of an authentication rejection, which the driver surfaces as + * `bad auth : Authentication failed`. So the username and password are not the obvious suspect, + * even though the failure appears immediately after the user typed them. + * + * What this deliberately does **not** claim is a cause. MongoDB documents that the project IP + * access list gates client connections to a cluster, and that a blocked address fails an + * end-to-end connectivity test on port 27017, but it nowhere documents that a blocked address + * surfaces as this particular alert. Other candidates (a paused or provisioning cluster, a TLS + * version or cipher mismatch) are equally undocumented for this signature. The UX therefore lists + * what to check rather than asserting a diagnosis that cannot be supported. + */ +const ATLAS_TLS_FAILURE_PATTERN = /SSL alert number 80|tlsv1 alert internal error|ssl3_read_bytes/i; + +/** True when the failure happened at the TLS layer rather than being an Atlas auth response. */ +export function isAtlasTlsHandshakeRejection(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ATLAS_TLS_FAILURE_PATTERN.test(message); +} diff --git a/src/plugins/service-atlas-mongodb/atlasDeepLinks.ts b/src/plugins/service-atlas-mongodb/atlasDeepLinks.ts new file mode 100644 index 000000000..4266e6f7f --- /dev/null +++ b/src/plugins/service-atlas-mongodb/atlasDeepLinks.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Deep links into the MongoDB Atlas web UI. + * + * These exist for the failures the extension can diagnose but cannot fix. A `403` from an enforced + * IP access list, or a `200 []` caused by a too-narrow role, is only resolvable in Atlas itself, + * and the settings page is several clicks deep behind an organization picker. Handing the user the + * exact page turns "your credential needs attention" into something actionable. + * + * The URLs follow the shape the Atlas console uses today. They are best-effort navigation aids: + * an outdated link lands the user on an Atlas page rather than breaking anything, so every builder + * degrades to the least specific destination it can still be sure about. + */ + +import { type AtlasAuthMethod } from './auth/AtlasSession'; +import { type AtlasCredentialRecord } from './credentials/atlasCredentialStore'; + +/** Root of the MongoDB Atlas web console. */ +const ATLAS_CLOUD_ROOT = 'https://cloud.mongodb.com'; + +/** + * Builds the Atlas access-management URL from the raw identity pieces. + * + * {@link buildAtlasAccessUrl} is the preferred form when a stored {@link AtlasCredentialRecord} is + * available. This lower-level variant exists for the add flow, where no record has been persisted + * yet but the organization was just resolved live from the authenticated client. + * + * - Service Account with a known client ID: its detail page, which is where its roles and its own + * IP access list live. + * - Service Account without a known client ID: the organization's Service Account list. + * - API Key: the organization's API key list. Per-key deep links need an internal key ID that the + * Admin API does not hand back with the data used here, and the list is one click away. + * - No organization ID: the Atlas console root, a useful landing page rather than a broken link. + * + * @param clientId Service Account client ID. Ignored for API keys. + */ +export function buildAtlasAccessUrlFor(authMethod: AtlasAuthMethod, orgId?: string, clientId?: string): string { + if (!orgId) { + return ATLAS_CLOUD_ROOT; + } + + const access = `${ATLAS_CLOUD_ROOT}/v2#/org/${encodeURIComponent(orgId)}/access`; + + if (authMethod !== 'serviceaccount') { + return `${access}/apiKeys`; + } + + return clientId ? `${access}/serviceAccounts/${encodeURIComponent(clientId)}` : `${access}/serviceAccounts`; +} + +/** + * Builds the Atlas access-management URL for a stored credential. + * + * @param clientId Service Account client ID, read from secret storage. Ignored for API keys. + */ +export function buildAtlasAccessUrl(record: AtlasCredentialRecord, clientId?: string): string { + return buildAtlasAccessUrlFor(record.authMethod, record.orgId, clientId); +} + +/** + * Builds the Atlas **Network Access** URL for a project, which is where the IP access list lives. + * + * MongoDB documents that Atlas allows client connections to a cluster only from addresses on this + * list, so it is the first thing to check when a connection fails for no obvious reason. Note it + * is a different list from the API access list attached to each credential. + */ +export function buildAtlasNetworkAccessUrl(projectId: string): string { + return `${ATLAS_CLOUD_ROOT}/v2/${encodeURIComponent(projectId)}#/security/network/accessList`; +} + +/** Builds the Atlas cluster overview URL for a discovered cluster. */ +export function buildAtlasClusterUrl(projectId: string, clusterName: string): string { + return `${ATLAS_CLOUD_ROOT}/v2/${encodeURIComponent(projectId)}#/clusters/detail/${encodeURIComponent(clusterName)}`; +} diff --git a/src/plugins/service-atlas-mongodb/atlasTrace.test.ts b/src/plugins/service-atlas-mongodb/atlasTrace.test.ts new file mode 100644 index 000000000..dceeeef46 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/atlasTrace.test.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +jest.mock('../../extensionVariables', () => ({ + ext: { + outputChannel: { trace: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), appendLine: jest.fn() }, + }, +})); + +import { formatMs, monotonicNow } from './atlasTrace'; + +describe('formatMs', () => { + it('never reports a negative duration when the wall clock steps backwards', () => { + // Observed live: an NTP correction landed mid-request and the log filled with lines like + // "GET /orgs -> 200 in -157ms", which discredits every other number on the line. + const startedAt = monotonicNow(); + const wallClock = jest.spyOn(Date, 'now').mockReturnValue(0); + try { + expect(formatMs(startedAt)).toMatch(/^\d+ms$/); + } finally { + wallClock.mockRestore(); + } + }); + + it('measures elapsed time from a monotonic reading', () => { + const realNow = performance.now.bind(performance); + const startedAt = realNow(); + const advanced = jest.spyOn(performance, 'now').mockImplementation(() => startedAt + 250); + try { + expect(formatMs(startedAt)).toBe('250ms'); + } finally { + advanced.mockRestore(); + } + }); +}); diff --git a/src/plugins/service-atlas-mongodb/atlasTrace.ts b/src/plugins/service-atlas-mongodb/atlasTrace.ts new file mode 100644 index 000000000..b8b0477c1 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/atlasTrace.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Diagnostic tracing for MongoDB Atlas discovery. + * + * Discovery fans out across a fleet of credentials, caches snapshots, and mints tokens in the + * background, so "why is the tree showing this?" is hard to answer from the UI alone. These + * helpers write a readable, chronological account of that work to the extension output channel. + * + * Rules for anything logged here: + * + * - **Never log secret material.** No API keys, client secrets, access tokens, or Authorization + * headers. Credentials are identified by their user-facing label plus a short record-id prefix. + * - Log *what happened*, not raw payloads: endpoint path, HTTP status, item counts, durations. + * - Trace level only, so the output stays silent unless the user opts into verbose logging. + */ + +import { ext } from '../../extensionVariables'; + +const PREFIX = '[Atlas]'; + +/** Writes a diagnostic line describing discovery activity. */ +export function atlasTrace(message: string): void { + ext.outputChannel.trace(`${PREFIX} ${message}`); +} + +/** Writes a diagnostic line for a recoverable problem worth seeing without verbose logging. */ +export function atlasWarn(message: string): void { + ext.outputChannel.warn(`${PREFIX} ${message}`); +} + +/** Writes a diagnostic line at error level for a failure worth surfacing without verbose logging. */ +export function atlasError(message: string): void { + ext.outputChannel.error(`${PREFIX} ${message}`); +} + +/** + * Shortens a record ID for log correlation. Record IDs are random UUIDs and carry no secret, but + * a full UUID on every line makes the log unreadable. + */ +export function shortId(id: string): string { + return id.slice(0, 8); +} + +/** + * Formats a credential for logs as `label (id-prefix)`. The label itself is user-supplied or an + * Atlas organization name, never secret material. + */ +export function describeCredential(label: string, credentialId: string): string { + return `${label} (${shortId(credentialId)})`; +} + +/** + * Reads the monotonic clock, for measuring how long something took. + * + * Deliberately not `Date.now()`. The wall clock can step backwards, and it does: an NTP + * correction, a resume from sleep, or a VM restore all move it. When that lands mid-request the + * log fills with negative durations, which is worse than useless because it silently discredits + * every other number on the line. `performance.now()` only ever moves forward. + * + * Wall-clock time is still the right choice for anything persisted or compared across processes, + * such as a Service Account token's `expiresAt`. + */ +export function monotonicNow(): number { + return performance.now(); +} + +/** Formats a duration for logs. Pair with {@link monotonicNow}, never with `Date.now()`. */ +export function formatMs(startedAt: number): string { + return `${String(Math.round(monotonicNow() - startedAt))}ms`; +} diff --git a/src/plugins/service-atlas-mongodb/auth/AtlasCredentialSessionRegistry.test.ts b/src/plugins/service-atlas-mongodb/auth/AtlasCredentialSessionRegistry.test.ts new file mode 100644 index 000000000..435bcf7c5 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/auth/AtlasCredentialSessionRegistry.test.ts @@ -0,0 +1,320 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const globalStateBacking = new Map(); +const secretStorageBacking = new Map(); + +jest.mock('vscode', () => ({ + ThemeIcon: class ThemeIcon { + constructor(public readonly id: string) {} + }, + l10n: { + t: jest.fn((message: string, ...args: string[]) => + args.reduce((m, value, index) => m.replace(`{${String(index)}}`, value), message), + ), + }, +})); + +jest.mock('../../../extensionVariables', () => ({ + ext: { + context: { + extension: { id: 'test-extension' }, + subscriptions: { push: (): void => {} }, + globalState: { + get: (key: string, defaultValue?: T): T | undefined => { + const value = globalStateBacking.has(key) ? (globalStateBacking.get(key) as T) : undefined; + return value === undefined ? defaultValue : value; + }, + update: async (key: string, value: unknown): Promise => { + if (value === undefined) { + globalStateBacking.delete(key); + } else { + globalStateBacking.set(key, value); + } + }, + keys: () => Array.from(globalStateBacking.keys()), + }, + }, + secretStorage: { + get: async (key: string): Promise => + secretStorageBacking.has(key) ? secretStorageBacking.get(key) : undefined, + store: async (key: string, value: string): Promise => { + secretStorageBacking.set(key, value); + }, + delete: async (key: string): Promise => { + secretStorageBacking.delete(key); + }, + onDidChange: (): { dispose: () => void } => ({ dispose: (): void => {} }), + }, + outputChannel: { trace: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), appendLine: jest.fn() }, + }, +})); + +const mockFetchToken = jest.fn(); +jest.mock('./AtlasServiceAccountClient', () => { + class AtlasTokenErrorMock extends Error { + constructor( + message: string, + public readonly statusCode: number, + public readonly code?: string, + ) { + super(message); + this.name = 'AtlasTokenError'; + } + } + return { + AtlasTokenError: AtlasTokenErrorMock, + fetchServiceAccountToken: (...args: unknown[]) => mockFetchToken(...args) as unknown, + }; +}); + +import { StorageService } from '../../../services/storageService'; +import { + readAtlasCredentialSecrets, + resetAtlasCredentialStoreCache, + upsertAtlasCredential, +} from '../credentials/atlasCredentialStore'; +import { AtlasCredentialSessionRegistry } from './AtlasCredentialSessionRegistry'; +import { AtlasTokenError } from './AtlasServiceAccountClient'; + +beforeEach(() => { + globalStateBacking.clear(); + secretStorageBacking.clear(); + StorageService._resetForTests(); + resetAtlasCredentialStoreCache(); + mockFetchToken.mockReset(); +}); + +describe('AtlasCredentialSessionRegistry', () => { + it('builds an API Key session straight from stored secrets', async () => { + const { record } = await upsertAtlasCredential({ + authMethod: 'apikey', + publicKey: 'pub-1', + privateKey: 'priv-1', + }); + + await expect(new AtlasCredentialSessionRegistry().getSession(record.id)).resolves.toEqual({ + type: 'apikey', + publicKey: 'pub-1', + privateKey: 'priv-1', + }); + expect(mockFetchToken).not.toHaveBeenCalled(); + }); + + it('returns undefined for a credential with no stored secrets', async () => { + await expect(new AtlasCredentialSessionRegistry().getSession('missing')).resolves.toBeUndefined(); + }); + + it('mints a Service Account token and caches it on the credential', async () => { + const { record } = await upsertAtlasCredential({ + authMethod: 'serviceaccount', + clientId: 'client-1', + clientSecret: 'secret-1', + }); + mockFetchToken.mockResolvedValue({ access_token: 'token-1', token_type: 'Bearer', expires_in: 3600 }); + + const session = await new AtlasCredentialSessionRegistry().getSession(record.id); + + expect(session).toEqual({ type: 'serviceaccount', accessToken: 'token-1' }); + await expect(readAtlasCredentialSecrets(record.id)).resolves.toMatchObject({ accessToken: 'token-1' }); + }); + + it('reuses a cached token that has not expired', async () => { + const { record } = await upsertAtlasCredential({ + authMethod: 'serviceaccount', + clientId: 'client-1', + clientSecret: 'secret-1', + }); + mockFetchToken.mockResolvedValue({ access_token: 'token-1', token_type: 'Bearer', expires_in: 3600 }); + + await new AtlasCredentialSessionRegistry().getSession(record.id); + // A brand-new registry has an empty in-memory map, so this exercises the stored token. + const session = await new AtlasCredentialSessionRegistry().getSession(record.id); + + expect(session).toEqual({ type: 'serviceaccount', accessToken: 'token-1' }); + expect(mockFetchToken).toHaveBeenCalledTimes(1); + }); + + it('re-mints when the cached token is inside the expiry skew', async () => { + const { record } = await upsertAtlasCredential({ + authMethod: 'serviceaccount', + clientId: 'client-1', + clientSecret: 'secret-1', + }); + // Expires in 30 seconds; the 60 second skew must treat it as already expired. + mockFetchToken.mockResolvedValueOnce({ access_token: 'token-1', token_type: 'Bearer', expires_in: 30 }); + await new AtlasCredentialSessionRegistry().getSession(record.id); + + mockFetchToken.mockResolvedValueOnce({ access_token: 'token-2', token_type: 'Bearer', expires_in: 3600 }); + const session = await new AtlasCredentialSessionRegistry().getSession(record.id); + + expect(session).toEqual({ type: 'serviceaccount', accessToken: 'token-2' }); + expect(mockFetchToken).toHaveBeenCalledTimes(2); + }); + + it('isolates a failing token refresh from a healthy peer credential', async () => { + const broken = await upsertAtlasCredential({ + authMethod: 'serviceaccount', + clientId: 'client-broken', + clientSecret: 'secret-broken', + }); + const healthy = await upsertAtlasCredential({ + authMethod: 'serviceaccount', + clientId: 'client-healthy', + clientSecret: 'secret-healthy', + }); + + mockFetchToken.mockImplementation((clientId: string) => + clientId === 'client-broken' + ? Promise.reject(new AtlasTokenError('invalid_client', 401, 'invalid_client')) + : Promise.resolve({ access_token: 'token-ok', token_type: 'Bearer', expires_in: 3600 }), + ); + + const registry = new AtlasCredentialSessionRegistry(); + + await expect(registry.getSession(broken.record.id)).resolves.toBeUndefined(); + await expect(registry.getSession(healthy.record.id)).resolves.toEqual({ + type: 'serviceaccount', + accessToken: 'token-ok', + }); + // The rejected credential keeps its secret so the user can fix Atlas and retry. + await expect(readAtlasCredentialSecrets(broken.record.id)).resolves.toMatchObject({ + clientSecret: 'secret-broken', + }); + }); + + it.each([ + [429, 'rate limited'], + [503, 'service unavailable'], + ])('rethrows a transient token failure (%s) instead of reporting a rejected credential', async (status) => { + // A `429` / `5xx` must not collapse to `undefined` (which the discovery pass maps to a + // credential-rejected error). Rethrowing lets the classifier report rate-limit / network. + const { record } = await upsertAtlasCredential({ + authMethod: 'serviceaccount', + clientId: 'client-1', + clientSecret: 'secret-1', + }); + mockFetchToken.mockRejectedValue(new AtlasTokenError('transient', status)); + + await expect(new AtlasCredentialSessionRegistry().getSession(record.id)).rejects.toBeInstanceOf( + AtlasTokenError, + ); + }); + + it('rethrows a network failure (TypeError) from token acquisition', async () => { + const { record } = await upsertAtlasCredential({ + authMethod: 'serviceaccount', + clientId: 'client-1', + clientSecret: 'secret-1', + }); + mockFetchToken.mockRejectedValue(new TypeError('fetch failed')); + + await expect(new AtlasCredentialSessionRegistry().getSession(record.id)).rejects.toBeInstanceOf(TypeError); + }); + + it('picks up a replaced secret after the credential is invalidated', async () => { + const { record } = await upsertAtlasCredential({ + authMethod: 'apikey', + publicKey: 'pub-1', + privateKey: 'priv-1', + }); + + const registry = new AtlasCredentialSessionRegistry(); + await registry.getSession(record.id); + + await upsertAtlasCredential({ authMethod: 'apikey', publicKey: 'pub-1', privateKey: 'priv-2' }); + registry.invalidate(record.id); + + await expect(registry.getSession(record.id)).resolves.toMatchObject({ privateKey: 'priv-2' }); + }); + + it('exposes a refresher scoped to a single credential', async () => { + const { record } = await upsertAtlasCredential({ + authMethod: 'serviceaccount', + clientId: 'client-1', + clientSecret: 'secret-1', + }); + mockFetchToken.mockResolvedValue({ access_token: 'token-fresh', token_type: 'Bearer', expires_in: 3600 }); + + const registry = new AtlasCredentialSessionRegistry(); + const refreshed = await registry.refresherFor(record.id).tryRefreshIfPossible(); + + expect(refreshed).toEqual({ type: 'serviceaccount', accessToken: 'token-fresh' }); + }); + + it('shares one in-flight resolution between concurrent callers', async () => { + const { record } = await upsertAtlasCredential({ + authMethod: 'serviceaccount', + clientId: 'client-1', + clientSecret: 'secret-1', + }); + mockFetchToken.mockResolvedValue({ access_token: 'token-1', token_type: 'Bearer', expires_in: 3600 }); + + const registry = new AtlasCredentialSessionRegistry(); + await Promise.all([registry.getSession(record.id), registry.getSession(record.id)]); + + expect(mockFetchToken).toHaveBeenCalledTimes(1); + }); + + it('shares one in-flight refresh between concurrent callers', async () => { + // A credential's discovery pass issues its organization and project requests together, so + // a rejected token makes both ask for a new one at the same moment. Without dedupe that + // minted two throwaway tokens for a single credential. + const { record } = await upsertAtlasCredential({ + authMethod: 'serviceaccount', + clientId: 'client-1', + clientSecret: 'secret-1', + }); + mockFetchToken.mockResolvedValue({ access_token: 'token-fresh', token_type: 'Bearer', expires_in: 3600 }); + + const registry = new AtlasCredentialSessionRegistry(); + const [first, second] = await Promise.all([ + registry.refreshSession(record.id), + registry.refreshSession(record.id), + ]); + + expect(mockFetchToken).toHaveBeenCalledTimes(1); + expect(first).toEqual({ type: 'serviceaccount', accessToken: 'token-fresh' }); + expect(second).toEqual(first); + }); + + it('does not repopulate the in-memory cache from a resolve invalidated mid-flight (MEDIUM-4)', async () => { + const { record } = await upsertAtlasCredential({ + authMethod: 'serviceaccount', + clientId: 'client-1', + clientSecret: 'secret-1', + }); + + let resolveToken!: (value: { access_token: string; token_type: string; expires_in: number }) => void; + mockFetchToken.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveToken = resolve as typeof resolveToken; + }), + ); + + const registry = new AtlasCredentialSessionRegistry(); + const pending = registry.getSession(record.id); + + // Let resolveSession read the secret and reach the deferred token mint (its generation is + // captured synchronously, before this point). + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Invalidate while the mint is still in flight: the resolve that finishes next is stale. + registry.invalidate(record.id); + + // Resolve with a token already inside the expiry skew, so the follow-up read must re-mint. + resolveToken({ access_token: 'stale-token', token_type: 'Bearer', expires_in: 30 }); + await pending; + + mockFetchToken.mockResolvedValueOnce({ access_token: 'fresh-token', token_type: 'Bearer', expires_in: 3600 }); + const session = await registry.getSession(record.id); + + // If the stale resolve had repopulated the in-memory cache, getSession would return + // 'stale-token' without minting again. + expect(session).toEqual({ type: 'serviceaccount', accessToken: 'fresh-token' }); + expect(mockFetchToken).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/plugins/service-atlas-mongodb/auth/AtlasCredentialSessionRegistry.ts b/src/plugins/service-atlas-mongodb/auth/AtlasCredentialSessionRegistry.ts new file mode 100644 index 000000000..c20426fb6 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/auth/AtlasCredentialSessionRegistry.ts @@ -0,0 +1,293 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Per-credential session ownership for MongoDB Atlas discovery. + * + * The legacy {@link AtlasSessionManager} holds exactly one session, which cannot express a fleet + * of independent credentials. This registry keys every piece of session state by the credential's + * stable record ID, so: + * + * - each credential restores independently after a reload (separate secret slots, no cross-write); + * - a Service Account token refresh for credential A never touches credential B; and + * - a credential whose secret was rejected can be marked failed without disturbing its peers. + */ + +import { atlasError, atlasTrace, atlasWarn, formatMs, monotonicNow, shortId } from '../atlasTrace'; +import { + cacheServiceAccountToken, + readAtlasCredentialSecrets, + type AtlasCredentialSecrets, +} from '../credentials/atlasCredentialStore'; +import { AtlasTokenError, fetchServiceAccountToken } from './AtlasServiceAccountClient'; +import { type AtlasSession } from './AtlasSession'; + +/** + * Minimal contract the API client needs in order to recover from a rejected access token. + * Implemented both by the legacy single-session manager and by {@link AtlasCredentialSession}. + */ +export interface AtlasSessionRefresher { + tryRefreshIfPossible(): Promise; +} + +/** Refresh a Service Account token this many milliseconds before it actually expires. */ +const EXPIRY_SKEW_MS = 60_000; + +function isExpired(expiresAtMs: string | undefined): boolean { + if (!expiresAtMs) { + return true; + } + const expiresAt = Number(expiresAtMs); + if (!Number.isFinite(expiresAt)) { + return true; + } + return Date.now() >= expiresAt - EXPIRY_SKEW_MS; +} + +/** + * A refresher bound to exactly one credential. Handed to {@link AtlasApiClient} so a rejected + * access token on that credential triggers a re-mint for that credential only. + */ +class AtlasCredentialSession implements AtlasSessionRefresher { + constructor( + private readonly registry: AtlasCredentialSessionRegistry, + private readonly credentialId: string, + ) {} + + public tryRefreshIfPossible(): Promise { + return this.registry.refreshSession(this.credentialId); + } +} + +/** + * Owns one {@link AtlasSession} per credential ID. + * + * Sessions are derived lazily from the credential store and cached in memory. API Key credentials + * need no refresh at all; Service Account credentials mint a token on demand and cache it back + * into their own storage item so the token survives a reload. + */ +export class AtlasCredentialSessionRegistry { + private readonly sessions = new Map(); + private readonly inflight = new Map>(); + private readonly inflightRefresh = new Map>(); + /** + * Bumped by {@link invalidate} / {@link invalidateAll}. A session resolve that started before + * the bump is stale by definition - the secret it read may already have been replaced - so it is + * allowed to finish, but not to become the cached session. Without this, rotating a credential + * and then losing the race against an in-flight discovery pass leaves the old key in memory + * until the next full {@link reset}. This is masked in the common flow because + * `configureAtlasCredentials()` ends with `discoveryService.reset()` (a full `invalidateAll()`), + * but `AtlasCredentialActionStep.update()` relies on the narrow `invalidate(credentialId)` alone. + */ + private readonly generations = new Map(); + + private currentGeneration(credentialId: string): number { + return this.generations.get(credentialId) ?? 0; + } + + /** + * Returns a usable session for the credential, minting or refreshing a Service Account token + * when required. Returns `undefined` when the credential has no usable secret material or the + * token endpoint rejected it. + */ + public async getSession(credentialId: string): Promise { + const cached = this.sessions.get(credentialId); + if (cached) { + return cached; + } + + const inflight = this.inflight.get(credentialId); + if (inflight) { + return inflight; + } + + const work = this.resolveSession(credentialId).finally(() => { + if (this.inflight.get(credentialId) === work) { + this.inflight.delete(credentialId); + } + }); + this.inflight.set(credentialId, work); + return work; + } + + /** + * Forces a fresh Service Account token for the credential. API Key credentials have nothing to + * refresh, so their stored session is simply returned again. + * + * Concurrent callers share one refresh. A credential's discovery pass issues its organization + * and project requests together, so an expired token makes both of them ask for a new one at + * the same moment; without this, that mints two throwaway tokens for one credential. + */ + public async refreshSession(credentialId: string): Promise { + const pending = this.inflightRefresh.get(credentialId); + if (pending) { + atlasTrace(`credential ${shortId(credentialId)}: joining the in-flight session refresh`); + return pending; + } + + const work = this.performRefresh(credentialId).finally(() => { + if (this.inflightRefresh.get(credentialId) === work) { + this.inflightRefresh.delete(credentialId); + } + }); + this.inflightRefresh.set(credentialId, work); + return work; + } + + private async performRefresh(credentialId: string): Promise { + // Snapshot the generation up front: a concurrent `invalidate()` after this point must make + // this refresh's result non-cacheable rather than overwrite the newer state. + const generation = this.currentGeneration(credentialId); + this.sessions.delete(credentialId); + + const secrets = await readAtlasCredentialSecrets(credentialId); + if (!secrets) { + atlasWarn(`credential ${shortId(credentialId)} has no stored secret; cannot build a session`); + return undefined; + } + + if (secrets.authMethod === 'apikey') { + // Digest auth carries no token, so there is nothing to refresh. Re-deriving the + // session is still the right answer: it picks up a secret the user just replaced. + atlasTrace(`credential ${shortId(credentialId)}: re-derived api key session from storage`); + return this.storeSession( + credentialId, + { + type: 'apikey', + publicKey: secrets.publicKey, + privateKey: secrets.privateKey, + }, + generation, + ); + } + + atlasTrace(`credential ${shortId(credentialId)}: forcing a fresh service account token`); + return this.mintServiceAccountToken(credentialId, secrets, generation); + } + + /** + * Drops the cached session for one credential, forcing the next request to re-derive it. + * Used after the credential's secret is replaced or the credential is removed. + */ + public invalidate(credentialId: string): void { + this.sessions.delete(credentialId); + this.inflight.delete(credentialId); + this.inflightRefresh.delete(credentialId); + // Any resolve/refresh already running for this credential is now stale and must not + // repopulate the cache when it finishes. + this.generations.set(credentialId, this.currentGeneration(credentialId) + 1); + } + + /** Drops every cached session. Used by "sign out of all". */ + public invalidateAll(): void { + this.sessions.clear(); + this.inflight.clear(); + this.inflightRefresh.clear(); + // Bump every known generation so no in-flight resolve/refresh can repopulate the cache. + for (const credentialId of this.generations.keys()) { + this.generations.set(credentialId, this.currentGeneration(credentialId) + 1); + } + } + + /** Returns a refresher scoped to a single credential. */ + public refresherFor(credentialId: string): AtlasSessionRefresher { + return new AtlasCredentialSession(this, credentialId); + } + + private async resolveSession(credentialId: string): Promise { + // Snapshot the generation before any await, so a concurrent invalidation makes this + // resolution non-cacheable rather than letting it overwrite the newer state. + const generation = this.currentGeneration(credentialId); + const secrets = await readAtlasCredentialSecrets(credentialId); + if (!secrets) { + atlasWarn(`credential ${shortId(credentialId)} has no stored secret; cannot build a session`); + return undefined; + } + + if (secrets.authMethod === 'apikey') { + atlasTrace(`credential ${shortId(credentialId)}: using api key session (digest auth, no token)`); + return this.storeSession( + credentialId, + { + type: 'apikey', + publicKey: secrets.publicKey, + privateKey: secrets.privateKey, + }, + generation, + ); + } + + if (secrets.accessToken && !isExpired(secrets.expiresAt)) { + atlasTrace(`credential ${shortId(credentialId)}: reusing the cached service account token`); + return this.storeSession( + credentialId, + { type: 'serviceaccount', accessToken: secrets.accessToken }, + generation, + ); + } + + atlasTrace( + `credential ${shortId(credentialId)}: cached service account token is missing or expired, minting a new one`, + ); + return this.mintServiceAccountToken(credentialId, secrets, generation); + } + + private async mintServiceAccountToken( + credentialId: string, + secrets: AtlasCredentialSecrets & { authMethod: 'serviceaccount' }, + generation: number, + ): Promise { + const startedAt = monotonicNow(); + try { + const tokenResponse = await fetchServiceAccountToken(secrets.clientId, secrets.clientSecret); + await cacheServiceAccountToken( + credentialId, + tokenResponse.access_token, + // Wall clock on purpose: this expiry is persisted and compared in a later session. + Date.now() + tokenResponse.expires_in * 1000, + ); + atlasTrace( + `credential ${shortId(credentialId)}: minted a service account token in ${formatMs(startedAt)}, valid for ${String(tokenResponse.expires_in)}s`, + ); + return this.storeSession( + credentialId, + { + type: 'serviceaccount', + accessToken: tokenResponse.access_token, + }, + generation, + ); + } catch (error) { + // The credential keeps its stored secret so the user can fix the Atlas-side problem + // and retry from the credential-management flow without re-entering it. + const message = error instanceof Error ? error.message : String(error); + + // Only a genuinely rejected client/secret (`400`/`401`, typically `invalid_client`) + // means the credential is bad. Collapsing every token failure into `undefined` here + // made a `429`, a `5xx`, or an offline machine look like a rejected credential and sent + // the user to update a working secret. Log the full failure, then rethrow the transient + // ones so the discovery pass classifies them as rate-limit / network / other instead. + if (error instanceof AtlasTokenError && (error.statusCode === 400 || error.statusCode === 401)) { + atlasWarn( + `credential ${shortId(credentialId)}: service account credentials were rejected (${String(error.statusCode)}${error.code ? ` ${error.code}` : ''}): ${message}`, + ); + return undefined; + } + + atlasError(`credential ${shortId(credentialId)}: service account token request failed: ${message}`); + throw error; + } + } + + private storeSession(credentialId: string, session: AtlasSession, generation: number): AtlasSession { + // A resolve/refresh that started before an `invalidate()` is stale: it may hold a session + // derived from a secret that has since been replaced. Return it to its caller, but do not + // let it repopulate the cache and mask the newer state. + if (generation === this.currentGeneration(credentialId)) { + this.sessions.set(credentialId, session); + } + return session; + } +} diff --git a/src/plugins/service-atlas-mongodb/auth/AtlasServiceAccountClient.ts b/src/plugins/service-atlas-mongodb/auth/AtlasServiceAccountClient.ts new file mode 100644 index 000000000..2f9201485 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/auth/AtlasServiceAccountClient.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { ATLAS_SERVICE_ACCOUNT_TOKEN_URL } from '../config'; + +/** + * Token response from Atlas Service Account client_credentials flow. + */ +export interface AtlasServiceAccountTokenResponse { + readonly access_token: string; + readonly token_type: string; + readonly expires_in: number; +} + +/** + * A non-2xx response from the Atlas Service Account token endpoint. + * + * Carries the HTTP status and the OAuth `error` code so callers can distinguish a genuinely + * rejected client/secret (`400`/`401`, typically `invalid_client`) from a transient failure + * (`429`, `5xx`) that must not send the user to re-enter a working credential. A network/DNS + * failure surfaces as a `TypeError` from `fetch` and is deliberately left as-is. + */ +export class AtlasTokenError extends Error { + constructor( + message: string, + public readonly statusCode: number, + public readonly code?: string, + ) { + super(message); + this.name = 'AtlasTokenError'; + } +} + +/** + * Fetches an access token using the client_credentials grant. + * Atlas Service Accounts use client_id + client_secret for machine-to-machine auth. + * + * @param clientId - The Service Account client ID + * @param clientSecret - The Service Account client secret + * @returns Token response with access_token and expires_in + */ +export async function fetchServiceAccountToken( + clientId: string, + clientSecret: string, +): Promise { + const body = new URLSearchParams({ + grant_type: 'client_credentials', + }); + + // Atlas requires client credentials in the Authorization header (HTTP Basic) + const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); + + const response = await fetch(ATLAS_SERVICE_ACCOUNT_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${credentials}`, + Accept: 'application/json', + 'Cache-Control': 'no-cache', + }, + body: body.toString(), + }); + + if (!response.ok) { + let errorDetail = `${response.status}`; + let errorCode: string | undefined; + try { + const errorBody = (await response.json()) as { + error?: string; + error_description?: string; + errorCode?: string; + }; + errorCode = errorBody.error ?? errorBody.errorCode; + errorDetail = errorCode ?? errorDetail; + if (errorBody.error_description) { + errorDetail += `: ${errorBody.error_description}`; + } + } catch { + // Ignore JSON parse errors for error body + } + throw new AtlasTokenError( + vscode.l10n.t('Failed to authenticate Service Account: {0}', errorDetail), + response.status, + errorCode, + ); + } + + return (await response.json()) as AtlasServiceAccountTokenResponse; +} diff --git a/src/plugins/service-atlas-mongodb/auth/AtlasSession.ts b/src/plugins/service-atlas-mongodb/auth/AtlasSession.ts new file mode 100644 index 000000000..9bb35c6a2 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/auth/AtlasSession.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Represents the authentication method used to connect to Atlas. + */ +export type AtlasAuthMethod = 'apikey' | 'serviceaccount'; + +/** + * Base session interface. + */ +interface AtlasSessionBase { + readonly type: AtlasAuthMethod; +} + +/** + * API Key session with public/private key pair (HTTP Digest Auth). + */ +export interface AtlasApiKeySession extends AtlasSessionBase { + readonly type: 'apikey'; + readonly publicKey: string; + readonly privateKey: string; +} + +/** + * Service Account session using the client_credentials grant. + * Uses client_id/client_secret to obtain a Bearer access token. + */ +export interface AtlasServiceAccountSession extends AtlasSessionBase { + readonly type: 'serviceaccount'; + readonly accessToken: string; +} + +/** + * Union type representing a valid Atlas session. + */ +export type AtlasSession = AtlasApiKeySession | AtlasServiceAccountSession; diff --git a/src/plugins/service-atlas-mongodb/commands/openAtlasCluster.ts b/src/plugins/service-atlas-mongodb/commands/openAtlasCluster.ts new file mode 100644 index 000000000..ca3d68114 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/commands/openAtlasCluster.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import { Views } from '../../../documentdb/Views'; +import { openUrl } from '../../../utils/openUrl'; +import { DISCOVERY_PROVIDER_ID } from '../config'; +import { type AtlasClusterItem } from '../discovery-tree/AtlasClusterItem'; + +export const OPEN_ATLAS_CLUSTER_COMMAND_ID = 'vscode-documentdb.command.discoveryView.atlas.openCluster'; + +export async function openAtlasCluster(context: IActionContext, node: AtlasClusterItem): Promise { + if (!node) { + throw new Error(l10n.t('No node selected.')); + } + + context.telemetry.properties.view = Views.DiscoveryView; + context.telemetry.properties.discoveryProviderId = DISCOVERY_PROVIDER_ID; + context.telemetry.properties.resourceType = 'atlas-mongodb-cluster'; + if (node.journeyCorrelationId) { + context.telemetry.properties.journeyCorrelationId = node.journeyCorrelationId; + } + + await openUrl(node.getAtlasConsoleUrl()); +} diff --git a/src/plugins/service-atlas-mongodb/commands/switchAtlasViewMode.ts b/src/plugins/service-atlas-mongodb/commands/switchAtlasViewMode.ts new file mode 100644 index 000000000..9fd903d3c --- /dev/null +++ b/src/plugins/service-atlas-mongodb/commands/switchAtlasViewMode.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { Views } from '../../../documentdb/Views'; +import { ext } from '../../../extensionVariables'; +import { + DEFAULT_ATLAS_VIEW_MODE, + DISCOVERY_PROVIDER_ID, + DISCOVERY_VIEW_MODE_STATE_KEY, + type AtlasViewMode, +} from '../config'; + +/** Reads the persisted MongoDB Atlas discovery view mode. */ +export function getAtlasViewMode(): AtlasViewMode { + return ext.context.globalState.get(DISCOVERY_VIEW_MODE_STATE_KEY, DEFAULT_ATLAS_VIEW_MODE); +} + +/** + * Persists the global MongoDB Atlas discovery {@link AtlasViewMode} and refreshes the tree. + * + * The mode is global (it applies to the whole MongoDB Atlas discovery provider) and stored + * directly in globalState, matching the Kubernetes view-mode toggle, so the choice always + * persists without exposing a user-facing setting. + */ +async function setAtlasViewMode(context: IActionContext, mode: AtlasViewMode): Promise { + context.telemetry.properties.discoveryProviderId = DISCOVERY_PROVIDER_ID; + context.telemetry.properties.atlasViewMode = mode; + + await ext.context.globalState.update(DISCOVERY_VIEW_MODE_STATE_KEY, mode); + + const rootId = `${Views.DiscoveryView}/${DISCOVERY_PROVIDER_ID}`; + ext.discoveryBranchDataProvider.resetNodeErrorState(rootId); + ext.discoveryBranchDataProvider.refresh(); +} + +/** Switches MongoDB Atlas discovery to the hierarchical organization tree. */ +export async function switchToAtlasTreeView(context: IActionContext): Promise { + await setAtlasViewMode(context, 'tree'); +} + +/** Switches MongoDB Atlas discovery to the flat, deduplicated cluster list. */ +export async function switchToAtlasFlatListView(context: IActionContext): Promise { + await setAtlasViewMode(context, 'list'); +} diff --git a/src/plugins/service-atlas-mongodb/config.ts b/src/plugins/service-atlas-mongodb/config.ts new file mode 100644 index 000000000..824a60f18 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/config.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { l10n, ThemeIcon } from 'vscode'; + +/** + * Configuration constants for the MongoDB Atlas discovery provider. + */ + +/** Unique identifier for this discovery provider */ +export const DISCOVERY_PROVIDER_ID = 'atlas-mongodb-discovery'; + +/** Display label for the discovery provider */ +export const LABEL = l10n.t('MongoDB Atlas'); + +/** Description shown in the discovery provider list */ +export const DESCRIPTION = l10n.t('Service Discovery for MongoDB Atlas'); + +/** Icon for the discovery provider */ +export const ICON_PATH = new ThemeIcon('cloud'); + +/** Title shown in the discovery wizard */ +export const WIZARD_TITLE = l10n.t('MongoDB Atlas Service Discovery'); + +/** Base URL for Atlas Admin API v2 */ +export const ATLAS_API_BASE_URL = 'https://cloud.mongodb.com/api/atlas/v2'; + +/** Atlas Service Account token endpoint (client_credentials grant) */ +export const ATLAS_SERVICE_ACCOUNT_TOKEN_URL = 'https://cloud.mongodb.com/api/oauth/token'; + +/** + * How the MongoDB Atlas discovery tree renders below its root: + * + * - `tree` (default): organization to project to cluster. + * - `list`: a flat, deduplicated cluster list with `organization · project` in the description. + * + * Both modes render the same consolidated recovery row when a credential fails, so a failure + * never forces a view-mode switch. + */ +export type AtlasViewMode = 'tree' | 'list'; + +/** Default view mode when the user has not toggled it yet. */ +export const DEFAULT_ATLAS_VIEW_MODE: AtlasViewMode = 'tree'; + +/** + * GlobalState key persisting the discovery tree {@link AtlasViewMode}. + * + * Stored directly via `ext.context.globalState`, matching the Kubernetes view-mode key, so the + * last choice always persists without exposing a user-facing setting. + */ +export const DISCOVERY_VIEW_MODE_STATE_KEY = `${DISCOVERY_PROVIDER_ID}.viewMode`; diff --git a/src/plugins/service-atlas-mongodb/connect/SelectAtlasDatabaseUserStep.test.ts b/src/plugins/service-atlas-mongodb/connect/SelectAtlasDatabaseUserStep.test.ts new file mode 100644 index 000000000..7b4b1a5b4 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/connect/SelectAtlasDatabaseUserStep.test.ts @@ -0,0 +1,250 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { UserCancelledError } from '@microsoft/vscode-azext-utils'; +import { AuthMethodId } from '../../../documentdb/auth/AuthMethod'; +import { type AuthenticateWizardContext } from '../../../documentdb/wizards/authenticate/AuthenticateWizardContext'; +import { type AtlasDatabaseUserCandidate } from './atlasDatabaseUsers'; +import { SelectAtlasDatabaseUserStep } from './SelectAtlasDatabaseUserStep'; + +jest.mock('@vscode/l10n', () => ({ + t: jest.fn((message: string, values?: Record) => { + if (!values) { + return message; + } + return Object.entries(values).reduce((result, [key, value]) => result.replace(`{${key}}`, value), message); + }), +})); + +jest.mock('vscode', () => ({ + ThemeIcon: class ThemeIcon { + constructor(public readonly id: string) {} + }, + QuickPickItemKind: { Separator: -1, Default: 0 }, + ProgressLocation: { Window: 10, Notification: 15 }, + l10n: { + t: jest.fn((message: string) => message), + }, + window: { + withProgress: async (_options: unknown, task: () => Promise) => task(), + }, +})); + +jest.mock('@microsoft/vscode-azext-utils', () => ({ + AzureWizardPromptStep: class AzureWizardPromptStep {}, + UserCancelledError: class UserCancelledError extends Error {}, +})); + +jest.mock('../atlasTrace', () => ({ + atlasTrace: jest.fn(), + atlasWarn: jest.fn(), +})); + +interface PickItem { + label: string; + kind?: number; + candidate?: AtlasDatabaseUserCandidate; + isCustomOption?: boolean; +} + +const showQuickPick = jest.fn(); +const showWarningMessage = jest.fn(); + +function createContext(): AuthenticateWizardContext { + return { + telemetry: { properties: {}, measurements: {} }, + errorHandling: {}, + valuesToMask: [], + ui: { showQuickPick, showWarningMessage }, + adminUserName: undefined, + resourceName: 'Cluster0', + availableAuthMethods: [AuthMethodId.NativeAuth], + selectedAuthMethod: AuthMethodId.NativeAuth, + } as unknown as AuthenticateWizardContext; +} + +function scram(username: string): AtlasDatabaseUserCandidate { + return { username, supported: true, authMethodLabel: 'Username and password' }; +} + +function federated(username: string, authMethodLabel: string): AtlasDatabaseUserCandidate { + return { username, supported: false, authMethodLabel }; +} + +function createStep(users: AtlasDatabaseUserCandidate[] | Error): SelectAtlasDatabaseUserStep { + return new SelectAtlasDatabaseUserStep(async () => { + if (users instanceof Error) { + throw users; + } + return users; + }, 'Cluster0'); +} + +beforeEach(() => { + showQuickPick.mockReset(); + showWarningMessage.mockReset(); +}); + +describe('SelectAtlasDatabaseUserStep decisions', () => { + it('stays out of the way when the project has no database users', async () => { + const context = createContext(); + const step = createStep([]); + + await step.configureBeforePrompt(context); + + expect(step.shouldPrompt(context)).toBe(false); + expect(context.adminUserName).toBeUndefined(); + expect(context.telemetry.properties.atlasDatabaseUserSource).toBe('unavailable'); + }); + + it('stays out of the way when the lookup fails, so a convenience never blocks sign-in', async () => { + const context = createContext(); + const step = createStep(new Error('403 IP_ADDRESS_NOT_ON_ACCESS_LIST')); + + await step.configureBeforePrompt(context); + + expect(step.shouldPrompt(context)).toBe(false); + expect(context.adminUserName).toBeUndefined(); + expect(context.telemetry.properties.atlasDatabaseUserSource).toBe('failed'); + }); + + it('prefills the username prompt instead of showing a list of one', async () => { + const context = createContext(); + const step = createStep([scram('app_rw')]); + + await step.configureBeforePrompt(context); + + expect(step.shouldPrompt(context)).toBe(false); + expect(context.adminUserName).toBe('app_rw'); + expect(context.telemetry.properties.atlasDatabaseUserSource).toBe('prefilled'); + }); + + it('still shows the list for a single unusable user, so the reason is visible', async () => { + const context = createContext(); + const step = createStep([federated('CN=svc,OU=eng', 'X.509')]); + + await step.configureBeforePrompt(context); + + expect(step.shouldPrompt(context)).toBe(true); + expect(context.adminUserName).toBeUndefined(); + }); + + it('does not prompt once a username is already known', async () => { + const context = createContext(); + context.selectedUserName = 'already_chosen'; + const step = createStep([scram('a'), scram('b')]); + + await step.configureBeforePrompt(context); + + expect(step.shouldPrompt(context)).toBe(false); + }); + + it('does not prompt when a non-native authentication method was chosen', async () => { + const context = createContext(); + context.selectedAuthMethod = AuthMethodId.MicrosoftEntraID; + const step = createStep([scram('a'), scram('b')]); + + await step.configureBeforePrompt(context); + + expect(step.shouldPrompt(context)).toBe(false); + }); +}); + +describe('SelectAtlasDatabaseUserStep list', () => { + it('offers manual entry first and groups usable users apart from the rest', async () => { + const context = createContext(); + const step = createStep([scram('app_rw'), federated('CN=svc,OU=eng', 'X.509'), scram('analytics_ro')]); + await step.configureBeforePrompt(context); + + showQuickPick.mockImplementation((items: PickItem[]) => { + expect(items[0].isCustomOption).toBe(true); + expect(items.map((item) => item.label)).toEqual([ + 'Enter a username', + 'Username and password (SCRAM)', + 'app_rw', + 'analytics_ro', + 'Not supported yet', + 'CN=svc,OU=eng', + ]); + return Promise.resolve(items[0]); + }); + + await step.prompt(context); + + expect(showQuickPick).toHaveBeenCalledTimes(1); + }); + + it('records a picked user so the username prompt is skipped', async () => { + const context = createContext(); + const step = createStep([scram('app_rw'), scram('analytics_ro')]); + await step.configureBeforePrompt(context); + + showQuickPick.mockImplementation((items: PickItem[]) => + Promise.resolve(items.find((item) => item.candidate?.username === 'analytics_ro')), + ); + + await step.prompt(context); + + expect(context.selectedUserName).toBe('analytics_ro'); + expect(context.nativeAuthConfig?.connectionUser).toBe('analytics_ro'); + expect(context.isUserNameUpdated).toBe(true); + expect(context.valuesToMask).toContain('analytics_ro'); + expect(context.telemetry.properties.atlasDatabaseUserSource).toBe('picked'); + }); + + it('leaves the username unset when manual entry is chosen', async () => { + const context = createContext(); + const step = createStep([scram('app_rw'), scram('analytics_ro')]); + await step.configureBeforePrompt(context); + + showQuickPick.mockImplementation((items: PickItem[]) => Promise.resolve(items[0])); + + await step.prompt(context); + + expect(context.selectedUserName).toBeUndefined(); + expect(context.telemetry.properties.atlasDatabaseUserSource).toBe('custom'); + }); + + it('explains an unusable user and returns to the list rather than accepting it', async () => { + const context = createContext(); + const step = createStep([scram('app_rw'), federated('CN=svc,OU=eng', 'X.509')]); + await step.configureBeforePrompt(context); + + showQuickPick + .mockImplementationOnce((items: PickItem[]) => + Promise.resolve(items.find((item) => item.candidate?.supported === false)), + ) + .mockImplementationOnce((items: PickItem[]) => + Promise.resolve(items.find((item) => item.candidate?.username === 'app_rw')), + ); + + await step.prompt(context); + + expect(showWarningMessage).toHaveBeenCalledTimes(1); + expect(showQuickPick).toHaveBeenCalledTimes(2); + expect(context.selectedUserName).toBe('app_rw'); + expect(context.telemetry.properties.atlasDatabaseUserUnsupportedPicked).toBe('true'); + }); + + it('returns to the list when the explanation modal is dismissed instead of cancelling sign-in', async () => { + const context = createContext(); + const step = createStep([scram('app_rw'), federated('CN=svc,OU=eng', 'X.509')]); + await step.configureBeforePrompt(context); + + // azext-utils reports a dismissed modal as a cancellation; here it means "never mind". + showWarningMessage.mockRejectedValueOnce(new UserCancelledError()); + + showQuickPick + .mockImplementationOnce((items: PickItem[]) => + Promise.resolve(items.find((item) => item.candidate?.supported === false)), + ) + .mockImplementationOnce((items: PickItem[]) => Promise.resolve(items[0])); + + await step.prompt(context); + + expect(showQuickPick).toHaveBeenCalledTimes(2); + expect(context.telemetry.properties.atlasDatabaseUserSource).toBe('custom'); + }); +}); diff --git a/src/plugins/service-atlas-mongodb/connect/SelectAtlasDatabaseUserStep.ts b/src/plugins/service-atlas-mongodb/connect/SelectAtlasDatabaseUserStep.ts new file mode 100644 index 000000000..7e3e3fbf8 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/connect/SelectAtlasDatabaseUserStep.ts @@ -0,0 +1,235 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep, UserCancelledError } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { AuthMethodId } from '../../../documentdb/auth/AuthMethod'; +import { type AuthenticateWizardContext } from '../../../documentdb/wizards/authenticate/AuthenticateWizardContext'; +import { atlasTrace, atlasWarn } from '../atlasTrace'; +import { type AtlasDatabaseUserCandidate } from './atlasDatabaseUsers'; + +/** How long the database-user lookup is allowed to take before the wizard gives up on it. */ +const USER_LOOKUP_TIMEOUT_MS = 8_000; + +/** Loads the database users that apply to one cluster. */ +export type AtlasDatabaseUserLoader = (signal: AbortSignal) => Promise; + +interface UserQuickPickItem extends vscode.QuickPickItem { + readonly candidate?: AtlasDatabaseUserCandidate; + readonly isCustomOption?: boolean; +} + +/** + * Offers the cluster's known database users instead of an empty username box. + * + * Atlas already knows which users exist, so making somebody retype one from memory is busywork, + * and a typo here only surfaces later as an authentication failure. The lookup needs just + * `Project Read Only`, which the credential that discovered the cluster already has. + * + * The step is unobtrusive and never becomes a dead end: + * + * - **Several users** are offered as a pick list, with **Enter a username** first so a user that + * is not in the list is always one keystroke away. + * - **Exactly one usable user and nothing else** skips the list and simply prefills the normal + * username prompt, which stays editable. + * - **No users, no permission, a slow server or any other failure** skips this step silently and + * leaves the normal username prompt exactly as it was. A convenience must never block sign-in. + * + * Users that authenticate through X.509, AWS IAM, LDAP or OIDC are listed under their own heading + * rather than hidden. The connect flow has only a username and a password to offer, so it cannot + * use them, but hiding them would answer "my username is missing" with silence when the honest + * answer is "it is there, and its method is not supported yet". Selecting one says so and returns + * to the list. + * + * The lookup runs in `configureBeforePrompt`, the only hook that runs before the wizard asks + * whether to prompt, so its outcome can pick between the three shapes above. It is bounded by a + * timeout and reports progress in the status bar, because a slow Atlas response must not leave + * the wizard looking frozen between steps. + */ +export class SelectAtlasDatabaseUserStep extends AzureWizardPromptStep { + private candidates: AtlasDatabaseUserCandidate[] = []; + + constructor( + private readonly loadUsers: AtlasDatabaseUserLoader, + private readonly clusterName: string, + ) { + super(); + } + + public async configureBeforePrompt(context: AuthenticateWizardContext): Promise { + this.candidates = []; + + if (!this.isNativeAuthPending(context)) { + return; + } + + const users = await this.loadUsersWithProgress(context); + + if (users.length === 0) { + context.telemetry.properties.atlasDatabaseUserSource ??= 'unavailable'; + return; + } + + if (users.length === 1 && users[0].supported) { + // A single usable user does not deserve a pick list. Prefilling the normal prompt keeps + // the value editable, which matters because the one user Atlas knows about is not + // necessarily the one this person wants to connect as. + context.adminUserName = users[0].username; + context.telemetry.properties.atlasDatabaseUserSource = 'prefilled'; + atlasTrace(`cluster "${this.clusterName}": one database user found, prefilling the username prompt`); + return; + } + + this.candidates = users; + context.telemetry.measurements.atlasDatabaseUserCount = users.length; + context.telemetry.measurements.atlasDatabaseUserUnsupportedCount = users.filter( + (user) => !user.supported, + ).length; + } + + public async prompt(context: AuthenticateWizardContext): Promise { + // Selecting an unsupported user explains why and comes back here, so the list stays the + // single place where this decision is made. + for (;;) { + const selected = await context.ui.showQuickPick(this.buildItems(), { + stepName: 'selectAtlasDatabaseUser', + placeHolder: l10n.t('Select a database user for "{cluster}"', { cluster: this.clusterName }), + matchOnDetail: true, + suppressPersistence: true, + }); + + if (selected.isCustomOption || !selected.candidate) { + context.telemetry.properties.atlasDatabaseUserSource = 'custom'; + return; + } + + if (!selected.candidate.supported) { + context.telemetry.properties.atlasDatabaseUserUnsupportedPicked = 'true'; + await this.explainUnsupported(context, selected.candidate); + continue; + } + + // Mirrors what ProvideUserNameStep records, so that step is skipped from here on. + context.nativeAuthConfig = { + connectionUser: selected.candidate.username, + connectionPassword: context.nativeAuthConfig?.connectionPassword ?? context.password ?? '', + }; + context.selectedUserName = selected.candidate.username; + context.valuesToMask.push(selected.candidate.username); + context.isUserNameUpdated = true; + context.telemetry.properties.atlasDatabaseUserSource = 'picked'; + return; + } + } + + public shouldPrompt(context: AuthenticateWizardContext): boolean { + return this.isNativeAuthPending(context) && this.candidates.length > 0; + } + + /** + * Builds the list: the manual escape hatch first, then the users we can sign in as, then the + * ones we cannot. The headings carry the explanation once instead of repeating it on every + * row, which leaves the description column free to name the method. + */ + private buildItems(): UserQuickPickItem[] { + const supported = this.candidates.filter((candidate) => candidate.supported); + const unsupported = this.candidates.filter((candidate) => !candidate.supported); + + const items: UserQuickPickItem[] = [ + { + label: l10n.t('Enter a username'), + detail: l10n.t('Type a username that is not in this list'), + iconPath: new vscode.ThemeIcon('edit'), + isCustomOption: true, + }, + ]; + + if (supported.length > 0) { + items.push({ label: l10n.t('Username and password (SCRAM)'), kind: vscode.QuickPickItemKind.Separator }); + items.push( + ...supported.map((candidate) => ({ + label: candidate.username, + iconPath: new vscode.ThemeIcon('account'), + candidate, + })), + ); + } + + if (unsupported.length > 0) { + items.push({ label: l10n.t('Not supported yet'), kind: vscode.QuickPickItemKind.Separator }); + items.push( + ...unsupported.map((candidate) => ({ + label: candidate.username, + description: candidate.authMethodLabel, + iconPath: new vscode.ThemeIcon('circle-slash'), + candidate, + })), + ); + } + + return items; + } + + private async explainUnsupported( + context: AuthenticateWizardContext, + candidate: AtlasDatabaseUserCandidate, + ): Promise { + try { + await context.ui.showWarningMessage( + l10n.t('Authentication method not supported'), + { + modal: true, + detail: + l10n.t('"{user}" signs in with {method}.', { + user: candidate.username, + method: candidate.authMethodLabel, + }) + + '\n' + + l10n.t('This extension can only connect with a username and a password.'), + }, + { title: l10n.t('Back to the list') }, + ); + } catch (error) { + // Dismissing a purely informational modal must not tear down the sign-in flow. Both + // answers mean the same thing here, so either one returns to the list; the quick pick + // itself remains the way to cancel. + if (!(error instanceof UserCancelledError)) { + throw error; + } + } + } + + /** True while the wizard still needs a username for native authentication. */ + private isNativeAuthPending(context: AuthenticateWizardContext): boolean { + if (context.selectedUserName !== undefined) { + return false; + } + + return context.availableAuthMethods ? context.selectedAuthMethod === AuthMethodId.NativeAuth : true; + } + + /** + * Runs the lookup with a status-bar progress message and a hard timeout, and turns every + * failure into an empty list. Losing the convenience is acceptable; blocking sign-in is not. + */ + private async loadUsersWithProgress(context: AuthenticateWizardContext): Promise { + try { + return await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Window, + title: l10n.t('Loading database users for "{cluster}"…', { cluster: this.clusterName }), + }, + async () => this.loadUsers(AbortSignal.timeout(USER_LOOKUP_TIMEOUT_MS)), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + context.telemetry.properties.atlasDatabaseUserSource = 'failed'; + context.telemetry.properties.atlasDatabaseUserLookupError = error instanceof Error ? error.name : 'unknown'; + atlasWarn(`cluster "${this.clusterName}": could not list database users (${message}); asking for one`); + return []; + } + } +} diff --git a/src/plugins/service-atlas-mongodb/connect/atlasDatabaseUsers.test.ts b/src/plugins/service-atlas-mongodb/connect/atlasDatabaseUsers.test.ts new file mode 100644 index 000000000..dc7078918 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/connect/atlasDatabaseUsers.test.ts @@ -0,0 +1,101 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type AtlasDatabaseUser } from '../models/AtlasProjectModel'; +import { describeAtlasUserAuthMethod, toAtlasDatabaseUserCandidates } from './atlasDatabaseUsers'; + +jest.mock('@vscode/l10n', () => ({ + t: jest.fn((message: string) => message), +})); + +function user(overrides: Partial & { username: string }): AtlasDatabaseUser { + return { + databaseName: 'admin', + x509Type: 'NONE', + awsIAMType: 'NONE', + ldapAuthType: 'NONE', + oidcAuthType: 'NONE', + ...overrides, + }; +} + +describe('describeAtlasUserAuthMethod', () => { + it('treats an admin-database user as a supported SCRAM user', () => { + expect(describeAtlasUserAuthMethod(user({ username: 'app_rw' }))).toEqual({ + supported: true, + authMethodLabel: 'Username and password', + }); + }); + + it.each([ + ['x509Type', 'CUSTOMER', 'X.509'], + ['awsIAMType', 'ROLE', 'AWS IAM'], + ['ldapAuthType', 'GROUP', 'LDAP'], + ['oidcAuthType', 'IDP_GROUP', 'OIDC'], + ])('names the %s method and marks it unsupported', (field, value, expectedLabel) => { + const result = describeAtlasUserAuthMethod( + user({ username: 'federated', databaseName: '$external', [field]: value }), + ); + + expect(result).toEqual({ supported: false, authMethodLabel: expectedLabel }); + }); + + it('falls back to a generic label for an external user with no known method flag', () => { + // Atlas can add authentication methods faster than this extension learns about them. + const result = describeAtlasUserAuthMethod(user({ username: 'future', databaseName: '$external' })); + + expect(result).toEqual({ supported: false, authMethodLabel: 'Federated' }); + }); +}); + +describe('toAtlasDatabaseUserCandidates', () => { + it('keeps users with no scopes, because they apply to every cluster in the project', () => { + const candidates = toAtlasDatabaseUserCandidates([user({ username: 'app_rw' })], 'Cluster0'); + + expect(candidates.map((candidate) => candidate.username)).toEqual(['app_rw']); + }); + + it('keeps a user scoped to this cluster and drops one scoped elsewhere', () => { + const users = [ + user({ username: 'here', scopes: [{ name: 'Cluster0', type: 'CLUSTER' }] }), + user({ username: 'elsewhere', scopes: [{ name: 'Cluster9', type: 'CLUSTER' }] }), + ]; + + expect(toAtlasDatabaseUserCandidates(users, 'Cluster0').map((c) => c.username)).toEqual(['here']); + }); + + it('ignores non-cluster scopes when deciding whether a user applies', () => { + const users = [user({ username: 'streamer', scopes: [{ name: 'SomeStream', type: 'STREAM' }] })]; + + expect(toAtlasDatabaseUserCandidates(users, 'Cluster0').map((c) => c.username)).toEqual(['streamer']); + }); + + it('keeps unsupported users so the list can explain why they cannot be used', () => { + const users = [ + user({ username: 'app_rw' }), + user({ username: 'CN=svc,OU=eng', databaseName: '$external', x509Type: 'CUSTOMER' }), + ]; + + expect(toAtlasDatabaseUserCandidates(users, 'Cluster0').map((c) => [c.username, c.supported])).toEqual([ + ['app_rw', true], + ['CN=svc,OU=eng', false], + ]); + }); + + it('sorts case-insensitively and drops entries without a username', () => { + const users = [ + user({ username: 'zeta' }), + user({ username: 'Alpha' }), + user({ username: '' }), + user({ username: 'beta' }), + ]; + + expect(toAtlasDatabaseUserCandidates(users, 'Cluster0').map((c) => c.username)).toEqual([ + 'Alpha', + 'beta', + 'zeta', + ]); + }); +}); diff --git a/src/plugins/service-atlas-mongodb/connect/atlasDatabaseUsers.ts b/src/plugins/service-atlas-mongodb/connect/atlasDatabaseUsers.ts new file mode 100644 index 000000000..29398c4f8 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/connect/atlasDatabaseUsers.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { type AtlasDatabaseUser } from '../models/AtlasProjectModel'; + +/** A database user offered as a ready-made answer to the username prompt. */ +export interface AtlasDatabaseUserCandidate { + readonly username: string; + /** + * Whether this extension can sign in as this user. Only SCRAM users qualify, because the + * connect flow has nothing but a username and a password to offer. + */ + readonly supported: boolean; + /** Display name of the authentication method, shown for users we cannot use. */ + readonly authMethodLabel: string; +} + +/** + * Names the authentication method behind a database user. + * + * `databaseName` is the primary discriminator: `admin` is SCRAM, anything else (in practice + * `$external`) is federated. Atlas then reports which federated method through four sibling + * fields, exactly one of which is set to something other than `NONE`. + */ +export function describeAtlasUserAuthMethod(user: AtlasDatabaseUser): { + supported: boolean; + authMethodLabel: string; +} { + if (user.databaseName === 'admin') { + return { supported: true, authMethodLabel: l10n.t('Username and password') }; + } + + const isSet = (value: string | undefined): boolean => + typeof value === 'string' && value.length > 0 && value !== 'NONE'; + + if (isSet(user.x509Type)) { + return { supported: false, authMethodLabel: 'X.509' }; + } + if (isSet(user.awsIAMType)) { + return { supported: false, authMethodLabel: 'AWS IAM' }; + } + if (isSet(user.ldapAuthType)) { + return { supported: false, authMethodLabel: 'LDAP' }; + } + if (isSet(user.oidcAuthType)) { + return { supported: false, authMethodLabel: 'OIDC' }; + } + + // A `$external` user with no method flag set. Atlas can add methods faster than this + // extension learns about them, so name it honestly rather than guessing or hiding it. + return { supported: false, authMethodLabel: l10n.t('Federated') }; +} + +/** + * Turns the project's database users into the candidates offered for one cluster. + * + * Database users are project-scoped. `scopes` is what narrows a user to particular clusters, and + * an empty or absent `scopes` array means the user applies to every cluster in the project, so + * only an explicitly scoped user that does not name this cluster is filtered out. + * + * Users we cannot sign in as are deliberately kept. Dropping them would answer "your username is + * not here" with silence, when the real answer is "it is here, and it uses a method this + * extension does not support yet". + */ +export function toAtlasDatabaseUserCandidates( + users: AtlasDatabaseUser[], + clusterName: string, +): AtlasDatabaseUserCandidate[] { + return users + .filter((user) => typeof user.username === 'string' && user.username.length > 0) + .filter((user) => appliesToCluster(user, clusterName)) + .map((user) => ({ username: user.username, ...describeAtlasUserAuthMethod(user) })) + .sort((a, b) => a.username.localeCompare(b.username, undefined, { numeric: true, sensitivity: 'base' })); +} + +function appliesToCluster(user: AtlasDatabaseUser, clusterName: string): boolean { + const clusterScopes = (user.scopes ?? []).filter((scope) => scope.type === 'CLUSTER'); + if (clusterScopes.length === 0) { + return true; + } + + return clusterScopes.some((scope) => scope.name === clusterName); +} diff --git a/src/plugins/service-atlas-mongodb/credentials/atlasCredentialStore.test.ts b/src/plugins/service-atlas-mongodb/credentials/atlasCredentialStore.test.ts new file mode 100644 index 000000000..4dd3604b3 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/credentials/atlasCredentialStore.test.ts @@ -0,0 +1,306 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const globalStateBacking = new Map(); +const secretStorageBacking = new Map(); + +jest.mock('vscode', () => ({ + ThemeIcon: class ThemeIcon { + constructor(public readonly id: string) {} + }, + l10n: { + t: jest.fn((message: string, ...args: string[]) => + args.reduce((m, value, index) => m.replace(`{${String(index)}}`, value), message), + ), + }, +})); + +jest.mock('../../../extensionVariables', () => ({ + ext: { + context: { + extension: { id: 'test-extension' }, + subscriptions: { push: (): void => {} }, + globalState: { + get: (key: string, defaultValue?: T): T | undefined => { + const value = globalStateBacking.has(key) ? (globalStateBacking.get(key) as T) : undefined; + return value === undefined ? defaultValue : value; + }, + update: async (key: string, value: unknown): Promise => { + if (value === undefined) { + globalStateBacking.delete(key); + } else { + globalStateBacking.set(key, value); + } + }, + keys: () => Array.from(globalStateBacking.keys()), + }, + }, + secretStorage: { + get: async (key: string): Promise => + secretStorageBacking.has(key) ? secretStorageBacking.get(key) : undefined, + store: async (key: string, value: string): Promise => { + secretStorageBacking.set(key, value); + }, + delete: async (key: string): Promise => { + secretStorageBacking.delete(key); + }, + onDidChange: (): { dispose: () => void } => ({ dispose: (): void => {} }), + }, + outputChannel: { trace: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), appendLine: jest.fn() }, + }, +})); + +import { StorageService } from '../../../services/storageService'; +import { + cacheServiceAccountToken, + getAtlasCredential, + readAtlasCredentials, + readAtlasCredentialSecrets, + removeAllAtlasCredentials, + removeAtlasCredential, + replaceAtlasCredentialSecrets, + resetAtlasCredentialStoreCache, + updateAtlasCredentialMetadata, + upsertAtlasCredential, +} from './atlasCredentialStore'; + +beforeEach(() => { + globalStateBacking.clear(); + secretStorageBacking.clear(); + StorageService._resetForTests(); + resetAtlasCredentialStoreCache(); +}); + +describe('atlasCredentialStore', () => { + it('stores an API Key credential with a stable id and non-secret identity hint', async () => { + const { record, created } = await upsertAtlasCredential({ + authMethod: 'apikey', + publicKey: 'abcdefgh1234', + privateKey: 'private-key-value', + }); + + expect(created).toBe(true); + expect(record.id).toEqual(expect.any(String)); + expect(record.authMethod).toBe('apikey'); + expect(record.identityHint).toBe('abcdefgh'); + expect(record.order).toBe(0); + + const secrets = await readAtlasCredentialSecrets(record.id); + expect(secrets).toEqual({ + authMethod: 'apikey', + publicKey: 'abcdefgh1234', + privateKey: 'private-key-value', + }); + }); + + it('keeps several credentials independent, in insertion order', async () => { + const first = await upsertAtlasCredential({ + authMethod: 'apikey', + publicKey: 'aaaaaaaa1', + privateKey: 'p1', + }); + const second = await upsertAtlasCredential({ + authMethod: 'serviceaccount', + clientId: 'bbbbbbbb-2222', + clientSecret: 's2', + }); + + const records = await readAtlasCredentials(); + expect(records.map((r) => r.id)).toEqual([first.record.id, second.record.id]); + expect(records[1].order).toBe(1); + + await expect(readAtlasCredentialSecrets(first.record.id)).resolves.toMatchObject({ publicKey: 'aaaaaaaa1' }); + await expect(readAtlasCredentialSecrets(second.record.id)).resolves.toMatchObject({ + clientId: 'bbbbbbbb-2222', + }); + }); + + it('keeps Service Accounts with the same identity hint independent', async () => { + const first = await upsertAtlasCredential({ + authMethod: 'serviceaccount', + clientId: 'mdb_sa_id_111111111111111111111111', + clientSecret: 'first-secret', + }); + const second = await upsertAtlasCredential({ + authMethod: 'serviceaccount', + clientId: 'mdb_sa_id_222222222222222222222222', + clientSecret: 'second-secret', + }); + + expect(second.created).toBe(true); + expect(second.record.id).not.toBe(first.record.id); + await expect(readAtlasCredentials()).resolves.toHaveLength(2); + await expect(readAtlasCredentialSecrets(first.record.id)).resolves.toMatchObject({ + clientId: 'mdb_sa_id_111111111111111111111111', + clientSecret: 'first-secret', + }); + }); + + it('reuses the record id when the same Atlas identity is re-entered', async () => { + const first = await upsertAtlasCredential({ + authMethod: 'apikey', + publicKey: 'abcdefgh1234', + privateKey: 'old-private', + }); + + const second = await upsertAtlasCredential({ + authMethod: 'apikey', + publicKey: 'abcdefgh1234', + privateKey: 'new-private', + }); + + expect(second.created).toBe(false); + expect(second.record.id).toBe(first.record.id); + await expect(readAtlasCredentialSecrets(first.record.id)).resolves.toMatchObject({ + privateKey: 'new-private', + }); + expect(await readAtlasCredentials()).toHaveLength(1); + }); + + it('replaces secrets in place without disturbing metadata or id', async () => { + const { record } = await upsertAtlasCredential( + { authMethod: 'serviceaccount', clientId: 'client-1', clientSecret: 'secret-1' }, + { label: 'Team key', orgId: 'org-1', orgName: 'Acme Corp' }, + ); + + const updated = await replaceAtlasCredentialSecrets(record.id, { + authMethod: 'serviceaccount', + clientId: 'client-1', + clientSecret: 'secret-2', + }); + + expect(updated?.id).toBe(record.id); + expect(updated?.label).toBe('Team key'); + expect(updated?.orgName).toBe('Acme Corp'); + await expect(readAtlasCredentialSecrets(record.id)).resolves.toMatchObject({ clientSecret: 'secret-2' }); + }); + + it('rejects changing the public identity during secret replacement', async () => { + const { record } = await upsertAtlasCredential({ + authMethod: 'apikey', + publicKey: 'public-key-1', + privateKey: 'private-key-1', + }); + + await expect( + replaceAtlasCredentialSecrets(record.id, { + authMethod: 'apikey', + publicKey: 'public-key-2', + privateKey: 'private-key-2', + }), + ).rejects.toThrow('Atlas credential identity cannot be changed'); + await expect(readAtlasCredentialSecrets(record.id)).resolves.toEqual({ + authMethod: 'apikey', + publicKey: 'public-key-1', + privateKey: 'private-key-1', + }); + }); + + it('updates metadata without dropping secrets', async () => { + const { record } = await upsertAtlasCredential({ + authMethod: 'apikey', + publicKey: 'pub-key-1', + privateKey: 'priv-key-1', + }); + + await updateAtlasCredentialMetadata(record.id, { orgId: 'org-9', orgName: 'Beta Ltd' }); + + const reloaded = await getAtlasCredential(record.id); + expect(reloaded?.orgName).toBe('Beta Ltd'); + await expect(readAtlasCredentialSecrets(record.id)).resolves.toMatchObject({ privateKey: 'priv-key-1' }); + }); + + it('preserves a rotated secret when a later metadata update lands (MEDIUM-4)', async () => { + // The race: a discovery pass reads the record for `cacheOrganizationMetadata`, a rotation + // replaces the secret, then the metadata write lands. `updateAtlasCredentialMetadata` no + // longer reads or writes the secret, so the rotated value survives. + const { record } = await upsertAtlasCredential({ + authMethod: 'serviceaccount', + clientId: 'client-1', + clientSecret: 'secret-old', + }); + + await replaceAtlasCredentialSecrets(record.id, { + authMethod: 'serviceaccount', + clientId: 'client-1', + clientSecret: 'secret-new', + }); + await updateAtlasCredentialMetadata(record.id, { orgName: 'Acme Corp' }); + + await expect(readAtlasCredentialSecrets(record.id)).resolves.toMatchObject({ clientSecret: 'secret-new' }); + const reloaded = await getAtlasCredential(record.id); + expect(reloaded?.orgName).toBe('Acme Corp'); + }); + + it('caches a Service Account token against one credential only', async () => { + const target = await upsertAtlasCredential({ + authMethod: 'serviceaccount', + clientId: 'client-a', + clientSecret: 'secret-a', + }); + const peer = await upsertAtlasCredential({ + authMethod: 'serviceaccount', + clientId: 'client-b', + clientSecret: 'secret-b', + }); + + await cacheServiceAccountToken(target.record.id, 'token-a', 1_800_000); + + await expect(readAtlasCredentialSecrets(target.record.id)).resolves.toMatchObject({ + accessToken: 'token-a', + expiresAt: '1800000', + }); + await expect(readAtlasCredentialSecrets(peer.record.id)).resolves.toMatchObject({ + accessToken: undefined, + }); + }); + + it('removes one credential without touching its peers', async () => { + const first = await upsertAtlasCredential({ + authMethod: 'apikey', + publicKey: 'pub-1', + privateKey: 'priv-1', + }); + const second = await upsertAtlasCredential({ + authMethod: 'apikey', + publicKey: 'pub-2', + privateKey: 'priv-2', + }); + + await removeAtlasCredential(first.record.id); + + expect((await readAtlasCredentials()).map((r) => r.id)).toEqual([second.record.id]); + await expect(readAtlasCredentialSecrets(first.record.id)).resolves.toBeUndefined(); + await expect(readAtlasCredentialSecrets(second.record.id)).resolves.toMatchObject({ privateKey: 'priv-2' }); + }); + + it('removes every credential for sign out of all', async () => { + await upsertAtlasCredential({ authMethod: 'apikey', publicKey: 'pub-1', privateKey: 'priv-1' }); + await upsertAtlasCredential({ authMethod: 'apikey', publicKey: 'pub-2', privateKey: 'priv-2' }); + + await expect(removeAllAtlasCredentials()).resolves.toBe(2); + await expect(readAtlasCredentials()).resolves.toEqual([]); + }); + + it('survives a reload by reading records back from storage', async () => { + const { record } = await upsertAtlasCredential( + { authMethod: 'apikey', publicKey: 'restored-key', privateKey: 'restored-secret' }, + { label: 'Restored' }, + ); + + // Simulate an extension reload: fresh storage instances, empty in-memory cache. + StorageService._resetForTests(); + resetAtlasCredentialStoreCache(); + + const records = await readAtlasCredentials(); + expect(records).toHaveLength(1); + expect(records[0].id).toBe(record.id); + expect(records[0].label).toBe('Restored'); + await expect(readAtlasCredentialSecrets(record.id)).resolves.toMatchObject({ + publicKey: 'restored-key', + privateKey: 'restored-secret', + }); + }); +}); diff --git a/src/plugins/service-atlas-mongodb/credentials/atlasCredentialStore.ts b/src/plugins/service-atlas-mongodb/credentials/atlasCredentialStore.ts new file mode 100644 index 000000000..116b8bd3e --- /dev/null +++ b/src/plugins/service-atlas-mongodb/credentials/atlasCredentialStore.ts @@ -0,0 +1,477 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Persistent store for MongoDB Atlas discovery credentials. + * + * Each credential is one {@link StorageItem} under + * `StorageService.get('atlas-mongodb-discovery')` in the `credentials` workspace, mirroring the + * Kubernetes `sourceStore` shape. Non-secret metadata lives in `properties`; the secret material + * (API key pair, or Service Account client id/secret plus its cached access token) lives in the + * item's `secrets` array, which the storage service backs with VS Code SecretStorage. + * + * Design points that the rest of the Atlas plugin depends on: + * + * - **Stable record ID.** A `randomUUID()` generated once per credential. It is never derived + * from the secret, so rotating a key keeps the same record ID, the same tree paths, and the + * same saved connections. + * - **Independent secret slots.** Every credential owns its own storage item, so restoring or + * removing one credential can never overwrite another one's secrets. + * - **Non-secret identity hint.** A short prefix of the API public key / Service Account client + * ID is persisted in `properties` so credential labels can be rendered without reading + * SecretStorage. + */ + +import { randomUUID } from 'crypto'; +import { StorageService, type StorageItem } from '../../../services/storageService'; +import { type AtlasAuthMethod } from '../auth/AtlasSession'; + +/** StorageService lookup name for Atlas discovery data. */ +export const ATLAS_STORAGE_NAME = 'atlas-mongodb-discovery'; + +/** Workspace holding one item per credential. */ +export const ATLAS_CREDENTIALS_WORKSPACE = 'credentials'; + +/** Schema version stamped on every stored credential item. */ +const CREDENTIAL_ITEM_VERSION = '1'; + +/** Number of leading characters kept as a non-secret identity hint. */ +const IDENTITY_HINT_LENGTH = 8; + +/** + * Non-secret metadata persisted next to every credential. + */ +interface AtlasCredentialItemProperties extends Record { + readonly authMethod: AtlasAuthMethod; + /** User-supplied friendly name. Wins over every other label source when present. */ + readonly label?: string; + /** Organization id cached from the first successful `listOrganizations()`. */ + readonly orgId?: string; + /** Organization name cached from the first successful `listOrganizations()`. */ + readonly orgName?: string; + /** Short, non-secret prefix of the public key / client id, used for label fallbacks. */ + readonly identityHint?: string; + /** Stable display order. */ + readonly order: number; + readonly version: typeof CREDENTIAL_ITEM_VERSION; +} + +/** + * A credential as the rest of the plugin sees it: identity and metadata only, never secrets. + */ +export interface AtlasCredentialRecord { + readonly id: string; + readonly authMethod: AtlasAuthMethod; + readonly label?: string; + readonly orgId?: string; + readonly orgName?: string; + readonly identityHint?: string; + readonly order: number; +} + +/** Secret material for an API Key credential. */ +export interface AtlasApiKeySecrets { + readonly authMethod: 'apikey'; + readonly publicKey: string; + readonly privateKey: string; +} + +/** Secret material for a Service Account credential, including its cached access token. */ +export interface AtlasServiceAccountSecrets { + readonly authMethod: 'serviceaccount'; + readonly clientId: string; + readonly clientSecret: string; + readonly accessToken?: string; + /** Epoch milliseconds, as a string, matching the storage representation. */ + readonly expiresAt?: string; +} + +export type AtlasCredentialSecrets = AtlasApiKeySecrets | AtlasServiceAccountSecrets; + +/** Metadata that may be updated without touching the secret material. */ +export interface AtlasCredentialMetadataUpdate { + readonly label?: string; + readonly orgId?: string; + readonly orgName?: string; +} + +// --------------------------------------------------------------------------- +// In-memory cache +// --------------------------------------------------------------------------- + +let cache: AtlasCredentialRecord[] | undefined; +let inflightLoad: Promise | undefined; + +function invalidateCache(): void { + cache = undefined; + inflightLoad = undefined; +} + +/** + * Drops the in-memory cache. Production code never needs this; the store invalidates itself on + * every write. Tests use it to start from a clean slate. + * + * @internal + */ +export function resetAtlasCredentialStoreCache(): void { + invalidateCache(); +} + +async function loadFromStorage(): Promise { + const items = + await StorageService.get(ATLAS_STORAGE_NAME).getItems( + ATLAS_CREDENTIALS_WORKSPACE, + ); + + return items + .filter(isValidCredentialItem) + .sort((a, b) => orderOf(a) - orderOf(b)) + .map(toRecord); +} + +async function ensureCache(): Promise { + if (cache) { + return cache; + } + if (!inflightLoad) { + inflightLoad = loadFromStorage(); + } + // Capture the in-flight promise locally so an `invalidateCache()` racing this load cannot + // trick us into committing a now-stale snapshot back into `cache`. + const currentLoad = inflightLoad; + const loaded = await currentLoad; + if (inflightLoad === currentLoad) { + cache = loaded; + inflightLoad = undefined; + } + return loaded; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Reads every stored credential in stable display order. + */ +export async function readAtlasCredentials(): Promise { + return [...(await ensureCache())]; +} + +/** + * Reads a single credential by its stable record ID. + */ +export async function getAtlasCredential(id: string): Promise { + return (await ensureCache()).find((record) => record.id === id); +} + +/** + * Reads the secret material for a credential, or `undefined` when the record is missing or its + * secrets have been cleared. + */ +export async function readAtlasCredentialSecrets(id: string): Promise { + const item = await StorageService.get(ATLAS_STORAGE_NAME).getItem( + ATLAS_CREDENTIALS_WORKSPACE, + id, + ); + + if (!item?.properties) { + return undefined; + } + + const secrets = item.secrets ?? []; + + if (item.properties.authMethod === 'apikey') { + const [publicKey, privateKey] = secrets; + if (!publicKey || !privateKey) { + return undefined; + } + return { authMethod: 'apikey', publicKey, privateKey }; + } + + const [clientId, clientSecret, accessToken, expiresAt] = secrets; + if (!clientId || !clientSecret) { + return undefined; + } + return { + authMethod: 'serviceaccount', + clientId, + clientSecret, + accessToken: accessToken && accessToken.length > 0 ? accessToken : undefined, + expiresAt: expiresAt && expiresAt.length > 0 ? expiresAt : undefined, + }; +} + +/** + * Result of {@link upsertAtlasCredential}: the persisted record plus whether the call created a + * brand-new credential or replaced the secret of an existing one. + */ +export interface UpsertAtlasCredentialResult { + readonly record: AtlasCredentialRecord; + /** `true` when a new credential record was created, `false` when an existing one was updated. */ + readonly created: boolean; +} + +/** + * Adds a credential, or replaces the secret material of the credential that already carries the + * same Atlas identity (API public key, or Service Account client id). + * + * Matching on the Atlas identity - rather than always creating a new record - keeps the store + * free of accidental duplicates when a user re-enters the same key to fix an Atlas-side access + * problem, and it keeps the record ID (and therefore tree paths and saved connections) stable + * across a secret rotation. + */ +export async function upsertAtlasCredential( + secrets: AtlasCredentialSecrets, + metadata: AtlasCredentialMetadataUpdate = {}, +): Promise { + const records = await ensureCache(); + const existing = await findCredentialByIdentity(records, secrets); + + if (existing) { + const updated: AtlasCredentialRecord = { + ...existing, + label: metadata.label ?? existing.label, + orgId: metadata.orgId ?? existing.orgId, + orgName: metadata.orgName ?? existing.orgName, + }; + await pushItem(updated, secrets); + invalidateCache(); + return { record: updated, created: false }; + } + + const record: AtlasCredentialRecord = { + id: randomUUID(), + authMethod: secrets.authMethod, + label: metadata.label, + orgId: metadata.orgId, + orgName: metadata.orgName, + identityHint: identityHint(identityOf(secrets)), + order: nextOrder(records), + }; + + await pushItem(record, secrets); + invalidateCache(); + return { record, created: true }; +} + +/** + * Replaces the secret material of an existing credential in place, keeping its ID, order, and + * metadata. Used by the "update credentials" flow, which only calls this once the replacement + * secret has been validated, so a failed update never destroys a working credential. + * + * Returns `undefined` when the credential no longer exists. + */ +export async function replaceAtlasCredentialSecrets( + id: string, + secrets: AtlasCredentialSecrets, + metadata: AtlasCredentialMetadataUpdate = {}, +): Promise { + const records = await ensureCache(); + const existing = records.find((record) => record.id === id); + if (!existing) { + return undefined; + } + + const currentSecrets = await readAtlasCredentialSecrets(id); + if ( + !currentSecrets || + currentSecrets.authMethod !== secrets.authMethod || + identityOf(currentSecrets) !== identityOf(secrets) + ) { + throw new Error('Atlas credential identity cannot be changed'); + } + + const updated: AtlasCredentialRecord = { + ...existing, + label: metadata.label ?? existing.label, + orgId: metadata.orgId ?? existing.orgId, + orgName: metadata.orgName ?? existing.orgName, + }; + + await pushItem(updated, secrets); + invalidateCache(); + return updated; +} + +/** + * Updates non-secret metadata (user label, cached organization) without touching the secrets. + */ +export async function updateAtlasCredentialMetadata( + id: string, + metadata: AtlasCredentialMetadataUpdate, +): Promise { + const records = await ensureCache(); + const existing = records.find((record) => record.id === id); + if (!existing) { + return undefined; + } + + const updated: AtlasCredentialRecord = { + ...existing, + label: metadata.label ?? existing.label, + orgId: metadata.orgId ?? existing.orgId, + orgName: metadata.orgName ?? existing.orgName, + }; + + // Deliberately no secret read here. `StorageService.push()` only writes SecretStorage when + // `item.secrets` is a non-empty array, so passing `undefined` leaves the stored secret exactly + // as it is. Reading the secret and writing it back was a real hazard: this runs on every + // discovery pass (via `cacheOrganizationMetadata`), and a credential rotation completing between + // the read and the push would have been silently overwritten with the old secret. + await pushItem(updated, undefined); + invalidateCache(); + return updated; +} + +/** + * Caches the Service Account access token and its expiry alongside the credential so a reload + * does not force an immediate re-mint. Only touches the credential identified by `id`. + */ +export async function cacheServiceAccountToken(id: string, accessToken: string, expiresAtMs: number): Promise { + const secrets = await readAtlasCredentialSecrets(id); + if (!secrets || secrets.authMethod !== 'serviceaccount') { + return; + } + + const records = await ensureCache(); + const record = records.find((candidate) => candidate.id === id); + if (!record) { + return; + } + + await pushItem(record, { + ...secrets, + accessToken, + expiresAt: String(expiresAtMs), + }); + invalidateCache(); +} + +/** + * Deletes one credential and its secrets. Other credentials are untouched. + */ +export async function removeAtlasCredential(id: string): Promise { + const records = await ensureCache(); + const target = records.find((record) => record.id === id); + if (!target) { + return undefined; + } + + await StorageService.get(ATLAS_STORAGE_NAME).delete(ATLAS_CREDENTIALS_WORKSPACE, id); + invalidateCache(); + return target; +} + +/** + * Deletes every credential. Backs the "sign out of all" action. + */ +export async function removeAllAtlasCredentials(): Promise { + const records = await ensureCache(); + for (const record of records) { + await StorageService.get(ATLAS_STORAGE_NAME).delete(ATLAS_CREDENTIALS_WORKSPACE, record.id); + } + invalidateCache(); + return records.length; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function identityOf(secrets: AtlasCredentialSecrets): string { + return secrets.authMethod === 'apikey' ? secrets.publicKey : secrets.clientId; +} + +function identityHint(identity: string): string { + return identity.slice(0, IDENTITY_HINT_LENGTH); +} + +async function findCredentialByIdentity( + records: readonly AtlasCredentialRecord[], + secrets: AtlasCredentialSecrets, +): Promise { + const identity = identityOf(secrets); + for (const record of records) { + if (record.authMethod !== secrets.authMethod) { + continue; + } + + const storedSecrets = await readAtlasCredentialSecrets(record.id); + if (storedSecrets?.authMethod === secrets.authMethod && identityOf(storedSecrets) === identity) { + return record; + } + } + return undefined; +} + +function secretsToArray(secrets: AtlasCredentialSecrets | undefined): string[] | undefined { + if (!secrets) { + return undefined; + } + if (secrets.authMethod === 'apikey') { + return [secrets.publicKey, secrets.privateKey]; + } + return [secrets.clientId, secrets.clientSecret, secrets.accessToken ?? '', secrets.expiresAt ?? '']; +} + +async function pushItem(record: AtlasCredentialRecord, secrets: AtlasCredentialSecrets | undefined): Promise { + const item: StorageItem = { + id: record.id, + name: record.label ?? record.orgName ?? record.identityHint ?? record.id, + version: CREDENTIAL_ITEM_VERSION, + properties: { + authMethod: record.authMethod, + label: record.label, + orgId: record.orgId, + orgName: record.orgName, + identityHint: record.identityHint, + order: record.order, + version: CREDENTIAL_ITEM_VERSION, + }, + secrets: secretsToArray(secrets), + }; + + await StorageService.get(ATLAS_STORAGE_NAME).push(ATLAS_CREDENTIALS_WORKSPACE, item, /* overwrite */ true); +} + +function nextOrder(records: readonly AtlasCredentialRecord[]): number { + if (records.length === 0) { + return 0; + } + let highest = -1; + for (const record of records) { + if (Number.isFinite(record.order) && record.order > highest) { + highest = record.order; + } + } + return highest + 1; +} + +function orderOf(item: StorageItem): number { + const order = item.properties?.order; + return typeof order === 'number' && Number.isFinite(order) ? order : Number.MAX_SAFE_INTEGER; +} + +function isValidCredentialItem(item: StorageItem): boolean { + if (typeof item.id !== 'string' || item.id.length === 0) { + return false; + } + const authMethod = item.properties?.authMethod; + return authMethod === 'apikey' || authMethod === 'serviceaccount'; +} + +function toRecord(item: StorageItem): AtlasCredentialRecord { + const properties = item.properties!; + return { + id: item.id, + authMethod: properties.authMethod, + label: properties.label, + orgId: properties.orgId, + orgName: properties.orgName, + identityHint: properties.identityHint, + order: orderOf(item), + }; +} diff --git a/src/plugins/service-atlas-mongodb/credentialsManagement/AtlasCredentialActionStep.ts b/src/plugins/service-atlas-mongodb/credentialsManagement/AtlasCredentialActionStep.ts new file mode 100644 index 000000000..b1a2618e4 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/credentialsManagement/AtlasCredentialActionStep.ts @@ -0,0 +1,249 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep, GoBackError, UserCancelledError } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { openAtlasCredentialsWebview } from '../../../webviews/documentdb/atlasCredentials/atlasCredentialsController'; +import { buildAtlasAccessUrl } from '../atlasDeepLinks'; +import { readAtlasCredentialSecrets, removeAtlasCredential } from '../credentials/atlasCredentialStore'; +import { type AtlasCredentialsManagementWizardContext } from './AtlasCredentialsManagementWizardContext'; +import { ATLAS_CREDENTIAL_MANAGEMENT_EXIT } from './SelectAtlasCredentialStep'; + +interface CredentialActionQuickPickItem extends vscode.QuickPickItem { + action?: 'retry' | 'openInAtlas' | 'update' | 'signOut' | 'back' | 'exit'; +} + +/** + * Second step of the credential-management wizard: the actions available for one credential. + * + * Mirrors the Azure `TenantActionStep`: every path either navigates (`GoBackError`) or exits + * (`UserCancelledError`), so the QuickPick chain keeps working back and forth without the caller + * having to re-enter the flow. + */ +export class AtlasCredentialActionStep extends AzureWizardPromptStep { + public async prompt(context: AtlasCredentialsManagementWizardContext): Promise { + const credentialId = context.selectedCredentialId!; + const status = context.credentials.find((candidate) => candidate.record.id === credentialId); + const label = status?.label ?? credentialId; + + const actions: CredentialActionQuickPickItem[] = [ + { + label: l10n.t('Retry'), + detail: l10n.t('Re-attempt this credential only, leaving the others untouched.'), + iconPath: new vscode.ThemeIcon('refresh'), + action: 'retry', + }, + { + // The two failures the extension can name but not fix, a denied IP access list and + // a role that is too narrow to see anything, are both only resolvable in the Atlas + // console, several clicks deep behind an organization picker. + label: l10n.t('Open in MongoDB Atlas'), + detail: + status?.record.authMethod === 'serviceaccount' + ? l10n.t('Review this Service Account\u2019s roles and IP access list in your browser.') + : l10n.t('Review the organization\u2019s API keys and their IP access lists in your browser.'), + iconPath: new vscode.ThemeIcon('link-external'), + action: 'openInAtlas', + }, + { + label: l10n.t('Update credentials…'), + detail: l10n.t('Enter a new secret. The stored one is replaced only after the new one validates.'), + iconPath: new vscode.ThemeIcon('key'), + action: 'update', + }, + { + // Deliberately "Sign out" rather than "Remove", to read as the single-credential + // form of the fleet-level "Sign out of all". Both delete the stored secret; using + // two different verbs for the same operation made them look like different things. + label: l10n.t('Sign out'), + detail: l10n.t('Sign out of this credential only. The others stay signed in.'), + iconPath: new vscode.ThemeIcon('sign-out'), + action: 'signOut', + }, + { label: '', kind: vscode.QuickPickItemKind.Separator }, + { + label: l10n.t('Back'), + iconPath: new vscode.ThemeIcon('arrow-left'), + action: 'back', + }, + { + label: l10n.t('Exit'), + iconPath: new vscode.ThemeIcon('close'), + action: 'exit', + }, + ]; + + const selected = await context.ui.showQuickPick(actions, { + stepName: 'atlasCredentialAction', + placeHolder: status?.error + ? l10n.t('{0} needs attention: {1}', label, status.error.message) + : l10n.t('{0} is signed in', label), + suppressPersistence: true, + }); + + switch (selected.action) { + case 'retry': + await this.retry(context, credentialId, label); + return; + case 'openInAtlas': + await this.openInAtlas(context, credentialId); + return; + case 'update': + await this.update(context, credentialId, label); + return; + case 'signOut': + await this.signOut(context, credentialId, label); + return; + case 'back': + context.telemetry.properties.atlasCredentialAction = 'back'; + context.selectedCredentialId = undefined; + throw new GoBackError(); + default: + context.telemetry.properties.atlasCredentialAction = 'exit'; + throw new UserCancelledError(ATLAS_CREDENTIAL_MANAGEMENT_EXIT); + } + } + + public shouldPrompt(context: AtlasCredentialsManagementWizardContext): boolean { + return !!context.selectedCredentialId; + } + + private async retry( + context: AtlasCredentialsManagementWizardContext, + credentialId: string, + label: string, + ): Promise { + context.telemetry.properties.atlasCredentialAction = 'retry'; + + const snapshot = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: l10n.t('Retrying {0}…', label) }, + () => context.discoveryService.retryCredential(credentialId), + ); + + const stillFailing = snapshot.credentialErrors.some((error) => error.credentialId === credentialId); + context.telemetry.properties.atlasCredentialRetryResult = stillFailing ? 'failed' : 'succeeded'; + context.changed = true; + + if (!stillFailing) { + void vscode.window.showInformationMessage(l10n.t('{0} is signed in again.', label)); + } + + // Reload the list so the row reflects the new status, then return to it. + context.credentials = []; + context.selectedCredentialId = undefined; + throw new GoBackError(); + } + + /** + * Opens this credential's access settings in the Atlas web console. + * + * Returns to the credential list rather than exiting: the user goes to Atlas, changes a role + * or allows their IP, comes back, and the next thing they need is "Retry". Nothing is marked + * as changed, because nothing in local storage was touched. + */ + private async openInAtlas(context: AtlasCredentialsManagementWizardContext, credentialId: string): Promise { + context.telemetry.properties.atlasCredentialAction = 'openInAtlas'; + + const record = context.credentials.find((candidate) => candidate.record.id === credentialId)?.record; + + // The Service Account client id is the only part of the deep link that lives in secret + // storage. It is an identifier rather than a secret, but a read failure must not block the + // navigation, so it degrades to the organization's Service Account list. + let clientId: string | undefined; + if (record?.authMethod === 'serviceaccount') { + const secrets = await readAtlasCredentialSecrets(credentialId); + clientId = secrets?.authMethod === 'serviceaccount' ? secrets.clientId : undefined; + } + + const url = record ? buildAtlasAccessUrl(record, clientId) : 'https://cloud.mongodb.com'; + context.telemetry.properties.atlasDeepLinkTarget = record?.orgId + ? record.authMethod === 'serviceaccount' + ? clientId + ? 'serviceAccount' + : 'serviceAccounts' + : 'apiKeys' + : 'root'; + + await vscode.env.openExternal(vscode.Uri.parse(url)); + + context.selectedCredentialId = undefined; + throw new GoBackError(); + } + + private async update( + context: AtlasCredentialsManagementWizardContext, + credentialId: string, + label: string, + ): Promise { + context.telemetry.properties.atlasCredentialAction = 'update'; + + const secrets = await readAtlasCredentialSecrets(credentialId); + if (!secrets) { + void vscode.window.showErrorMessage( + l10n.t('The stored credential could not be read. Sign out and add it again.'), + ); + context.credentials = []; + context.selectedCredentialId = undefined; + throw new GoBackError(); + } + + const credentialIdentity = secrets.authMethod === 'apikey' ? secrets.publicKey : secrets.clientId; + const stored = await openAtlasCredentialsWebview({ + authMethod: secrets.authMethod, + credentialId, + credentialLabel: label, + credentialIdentity, + }); + + context.telemetry.properties.atlasCredentialUpdateResult = stored ? 'succeeded' : 'cancelled'; + + if (stored) { + context.changed = true; + context.discoveryService.sessionRegistry.invalidate(credentialId); + context.discoveryService.invalidate(); + } + + // Whether the update succeeded or the user closed the panel, the previous credential is + // still intact, so the natural landing place is the refreshed credential list. + context.credentials = []; + context.selectedCredentialId = undefined; + throw new GoBackError(); + } + + private async signOut( + context: AtlasCredentialsManagementWizardContext, + credentialId: string, + label: string, + ): Promise { + context.telemetry.properties.atlasCredentialAction = 'signOut'; + + // Short, constant title with the credential name in the detail, matching + // `removeConnection` and `deleteFolder`. A title that changes with the subject reads as a + // different dialog every time and pushes the part the user must actually read out of view. + await context.ui.showWarningMessage( + l10n.t('Are you sure?'), + { + modal: true, + detail: + l10n.t('Sign out of the MongoDB Atlas credential "{0}"?', label) + + '\n' + + l10n.t('Only this credential and its secrets are removed. Other credentials stay signed in.'), + }, + { title: l10n.t('Sign out') }, + ); + + await removeAtlasCredential(credentialId); + context.discoveryService.sessionRegistry.invalidate(credentialId); + context.discoveryService.invalidate(); + context.changed = true; + + void vscode.window.showInformationMessage(l10n.t('Signed out of the MongoDB Atlas credential "{0}".', label)); + + context.credentials = []; + context.selectedCredentialId = undefined; + throw new GoBackError(); + } +} diff --git a/src/plugins/service-atlas-mongodb/credentialsManagement/AtlasCredentialsManagement.test.ts b/src/plugins/service-atlas-mongodb/credentialsManagement/AtlasCredentialsManagement.test.ts new file mode 100644 index 000000000..d2edbd545 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/credentialsManagement/AtlasCredentialsManagement.test.ts @@ -0,0 +1,462 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const globalStateBacking = new Map(); +const secretStorageBacking = new Map(); + +jest.mock('vscode', () => ({ + ThemeIcon: class ThemeIcon { + constructor(public readonly id: string) {} + }, + QuickPickItemKind: { Separator: -1, Default: 0 }, + ProgressLocation: { Notification: 15 }, + Uri: { parse: jest.fn((value: string) => ({ toString: () => value })) }, + env: { openExternal: jest.fn().mockResolvedValue(true) }, + window: { + showInformationMessage: jest.fn(), + withProgress: jest.fn(async (_options: unknown, task: () => Promise) => task()), + }, + l10n: { + t: jest.fn((message: string, ...args: string[]) => + args.reduce((m, value, index) => m.replace(`{${String(index)}}`, value), message), + ), + }, +})); + +jest.mock('@vscode/l10n', () => ({ + t: jest.fn((message: string, ...args: string[]) => + args.reduce((m, value, index) => m.replace(`{${String(index)}}`, value), message), + ), +})); + +class UserCancelledErrorMock extends Error {} +class GoBackErrorMock extends Error {} + +jest.mock('@microsoft/vscode-azext-utils', () => ({ + AzureWizardPromptStep: class AzureWizardPromptStep {}, + UserCancelledError: UserCancelledErrorMock, + GoBackError: GoBackErrorMock, +})); + +jest.mock('../../../extensionVariables', () => ({ + ext: { + context: { + extension: { id: 'test-extension' }, + subscriptions: { push: (): void => {} }, + globalState: { + get: (key: string, defaultValue?: T): T | undefined => { + const value = globalStateBacking.has(key) ? (globalStateBacking.get(key) as T) : undefined; + return value === undefined ? defaultValue : value; + }, + update: async (key: string, value: unknown): Promise => { + if (value === undefined) { + globalStateBacking.delete(key); + } else { + globalStateBacking.set(key, value); + } + }, + keys: () => Array.from(globalStateBacking.keys()), + }, + }, + discoveryBranchDataProvider: { refresh: jest.fn(), resetNodeErrorState: jest.fn() }, + secretStorage: { + get: async (key: string): Promise => + secretStorageBacking.has(key) ? secretStorageBacking.get(key) : undefined, + store: async (key: string, value: string): Promise => { + secretStorageBacking.set(key, value); + }, + delete: async (key: string): Promise => { + secretStorageBacking.delete(key); + }, + onDidChange: (): { dispose: () => void } => ({ dispose: (): void => {} }), + }, + outputChannel: { trace: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), appendLine: jest.fn() }, + }, +})); + +const mockOpenWebview = jest.fn(); +jest.mock('../../../webviews/documentdb/atlasCredentials/atlasCredentialsController', () => ({ + openAtlasCredentialsWebview: (...args: unknown[]) => mockOpenWebview(...args) as unknown, +})); + +import * as vscode from 'vscode'; +import { ext } from '../../../extensionVariables'; +import { StorageService } from '../../../services/storageService'; +import { type TreeElement } from '../../../tree/TreeElement'; +import { buildAtlasAccessUrl } from '../atlasDeepLinks'; +import { + readAtlasCredentials, + resetAtlasCredentialStoreCache, + upsertAtlasCredential, + type AtlasCredentialRecord, +} from '../credentials/atlasCredentialStore'; +import { type AtlasDiscoveryService, type AtlasDiscoverySnapshot } from '../discovery/AtlasDiscoveryService'; +import { addAtlasCredential } from './addAtlasCredential'; +import { AtlasCredentialActionStep } from './AtlasCredentialActionStep'; +import { type AtlasCredentialsManagementWizardContext } from './AtlasCredentialsManagementWizardContext'; +import { SelectAtlasCredentialStep } from './SelectAtlasCredentialStep'; + +interface QuickPickLike { + label?: string; + [key: string]: unknown; +} + +function emptySnapshot(overrides: Partial = {}): AtlasDiscoverySnapshot { + return { + organizations: [], + projects: [], + clusters: [], + credentialErrors: [], + projectErrors: [], + credentialsQueried: 0, + clustersIncluded: false, + ...overrides, + }; +} + +function buildContext( + pick: (items: QuickPickLike[]) => QuickPickLike, + snapshot: AtlasDiscoverySnapshot = emptySnapshot(), +): AtlasCredentialsManagementWizardContext & { + discoveryService: jest.Mocked< + Pick + >; +} { + const sessionRegistry = { invalidate: jest.fn(), invalidateAll: jest.fn() }; + const discoveryService = { + listAll: jest.fn().mockResolvedValue(snapshot), + refreshAll: jest.fn().mockResolvedValue(snapshot), + invalidate: jest.fn(), + reset: jest.fn(), + retryCredential: jest.fn().mockResolvedValue(snapshot), + sessionRegistry, + }; + + return { + telemetry: { properties: {}, measurements: {} }, + errorHandling: { issueProperties: {} }, + valuesToMask: [], + ui: { + showQuickPick: jest.fn(async (items: QuickPickLike[] | Promise) => pick(await items)), + showWarningMessage: jest.fn().mockResolvedValue({ title: 'ok' }), + }, + discoveryService, + credentials: [], + selectedCredentialId: undefined, + changed: false, + } as unknown as AtlasCredentialsManagementWizardContext & { + discoveryService: jest.Mocked< + Pick + >; + }; +} + +beforeEach(() => { + globalStateBacking.clear(); + secretStorageBacking.clear(); + StorageService._resetForTests(); + resetAtlasCredentialStoreCache(); + mockOpenWebview.mockReset(); + (vscode.window.showInformationMessage as jest.Mock).mockReset(); + (vscode.env.openExternal as jest.Mock).mockClear(); +}); + +describe('addAtlasCredential', () => { + it('does not open credential management after the add webview is closed', async () => { + mockOpenWebview.mockResolvedValue(false); + const context = buildContext(() => { + throw new Error('The direct add path must not show a QuickPick.'); + }); + const node = { id: 'discoveryView/atlas-mongodb' } as TreeElement; + + const stored = await addAtlasCredential(context, context.discoveryService as AtlasDiscoveryService, node); + + expect(stored).toBe(false); + expect(mockOpenWebview).toHaveBeenCalledTimes(1); + expect(context.ui.showQuickPick).not.toHaveBeenCalled(); + expect(context.discoveryService.reset).toHaveBeenCalledTimes(1); + expect(ext.discoveryBranchDataProvider.resetNodeErrorState).toHaveBeenCalledWith(node.id); + expect(ext.discoveryBranchDataProvider.refresh).toHaveBeenCalledWith(node); + }); +}); + +describe('SelectAtlasCredentialStep', () => { + it('offers only add and exit when nothing is stored', async () => { + let seen: QuickPickLike[] = []; + const context = buildContext((items) => { + seen = items; + return items.find((item) => item.isExitOption)!; + }); + + await expect(new SelectAtlasCredentialStep().prompt(context)).rejects.toBeInstanceOf(UserCancelledErrorMock); + expect(seen.some((item) => item.isAddOption)).toBe(true); + expect(seen.some((item) => item.isSignOutAllOption)).toBe(false); + expect(seen.some((item) => item.isRetryAllOption)).toBe(false); + }); + + it('lists stored credentials with their failure reason', async () => { + const { record } = await upsertAtlasCredential( + { authMethod: 'serviceaccount', clientId: 'client-1', clientSecret: 'secret-1' }, + { orgName: 'Beta Ltd' }, + ); + + let seen: QuickPickLike[] = []; + const context = buildContext( + (items) => { + seen = items; + return items.find((item) => item.isExitOption)!; + }, + emptySnapshot({ + credentialErrors: [ + { + credentialId: record.id, + label: 'Beta Ltd', + kind: 'auth', + message: 'Secret expired', + retryable: true, + }, + ], + }), + ); + + await expect(new SelectAtlasCredentialStep().prompt(context)).rejects.toBeInstanceOf(UserCancelledErrorMock); + + const row = seen.find((item) => item.credentialId === record.id); + expect(row?.label).toBe('Beta Ltd'); + expect(row?.description).toBe('Service Account'); + expect(String(row?.detail)).toContain('Secret expired'); + expect(seen.some((item) => item.isSignOutAllOption)).toBe(true); + }); + + it('lists the fleet actions in order: add, retry all, sign out of all, exit', async () => { + await upsertAtlasCredential({ authMethod: 'apikey', publicKey: 'pub-1', privateKey: 'priv-1' }); + + let seen: QuickPickLike[] = []; + const context = buildContext((items) => { + seen = items; + return items.find((item) => item.isExitOption)!; + }); + + await expect(new SelectAtlasCredentialStep().prompt(context)).rejects.toBeInstanceOf(UserCancelledErrorMock); + + // Adding comes first because this flow is the everyday way to widen what discovery can + // see, not just a recovery surface. + const actions = seen + .filter((item) => item.isAddOption ?? item.isRetryAllOption ?? item.isSignOutAllOption ?? item.isExitOption) + .map((item) => item.label); + expect(actions).toEqual(['Add a credential…', 'Retry all', 'Sign out of all', 'Exit']); + expect(seen.find((item) => item.isAddOption)?.detail).toBeTruthy(); + }); + + it('selects a credential so the action step can take over', async () => { + const { record } = await upsertAtlasCredential({ + authMethod: 'apikey', + publicKey: 'pub-1', + privateKey: 'priv-1', + }); + + const context = buildContext((items) => items.find((item) => item.credentialId === record.id)!); + await new SelectAtlasCredentialStep().prompt(context); + + expect(context.selectedCredentialId).toBe(record.id); + expect(new AtlasCredentialActionStep().shouldPrompt(context)).toBe(true); + }); + + it('does not report a change when the add webview is cancelled', async () => { + mockOpenWebview.mockResolvedValue(false); + let call = 0; + const context = buildContext((items) => { + call++; + return call === 1 ? items.find((item) => item.isAddOption)! : items.find((item) => item.isExitOption)!; + }); + + await expect(new SelectAtlasCredentialStep().prompt(context)).rejects.toBeInstanceOf(UserCancelledErrorMock); + expect(context.changed).toBe(false); + expect(context.discoveryService.invalidate).not.toHaveBeenCalled(); + }); + + it('reports a change when the add webview stores a credential', async () => { + mockOpenWebview.mockResolvedValue(true); + const context = buildContext((items) => items.find((item) => item.isAddOption)!); + + await expect(new SelectAtlasCredentialStep().prompt(context)).rejects.toBeInstanceOf(UserCancelledErrorMock); + expect(context.changed).toBe(true); + expect(context.discoveryService.invalidate).toHaveBeenCalled(); + }); + + it('removes every credential on sign out of all', async () => { + await upsertAtlasCredential({ authMethod: 'apikey', publicKey: 'pub-1', privateKey: 'priv-1' }); + await upsertAtlasCredential({ authMethod: 'apikey', publicKey: 'pub-2', privateKey: 'priv-2' }); + + const context = buildContext((items) => items.find((item) => item.isSignOutAllOption)!); + + await expect(new SelectAtlasCredentialStep().prompt(context)).rejects.toBeInstanceOf(UserCancelledErrorMock); + await expect(readAtlasCredentials()).resolves.toEqual([]); + expect(context.changed).toBe(true); + expect(context.discoveryService.reset).toHaveBeenCalled(); + }); + + it('re-checks the whole fleet on retry all and returns to the refreshed list', async () => { + await upsertAtlasCredential({ authMethod: 'apikey', publicKey: 'pub-1', privateKey: 'priv-1' }); + + let call = 0; + const context = buildContext((items) => { + call++; + // Without a fleet-wide retry the list is a snapshot of the last discovery pass, so the + // user would have to walk into every credential in turn to re-check them. + return call === 1 ? items.find((item) => item.isRetryAllOption)! : items.find((item) => item.isExitOption)!; + }); + + await expect(new SelectAtlasCredentialStep().prompt(context)).rejects.toBeInstanceOf(UserCancelledErrorMock); + + expect(context.discoveryService.refreshAll).toHaveBeenCalledTimes(1); + expect(context.changed).toBe(true); + // The list was shown a second time, with statuses reloaded from the fresh snapshot. + expect(call).toBe(2); + }); +}); + +describe('AtlasCredentialActionStep', () => { + async function contextWithSelection( + pick: (items: QuickPickLike[]) => QuickPickLike, + ): Promise<[ReturnType, string]> { + const { record } = await upsertAtlasCredential( + { authMethod: 'apikey', publicKey: 'pub-1', privateKey: 'priv-1' }, + { label: 'Work key' }, + ); + const context = buildContext(pick); + context.credentials = [{ record, label: 'Work key' }]; + context.selectedCredentialId = record.id; + return [context, record.id]; + } + + it('navigates back without changing anything', async () => { + const [context] = await contextWithSelection((items) => items.find((item) => item.action === 'back')!); + + await expect(new AtlasCredentialActionStep().prompt(context)).rejects.toBeInstanceOf(GoBackErrorMock); + expect(context.selectedCredentialId).toBeUndefined(); + expect(context.changed).toBe(false); + }); + + it('retries only the selected credential', async () => { + const [context, credentialId] = await contextWithSelection( + (items) => items.find((item) => item.action === 'retry')!, + ); + + await expect(new AtlasCredentialActionStep().prompt(context)).rejects.toBeInstanceOf(GoBackErrorMock); + expect(context.discoveryService.retryCredential).toHaveBeenCalledWith(credentialId); + expect(context.changed).toBe(true); + expect(context.credentials).toEqual([]); + }); + + it('opens the webview in edit mode and keeps the record id stable', async () => { + mockOpenWebview.mockResolvedValue(true); + const [context, credentialId] = await contextWithSelection( + (items) => items.find((item) => item.action === 'update')!, + ); + + await expect(new AtlasCredentialActionStep().prompt(context)).rejects.toBeInstanceOf(GoBackErrorMock); + expect(mockOpenWebview).toHaveBeenCalledWith( + expect.objectContaining({ + authMethod: 'apikey', + credentialId, + credentialLabel: 'Work key', + credentialIdentity: 'pub-1', + }), + ); + expect(context.changed).toBe(true); + }); + + it('deep-links a Service Account to its own Atlas page and changes nothing locally', async () => { + const { record } = await upsertAtlasCredential( + { authMethod: 'serviceaccount', clientId: 'mdb_sa_id_6a6535fe4a4e5dd61fcd3c9a', clientSecret: 'secret' }, + { orgId: '5ec7c48379933f4c750e478b' }, + ); + + const context = buildContext((items) => items.find((item) => item.action === 'openInAtlas')!); + context.credentials = [{ record: { ...record, orgId: '5ec7c48379933f4c750e478b' }, label: 'Work SA' }]; + context.selectedCredentialId = record.id; + + await expect(new AtlasCredentialActionStep().prompt(context)).rejects.toBeInstanceOf(GoBackErrorMock); + + expect(vscode.Uri.parse).toHaveBeenCalledWith( + 'https://cloud.mongodb.com/v2#/org/5ec7c48379933f4c750e478b/access/serviceAccounts/mdb_sa_id_6a6535fe4a4e5dd61fcd3c9a', + ); + expect(vscode.env.openExternal).toHaveBeenCalled(); + // Navigating to Atlas touches no local storage, so it must not claim a change. + expect(context.changed).toBe(false); + }); + + it('leaves the working credential untouched when an update is cancelled', async () => { + mockOpenWebview.mockResolvedValue(false); + const [context, credentialId] = await contextWithSelection( + (items) => items.find((item) => item.action === 'update')!, + ); + + await expect(new AtlasCredentialActionStep().prompt(context)).rejects.toBeInstanceOf(GoBackErrorMock); + expect(context.changed).toBe(false); + await expect(readAtlasCredentials()).resolves.toHaveLength(1); + expect((await readAtlasCredentials())[0].id).toBe(credentialId); + }); + + it('signs out of only the selected credential after confirmation', async () => { + const peer = await upsertAtlasCredential({ + authMethod: 'apikey', + publicKey: 'peer-key', + privateKey: 'peer-secret', + }); + const [context, credentialId] = await contextWithSelection( + (items) => items.find((item) => item.action === 'signOut')!, + ); + + await expect(new AtlasCredentialActionStep().prompt(context)).rejects.toBeInstanceOf(GoBackErrorMock); + + const remaining = await readAtlasCredentials(); + expect(remaining.map((r) => r.id)).toEqual([peer.record.id]); + expect(remaining.map((r) => r.id)).not.toContain(credentialId); + expect(context.changed).toBe(true); + // Reads as the single-credential form of the fleet-level "Sign out of all". + expect(context.telemetry.properties.atlasCredentialAction).toBe('signOut'); + }); + + it('exits the flow when the user picks Exit', async () => { + const [context] = await contextWithSelection((items) => items.find((item) => item.action === 'exit')!); + + await expect(new AtlasCredentialActionStep().prompt(context)).rejects.toBeInstanceOf(UserCancelledErrorMock); + }); +}); + +describe('buildAtlasAccessUrl', () => { + const base: AtlasCredentialRecord = { id: 'r1', authMethod: 'apikey', order: 0 }; + + it('sends an API key to the organization key list, because per-key deep links need an internal id', () => { + expect(buildAtlasAccessUrl({ ...base, orgId: '5ec7c48379933f4c750e478b' })).toBe( + 'https://cloud.mongodb.com/v2#/org/5ec7c48379933f4c750e478b/access/apiKeys', + ); + }); + + it('sends a Service Account to its own page when the client id is known', () => { + expect( + buildAtlasAccessUrl( + { ...base, authMethod: 'serviceaccount', orgId: '5ec7c48379933f4c750e478b' }, + 'mdb_sa_id_6a6535fe4a4e5dd61fcd3c9a', + ), + ).toBe( + 'https://cloud.mongodb.com/v2#/org/5ec7c48379933f4c750e478b/access/serviceAccounts/mdb_sa_id_6a6535fe4a4e5dd61fcd3c9a', + ); + }); + + it('falls back to the Service Account list when the client id cannot be read', () => { + expect(buildAtlasAccessUrl({ ...base, authMethod: 'serviceaccount', orgId: 'org-1' })).toBe( + 'https://cloud.mongodb.com/v2#/org/org-1/access/serviceAccounts', + ); + }); + + it('falls back to the console root when no organization has been observed yet', () => { + // A credential rejected by an IP access list may never have completed a request, so it has + // no cached org id. That is exactly when the user needs the link, so it must not be dropped. + expect(buildAtlasAccessUrl({ ...base, authMethod: 'serviceaccount' })).toBe('https://cloud.mongodb.com'); + }); +}); diff --git a/src/plugins/service-atlas-mongodb/credentialsManagement/AtlasCredentialsManagementWizardContext.ts b/src/plugins/service-atlas-mongodb/credentialsManagement/AtlasCredentialsManagementWizardContext.ts new file mode 100644 index 000000000..8463155a0 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/credentialsManagement/AtlasCredentialsManagementWizardContext.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type AtlasCredentialRecord } from '../credentials/atlasCredentialStore'; +import { type AtlasCredentialError, type AtlasDiscoveryService } from '../discovery/AtlasDiscoveryService'; + +/** + * One credential plus its resolved display label and, when it is unhealthy, the reason. + */ +export interface AtlasCredentialStatus { + readonly record: AtlasCredentialRecord; + readonly label: string; + readonly error?: AtlasCredentialError; +} + +/** + * Wizard context for the "Manage MongoDB Atlas Credentials" QuickPick flow. + * + * Mirrors the Azure `CredentialsManagementWizardContext` shape so both providers stay + * maintainable side by side: a cached collection that is initialised to `[]` (so `AzureWizard` + * captures it in `propertiesBeforePrompt` and it survives back navigation), a selected item, and + * a flag telling the caller whether anything changed. + */ +export interface AtlasCredentialsManagementWizardContext extends IActionContext { + /** Aggregation service used for credential status and single-credential retries. */ + readonly discoveryService: AtlasDiscoveryService; + + /** + * All credentials with their current status. Initialised with `[]` so it is captured in + * `propertiesBeforePrompt` and survives back navigation; cleared to force a reload. + */ + credentials: AtlasCredentialStatus[]; + + /** The credential the user drilled into, if any. */ + selectedCredentialId?: string; + + /** Set when storage changed, so the caller knows to refresh the discovery tree. */ + changed: boolean; +} diff --git a/src/plugins/service-atlas-mongodb/credentialsManagement/SelectAtlasCredentialStep.ts b/src/plugins/service-atlas-mongodb/credentialsManagement/SelectAtlasCredentialStep.ts new file mode 100644 index 000000000..fd1463b21 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/credentialsManagement/SelectAtlasCredentialStep.ts @@ -0,0 +1,220 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep, UserCancelledError } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { openAtlasCredentialsWebview } from '../../../webviews/documentdb/atlasCredentials/atlasCredentialsController'; +import { readAtlasCredentials, removeAllAtlasCredentials } from '../credentials/atlasCredentialStore'; +import { resolveCredentialLabel, type AtlasCredentialError } from '../discovery/AtlasDiscoveryService'; +import { + type AtlasCredentialsManagementWizardContext, + type AtlasCredentialStatus, +} from './AtlasCredentialsManagementWizardContext'; + +/** Sentinel messages the entry point uses to tell a graceful exit from a real cancellation. */ +export const ATLAS_CREDENTIAL_ADDED = 'atlasCredentialAdded'; +export const ATLAS_CREDENTIAL_MANAGEMENT_EXIT = 'exitAtlasCredentialManagement'; + +interface CredentialQuickPickItem extends vscode.QuickPickItem { + credentialId?: string; + isAddOption?: boolean; + isRetryAllOption?: boolean; + isSignOutAllOption?: boolean; + isExitOption?: boolean; +} + +/** + * First step of the credential-management wizard: the list of stored credentials plus the + * fleet-level actions (retry all, add, sign out of all, exit). + * + * Credential management deliberately lives outside the discovery tree, exactly like the Azure + * account flow, so the healthy tree never has to carry credential-management rows. + */ +export class SelectAtlasCredentialStep extends AzureWizardPromptStep { + public async prompt(context: AtlasCredentialsManagementWizardContext): Promise { + const buildItems = async (): Promise => { + if (context.credentials.length === 0) { + context.credentials = await loadCredentialStatuses(context); + } + + context.telemetry.measurements.atlasCredentialCount = context.credentials.length; + context.telemetry.measurements.atlasFailedCredentialCount = context.credentials.filter( + (status) => status.error, + ).length; + + const credentialItems: CredentialQuickPickItem[] = context.credentials.map((status) => ({ + label: status.label, + description: status.record.authMethod === 'apikey' ? l10n.t('API Key') : l10n.t('Service Account'), + detail: status.error ? `$(warning) ${status.error.message}` : l10n.t('$(pass) Signed in'), + iconPath: new vscode.ThemeIcon(status.record.authMethod === 'apikey' ? 'key' : 'cloud'), + credentialId: status.record.id, + })); + + const trailingItems: CredentialQuickPickItem[] = [{ label: '', kind: vscode.QuickPickItemKind.Separator }]; + + // Adding comes first: this flow is the everyday way to widen what discovery can see, + // not just a recovery surface, and a single credential is frequently least-privileged. + trailingItems.push({ + label: l10n.t('Add a credential…'), + detail: l10n.t('Connect another API Key or Service Account to see more organizations and projects.'), + iconPath: new vscode.ThemeIcon('add'), + isAddOption: true, + }); + + if (credentialItems.length > 0) { + // Without this the list is a snapshot: every row shows the status from the last + // discovery pass, and the only way to re-check is to walk into each credential in + // turn. With several failures that is both tedious and misleading, because the + // rows the user is not looking at keep showing stale outcomes. + trailingItems.push({ + label: l10n.t('Retry all'), + detail: l10n.t('Re-check every credential against MongoDB Atlas, including the healthy ones.'), + iconPath: new vscode.ThemeIcon('refresh'), + isRetryAllOption: true, + }); + + trailingItems.push({ + label: l10n.t('Sign out of all'), + iconPath: new vscode.ThemeIcon('sign-out'), + isSignOutAllOption: true, + }); + } + + trailingItems.push({ + label: l10n.t('Exit'), + iconPath: new vscode.ThemeIcon('close'), + isExitOption: true, + }); + + return [...credentialItems, ...trailingItems]; + }; + + const selected = await context.ui.showQuickPick(buildItems(), { + stepName: 'selectAtlasCredential', + placeHolder: l10n.t('MongoDB Atlas credentials used for service discovery'), + matchOnDescription: true, + suppressPersistence: true, + loadingPlaceHolder: l10n.t('Loading MongoDB Atlas credentials…'), + }); + + if (selected.isAddOption) { + context.telemetry.properties.atlasCredentialAction = 'add'; + const stored = await openAtlasCredentialsWebview(); + if (!stored) { + // Cancelling the webview stores nothing. Return to the list rather than closing + // the whole flow, so the user can pick another action. + context.credentials = []; + await this.prompt(context); + return; + } + context.changed = true; + context.discoveryService.invalidate(); + throw new UserCancelledError(ATLAS_CREDENTIAL_ADDED); + } + + if (selected.isRetryAllOption) { + await this.retryAll(context); + return; + } + + if (selected.isSignOutAllOption) { + context.telemetry.properties.atlasCredentialAction = 'signOutAll'; + const confirm = l10n.t('Sign out of all'); + // Short, constant title with the specifics in the detail, matching `removeConnection` + // and `deleteFolder`. A title that changes with the subject reads as a different + // dialog every time and pushes the part the user must actually read out of view. + await context.ui.showWarningMessage( + l10n.t('Are you sure?'), + { + modal: true, + detail: + l10n.t('Sign out of every MongoDB Atlas credential?') + + '\n' + + l10n.t('All stored MongoDB Atlas credentials will be removed.'), + }, + { title: confirm }, + ); + + const removed = await removeAllAtlasCredentials(); + context.telemetry.measurements.atlasCredentialsRemoved = removed; + context.discoveryService.reset(); + context.changed = true; + throw new UserCancelledError(ATLAS_CREDENTIAL_MANAGEMENT_EXIT); + } + + if (selected.isExitOption) { + context.telemetry.properties.atlasCredentialAction = 'exit'; + throw new UserCancelledError(ATLAS_CREDENTIAL_MANAGEMENT_EXIT); + } + + context.telemetry.properties.atlasCredentialAction = 'selectCredential'; + context.selectedCredentialId = selected.credentialId; + } + + public shouldPrompt(context: AtlasCredentialsManagementWizardContext): boolean { + return !context.selectedCredentialId; + } + + /** + * Re-attempts the whole fleet and returns to the refreshed list. + * + * Uses `refreshAll` rather than a plain `invalidate`, so cached Service Account access tokens + * are discarded too. A token carries the roles it was minted with, and the most common reason + * to open this flow at all is that the user just changed something in Atlas. + */ + private async retryAll(context: AtlasCredentialsManagementWizardContext): Promise { + context.telemetry.properties.atlasCredentialAction = 'retryAll'; + + const snapshot = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: l10n.t('Re-checking every MongoDB Atlas credential…'), + }, + () => context.discoveryService.refreshAll(), + ); + + context.telemetry.measurements.atlasFailedCredentialCountAfterRetryAll = snapshot.credentialErrors.length; + context.changed = true; + + if (snapshot.credentialErrors.length === 0) { + void vscode.window.showInformationMessage(l10n.t('Every MongoDB Atlas credential is signed in.')); + } + + // Reload the statuses so every row reflects the new outcome, then show the list again. + context.credentials = []; + await this.prompt(context); + } +} + +/** + * Reads every credential and pairs it with the failure recorded for it in the latest discovery + * snapshot. Reads the snapshot rather than re-querying, so simply opening the manager does not + * re-hammer a credential that is already known to be failing. "Retry all" is the explicit way to + * ask for fresh statuses. + */ +export async function loadCredentialStatuses( + context: AtlasCredentialsManagementWizardContext, +): Promise { + const records = await readAtlasCredentials(); + if (records.length === 0) { + return []; + } + + let errorsById = new Map(); + try { + const snapshot = await context.discoveryService.listAll(); + errorsById = new Map(snapshot.credentialErrors.map((error) => [error.credentialId, error])); + } catch { + // listAll never throws for a single credential; a failure here means the whole read failed, + // and the list is still worth showing without status annotations. + } + + return records.map((record) => ({ + record, + label: resolveCredentialLabel(record), + error: errorsById.get(record.id), + })); +} diff --git a/src/plugins/service-atlas-mongodb/credentialsManagement/addAtlasCredential.ts b/src/plugins/service-atlas-mongodb/credentialsManagement/addAtlasCredential.ts new file mode 100644 index 000000000..b2fb81fdf --- /dev/null +++ b/src/plugins/service-atlas-mongodb/credentialsManagement/addAtlasCredential.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import { ext } from '../../../extensionVariables'; +import { type TreeElement } from '../../../tree/TreeElement'; +import { openAtlasCredentialsWebview } from '../../../webviews/documentdb/atlasCredentials/atlasCredentialsController'; +import { DISCOVERY_PROVIDER_ID } from '../config'; +import { type AtlasDiscoveryService } from '../discovery/AtlasDiscoveryService'; + +export const ADD_ATLAS_CREDENTIAL_COMMAND_ID = 'vscode-documentdb.command.internal.atlas.addCredential'; + +/** Opens credential entry directly for the empty Atlas tree state. */ +export async function addAtlasCredential( + context: IActionContext, + discoveryService: AtlasDiscoveryService, + node: TreeElement, +): Promise { + context.telemetry.properties.credentialConfigActivated = 'true'; + context.telemetry.properties.discoveryProviderId = DISCOVERY_PROVIDER_ID; + context.telemetry.properties.atlasCredentialAction = 'add'; + + const stored = await openAtlasCredentialsWebview(); + + context.telemetry.properties.credentialsManagementResult = stored ? 'Succeeded' : 'Canceled'; + if (stored) { + ext.outputChannel.info(l10n.t('MongoDB Atlas credential added.')); + } + + discoveryService.reset(); + if (node.id) { + ext.discoveryBranchDataProvider.resetNodeErrorState(node.id); + } + ext.discoveryBranchDataProvider.refresh(node); + + return stored; +} diff --git a/src/plugins/service-atlas-mongodb/credentialsManagement/configureAtlasCredentials.ts b/src/plugins/service-atlas-mongodb/credentialsManagement/configureAtlasCredentials.ts new file mode 100644 index 000000000..465d62ba4 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/credentialsManagement/configureAtlasCredentials.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + AzureWizard, + callWithTelemetryAndErrorHandling, + UserCancelledError, + type IActionContext, +} from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import { ext } from '../../../extensionVariables'; +import { type TreeElement } from '../../../tree/TreeElement'; +import { DISCOVERY_PROVIDER_ID } from '../config'; +import { type AtlasDiscoveryService } from '../discovery/AtlasDiscoveryService'; +import { AtlasCredentialActionStep } from './AtlasCredentialActionStep'; +import { type AtlasCredentialsManagementWizardContext } from './AtlasCredentialsManagementWizardContext'; +import { + ATLAS_CREDENTIAL_ADDED, + ATLAS_CREDENTIAL_MANAGEMENT_EXIT, + SelectAtlasCredentialStep, +} from './SelectAtlasCredentialStep'; + +/** + * Entry point for "Manage MongoDB Atlas Credentials". + * + * Deliberately mirrors {@link configureAzureCredentials}: an `AzureWizard` of QuickPick prompt + * steps, `GoBackError` for navigation, and a sentinel `UserCancelledError` message to distinguish + * a graceful exit from a real cancellation. Keeping the two providers on the same shape is what + * makes the credential flows maintainable side by side. + * + * One deliberate difference: leaving this flow always forces a full Atlas refresh, not only when + * storage changed. See the comment at the call site. + * + * @returns `true` when credential storage changed and the caller (for example the connection + * wizard) may proceed. + */ +export async function configureAtlasCredentials( + context: IActionContext, + discoveryService: AtlasDiscoveryService, + node?: TreeElement, +): Promise { + const result = await callWithTelemetryAndErrorHandling( + 'serviceDiscovery.configureAtlasCredentials', + async (telemetryContext: IActionContext) => { + telemetryContext.telemetry.properties.discoveryProviderId = DISCOVERY_PROVIDER_ID; + telemetryContext.telemetry.properties.nodeProvided = node ? 'true' : 'false'; + + const wizardContext: AtlasCredentialsManagementWizardContext = { + ...telemetryContext, + discoveryService, + // Initialised with [] so AzureWizard captures it in propertiesBeforePrompt and it + // survives back navigation (null/undefined values are filtered out). + credentials: [], + selectedCredentialId: undefined, + changed: false, + }; + + const wizard = new AzureWizard(wizardContext, { + title: l10n.t('Manage MongoDB Atlas Credentials'), + promptSteps: [new SelectAtlasCredentialStep(), new AtlasCredentialActionStep()], + }); + + try { + await wizard.prompt(); + } catch (error) { + if (!(error instanceof UserCancelledError)) { + throw error; + } + + if (error.message === ATLAS_CREDENTIAL_ADDED) { + telemetryContext.telemetry.properties.credentialsManagementResult = 'Succeeded'; + ext.outputChannel.info(l10n.t('MongoDB Atlas credential added.')); + } else if (error.message === ATLAS_CREDENTIAL_MANAGEMENT_EXIT) { + telemetryContext.telemetry.properties.credentialsManagementResult = 'Succeeded'; + } else { + telemetryContext.telemetry.properties.credentialsManagementResult = 'Canceled'; + } + } + + // Credential management always ends with a full Atlas refresh, even when nothing was + // stored. This is deliberately stronger than the shared discovery default of + // refreshing only when storage changed, because Atlas has state the extension cannot + // observe: the user may have granted a role or added project access in the Atlas UI + // while this QuickPick was open, and a Service Account access token carries the scope + // it was minted with for about an hour. Dropping the sessions as well as the snapshot + // is what makes such a change visible immediately instead of at the next token expiry. + discoveryService.reset(); + refreshDiscoveryTree(node); + + // Only report success when something actually happened; a cancelled or dismissed flow + // must never claim that credential management completed. + context.telemetry.properties.credentialsManagementResult = + telemetryContext.telemetry.properties.credentialsManagementResult ?? 'Canceled'; + + return wizardContext.changed; + }, + ); + + return result ?? false; +} + +function refreshDiscoveryTree(node?: TreeElement): void { + if (node?.id) { + ext.discoveryBranchDataProvider.resetNodeErrorState(node.id); + } + if (node) { + ext.discoveryBranchDataProvider.refresh(node); + } else { + ext.discoveryBranchDataProvider.refresh(); + } +} diff --git a/src/plugins/service-atlas-mongodb/discovery-tree/AtlasClusterItem.test.ts b/src/plugins/service-atlas-mongodb/discovery-tree/AtlasClusterItem.test.ts new file mode 100644 index 000000000..43bf319a1 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/discovery-tree/AtlasClusterItem.test.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DocumentDBExperience } from '../../../DocumentDBExperiences'; +import { Views } from '../../../documentdb/Views'; +import { type TreeCluster } from '../../../tree/models/BaseClusterModel'; +import { type AtlasClusterModel } from '../models/AtlasClusterModel'; +import { AtlasClusterItem } from './AtlasClusterItem'; + +jest.mock('@vscode/l10n', () => ({ + t: jest.fn((message: string, values?: Record) => { + if (!values) { + return message; + } + + return Object.entries(values).reduce((result, [key, value]) => result.replace(`{${key}}`, value), message); + }), +})); + +jest.mock('vscode', () => ({ + ThemeIcon: class ThemeIcon { + constructor(public readonly id: string) {} + }, + Uri: { + file: (p: string) => ({ scheme: 'file', path: p, fsPath: p, toString: () => p }), + parse: (value: string) => ({ toString: () => value }), + }, + MarkdownString: class MarkdownString { + public isTrusted = false; + private readonly chunks: string[] = []; + + constructor(initialValue?: string) { + if (initialValue) { + this.chunks.push(initialValue); + } + } + + public appendMarkdown(value: string): void { + this.chunks.push(value); + } + + public toString(): string { + return this.chunks.join(''); + } + }, + l10n: { + t: jest.fn((message: string) => message), + }, + TreeItemCollapsibleState: { + None: 0, + Collapsed: 1, + Expanded: 2, + }, + ProgressLocation: { + Notification: 15, + }, + env: { + openExternal: jest.fn(), + }, + window: { + showErrorMessage: jest.fn(), + showWarningMessage: jest.fn(), + showInformationMessage: jest.fn(), + }, +})); + +jest.mock('@microsoft/vscode-azext-utils', () => ({ + createContextValue: (parts: string[]) => parts.join(';'), + callWithTelemetryAndErrorHandling: jest.fn( + async (_eventName: string, callback: (context: unknown) => Promise) => + await callback({ + telemetry: { properties: {}, measurements: {} }, + errorHandling: {}, + valuesToMask: [], + }), + ), + AzureWizard: class AzureWizard { + constructor(_context: unknown, _options: unknown) {} + public async prompt(): Promise {} + }, + AzureWizardPromptStep: class AzureWizardPromptStep {}, + UserCancelledError: class UserCancelledError extends Error {}, +})); + +jest.mock('../../../extensionVariables', () => ({ + ext: { + outputChannel: { + append: jest.fn(), + appendLine: jest.fn(), + debug: jest.fn(), + }, + state: { + notifyChildrenChanged: jest.fn(), + }, + }, +})); + +jest.mock('../../../documentdb/CredentialCache', () => ({ + CredentialCache: { + hasCredentials: jest.fn(), + deleteCredentials: jest.fn(), + setAuthCredentials: jest.fn(), + }, +})); + +jest.mock('../../../documentdb/ClustersClient', () => ({ + ClustersClient: { + getClient: jest.fn(), + deleteClient: jest.fn(), + }, +})); + +function createTreeCluster(): TreeCluster { + return { + name: 'Cluster0', + connectionString: 'mongodb+srv://cluster0.example.mongodb.net', + dbExperience: DocumentDBExperience, + clusterId: 'atlas-mongodb-discovery_cluster0', + treeId: 'atlas/org/project/Cluster0', + viewId: Views.DiscoveryView, + projectId: '507f1f77bcf86cd799439011', + projectName: 'Project 0', + paused: false, + stateName: 'IDLE', + clusterType: 'REPLICASET', + providerName: 'AWS', + regionName: 'US_EAST_1', + instanceSizeName: 'M10', + mongoDBVersion: '7.0.0', + }; +} + +describe('AtlasClusterItem icon', () => { + it('uses the neutral server-environment codicon', () => { + const item = new AtlasClusterItem('', createTreeCluster()); + + expect((item.getTreeItem().iconPath as { id: string }).id).toBe('server-environment'); + }); + + it('does not brand an Atlas cluster with the DocumentDB product logo', () => { + // `vscode-documentdb-cluster-{light,dark}-themes.svg` are byte-identical copies of the + // DocumentDB product logo. The Kubernetes plugin uses them because it discovers real + // DocumentDB deployments; Atlas clusters are somebody else's managed service, so the + // brand mark must not leak into this tree. + const iconPath = new AtlasClusterItem('', createTreeCluster()).getTreeItem().iconPath; + + expect(JSON.stringify(iconPath)).not.toContain('vscode-documentdb'); + }); +}); + +describe('AtlasClusterItem console URL', () => { + it('links directly to the cluster overview in its Atlas project', () => { + const item = new AtlasClusterItem('', { + ...createTreeCluster(), + projectId: '6a4385d0c24161dbcd3bd66f', + name: 'Experimental 1/West', + }); + + expect(item.getAtlasConsoleUrl()).toBe( + 'https://cloud.mongodb.com/v2/6a4385d0c24161dbcd3bd66f#/clusters/detail/Experimental%201%2FWest', + ); + }); +}); + +describe('AtlasClusterItem tooltip', () => { + const tooltipText = (item: AtlasClusterItem): string => + (item.getTreeItem().tooltip as unknown as { toString(): string }).toString(); + + it('labels the server version without using "MongoDB" as a standalone product name', () => { + const tooltip = tooltipText(new AtlasClusterItem('', createTreeCluster())); + + expect(tooltip).toContain('**Server version:**'); + expect(tooltip).toContain('v7'); + expect(tooltip).not.toContain('**MongoDB:**'); + }); + + it('renders every field label through the localizer', () => { + const tooltip = tooltipText(new AtlasClusterItem('', createTreeCluster())); + + for (const label of ['State', 'Type', 'Tier', 'Provider', 'Region', 'Project']) { + expect(tooltip).toContain(`**${label}:**`); + } + expect(tooltip).toContain('Connection string available'); + }); +}); + +describe('AtlasClusterItem connectability (NEW-5)', () => { + const tooltipText = (item: AtlasClusterItem): string => + (item.getTreeItem().tooltip as unknown as { toString(): string }).toString(); + + it('is expandable when IDLE with a connection string', () => { + const item = new AtlasClusterItem('', createTreeCluster()); + expect(item.getTreeItem().collapsibleState).toBe(1); // Collapsed + }); + + it('is a leaf when the cluster is not IDLE', () => { + const item = new AtlasClusterItem('', { ...createTreeCluster(), stateName: 'CREATING' }); + expect(item.getTreeItem().collapsibleState).toBe(0); // None + expect(tooltipText(item)).toContain('being created'); + }); + + it('is a leaf when no connection string is available', () => { + const item = new AtlasClusterItem('', { ...createTreeCluster(), connectionString: undefined }); + expect(item.getTreeItem().collapsibleState).toBe(0); // None + expect(tooltipText(item)).toContain('does not expose a connection string'); + }); + + it('is a leaf and explains how to recover when Atlas reports it paused', () => { + const item = new AtlasClusterItem('', { ...createTreeCluster(), paused: true }); + + expect(item.getTreeItem().collapsibleState).toBe(0); // None + expect(item.getTreeItem().description).toContain('Paused'); + expect(tooltipText(item)).toContain('Resume it in MongoDB Atlas before connecting'); + }); +}); diff --git a/src/plugins/service-atlas-mongodb/discovery-tree/AtlasClusterItem.ts b/src/plugins/service-atlas-mongodb/discovery-tree/AtlasClusterItem.ts new file mode 100644 index 000000000..daca7109b --- /dev/null +++ b/src/plugins/service-atlas-mongodb/discovery-tree/AtlasClusterItem.ts @@ -0,0 +1,511 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + AzureWizard, + callWithTelemetryAndErrorHandling, + createContextValue, + UserCancelledError, + type IActionContext, +} from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { AuthMethodId } from '../../../documentdb/auth/AuthMethod'; +import { ClustersClient } from '../../../documentdb/ClustersClient'; +import { CredentialCache } from '../../../documentdb/CredentialCache'; +import { Views } from '../../../documentdb/Views'; +import { type AuthenticateWizardContext } from '../../../documentdb/wizards/authenticate/AuthenticateWizardContext'; +import { ChooseAuthMethodStep } from '../../../documentdb/wizards/authenticate/ChooseAuthMethodStep'; +import { ProvidePasswordStep } from '../../../documentdb/wizards/authenticate/ProvidePasswordStep'; +import { ProvideUserNameStep } from '../../../documentdb/wizards/authenticate/ProvideUsernameStep'; +import { ext } from '../../../extensionVariables'; +import { ClusterItemBase, type EphemeralClusterCredentials } from '../../../tree/documentdb/ClusterItemBase'; +import { type TreeCluster } from '../../../tree/models/BaseClusterModel'; +import { nonNullValue } from '../../../utils/nonNull'; +import { escapeMarkdown } from '../../../webviews/utils/escapeMarkdown'; +import { AtlasApiClient } from '../api/AtlasApiClient'; +import { + getAtlasClusterStateLabel, + getAtlasPausedExplanation, + isAtlasClusterConnectable, + isAtlasClusterPaused, +} from '../atlasClusterAvailability'; +import { isAtlasTlsHandshakeRejection } from '../atlasConnectionErrors'; +import { buildAtlasClusterUrl, buildAtlasNetworkAccessUrl } from '../atlasDeepLinks'; +import { atlasTrace, monotonicNow } from '../atlasTrace'; +import { DISCOVERY_PROVIDER_ID } from '../config'; +import { toAtlasDatabaseUserCandidates, type AtlasDatabaseUserCandidate } from '../connect/atlasDatabaseUsers'; +import { SelectAtlasDatabaseUserStep } from '../connect/SelectAtlasDatabaseUserStep'; +import { type AtlasDiscoveryService } from '../discovery/AtlasDiscoveryService'; +import { type AtlasClusterModel } from '../models/AtlasClusterModel'; + +/** Resource type identifier for telemetry */ +const RESOURCE_TYPE = 'atlas-mongodb-cluster'; + +/** + * Tree item representing a MongoDB Atlas cluster within a project. + * Extends ClusterItemBase to support expanding into databases, + * credential caching, and the unified connection experience. + */ +export class AtlasClusterItem extends ClusterItemBase { + constructor( + /** + * Correlation ID for telemetry funnel analysis. + * For statistics only - does not influence functionality. + */ + journeyCorrelationId: string, + cluster: TreeCluster, + /** + * Context shown instead of the tier/region description, used by List mode to render + * `organization · project` next to a flat cluster row. + */ + private readonly contextDescription?: string, + /** + * The discovery service and the credential that surfaced this cluster. Optional so the + * item stays constructible without them; when absent the sign-in flow simply asks for a + * username instead of offering the project's database users. + */ + private readonly discovery?: { service: AtlasDiscoveryService; ownerCredentialId: string }, + ) { + super(cluster); + this.journeyCorrelationId = journeyCorrelationId; + + // Add enableAddToConnectionsCommand so the "Save to Connections" menu item appears + this.contextValue = createContextValue([this.contextValue, 'enableAddToConnectionsCommand']); + } + + /** + * Returns the Atlas console URL for this cluster. + */ + public getAtlasConsoleUrl(): string { + return buildAtlasClusterUrl(this.cluster.projectId, this.cluster.name); + } + + /** + * Lists the database users that apply to this cluster, for the username prompt. + * + * Reuses the very credential that discovered the cluster, so no extra sign-in is involved and + * the call needs no permission the user has not already granted. Failures propagate to the + * step, which downgrades to a plain username prompt rather than blocking sign-in. + */ + private async listDatabaseUserCandidates(signal: AbortSignal): Promise { + if (!this.discovery) { + return []; + } + + const { service, ownerCredentialId } = this.discovery; + const session = await service.sessionRegistry.getSession(ownerCredentialId); + if (!session) { + atlasTrace(`cluster "${this.cluster.name}": no usable session, skipping the database user lookup`); + return []; + } + + const client = new AtlasApiClient(session, service.sessionRegistry.refresherFor(ownerCredentialId)); + const users = await client.listDatabaseUsers(this.cluster.projectId, signal); + + return toAtlasDatabaseUserCandidates(users, this.cluster.name); + } + + /** + * Returns credentials for this Atlas cluster. + * Used by the "Save to Connections" flow (addConnectionFromRegistry command). + * + * Atlas clusters use native MongoDB auth (SCRAM username/password). + * The connection string is already known from the Atlas Admin API. + */ + public async getCredentials(): Promise { + return callWithTelemetryAndErrorHandling('getCredentials', async (context: IActionContext) => { + context.telemetry.properties.view = Views.DiscoveryView; + context.telemetry.properties.discoveryProviderId = DISCOVERY_PROVIDER_ID; + context.telemetry.properties.resourceType = RESOURCE_TYPE; + if (this.journeyCorrelationId) { + context.telemetry.properties.journeyCorrelationId = this.journeyCorrelationId; + } + + // "Save to Connections" is offered even for a non-IDLE / connection-string-less cluster. + // Explain why it cannot be saved instead of tripping the internal `nonNullValue` assert. + if (!this.isConnectable()) { + void vscode.window.showWarningMessage(this.describeUnavailable()); + return undefined; + } + + return { + connectionString: nonNullValue( + this.cluster.connectionString, + 'cluster.connectionString', + 'AtlasClusterItem.ts', + ), + availableAuthMethods: [AuthMethodId.NativeAuth], + }; + }); + } + + /** + * Authenticates and connects to the MongoDB Atlas cluster. + * + * Atlas uses a two-layer auth model: + * - Layer 1 (Atlas Admin API): API Key or Service Account — used only for discovery (listing clusters). + * - Layer 2 (MongoDB wire protocol): SCRAM username/password — used to connect to the database. + * + * This method handles Layer 2: it prompts the user for their MongoDB database credentials, + * caches them in CredentialCache, and establishes a ClustersClient connection. + * + * @returns ClustersClient if successful; null if the user cancels or auth fails. + */ + protected async authenticateAndConnect(): Promise { + const result = await callWithTelemetryAndErrorHandling('connect', async (context: IActionContext) => { + const connectionStartTime = monotonicNow(); + context.telemetry.properties.view = Views.DiscoveryView; + context.telemetry.properties.discoveryProviderId = DISCOVERY_PROVIDER_ID; + context.telemetry.properties.connectionInitiatedFrom = 'discoveryView'; + context.telemetry.properties.resourceType = RESOURCE_TYPE; + if (this.journeyCorrelationId) { + context.telemetry.properties.journeyCorrelationId = this.journeyCorrelationId; + } + + // Defense in depth: the tree marks a non-connectable cluster as a leaf, but if this is + // reached anyway, explain why rather than tripping the internal connection-string assert. + if (!this.isConnectable()) { + void vscode.window.showWarningMessage(this.describeUnavailable()); + return null; + } + + ext.outputChannel.appendLine( + l10n.t('Attempting to authenticate with "{cluster}"…', { + cluster: this.cluster.name, + }), + ); + + // Prepare wizard context — Atlas clusters support native auth only + const wizardContext: AuthenticateWizardContext = { + ...context, + adminUserName: undefined, + resourceName: this.cluster.name, + availableAuthMethods: [AuthMethodId.NativeAuth], + }; + + // Prompt for credentials + const credentialsProvided = await this.promptForCredentials(wizardContext); + if (!credentialsProvided) { + return null; + } + + if (wizardContext.password) { + context.valuesToMask.push(wizardContext.password); + } + + // Cache credentials using clusterId (stable identifier) — NOT this.id (treeId) + CredentialCache.setAuthCredentials( + this.cluster.clusterId, + nonNullValue( + wizardContext.selectedAuthMethod, + 'wizardContext.selectedAuthMethod', + 'AtlasClusterItem.ts', + ), + nonNullValue(this.cluster.connectionString, 'cluster.connectionString', 'AtlasClusterItem.ts'), + wizardContext.selectedUserName || wizardContext.password + ? { + connectionUser: wizardContext.selectedUserName ?? '', + connectionPassword: wizardContext.password, + } + : undefined, + ); + + ext.outputChannel.append( + l10n.t('Connecting to the cluster as "{username}"…', { + username: wizardContext.selectedUserName ?? '', + }), + ); + + try { + const clustersClient = await this.getClientWithProgress(this.cluster.clusterId); + + ext.outputChannel.appendLine( + l10n.t('Connected to the cluster "{cluster}".', { + cluster: this.cluster.name, + }), + ); + + context.telemetry.measurements.connectionEstablishmentTimeMs = monotonicNow() - connectionStartTime; + context.telemetry.properties.connectionResult = 'success'; + context.telemetry.properties.connectionCorrelationId = clustersClient.connectionCorrelationId ?? ''; + + return clustersClient; + } catch (error) { + if (error instanceof UserCancelledError) { + context.telemetry.measurements.connectionEstablishmentTimeMs = monotonicNow() - connectionStartTime; + context.telemetry.properties.connectionResult = 'cancelled'; + throw error; + } + + context.telemetry.measurements.connectionEstablishmentTimeMs = monotonicNow() - connectionStartTime; + context.telemetry.properties.connectionResult = 'failed'; + context.telemetry.properties.connectionErrorType = error instanceof Error ? error.name : 'UnknownError'; + + ext.outputChannel.appendLine( + l10n.t('Error: {error}', { error: error instanceof Error ? error.message : String(error) }), + ); + + await this.showConnectionFailure(context, error); + + // Clean up failed connection + await ClustersClient.deleteClient(this.cluster.clusterId); + CredentialCache.deleteCredentials(this.cluster.clusterId); + + return null; + } + }); + + return result ?? null; + } + + /** + * Reports a failed connection attempt. + * + * A TLS-level failure gets its own wording. What can be stated with confidence is only what + * the error itself proves: the connection died at the transport layer, and that is not the + * shape of an authentication rejection, which arrives as `bad auth : Authentication failed`. + * Naming a single cause would be a guess. MongoDB documents that the project IP access list + * gates client connections, but it does not document that a blocked address surfaces as this + * particular alert, so the modal lists what to check rather than claiming a diagnosis. + */ + private async showConnectionFailure(context: IActionContext, error: unknown): Promise { + const errorMessage = error instanceof Error ? error.message : String(error); + + if (!isAtlasTlsHandshakeRejection(error)) { + context.telemetry.properties.atlasConnectionFailureKind = 'other'; + void vscode.window.showErrorMessage( + l10n.t('Failed to connect to "{cluster}"', { cluster: this.cluster.name }), + { + modal: true, + detail: + l10n.t('Revisit connection details and try again.') + + '\n\n' + + l10n.t('Error: {error}', { error: errorMessage }), + }, + ); + return; + } + + context.telemetry.properties.atlasConnectionFailureKind = 'tlsFailure'; + + const openNetworkAccess = l10n.t('Open Network Access in Atlas'); + const selected = await vscode.window.showErrorMessage( + l10n.t('Failed to connect to "{cluster}"', { cluster: this.cluster.name }), + { + modal: true, + detail: + l10n.t( + 'MongoDB Atlas closed the TLS connection with an internal error. This is a transport-level failure rather than an authentication response, so it is not what an incorrect username or password looks like: those report "bad auth : Authentication failed".', + ) + + '\n\n' + + l10n.t('Worth checking in MongoDB Atlas:') + + '\n' + + l10n.t('- Is this machine\u2019s IP address on the project\u2019s IP access list?') + + '\n' + + l10n.t('- Is the cluster paused, or still being provisioned?') + + '\n\n' + + l10n.t('Error: {error}', { error: errorMessage }), + }, + openNetworkAccess, + ); + + if (selected === openNetworkAccess) { + context.telemetry.properties.atlasNetworkAccessOpened = 'true'; + await vscode.env.openExternal(vscode.Uri.parse(buildAtlasNetworkAccessUrl(this.cluster.projectId))); + } + } + + /** + * Returns the tree item representation with Atlas-specific display. + * + * Deliberately does NOT use `vscode-documentdb-cluster-{light,dark}-themes.svg`. Those + * files are byte-identical copies of the DocumentDB product logo, so stamping them on an + * Atlas cluster would brand somebody else's managed service as DocumentDB. The Kubernetes + * plugin does use them, and correctly so: it discovers actual DocumentDB deployments. + * + * `server-environment` is the same neutral codicon the Connections view already draws for a + * non-emulator cluster, so a discovered Atlas cluster and a saved one read the same. + * The icon stays fixed across refreshes; transient cluster state is carried by the + * description and tooltip instead. + */ + getTreeItem(): vscode.TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + label: this.cluster.name, + description: this.buildDescription(), + tooltip: this.buildTooltip(), + iconPath: new vscode.ThemeIcon('server-environment'), + // A non-IDLE cluster, or one Atlas has not published a connection string for yet, cannot + // be connected to. Mark it as a leaf so expanding it does not reach an internal + // assertion; the tooltip explains why. This mirrors the wizard's guard in + // `SelectAtlasClusterStep`, so the two surfaces agree. + collapsibleState: this.isConnectable() + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None, + }; + } + + /** IDLE with a known connection string is the only state a cluster can be opened from. */ + private isConnectable(): boolean { + return isAtlasClusterConnectable(this.cluster); + } + + /** Localized reason a non-connectable cluster cannot be opened, for tooltips and guards. */ + private describeUnavailable(): string { + return ( + this.getStateExplanation() ?? + l10n.t( + 'This cluster does not expose a connection string yet. Try refreshing once it finishes provisioning.', + ) + ); + } + + /** + * Prompts the user for credentials using a wizard. + */ + private async promptForCredentials(wizardContext: AuthenticateWizardContext): Promise { + const wizard = new AzureWizard(wizardContext, { + promptSteps: [ + new ChooseAuthMethodStep(), + new SelectAtlasDatabaseUserStep((signal) => this.listDatabaseUserCandidates(signal), this.cluster.name), + new ProvideUserNameStep(), + new ProvidePasswordStep(), + ], + title: l10n.t('Authenticate to Connect with Your Atlas Cluster'), + showLoadingPrompt: true, + }); + + await callWithTelemetryAndErrorHandling('connect.promptForCredentials', async (context: IActionContext) => { + context.telemetry.properties.view = Views.DiscoveryView; + context.telemetry.properties.discoveryProviderId = DISCOVERY_PROVIDER_ID; + context.telemetry.properties.credentialsRequired = 'true'; + context.telemetry.properties.credentialPromptReason = 'firstTime'; + + context.errorHandling.rethrow = true; + context.errorHandling.suppressDisplay = false; + try { + await wizard.prompt(); + } catch (error) { + if (error instanceof UserCancelledError) { + wizardContext.aborted = true; + } + } + }); + + return !wizardContext.aborted; + } + + private buildDescription(): string { + const parts: string[] = []; + + if (this.contextDescription) { + // List mode already carries the organization and project, so repeating the tier here + // would only add noise. State is still worth showing when it is not IDLE. + parts.push(this.contextDescription); + } else if (this.cluster.instanceSizeName) { + // The tier (e.g. "M10") should show. When the tier is unavailable (e.g. serverless clusters), fall back to the provider/region pair. + parts.push(this.cluster.instanceSizeName); + } else { + if (this.cluster.providerName) { + parts.push(this.cluster.providerName); + } + if (this.cluster.regionName) { + parts.push(this.formatRegion(this.cluster.regionName)); + } + } + + const stateLabel = this.getStateLabel(); + if (stateLabel) { + parts.push(stateLabel); + } + + return parts.join(' · '); + } + + private buildTooltip(): vscode.MarkdownString { + const md = new vscode.MarkdownString(); + md.isTrusted = false; + + md.appendMarkdown(`**${escapeMarkdown(this.cluster.name)}**\n\n`); + + // One localized field list so every label defaults to being translated. "Server version" + // (not "MongoDB") both localizes the label and avoids using "MongoDB" as a standalone + // product name, per the repository terminology policy. + const fields: Array<[string, string | undefined]> = [ + [l10n.t('State'), this.cluster.stateName], + [l10n.t('Availability'), this.cluster.paused ? l10n.t('Paused') : undefined], + [l10n.t('Type'), this.cluster.clusterType], + [l10n.t('Server version'), this.cluster.mongoDBVersion ? `v${this.cluster.mongoDBVersion}` : undefined], + [l10n.t('Tier'), this.cluster.instanceSizeName], + [l10n.t('Provider'), this.cluster.providerName], + [l10n.t('Region'), this.cluster.regionName ? this.formatRegion(this.cluster.regionName) : undefined], + [l10n.t('Project'), this.cluster.projectName], + ]; + + for (const [label, value] of fields) { + if (value) { + md.appendMarkdown(`- **${label}:** ${escapeMarkdown(value)}\n`); + } + } + + const stateExplanation = this.getStateExplanation(); + if (stateExplanation) { + md.appendMarkdown(`\n---\n`); + md.appendMarkdown(escapeMarkdown(stateExplanation)); + return md; + } + + md.appendMarkdown(`\n---\n`); + md.appendMarkdown( + this.cluster.connectionString + ? l10n.t('Connection string available. Expand to connect and browse databases.') + : escapeMarkdown(this.describeUnavailable()), + ); + + return md; + } + + /** + * Returns a short, localized label for the current cluster state, or `undefined` for the + * normal IDLE state (which needs no annotation). Shown in the tree item description. + */ + private getStateLabel(): string | undefined { + return getAtlasClusterStateLabel(this.cluster); + } + + /** + * Returns a localized, human-readable explanation of a non-IDLE cluster state for the + * tooltip, or `undefined` when the cluster is IDLE. + */ + private getStateExplanation(): string | undefined { + if (isAtlasClusterPaused(this.cluster)) { + return getAtlasPausedExplanation(); + } + + switch (this.cluster.stateName) { + case 'CREATING': + return l10n.t( + 'This cluster is being created. It will be available to connect once creation is complete.', + ); + case 'UPDATING': + return l10n.t('This cluster is being updated. It may be temporarily unavailable.'); + case 'REPAIRING': + return l10n.t('This cluster is being repaired. It may be temporarily unavailable.'); + case 'DELETING': + return l10n.t('This cluster is being deleted and will no longer be available.'); + case 'UNKNOWN': + return l10n.t('This cluster is in an unknown state. Try refreshing to update its status.'); + case 'IDLE': + default: + return undefined; + } + } + + private formatRegion(region: string): string { + return region.replace(/_/g, '-').toLowerCase(); + } +} diff --git a/src/plugins/service-atlas-mongodb/discovery-tree/AtlasOrganizationItem.ts b/src/plugins/service-atlas-mongodb/discovery-tree/AtlasOrganizationItem.ts new file mode 100644 index 000000000..b5c91d80a --- /dev/null +++ b/src/plugins/service-atlas-mongodb/discovery-tree/AtlasOrganizationItem.ts @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as vscode from 'vscode'; +import { ext } from '../../../extensionVariables'; +import { type ExtTreeElementBase, type TreeElement } from '../../../tree/TreeElement'; +import { + isTreeElementWithContextValue, + type TreeElementWithContextValue, +} from '../../../tree/TreeElementWithContextValue'; +import { type TreeElementWithRetryChildren } from '../../../tree/TreeElementWithRetryChildren'; +import { escapeMarkdown } from '../../../webviews/utils/escapeMarkdown'; +import { atlasTrace } from '../atlasTrace'; +import { type AtlasDiscoveryService } from '../discovery/AtlasDiscoveryService'; +import { type AtlasOrganization } from '../models/AtlasProjectModel'; +import { AtlasProjectItem } from './AtlasProjectItem'; +import { createEmptyPlaceholderNode } from './atlasTreeNodes'; + +/** + * Tree item for a MongoDB Atlas organization. + * + * The organization is the natural top level: every credential resolves to one organization, and + * two credentials for the same organization merge into a single node whose project children are + * the union of what each credential can see. + * + * The node is deliberately quiet: no `via ` description, no credential attribution. When + * one of several credentials for this organization is unhealthy, the node carries a warning icon + * only, and recovery happens through the single consolidated credentials row at the root. + */ +export class AtlasOrganizationItem implements TreeElement, TreeElementWithContextValue, TreeElementWithRetryChildren { + public readonly id: string; + public contextValue: string = 'enableRefreshCommand;treeItem_atlasOrganization'; + + constructor( + parentId: string, + private readonly organization: AtlasOrganization, + private readonly discoveryService: AtlasDiscoveryService, + /** True when at least one credential that resolves to this organization is unhealthy. */ + private readonly degraded: boolean = false, + /** Correlates the discovery journey down to each cluster; empty when not threaded. */ + private readonly journeyCorrelationId: string = '', + ) { + this.id = `${parentId}/${organization.id}`; + } + + async getChildren(): Promise { + // Reads the shared snapshot, which is cached only briefly: expanding several organizations + // in one go must not re-run the fleet query per node, but navigating back later should see + // current data rather than a frozen one. + const snapshot = await this.discoveryService.listAll(); + const projects = snapshot.projects.filter((entry) => entry.project.orgId === this.organization.id); + + atlasTrace( + `organization "${this.organization.name}": ${String(projects.length)} project(s) from the current snapshot`, + ); + + if (projects.length === 0) { + return [ + createEmptyPlaceholderNode( + this, + vscode.l10n.t( + 'No projects are visible here yet. Check the project access and roles of the credentials for this organization in MongoDB Atlas.', + ), + ), + ]; + } + + return projects.map( + (entry) => + new AtlasProjectItem( + this.id, + entry.project, + this.discoveryService, + entry.ownerCredentialId, + this.organization.name, + this.journeyCorrelationId, + ), + ); + } + + public hasRetryNode(children: TreeElement[] | null | undefined): boolean { + return ( + children?.some((child) => isTreeElementWithContextValue(child) && child.contextValue === 'error') ?? false + ); + } + + /** + * Refreshing an organization re-attempts the whole fleet, because its project children come + * from the shared snapshot rather than from a request of its own. + * + * Without this hook the generic refresh path would simply re-read the cached snapshot, so a + * user who fixed roles in Atlas and refreshed the organization they were looking at would keep + * seeing the stale result. + */ + public async refresh(_context: IActionContext): Promise { + atlasTrace(`organization "${this.organization.name}": explicit refresh requested`); + await this.discoveryService.refreshAll(); + ext.discoveryBranchDataProvider.resetNodeErrorState(this.id); + ext.discoveryBranchDataProvider.refresh(this); + } + + public getTreeItem(): vscode.TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + label: this.organization.name, + tooltip: this.buildTooltip(), + iconPath: new vscode.ThemeIcon(this.degraded ? 'warning' : 'organization'), + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }; + } + + private buildTooltip(): vscode.MarkdownString { + const md = new vscode.MarkdownString(); + md.isTrusted = false; + + md.appendMarkdown(`**${escapeMarkdown(this.organization.name)}**\n\n`); + md.appendMarkdown(`- **${vscode.l10n.t('Organization ID')}:** ${escapeMarkdown(this.organization.id)}\n`); + + if (this.degraded) { + md.appendMarkdown(`\n---\n`); + md.appendMarkdown( + escapeMarkdown( + vscode.l10n.t( + 'Some projects may be hidden. A credential for this organization needs attention; use "Click here to revisit credentials".', + ), + ), + ); + } + + return md; + } +} diff --git a/src/plugins/service-atlas-mongodb/discovery-tree/AtlasProjectItem.test.ts b/src/plugins/service-atlas-mongodb/discovery-tree/AtlasProjectItem.test.ts new file mode 100644 index 000000000..9e2d40bc6 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/discovery-tree/AtlasProjectItem.test.ts @@ -0,0 +1,214 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +class MarkdownStringMock { + public value = ''; + public isTrusted = false; + public appendMarkdown(text: string): this { + this.value += text; + return this; + } +} + +jest.mock('vscode', () => ({ + TreeItemCollapsibleState: { None: 0, Collapsed: 1, Expanded: 2 }, + ThemeIcon: class ThemeIcon { + constructor(public readonly id: string) {} + }, + MarkdownString: MarkdownStringMock, + l10n: { + t: jest.fn((template: string, ...args: unknown[]) => + template.replace(/\{(\d+)\}/g, (_match: string, index: string) => String(args[Number(index)])), + ), + }, + window: { showErrorMessage: jest.fn() }, +})); + +jest.mock('./AtlasClusterItem', () => ({ + AtlasClusterItem: class AtlasClusterItem { + constructor( + _journeyCorrelationId: string, + public readonly cluster: { clusterId: string; treeId: string }, + ) {} + }, +})); + +const mockListClusters = jest.fn(); + +jest.mock('../api/AtlasApiClient', () => ({ + AtlasApiError: class AtlasApiError extends Error { + constructor( + message: string, + public readonly statusCode: number, + ) { + super(message); + } + }, + AtlasApiClient: class AtlasApiClientMock { + constructor( + public readonly session: unknown, + public readonly refresher: unknown, + ) {} + listClusters = (...args: unknown[]) => mockListClusters(...args) as unknown; + }, +})); + +jest.mock('../../../extensionVariables', () => ({ + ext: { + outputChannel: { trace: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), appendLine: jest.fn() }, + discoveryBranchDataProvider: { resetNodeErrorState: jest.fn(), refresh: jest.fn() }, + }, +})); + +import { window } from 'vscode'; +import { type AtlasDiscoveryService } from '../discovery/AtlasDiscoveryService'; +import { type AtlasProject } from '../models/AtlasProjectModel'; +import { AtlasProjectItem } from './AtlasProjectItem'; + +const discoveryServiceStub = {} as AtlasDiscoveryService; + +function buildProject(overrides: Partial = {}): AtlasProject { + return { + id: '5f1a2b3c4d5e6f7a8b9c0d1e', + name: 'Payments', + orgId: 'org-1', + clusterCount: 2, + created: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +function tooltipValue(project: AtlasProject, orgName?: string): string { + const item = new AtlasProjectItem('parent', project, discoveryServiceStub, 'credential-1', orgName); + const tooltip = item.getTreeItem().tooltip as unknown as MarkdownStringMock; + return tooltip.value; +} + +describe('AtlasProjectItem tooltip', () => { + it('renders plain names unchanged apart from Markdown escaping', () => { + const value = tooltipValue(buildProject(), 'Acme Corp'); + + expect(value).toContain('**Payments**'); + expect(value).toContain('Acme Corp'); + expect(value).toContain('**Clusters:** 2'); + }); + + it('escapes Markdown emphasis in the project name', () => { + const value = tooltipValue(buildProject({ name: '**not bold**' })); + + expect(value).toContain('\\*\\*not bold\\*\\*'); + expect(value).not.toContain('***not bold***'); + }); + + it('escapes link-like organization names so they cannot render as links', () => { + const value = tooltipValue(buildProject(), '[click me](https://example.invalid)'); + + expect(value).toContain('\\[click me\\]\\(https://example\\.invalid\\)'); + expect(value).not.toContain('](https://example.invalid)'); + }); + + it('escapes Markdown punctuation in the project id', () => { + const value = tooltipValue(buildProject({ id: 'id_with_underscores' })); + + expect(value).toContain('id\\_with\\_underscores'); + }); + + it('keeps the tooltip untrusted', () => { + const item = new AtlasProjectItem('parent', buildProject(), discoveryServiceStub, 'credential-1'); + const tooltip = item.getTreeItem().tooltip as unknown as MarkdownStringMock; + + expect(tooltip.isTrusted).toBe(false); + }); +}); + +describe('AtlasProjectItem getChildren failure handling (NEW-3)', () => { + const showErrorMessage = window.showErrorMessage as jest.Mock; + const mockGetSession = jest.fn(); + const mockRefreshSession = jest.fn(); + + function makeService(): AtlasDiscoveryService { + return { + sessionRegistry: { + getSession: mockGetSession, + refreshSession: mockRefreshSession, + refresherFor: () => ({ tryRefreshIfPossible: jest.fn() }), + }, + } as unknown as AtlasDiscoveryService; + } + + beforeEach(() => { + showErrorMessage.mockReset(); + mockListClusters.mockReset(); + mockGetSession.mockReset(); + mockRefreshSession.mockReset(); + mockGetSession.mockResolvedValue({ type: 'apikey', publicKey: 'p', privateKey: 's' }); + }); + + it('uses the stable unprefixed cluster suffix as the tree leaf', async () => { + mockListClusters.mockResolvedValue([ + { + id: 'cluster-1', + name: 'Cluster0', + mongoDBVersion: '7.0', + stateName: 'IDLE', + clusterType: 'REPLICASET', + }, + ]); + const project = buildProject({ id: 'p1' }); + const item = new AtlasProjectItem('parent/org-1', project, makeService(), 'credential-1'); + + const children = (await item.getChildren()) as unknown as Array<{ + cluster: { clusterId: string; treeId: string }; + }>; + + expect(children[0].cluster.clusterId).toBe('atlas-mongodb-discovery_p1_Cluster0'); + expect(children[0].cluster.treeId).toBe('parent/org-1/p1/p1_Cluster0'); + }); + + it('shows a modal once on a plain expansion failure and returns the retry node', async () => { + mockListClusters.mockRejectedValue(new TypeError('fetch failed')); + const item = new AtlasProjectItem('parent', buildProject(), makeService(), 'credential-1'); + + const children = await item.getChildren(); + + expect(showErrorMessage).toHaveBeenCalledTimes(1); + expect(children).toHaveLength(1); + }); + + it('classifies a network failure with retry wording, not credential-blaming wording', async () => { + mockListClusters.mockRejectedValue(new TypeError('fetch failed')); + const item = new AtlasProjectItem('parent', buildProject(), makeService(), 'credential-1'); + + await item.getChildren(); + + const detail = (showErrorMessage.mock.calls[0][1] as { detail: string }).detail; + expect(detail).toContain('could not be reached'); + expect(detail).not.toContain('rejected'); + }); + + it('suppresses the modal on the expansion that immediately follows a refresh', async () => { + mockListClusters.mockRejectedValue(new TypeError('fetch failed')); + mockRefreshSession.mockResolvedValue(undefined); + const item = new AtlasProjectItem('parent', buildProject(), makeService(), 'credential-1'); + + await item.refresh({} as never); + const children = await item.getChildren(); + + expect(showErrorMessage).not.toHaveBeenCalled(); + expect(children).toHaveLength(1); + }); + + it('shows the modal again on a second expansion after a single refresh', async () => { + mockListClusters.mockRejectedValue(new TypeError('fetch failed')); + mockRefreshSession.mockResolvedValue(undefined); + const item = new AtlasProjectItem('parent', buildProject(), makeService(), 'credential-1'); + + await item.refresh({} as never); + await item.getChildren(); // quiet + await item.getChildren(); // must show + + expect(showErrorMessage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/plugins/service-atlas-mongodb/discovery-tree/AtlasProjectItem.ts b/src/plugins/service-atlas-mongodb/discovery-tree/AtlasProjectItem.ts new file mode 100644 index 000000000..4fc33cc10 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/discovery-tree/AtlasProjectItem.ts @@ -0,0 +1,187 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as vscode from 'vscode'; +import { Views } from '../../../documentdb/Views'; +import { AtlasExperience } from '../../../DocumentDBExperiences'; +import { ext } from '../../../extensionVariables'; +import { createGenericElementWithContext } from '../../../tree/api/createGenericElementWithContext'; +import { type ExtTreeElementBase, type TreeElement } from '../../../tree/TreeElement'; +import { + isTreeElementWithContextValue, + type TreeElementWithContextValue, +} from '../../../tree/TreeElementWithContextValue'; +import { type TreeElementWithRetryChildren } from '../../../tree/TreeElementWithRetryChildren'; +import { escapeMarkdown } from '../../../webviews/utils/escapeMarkdown'; +import { AtlasApiClient } from '../api/AtlasApiClient'; +import { atlasTrace } from '../atlasTrace'; +import { type AtlasDiscoveryService, classifyAtlasError } from '../discovery/AtlasDiscoveryService'; +import { createAtlasClusterModel, createAtlasClusterStableSuffix } from '../models/AtlasClusterModel'; +import { type AtlasProject } from '../models/AtlasProjectModel'; +import { AtlasClusterItem } from './AtlasClusterItem'; +import { createEmptyPlaceholderNode } from './atlasTreeNodes'; +import { recoveryHintFor, showAtlasLoadFailure } from './showAtlasLoadFailure'; + +/** + * Tree item representing a MongoDB Atlas project. + * + * Clusters are fetched on expand through the credential that owns this project in the merged + * snapshot, so a project visible through two credentials still issues exactly one request. + */ +export class AtlasProjectItem implements TreeElement, TreeElementWithContextValue, TreeElementWithRetryChildren { + public readonly id: string; + public contextValue: string = 'enableRefreshCommand;treeItem_atlasProject'; + + /** + * Set by {@link refresh} and consumed by the next {@link getChildren}. + * + * Refresh is a passive, whole-subtree action: the user is not asking about this project in + * particular, so a failure belongs in the retry node, not a dialog. Expanding the node, or + * clicking "Click here to retry" (which routes through `retryAuthentication`, not this method), + * *is* a question about this project and still answers with a modal. That asymmetry is what + * keeps the two paths distinguishable without extra command plumbing. + */ + private suppressNextLoadModal = false; + + constructor( + parentId: string, + private readonly project: AtlasProject, + private readonly discoveryService: AtlasDiscoveryService, + private readonly ownerCredentialId: string, + private readonly orgName?: string, + /** Correlates the discovery journey down to each cluster; empty when not threaded. */ + private readonly journeyCorrelationId: string = '', + ) { + this.id = `${parentId}/${project.id}`; + } + + async getChildren(): Promise { + // One-shot, read (and reset) at the very top so an early return or a throw cannot leave a + // stale `true` that silences the next genuine expansion. + const quiet = this.suppressNextLoadModal; + this.suppressNextLoadModal = false; + + atlasTrace(`project "${this.project.name}": expanding, listing clusters through its owning credential`); + try { + const session = await this.discoveryService.sessionRegistry.getSession(this.ownerCredentialId); + if (!session) { + // The registry returns `undefined` only for a genuinely rejected/absent credential; + // transient token failures throw and are handled by the catch below. + if (!quiet) { + showAtlasLoadFailure( + vscode.l10n.t('Failed to load MongoDB Atlas clusters.'), + new Error(vscode.l10n.t('The credential for this project was rejected.')), + recoveryHintFor('auth'), + ); + } + return [this.createRetryNode()]; + } + + const client = new AtlasApiClient( + session, + this.discoveryService.sessionRegistry.refresherFor(this.ownerCredentialId), + ); + const clusters = await client.listClusters(this.project.id); + + if (clusters.length === 0) { + return [ + createEmptyPlaceholderNode(this, vscode.l10n.t('This project does not contain any clusters yet.')), + ]; + } + + return clusters + .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })) + .map((cluster) => { + const model = createAtlasClusterModel(this.project.id, this.project.name, cluster, AtlasExperience); + const treeCluster = { + ...model, + treeId: `${this.id}/${createAtlasClusterStableSuffix(this.project.id, cluster.name)}`, + viewId: Views.DiscoveryView, + }; + return new AtlasClusterItem(this.journeyCorrelationId, treeCluster, undefined, { + service: this.discoveryService, + ownerCredentialId: this.ownerCredentialId, + }); + }); + } catch (error) { + // Classify so a network / rate-limit failure says "retry" rather than "revisit + // credentials". The real error text is carried into the modal and the output channel. + if (!quiet) { + showAtlasLoadFailure( + vscode.l10n.t('Failed to load MongoDB Atlas clusters.'), + error, + recoveryHintFor(classifyAtlasError(error).kind), + ); + } + return [this.createRetryNode()]; + } + } + + public hasRetryNode(children: TreeElement[] | null | undefined): boolean { + return ( + children?.some((child) => isTreeElementWithContextValue(child) && child.contextValue === 'error') ?? false + ); + } + + /** + * Refreshing a project re-derives its owning credential's session before listing clusters, so + * a role change made in Atlas takes effect immediately instead of waiting for the cached + * Service Account token to expire. + */ + public async refresh(_context: IActionContext): Promise { + // Refresh is passive: suppress the next expansion's modal so the failure lands quietly in + // the retry node. `retryAuthentication` (the "Click here to retry" handler) does not call + // this method, so an explicit retry still shows the modal. + this.suppressNextLoadModal = true; + atlasTrace(`project "${this.project.name}": explicit refresh requested`); + // A transient token failure now throws; swallow it so the refresh still resets the error + // state and re-runs getChildren, which reclassifies and returns the retry node quietly. + await this.discoveryService.sessionRegistry.refreshSession(this.ownerCredentialId).catch(() => undefined); + ext.discoveryBranchDataProvider.resetNodeErrorState(this.id); + ext.discoveryBranchDataProvider.refresh(this); + } + + public getTreeItem(): vscode.TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + label: this.project.name, + tooltip: this.buildTooltip(), + iconPath: new vscode.ThemeIcon('project'), + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }; + } + + private buildTooltip(): vscode.MarkdownString { + const md = new vscode.MarkdownString(); + md.isTrusted = false; + + md.appendMarkdown(`**${escapeMarkdown(this.project.name)}**\n\n`); + if (this.orgName) { + md.appendMarkdown(`- **${vscode.l10n.t('Organization')}:** ${escapeMarkdown(this.orgName)}\n`); + } + md.appendMarkdown(`- **${vscode.l10n.t('Project ID')}:** ${escapeMarkdown(this.project.id)}\n`); + md.appendMarkdown(`- **${vscode.l10n.t('Clusters')}:** ${String(this.project.clusterCount)}\n`); + + return md; + } + + /** + * A scoped cluster-list failure is a project-level problem, not necessarily a credential one, + * so it offers a plain retry. Credential-level failures are handled by the single + * "revisit credentials" row at the root. + */ + private createRetryNode(): TreeElement & TreeElementWithContextValue { + return createGenericElementWithContext({ + contextValue: 'error', + id: `${this.id}/retry`, + label: vscode.l10n.t('Click here to retry'), + iconPath: new vscode.ThemeIcon('refresh'), + commandId: 'vscode-documentdb.command.internal.retry', + commandArgs: [this], + }); + } +} diff --git a/src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts b/src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts new file mode 100644 index 000000000..14324b35f --- /dev/null +++ b/src/plugins/service-atlas-mongodb/discovery-tree/AtlasServiceRootItem.ts @@ -0,0 +1,202 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createContextValue, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { randomUUID } from 'crypto'; +import * as vscode from 'vscode'; +import { Views } from '../../../documentdb/Views'; +import { AtlasExperience } from '../../../DocumentDBExperiences'; +import { ext } from '../../../extensionVariables'; +import { createGenericElementWithContext } from '../../../tree/api/createGenericElementWithContext'; +import { type ExtTreeElementBase, type TreeElement } from '../../../tree/TreeElement'; +import { + isTreeElementWithContextValue, + type TreeElementWithContextValue, +} from '../../../tree/TreeElementWithContextValue'; +import { type TreeElementWithRetryChildren } from '../../../tree/TreeElementWithRetryChildren'; +import { atlasTrace } from '../atlasTrace'; +import { getAtlasViewMode } from '../commands/switchAtlasViewMode'; +import { DISCOVERY_PROVIDER_ID } from '../config'; +import { readAtlasCredentials } from '../credentials/atlasCredentialStore'; +import { ADD_ATLAS_CREDENTIAL_COMMAND_ID } from '../credentialsManagement/addAtlasCredential'; +import { + snapshotHasFailures, + type AtlasDiscoveryService, + type AtlasDiscoverySnapshot, +} from '../discovery/AtlasDiscoveryService'; +import { createAtlasClusterModel, createAtlasClusterStableSuffix } from '../models/AtlasClusterModel'; +import { AtlasClusterItem } from './AtlasClusterItem'; +import { AtlasOrganizationItem } from './AtlasOrganizationItem'; +import { createEmptyPlaceholderNode, createRecoveryNode } from './atlasTreeNodes'; + +/** + * Root tree item for the MongoDB Atlas discovery provider. + * + * Renders the quiet merged tree: organization to project to cluster, with duplicate resources + * merged by Atlas ID and no per-node credential attribution. Whatever goes wrong across the + * credential fleet collapses into a single recovery row, so one broken credential never blanks the + * healthy data and never produces a storm of nodes or modals. That row asks for a retry or for a + * credential review depending on what actually failed. + */ +export class AtlasServiceRootItem implements TreeElement, TreeElementWithContextValue, TreeElementWithRetryChildren { + public readonly id: string; + + /** + * Must stay a writable property: the discovery branch data provider appends its own markers + * (for example `rootItem`) onto root elements, so a getter-only accessor breaks activation. + * The view-mode marker is therefore folded in at {@link getTreeItem} time instead of being + * baked into this field, which keeps it current after a toggle without accumulating stale + * markers. + */ + public contextValue: string = + 'enableRefreshCommand;enableManageCredentialsCommand;enableLearnMoreCommand;discoveryAtlasServiceRootItem'; + + /** + * Correlates a single discovery journey (root expansion → connect) across telemetry events, + * matching the other discovery providers. Threaded down to every cluster item so a connection + * can be attributed back to the expansion that surfaced it. + */ + private readonly journeyCorrelationId = randomUUID(); + + constructor( + private readonly discoveryService: AtlasDiscoveryService, + public readonly parentId: string, + ) { + this.id = `${parentId}/${DISCOVERY_PROVIDER_ID}`; + } + + /** + * The current view mode is part of the rendered context value so the toggle command can be + * gated on it: the icon reflects the current mode and the action switches to the other one. + */ + private get viewModeContextValue(): string { + return getAtlasViewMode() === 'list' ? 'discoveryAtlasViewModeList' : 'discoveryAtlasViewModeTree'; + } + + async getChildren(): Promise { + const credentials = await readAtlasCredentials(); + if (credentials.length === 0) { + atlasTrace('root: no credentials stored, showing the sign-in row'); + return [this.createSignInNode()]; + } + + const listMode = getAtlasViewMode() === 'list'; + atlasTrace( + `root: expanding in ${listMode ? 'list' : 'tree'} mode with ${String(credentials.length)} credential(s)`, + ); + const snapshot = await this.discoveryService.listAll({ includeClusters: listMode }); + + const children: ExtTreeElementBase[] = []; + if (snapshotHasFailures(snapshot)) { + // The recovery row is just another row, so it drops into a flat list unchanged and + // List mode needs no special casing: a failure never forces a view-mode switch. + children.push(createRecoveryNode(this, snapshot)); + } + + children.push( + ...(listMode ? this.buildClusterRows(snapshot) : this.buildOrganizationRows(snapshot, credentials)), + ); + + if (children.length === 0) { + atlasTrace('root: nothing visible to any credential, showing the empty placeholder'); + return [ + createEmptyPlaceholderNode( + this, + vscode.l10n.t( + 'These credentials cannot see any organizations yet. Check their project access and roles in MongoDB Atlas.', + ), + ), + ]; + } + + return children; + } + + /** Tree mode: one node per merged organization. */ + private buildOrganizationRows( + snapshot: AtlasDiscoverySnapshot, + credentials: Awaited>, + ): ExtTreeElementBase[] { + // Organizations whose only credentials failed keep no data of their own; a credential's + // cached organization id is what lets a partially-degraded organization still be flagged. + const degradedOrgIds = new Set( + snapshot.credentialErrors + .map((error) => credentials.find((record) => record.id === error.credentialId)?.orgId) + .filter((orgId): orgId is string => typeof orgId === 'string'), + ); + + return snapshot.organizations.map( + (entry) => + new AtlasOrganizationItem( + this.id, + entry.organization, + this.discoveryService, + degradedOrgIds.has(entry.organization.id), + this.journeyCorrelationId, + ), + ); + } + + /** List mode: a flat, deduplicated cluster list carrying `organization · project` context. */ + private buildClusterRows(snapshot: AtlasDiscoverySnapshot): ExtTreeElementBase[] { + const orgNames = new Map( + snapshot.organizations.map((entry) => [entry.organization.id, entry.organization.name]), + ); + + return snapshot.clusters.map((entry) => { + const model = createAtlasClusterModel(entry.projectId, entry.projectName, entry.cluster, AtlasExperience); + const treeCluster = { + ...model, + treeId: `${this.id}/${entry.projectId}/${createAtlasClusterStableSuffix(entry.projectId, entry.cluster.name)}`, + viewId: Views.DiscoveryView, + }; + const orgName = orgNames.get(entry.orgId); + const context = orgName ? `${orgName} · ${entry.projectName}` : entry.projectName; + return new AtlasClusterItem(this.journeyCorrelationId, treeCluster, context, { + service: this.discoveryService, + ownerCredentialId: entry.ownerCredentialId, + }); + }); + } + + /** + * Explicit refresh re-attempts every credential, healthy and failed alike, and re-derives + * every session first. Passive expansion reuses the cached snapshot, so a persistently failing + * credential is not hammered every time a node is expanded. + */ + public async refresh(_context: IActionContext): Promise { + atlasTrace('root: explicit refresh requested'); + await this.discoveryService.refreshAll({ includeClusters: getAtlasViewMode() === 'list' }); + ext.discoveryBranchDataProvider.resetNodeErrorState(this.id); + ext.discoveryBranchDataProvider.refresh(this); + } + + public hasRetryNode(children: TreeElement[] | null | undefined): boolean { + return ( + children?.some((child) => isTreeElementWithContextValue(child) && child.contextValue === 'error') ?? false + ); + } + + public getTreeItem(): vscode.TreeItem { + return { + id: this.id, + contextValue: createContextValue([this.contextValue, this.viewModeContextValue]), + label: vscode.l10n.t('MongoDB Atlas'), + iconPath: new vscode.ThemeIcon('cloud'), + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }; + } + + private createSignInNode(): TreeElement & TreeElementWithContextValue { + return createGenericElementWithContext({ + contextValue: 'error', + id: `${this.id}/sign-in`, + label: vscode.l10n.t('Sign in to view MongoDB Atlas clusters'), + iconPath: new vscode.ThemeIcon('sign-in'), + commandId: ADD_ATLAS_CREDENTIAL_COMMAND_ID, + commandArgs: [this], + }); + } +} diff --git a/src/plugins/service-atlas-mongodb/discovery-tree/atlasTree.test.ts b/src/plugins/service-atlas-mongodb/discovery-tree/atlasTree.test.ts new file mode 100644 index 000000000..f1943477d --- /dev/null +++ b/src/plugins/service-atlas-mongodb/discovery-tree/atlasTree.test.ts @@ -0,0 +1,473 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const globalStateBacking = new Map(); +const secretStorageBacking = new Map(); + +class MarkdownStringMock { + public value = ''; + public isTrusted = false; + public appendMarkdown(text: string): this { + this.value += text; + return this; + } +} + +jest.mock('vscode', () => ({ + TreeItemCollapsibleState: { None: 0, Collapsed: 1, Expanded: 2 }, + ThemeIcon: class ThemeIcon { + constructor(public readonly id: string) {} + }, + MarkdownString: MarkdownStringMock, + EventEmitter: class EventEmitter { + public fire(): void { + // no-op + } + public get event(): jest.Mock { + return jest.fn(); + } + public dispose(): void { + // no-op + } + }, + window: { showErrorMessage: jest.fn(), showWarningMessage: jest.fn() }, + l10n: { + t: jest.fn((template: string, ...args: unknown[]) => + template.replace(/\{(\d+)\}/g, (_match: string, index: string) => String(args[Number(index)])), + ), + }, +})); + +jest.mock('../../../extensionVariables', () => ({ + ext: { + context: { + extension: { id: 'test-extension' }, + subscriptions: { push: (): void => {} }, + globalState: { + get: (key: string, defaultValue?: T): T | undefined => { + const value = globalStateBacking.has(key) ? (globalStateBacking.get(key) as T) : undefined; + return value === undefined ? defaultValue : value; + }, + update: async (key: string, value: unknown): Promise => { + if (value === undefined) { + globalStateBacking.delete(key); + } else { + globalStateBacking.set(key, value); + } + }, + keys: () => Array.from(globalStateBacking.keys()), + }, + }, + secretStorage: { + get: async (key: string): Promise => + secretStorageBacking.has(key) ? secretStorageBacking.get(key) : undefined, + store: async (key: string, value: string): Promise => { + secretStorageBacking.set(key, value); + }, + delete: async (key: string): Promise => { + secretStorageBacking.delete(key); + }, + onDidChange: (): { dispose: () => void } => ({ dispose: (): void => {} }), + }, + discoveryBranchDataProvider: { refresh: jest.fn(), resetNodeErrorState: jest.fn() }, + outputChannel: { trace: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), appendLine: jest.fn() }, + }, +})); + +jest.mock('./AtlasClusterItem', () => ({ + AtlasClusterItem: class AtlasClusterItem { + constructor( + public readonly journeyCorrelationId: string, + public readonly cluster: { name: string }, + public readonly contextDescription?: string, + ) {} + }, +})); + +jest.mock('../../../tree/api/createGenericElementWithContext', () => ({ + createGenericElementWithContext: jest.fn((options: Record) => ({ ...options })), +})); + +// The azext-utils entry point evaluates VS Code APIs at module load time, so the whole package is +// stubbed here (matching the other tree-item test suites) instead of widening the `vscode` mock. +jest.mock('@microsoft/vscode-azext-utils', () => ({ + createContextValue: (values: string[]) => Array.from(new Set(values)).sort().join(';'), +})); + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { StorageService } from '../../../services/storageService'; +import { + resetAtlasCredentialStoreCache, + updateAtlasCredentialMetadata, + upsertAtlasCredential, +} from '../credentials/atlasCredentialStore'; +import { type AtlasDiscoveryService, type AtlasDiscoverySnapshot } from '../discovery/AtlasDiscoveryService'; +import { type AtlasCluster, type AtlasOrganization, type AtlasProject } from '../models/AtlasProjectModel'; +import { AtlasClusterItem } from './AtlasClusterItem'; +import { AtlasOrganizationItem } from './AtlasOrganizationItem'; +import { AtlasServiceRootItem } from './AtlasServiceRootItem'; + +const VIEW_MODE_KEY = 'atlas-mongodb-discovery.viewMode'; + +function cluster(name: string, groupId: string): AtlasCluster { + return { + id: `cluster-${name}`, + name, + groupId, + mongoDBVersion: '7.0', + connectionStrings: { standardSrv: `mongodb+srv://${name}.example.invalid` }, + stateName: 'IDLE', + clusterType: 'REPLICASET', + }; +} + +function org(id: string, name: string): AtlasOrganization { + return { id, name }; +} + +function project(id: string, name: string, orgId: string): AtlasProject { + return { id, name, orgId, clusterCount: 1, created: '2026-01-01T00:00:00Z' }; +} + +function snapshotOf(overrides: Partial = {}): AtlasDiscoverySnapshot { + return { + organizations: [], + projects: [], + clusters: [], + credentialErrors: [], + projectErrors: [], + credentialsQueried: 0, + clustersIncluded: false, + ...overrides, + }; +} + +function serviceStub(snapshot: AtlasDiscoverySnapshot): AtlasDiscoveryService { + return { + listAll: jest.fn().mockResolvedValue(snapshot), + refreshAll: jest.fn().mockResolvedValue(snapshot), + invalidate: jest.fn(), + reset: jest.fn(), + retryCredential: jest.fn(), + sessionRegistry: { + getSession: jest.fn(), + refresherFor: jest.fn(), + invalidate: jest.fn(), + refreshSession: jest.fn(), + }, + } as unknown as AtlasDiscoveryService; +} + +beforeEach(() => { + globalStateBacking.clear(); + secretStorageBacking.clear(); + StorageService._resetForTests(); + resetAtlasCredentialStoreCache(); +}); + +describe('AtlasServiceRootItem', () => { + it('offers a sign-in row when no credentials are stored', async () => { + const root = new AtlasServiceRootItem(serviceStub(snapshotOf()), 'discoveryView'); + + const children = (await root.getChildren()) as Array<{ id: string; commandId?: string }>; + + expect(children).toHaveLength(1); + expect(children[0].id).toContain('/sign-in'); + expect(children[0].commandId).toBe('vscode-documentdb.command.internal.atlas.addCredential'); + }); + + it('renders a quiet organization tree with no descriptions on the happy path', async () => { + await upsertAtlasCredential({ authMethod: 'apikey', publicKey: 'pub-1', privateKey: 'priv-1' }); + const service = serviceStub( + snapshotOf({ + organizations: [ + { organization: org('org-1', 'Acme Corp'), credentialIds: ['c1'], ownerCredentialId: 'c1' }, + { organization: org('org-2', 'Beta Ltd'), credentialIds: ['c1'], ownerCredentialId: 'c1' }, + ], + }), + ); + + const root = new AtlasServiceRootItem(service, 'discoveryView'); + const children = await root.getChildren(); + + expect(children).toHaveLength(2); + expect(children.every((child) => child instanceof AtlasOrganizationItem)).toBe(true); + expect(root.getTreeItem().description).toBeUndefined(); + }); + + it('adds exactly one recovery row no matter how many credentials failed', async () => { + await upsertAtlasCredential({ authMethod: 'apikey', publicKey: 'pub-1', privateKey: 'priv-1' }); + const service = serviceStub( + snapshotOf({ + organizations: [ + { organization: org('org-1', 'Acme Corp'), credentialIds: ['c1'], ownerCredentialId: 'c1' }, + ], + credentialErrors: [ + { credentialId: 'c2', label: 'Beta', kind: 'auth', message: 'session expired', retryable: true }, + { + credentialId: 'c3', + label: 'Gamma', + kind: 'forbidden', + message: 'access denied', + retryable: true, + }, + ], + }), + ); + + const root = new AtlasServiceRootItem(service, 'discoveryView'); + const children = (await root.getChildren()) as Array<{ id: string; label?: string; tooltip?: string }>; + + const recoveryRows = children.filter((child) => child.id.endsWith('/recovery')); + expect(recoveryRows).toHaveLength(1); + expect(recoveryRows[0].label).toBe('Click here to revisit credentials'); + expect(recoveryRows[0].tooltip).toContain('Beta: session expired'); + expect(recoveryRows[0].tooltip).toContain('Gamma: access denied'); + // Healthy data is still rendered next to the recovery row. + expect(children.some((child) => child instanceof AtlasOrganizationItem)).toBe(true); + }); + + it('offers a retry instead of a credential review when the failure is connectivity', async () => { + await upsertAtlasCredential({ authMethod: 'apikey', publicKey: 'pub-1', privateKey: 'priv-1' }); + const service = serviceStub( + snapshotOf({ + credentialErrors: [ + { credentialId: 'c1', label: 'Acme', kind: 'network', message: 'fetch failed', retryable: true }, + ], + }), + ); + + const root = new AtlasServiceRootItem(service, 'discoveryView'); + const children = (await root.getChildren()) as Array<{ + id: string; + label?: string; + tooltip?: string; + commandId?: string; + }>; + + const recovery = children.find((child) => child.id.endsWith('/recovery')); + expect(recovery?.label).toBe('Click here to retry'); + // The refresh command honours the root's own refresh() hook; the plain retry command would + // only re-run getChildren() and re-read the cache. + expect(recovery?.commandId).toBe('vscode-documentdb.command.refresh'); + expect(recovery?.tooltip).toContain('MongoDB Atlas could not be reached'); + }); + + it('sends a mixed failure to the credential manager, where a fleet-wide retry also lives', async () => { + await upsertAtlasCredential({ authMethod: 'apikey', publicKey: 'pub-1', privateKey: 'priv-1' }); + const service = serviceStub( + snapshotOf({ + credentialErrors: [ + { credentialId: 'c1', label: 'Acme', kind: 'network', message: 'fetch failed', retryable: true }, + { credentialId: 'c2', label: 'Beta', kind: 'auth', message: 'session expired', retryable: true }, + ], + }), + ); + + const root = new AtlasServiceRootItem(service, 'discoveryView'); + const children = (await root.getChildren()) as Array<{ id: string; label?: string; commandId?: string }>; + + const recovery = children.find((child) => child.id.endsWith('/recovery')); + expect(recovery?.label).toBe('Click here to resolve the issues'); + expect(recovery?.commandId).toBe('vscode-documentdb.command.discoveryView.manageCredentials'); + }); + + it('flags an organization whose other credential failed, keeping its healthy projects', async () => { + const healthy = await upsertAtlasCredential({ authMethod: 'apikey', publicKey: 'pub-1', privateKey: 'priv-1' }); + const broken = await upsertAtlasCredential({ authMethod: 'apikey', publicKey: 'pub-2', privateKey: 'priv-2' }); + await updateAtlasCredentialMetadata(broken.record.id, { orgId: 'org-1', orgName: 'Acme Corp' }); + + const service = serviceStub( + snapshotOf({ + organizations: [ + { + organization: org('org-1', 'Acme Corp'), + credentialIds: [healthy.record.id], + ownerCredentialId: healthy.record.id, + }, + ], + credentialErrors: [ + { + credentialId: broken.record.id, + label: 'Acme Corp', + kind: 'forbidden', + message: 'access denied', + retryable: true, + }, + ], + }), + ); + + const root = new AtlasServiceRootItem(service, 'discoveryView'); + const children = await root.getChildren(); + const orgItem = children.find((child) => child instanceof AtlasOrganizationItem) as AtlasOrganizationItem; + + expect((orgItem.getTreeItem().iconPath as { id: string }).id).toBe('warning'); + }); + + it('shows the standard empty placeholder when a healthy credential sees nothing', async () => { + await upsertAtlasCredential({ authMethod: 'apikey', publicKey: 'pub-1', privateKey: 'priv-1' }); + const root = new AtlasServiceRootItem(serviceStub(snapshotOf()), 'discoveryView'); + + const children = (await root.getChildren()) as Array<{ id: string; label?: string; iconPath?: { id: string } }>; + + expect(children).toHaveLength(1); + expect(children[0].label).toBe('empty'); + expect(children[0].iconPath?.id).toBe('indent'); + // A healthy empty result is an authoritative answer, so it must not offer a retry. + expect(children[0].id).not.toContain('retry'); + }); + + it('re-queries every credential with a fresh session on an explicit refresh', async () => { + const service = serviceStub(snapshotOf()); + const root = new AtlasServiceRootItem(service, 'discoveryView'); + + await root.refresh({} as IActionContext); + + // refreshAll re-derives every session, which is what makes a role change in Atlas visible + // instead of reusing a Service Account token minted with the old scope. + expect(service.refreshAll).toHaveBeenCalledWith({ includeClusters: false }); + }); + + it('marks the current view mode in the context value so the toggle can be gated', () => { + const root = new AtlasServiceRootItem(serviceStub(snapshotOf()), 'discoveryView'); + expect(root.getTreeItem().contextValue).toContain('discoveryAtlasViewModeTree'); + + globalStateBacking.set(VIEW_MODE_KEY, 'list'); + expect(root.getTreeItem().contextValue).toContain('discoveryAtlasViewModeList'); + }); + + it('keeps contextValue writable so the tree data provider can append its own markers', () => { + const root = new AtlasServiceRootItem(serviceStub(snapshotOf()), 'discoveryView'); + + expect(() => { + root.contextValue = `${root.contextValue};rootItem`; + }).not.toThrow(); + expect(root.getTreeItem().contextValue).toContain('rootItem'); + }); +}); + +describe('AtlasServiceRootItem in List mode', () => { + beforeEach(() => { + globalStateBacking.set(VIEW_MODE_KEY, 'list'); + }); + + it('renders a flat deduplicated cluster list with organization and project context', async () => { + await upsertAtlasCredential({ authMethod: 'apikey', publicKey: 'pub-1', privateKey: 'priv-1' }); + const service = serviceStub( + snapshotOf({ + clustersIncluded: true, + organizations: [ + { organization: org('org-1', 'Acme Corp'), credentialIds: ['c1'], ownerCredentialId: 'c1' }, + ], + clusters: [ + { + cluster: cluster('payments-prod', 'p1'), + projectId: 'p1', + projectName: 'Payments', + orgId: 'org-1', + credentialIds: ['c1', 'c2'], + ownerCredentialId: 'c1', + }, + ], + }), + ); + + const root = new AtlasServiceRootItem(service, 'discoveryView'); + const children = await root.getChildren(); + + expect(service.listAll).toHaveBeenCalledWith({ includeClusters: true }); + expect(children).toHaveLength(1); + const row = children[0] as unknown as { + cluster: { clusterId: string; treeId: string }; + contextDescription?: string; + }; + expect(children[0]).toBeInstanceOf(AtlasClusterItem); + expect(row.contextDescription).toBe('Acme Corp · Payments'); + expect(row.cluster.clusterId).toBe('atlas-mongodb-discovery_p1_payments-prod'); + expect(row.cluster.treeId).toBe('discoveryView/atlas-mongodb-discovery/p1/p1_payments-prod'); + }); + + it('keeps the same recovery row in List mode without switching views', async () => { + await upsertAtlasCredential({ authMethod: 'apikey', publicKey: 'pub-1', privateKey: 'priv-1' }); + const service = serviceStub( + snapshotOf({ + clustersIncluded: true, + organizations: [ + { organization: org('org-1', 'Acme Corp'), credentialIds: ['c1'], ownerCredentialId: 'c1' }, + ], + clusters: [ + { + cluster: cluster('payments-prod', 'p1'), + projectId: 'p1', + projectName: 'Payments', + orgId: 'org-1', + credentialIds: ['c1'], + ownerCredentialId: 'c1', + }, + ], + credentialErrors: [ + { credentialId: 'c2', label: 'Beta', kind: 'auth', message: 'session expired', retryable: true }, + ], + }), + ); + + const root = new AtlasServiceRootItem(service, 'discoveryView'); + const children = (await root.getChildren()) as Array<{ id?: string; label?: string }>; + + expect(children[0].id).toContain('/recovery'); + expect(children).toHaveLength(2); + // The healthy cluster is still listed next to the recovery row. + expect(children[1]).toBeInstanceOf(AtlasClusterItem); + }); +}); + +describe('AtlasOrganizationItem', () => { + it('lists the union of projects for its organization', async () => { + const snapshot = snapshotOf({ + projects: [ + { project: project('p1', 'Payments', 'org-1'), credentialIds: ['c1'], ownerCredentialId: 'c1' }, + { project: project('p2', 'Web', 'org-2'), credentialIds: ['c2'], ownerCredentialId: 'c2' }, + { project: project('p3', 'Analytics', 'org-1'), credentialIds: ['c2'], ownerCredentialId: 'c2' }, + ], + }); + + const item = new AtlasOrganizationItem('root', org('org-1', 'Acme Corp'), serviceStub(snapshot)); + const children = (await item.getChildren()) as Array<{ id: string }>; + + expect(children).toHaveLength(2); + expect(children.map((child) => child.id)).toEqual(['root/org-1/p1', 'root/org-1/p3']); + }); + + it('shows the empty placeholder when the organization has no visible projects', async () => { + const item = new AtlasOrganizationItem('root', org('org-1', 'Acme Corp'), serviceStub(snapshotOf())); + const children = (await item.getChildren()) as Array<{ label?: string; tooltip?: string }>; + + expect(children).toHaveLength(1); + expect(children[0].label).toBe('empty'); + expect(children[0].tooltip).toContain('project access'); + }); + + it('stays quiet on the happy path and escapes Atlas-provided text in its tooltip', () => { + const item = new AtlasOrganizationItem('root', org('org-1', '**Acme**'), serviceStub(snapshotOf())); + const treeItem = item.getTreeItem(); + + expect(treeItem.description).toBeUndefined(); + expect((treeItem.iconPath as { id: string }).id).toBe('organization'); + expect((treeItem.tooltip as unknown as MarkdownStringMock).value).toContain('\\*\\*Acme\\*\\*'); + }); + + it('re-queries the fleet when the organization itself is refreshed', async () => { + // Regression: the organization's children come from the shared snapshot, so without its own + // refresh hook the generic path just re-read the cache. A user who widened a credential's + // roles in Atlas and refreshed the organization kept seeing the stale `empty` placeholder. + const service = serviceStub(snapshotOf()); + const item = new AtlasOrganizationItem('root', org('org-1', 'Acme Corp'), service); + + await item.refresh({} as IActionContext); + + expect(service.refreshAll).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/service-atlas-mongodb/discovery-tree/atlasTreeNodes.ts b/src/plugins/service-atlas-mongodb/discovery-tree/atlasTreeNodes.ts new file mode 100644 index 000000000..4e600f049 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/discovery-tree/atlasTreeNodes.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { createGenericElementWithContext } from '../../../tree/api/createGenericElementWithContext'; +import { type TreeElement } from '../../../tree/TreeElement'; +import { type TreeElementWithContextValue } from '../../../tree/TreeElementWithContextValue'; +import { type AtlasDiscoverySnapshot, type AtlasErrorKind } from '../discovery/AtlasDiscoveryService'; + +/** Command that opens the credential-management QuickPick for a discovery provider. */ +const MANAGE_CREDENTIALS_COMMAND = 'vscode-documentdb.command.discoveryView.manageCredentials'; + +/** + * The shared refresh command. It honours a tree element's own `refresh()` hook, which is what the + * Atlas root needs here: the plain retry command only re-runs `getChildren()`, and that would + * re-read the cached snapshot instead of going back to Atlas. + */ +const REFRESH_COMMAND = 'vscode-documentdb.command.refresh'; + +/** + * Which recovery the user actually needs, derived from the error taxonomy. + * + * A single row can only offer one verb, so it has to pick the right one instead of always + * assuming the credentials are at fault. Telling someone whose network is down to go and re-enter + * their API key is both wrong and unactionable. + */ +export type AtlasRecoveryAction = 'retry' | 'revisitCredentials' | 'resolve'; + +/** + * Picks the recovery action for a snapshot. + * + * `auth` and `forbidden` are the only kinds that point at the stored secret or its roles; + * everything else (`network`, `rateLimited`, and unexpected statuses) is transient and may clear + * on a retry. When both are present the user has to triage, so the row leads to the credential + * manager, which carries both a fleet-wide retry and the per-credential actions. + */ +export function classifyRecoveryAction(snapshot: AtlasDiscoverySnapshot): AtlasRecoveryAction { + const kinds = new Set([ + ...snapshot.credentialErrors.map((error) => error.kind), + ...snapshot.projectErrors.map((error) => error.kind), + ]); + + const credentialProblem = kinds.has('auth') || kinds.has('forbidden'); + const transientProblem = kinds.has('network') || kinds.has('rateLimited') || kinds.has('other'); + + if (credentialProblem && transientProblem) { + return 'resolve'; + } + if (credentialProblem) { + return 'revisitCredentials'; + } + return 'retry'; +} + +/** + * Builds the single consolidated recovery row shown whenever any credential is unhealthy. + * + * There is exactly one row no matter how many credentials failed, so the view stays quiet. Its + * label, icon and command follow {@link classifyRecoveryAction}; the tooltip always enumerates + * the affected credentials and their reasons. + */ +export function createRecoveryNode( + parent: TreeElement, + snapshot: AtlasDiscoverySnapshot, +): TreeElement & TreeElementWithContextValue { + const action = classifyRecoveryAction(snapshot); + + return createGenericElementWithContext({ + contextValue: 'error', + id: `${parent.id}/recovery`, + label: recoveryLabel(action), + tooltip: buildRecoveryTooltip(snapshot, action), + iconPath: new vscode.ThemeIcon(action === 'retry' ? 'refresh' : 'warning'), + commandId: action === 'retry' ? REFRESH_COMMAND : MANAGE_CREDENTIALS_COMMAND, + commandArgs: [parent], + }); +} + +function recoveryLabel(action: AtlasRecoveryAction): string { + switch (action) { + case 'retry': + return vscode.l10n.t('Click here to retry'); + case 'revisitCredentials': + return vscode.l10n.t('Click here to revisit credentials'); + default: + return vscode.l10n.t('Click here to resolve the issues'); + } +} + +/** + * Summarises which credentials need attention and why, for the recovery row's tooltip. + */ +export function buildRecoveryTooltip( + snapshot: AtlasDiscoverySnapshot, + action: AtlasRecoveryAction = classifyRecoveryAction(snapshot), +): string { + const lines: string[] = []; + + if (action === 'retry') { + // Say this first: the per-credential messages below are raw API text and read like + // credential problems even when the only thing that failed was the connection itself. + lines.push(vscode.l10n.t('MongoDB Atlas could not be reached. The stored credentials are most likely fine.')); + lines.push(''); + } + + if (snapshot.credentialErrors.length === 1) { + lines.push(vscode.l10n.t('1 credential needs attention:')); + } else if (snapshot.credentialErrors.length > 1) { + lines.push(vscode.l10n.t('{0} credentials need attention:', String(snapshot.credentialErrors.length))); + } + + for (const error of snapshot.credentialErrors) { + lines.push(`• ${error.label}: ${error.message}`); + } + + if (snapshot.projectErrors.length > 0) { + lines.push(''); + lines.push(vscode.l10n.t('Some projects could not be read:')); + for (const error of snapshot.projectErrors) { + lines.push(`• ${error.projectName}: ${error.message}`); + } + } + + return lines.join('\n'); +} + +/** + * The standard "nothing here" placeholder used across the extension: an `indent` icon, the label + * `empty`, and the explanation in the tooltip. A healthy `200 []` from Atlas is an authoritative + * answer, not a failure, so it must not offer a retry. + */ +export function createEmptyPlaceholderNode( + parent: TreeElement, + tooltip: string, +): TreeElement & TreeElementWithContextValue { + return createGenericElementWithContext({ + contextValue: 'info', + id: `${parent.id}/empty`, + label: vscode.l10n.t('empty'), + tooltip, + iconPath: new vscode.ThemeIcon('indent'), + }); +} diff --git a/src/plugins/service-atlas-mongodb/discovery-tree/showAtlasLoadFailure.ts b/src/plugins/service-atlas-mongodb/discovery-tree/showAtlasLoadFailure.ts new file mode 100644 index 000000000..f6eef1958 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/discovery-tree/showAtlasLoadFailure.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { l10n, window } from 'vscode'; +import { ext } from '../../../extensionVariables'; +import { type AtlasErrorKind } from '../discovery/AtlasDiscoveryService'; + +/** + * Recovery wording for a classified discovery failure. + * + * A transient failure (rate limit, dropped connection) must not tell the user to re-enter a working + * credential, which is the credential-blaming default this replaces. + */ +export function recoveryHintFor(kind: AtlasErrorKind): string { + switch (kind) { + case 'auth': + return l10n.t('The stored credential was rejected. Update it, then try again.'); + case 'forbidden': + return l10n.t( + 'The credential is signed in but lacks access to this project. Review its roles and IP access list in MongoDB Atlas.', + ); + case 'rateLimited': + return l10n.t('MongoDB Atlas asked us to slow down. Wait briefly, then try again.'); + case 'network': + return l10n.t( + 'MongoDB Atlas could not be reached. Check your connection or proxy settings, then try again.', + ); + default: + return l10n.t('Try again. If this persists, check the output channel for details.'); + } +} + +/** + * Reports a discovery load failure: the full error to the output channel, and a modal with the + * classified recovery hint plus the real error text. + * + * `void`, not `await`, on purpose - the Kubernetes plugin does the same. Awaiting a modal inside + * `getChildren()` keeps the tree node spinning until the dialog is dismissed and queues one dialog + * per expanded project. The caller decides whether to show it at all (a passive Refresh suppresses + * it; an expand or an explicit retry does not). + */ +export function showAtlasLoadFailure(title: string, error: unknown, hint: string): void { + const message = error instanceof Error ? error.message : String(error); + ext.outputChannel.error(l10n.t('Failed to load MongoDB Atlas discovery: {0}', message)); + + void window.showErrorMessage(title, { + modal: true, + detail: `${hint}\n\n${l10n.t('Error: {0}', message)}`, + }); +} diff --git a/src/plugins/service-atlas-mongodb/discovery-wizard/AtlasExecuteStep.ts b/src/plugins/service-atlas-mongodb/discovery-wizard/AtlasExecuteStep.ts new file mode 100644 index 000000000..55f495637 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/discovery-wizard/AtlasExecuteStep.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; +import * as vscode from 'vscode'; +import { type NewConnectionWizardContext } from '../../../commands/newConnection/NewConnectionWizardContext'; + +/** + * Execute step for the Atlas discovery wizard. + * Retrieves the connection string from the selected Atlas cluster and sets it on the context. + */ +export class AtlasExecuteStep extends AzureWizardExecuteStep { + public priority: number = -1; + + // eslint-disable-next-line @typescript-eslint/require-await + public async execute(context: NewConnectionWizardContext): Promise { + const connectionString = context.properties['atlas.selectedClusterConnectionString'] as string | undefined; + + if (!connectionString) { + throw new Error(vscode.l10n.t('No Atlas cluster connection string available.')); + } + + context.connectionString = connectionString; + + // Clean up wizard properties + context.properties['atlas.selectedClusterConnectionString'] = undefined; + context.properties['atlas.selectedProject'] = undefined; + context.properties['atlas.selectedProjectCredentialId'] = undefined; + } + + public shouldExecute(context: NewConnectionWizardContext): boolean { + return !context.connectionString; + } +} diff --git a/src/plugins/service-atlas-mongodb/discovery-wizard/SelectAtlasSteps.ts b/src/plugins/service-atlas-mongodb/discovery-wizard/SelectAtlasSteps.ts new file mode 100644 index 000000000..94f4c82f7 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/discovery-wizard/SelectAtlasSteps.ts @@ -0,0 +1,373 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep, UserCancelledError } from '@microsoft/vscode-azext-utils'; +import * as vscode from 'vscode'; +import { type NewConnectionWizardContext } from '../../../commands/newConnection/NewConnectionWizardContext'; +import { AtlasApiClient } from '../api/AtlasApiClient'; +import { + getAtlasClusterStateLabel, + getAtlasPausedExplanation, + isAtlasClusterConnectable, + isAtlasClusterPaused, +} from '../atlasClusterAvailability'; +import { configureAtlasCredentials } from '../credentialsManagement/configureAtlasCredentials'; +import { snapshotHasFailures, type AtlasDiscoveryService } from '../discovery/AtlasDiscoveryService'; +import { type AtlasCluster, type AtlasClusterState, type AtlasProject } from '../models/AtlasProjectModel'; + +interface AtlasProjectQuickPickItem extends vscode.QuickPickItem { + readonly itemType: 'manageCredentials' | 'project' | 'noProjects' | 'separator'; + readonly project?: AtlasProject; + /** The healthy credential that owns this project in the merged snapshot. */ + readonly credentialId?: string; +} + +interface AtlasClusterQuickPickItem extends vscode.QuickPickItem { + readonly itemType: 'manageCredentials' | 'cluster' | 'unavailableCluster' | 'noClusters' | 'separator'; + readonly cluster?: AtlasCluster; +} + +/** + * Builds the shared "manage credentials" row. It doubles as the recovery affordance: when the + * snapshot carries failures, the same row explains that some credentials need attention, so a + * partial failure never dead-ends the wizard. + */ +function createManageCredentialsItem(hasFailures: boolean): { + label: string; + detail: string; + iconPath: vscode.ThemeIcon; + alwaysShow: true; +} { + return { + label: hasFailures + ? vscode.l10n.t('Click here to revisit credentials') + : vscode.l10n.t('Manage MongoDB Atlas Credentials…'), + detail: hasFailures + ? vscode.l10n.t('Some credentials need attention, so parts of your fleet may be missing from this list.') + : vscode.l10n.t('Add or update credentials to see more projects and clusters.'), + iconPath: new vscode.ThemeIcon(hasFailures ? 'warning' : 'key'), + alwaysShow: true, + }; +} + +/** + * Runs the credential-management flow from inside the wizard. + * + * Returns `true` only when credential storage actually changed. Cancelling must never be + * reported as "credential management completed". + */ +async function manageCredentialsFromWizard( + context: NewConnectionWizardContext, + discoveryService: AtlasDiscoveryService, +): Promise { + context.telemetry.properties.credentialConfigActivated = 'true'; + context.telemetry.properties.initiatedFrom = 'newConnectionWizard'; + + const changed = await configureAtlasCredentials(context, discoveryService); + context.telemetry.properties.credentialsChanged = changed ? 'true' : 'false'; + return changed; +} + +/** + * Wizard step that prompts the user to select an Atlas project. + * + * Consumes the same merged snapshot as the tree, so a project visible through two credentials + * appears once and carries the healthy credential that owns it. + */ +export class SelectAtlasProjectStep extends AzureWizardPromptStep { + constructor(private readonly discoveryService: AtlasDiscoveryService) { + super(); + } + + public async prompt(context: NewConnectionWizardContext): Promise { + const selected = await context.ui.showQuickPick(this.getProjectItems(), { + placeHolder: vscode.l10n.t('Select an Atlas project'), + loadingPlaceHolder: vscode.l10n.t('Loading Atlas projects…'), + suppressPersistence: true, + matchOnDescription: true, + }); + + if (selected.itemType === 'manageCredentials') { + const changed = await manageCredentialsFromWizard(context, this.discoveryService); + if (!changed) { + throw new UserCancelledError(); + } + + await vscode.window.showInformationMessage( + vscode.l10n.t('Credential management completed'), + { + modal: true, + detail: vscode.l10n.t( + 'Please retry discovery to refresh the available MongoDB Atlas projects and clusters.', + ), + }, + vscode.l10n.t('OK'), + ); + throw new UserCancelledError(vscode.l10n.t('Credential management completed')); + } + + if (selected.itemType === 'noProjects') { + await vscode.window.showInformationMessage( + vscode.l10n.t('No projects available'), + { + modal: true, + detail: vscode.l10n.t( + 'No MongoDB Atlas projects are currently visible to your stored credentials. Manage credentials to add or update a credential.', + ), + }, + vscode.l10n.t('OK'), + ); + + throw new UserCancelledError(vscode.l10n.t('No Atlas projects available')); + } + + if (!selected.project || !selected.credentialId) { + throw new UserCancelledError(vscode.l10n.t('No Atlas project selected')); + } + + context.properties['atlas.selectedProject'] = selected.project; + context.properties['atlas.selectedProjectCredentialId'] = selected.credentialId; + } + + public shouldPrompt(context: NewConnectionWizardContext): boolean { + return !context.properties['atlas.selectedProject']; + } + + private async getProjectItems(): Promise { + const snapshot = await this.discoveryService.listAll(); + const orgNames = new Map( + snapshot.organizations.map((entry) => [entry.organization.id, entry.organization.name]), + ); + + const manageItem: AtlasProjectQuickPickItem = { + itemType: 'manageCredentials', + ...createManageCredentialsItem(snapshotHasFailures(snapshot)), + }; + + const projectItems: AtlasProjectQuickPickItem[] = snapshot.projects.map((entry) => { + const orgName = orgNames.get(entry.project.orgId); + return { + itemType: 'project', + label: entry.project.name, + description: orgName ?? '', + detail: vscode.l10n.t('{0} clusters', String(entry.project.clusterCount)), + project: entry.project, + credentialId: entry.ownerCredentialId, + }; + }); + + if (projectItems.length === 0) { + projectItems.push({ + itemType: 'noProjects', + label: vscode.l10n.t('No projects available for these credentials'), + detail: vscode.l10n.t('Manage credentials to add or update a credential.'), + iconPath: new vscode.ThemeIcon('info'), + alwaysShow: true, + }); + } + + return [ + manageItem, + { label: '', kind: vscode.QuickPickItemKind.Separator, itemType: 'separator' }, + ...projectItems, + ]; + } +} + +/** + * Wizard step that prompts the user to select an Atlas cluster within the selected project. + */ +export class SelectAtlasClusterStep extends AzureWizardPromptStep { + constructor(private readonly discoveryService: AtlasDiscoveryService) { + super(); + } + + public async prompt(context: NewConnectionWizardContext): Promise { + const project = context.properties['atlas.selectedProject'] as AtlasProject | undefined; + const credentialId = context.properties['atlas.selectedProjectCredentialId'] as string | undefined; + if (!project || !credentialId) { + throw new UserCancelledError(vscode.l10n.t('Atlas project not selected')); + } + + const items = await this.getClusterItems(project, credentialId); + + while (true) { + const selected = await context.ui.showQuickPick(items, { + placeHolder: vscode.l10n.t('Select a cluster'), + loadingPlaceHolder: vscode.l10n.t('Loading Atlas clusters…'), + suppressPersistence: true, + matchOnDescription: true, + }); + + if (selected.itemType === 'manageCredentials') { + const changed = await manageCredentialsFromWizard(context, this.discoveryService); + if (!changed) { + throw new UserCancelledError(); + } + throw new UserCancelledError(vscode.l10n.t('Credential management completed')); + } + + if (selected.itemType === 'noClusters') { + await vscode.window.showInformationMessage( + vscode.l10n.t('No clusters available'), + { + modal: true, + detail: vscode.l10n.t( + 'This MongoDB Atlas project does not currently contain any clusters to connect to.', + ), + }, + vscode.l10n.t('OK'), + ); + + throw new UserCancelledError(vscode.l10n.t('No Atlas clusters available')); + } + + if (selected.itemType === 'unavailableCluster') { + await this.showClusterUnavailableMessage(selected.cluster); + continue; + } + + if (!selected.cluster) { + throw new UserCancelledError(vscode.l10n.t('No Atlas cluster selected')); + } + + const connectionString = + selected.cluster.connectionStrings?.standardSrv ?? selected.cluster.connectionStrings?.standard; + if (!connectionString) { + throw new UserCancelledError(vscode.l10n.t('No Atlas cluster connection string available.')); + } + + context.properties['atlas.selectedClusterConnectionString'] = connectionString; + return; + } + } + + public shouldPrompt(context: NewConnectionWizardContext): boolean { + return !context.properties['atlas.selectedClusterConnectionString']; + } + + private async getClusterItems(project: AtlasProject, credentialId: string): Promise { + const registry = this.discoveryService.sessionRegistry; + // A transient token failure now throws rather than resolving `undefined`; either way the + // wizard's fallback is the same neutral "manage credentials" affordance, so treat any + // failure as "no usable session" here instead of surfacing it inside the QuickPick. + const session = await registry.getSession(credentialId).catch(() => undefined); + + const manageItem: AtlasClusterQuickPickItem = { + itemType: 'manageCredentials', + ...createManageCredentialsItem(session === undefined), + }; + + if (!session) { + return [manageItem]; + } + + const client = new AtlasApiClient(session, registry.refresherFor(credentialId)); + // Deduplicate by cluster name: the same cluster can be reachable through more than one + // credential, and Atlas cluster names are unique within a project. + const byName = new Map(); + for (const cluster of await client.listClusters(project.id)) { + if (!byName.has(cluster.name)) { + byName.set(cluster.name, cluster); + } + } + + const clusterItems: AtlasClusterQuickPickItem[] = [...byName.values()] + .map((c) => { + const provider = + c.providerSettings ?? + (() => { + const rc = c.replicationSpecs?.[0]?.regionConfigs?.[0]; + return rc + ? { + instanceSizeName: rc.electableSpecs?.instanceSize ?? '', + providerName: rc.providerName ?? '', + } + : undefined; + })(); + const providerDescription = provider + ? `${provider.instanceSizeName}, ${provider.providerName}` + : c.clusterType; + const connectionString = c.connectionStrings?.standardSrv ?? c.connectionStrings?.standard; + const availability = { paused: c.paused, stateName: c.stateName, connectionString }; + const stateLabel = getAtlasClusterStateLabel(availability); + return { + itemType: isAtlasClusterConnectable(availability) + ? ('cluster' as const) + : ('unavailableCluster' as const), + label: c.name, + description: stateLabel ? `${providerDescription} · ${stateLabel}` : providerDescription, + detail: isAtlasClusterConnectable(availability) + ? connectionString + : isAtlasClusterPaused(availability) + ? vscode.l10n.t('Resume this cluster in MongoDB Atlas before connecting.') + : vscode.l10n.t( + 'Visible in the tree, but not connectable until the cluster returns to IDLE.', + ), + cluster: c, + }; + }) + .sort((a, b) => a.label.localeCompare(b.label, undefined, { numeric: true })); + + if (clusterItems.length === 0) { + clusterItems.push({ + itemType: 'noClusters', + label: vscode.l10n.t('No clusters found in project "{0}"', project.name), + detail: vscode.l10n.t('This project does not currently contain any MongoDB Atlas clusters.'), + iconPath: new vscode.ThemeIcon('info'), + alwaysShow: true, + }); + } + + return [ + manageItem, + { label: '', kind: vscode.QuickPickItemKind.Separator, itemType: 'separator' }, + ...clusterItems, + ]; + } + + private async showClusterUnavailableMessage(cluster: AtlasCluster | undefined): Promise { + if (!cluster) { + throw new UserCancelledError(vscode.l10n.t('No Atlas cluster selected')); + } + + await vscode.window.showInformationMessage( + vscode.l10n.t('Cluster not connectable yet'), + { + modal: true, + detail: isAtlasClusterPaused(cluster) + ? getAtlasPausedExplanation() + : getClusterStateExplanation(cluster.stateName), + }, + vscode.l10n.t('OK'), + ); + } +} + +function getClusterStateExplanation(state: AtlasClusterState): string { + switch (state) { + case 'CREATING': + return vscode.l10n.t( + 'This cluster is being created. It will be connectable from the wizard once creation is complete and the cluster returns to IDLE.', + ); + case 'UPDATING': + return vscode.l10n.t( + 'This cluster is being updated. It is visible here to match the discovery tree, but it is not connectable until the update completes and the cluster returns to IDLE.', + ); + case 'REPAIRING': + return vscode.l10n.t( + 'This cluster is being repaired. It is visible here to match the discovery tree, but it is not connectable until repair completes and the cluster returns to IDLE.', + ); + case 'DELETING': + return vscode.l10n.t('This cluster is being deleted and cannot be connected to from the wizard.'); + case 'UNKNOWN': + return vscode.l10n.t( + 'This cluster is in an unknown state. Try refreshing to update its status before connecting from the wizard.', + ); + case 'IDLE': + default: + return vscode.l10n.t('This cluster is not connectable from the wizard right now.'); + } +} diff --git a/src/plugins/service-atlas-mongodb/discovery/AtlasDiscoveryService.test.ts b/src/plugins/service-atlas-mongodb/discovery/AtlasDiscoveryService.test.ts new file mode 100644 index 000000000..bb0dabff7 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/discovery/AtlasDiscoveryService.test.ts @@ -0,0 +1,543 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const globalStateBacking = new Map(); +const secretStorageBacking = new Map(); + +jest.mock('vscode', () => ({ + ThemeIcon: class ThemeIcon { + constructor(public readonly id: string) {} + }, + l10n: { + t: jest.fn((message: string, ...args: string[]) => + args.reduce((m, value, index) => m.replace(`{${String(index)}}`, value), message), + ), + }, +})); + +jest.mock('@vscode/l10n', () => ({ + t: jest.fn((message: string, ...args: string[]) => + args.reduce((m, value, index) => m.replace(`{${String(index)}}`, value), message), + ), +})); + +jest.mock('../../../extensionVariables', () => ({ + ext: { + context: { + extension: { id: 'test-extension' }, + subscriptions: { push: (): void => {} }, + globalState: { + get: (key: string, defaultValue?: T): T | undefined => { + const value = globalStateBacking.has(key) ? (globalStateBacking.get(key) as T) : undefined; + return value === undefined ? defaultValue : value; + }, + update: async (key: string, value: unknown): Promise => { + if (value === undefined) { + globalStateBacking.delete(key); + } else { + globalStateBacking.set(key, value); + } + }, + keys: () => Array.from(globalStateBacking.keys()), + }, + }, + secretStorage: { + get: async (key: string): Promise => + secretStorageBacking.has(key) ? secretStorageBacking.get(key) : undefined, + store: async (key: string, value: string): Promise => { + secretStorageBacking.set(key, value); + }, + delete: async (key: string): Promise => { + secretStorageBacking.delete(key); + }, + onDidChange: (): { dispose: () => void } => ({ dispose: (): void => {} }), + }, + outputChannel: { trace: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), appendLine: jest.fn() }, + }, +})); + +const mockListOrganizations = jest.fn(); +const mockListProjects = jest.fn(); +const mockListClusters = jest.fn(); + +class AtlasApiErrorMock extends Error { + constructor( + message: string, + public readonly statusCode: number, + public readonly detail?: string, + ) { + super(message); + this.name = 'AtlasApiError'; + } +} + +jest.mock('../api/AtlasApiClient', () => ({ + AtlasApiError: AtlasApiErrorMock, + AtlasApiClient: class AtlasApiClientMock { + constructor( + public readonly session: unknown, + public readonly refresher: unknown, + ) {} + listOrganizations = (...args: unknown[]) => mockListOrganizations(this.session, ...args) as unknown; + listProjects = (...args: unknown[]) => mockListProjects(this.session, ...args) as unknown; + listClusters = (...args: unknown[]) => mockListClusters(this.session, ...args) as unknown; + }, +})); + +import { StorageService } from '../../../services/storageService'; +import { AtlasCredentialSessionRegistry } from '../auth/AtlasCredentialSessionRegistry'; +import { + getAtlasCredential, + resetAtlasCredentialStoreCache, + upsertAtlasCredential, +} from '../credentials/atlasCredentialStore'; +import { type AtlasCluster, type AtlasProject } from '../models/AtlasProjectModel'; +import { AtlasDiscoveryService, classifyAtlasError, resolveCredentialLabel } from './AtlasDiscoveryService'; + +function project(id: string, name: string, orgId = 'org-1'): AtlasProject { + return { id, name, orgId, clusterCount: 1, created: '2026-01-01T00:00:00Z' }; +} + +function cluster(name: string, groupId: string): AtlasCluster { + return { + id: `cluster-${name}`, + name, + groupId, + mongoDBVersion: '7.0', + connectionStrings: { standardSrv: `mongodb+srv://${name}.example.invalid` }, + stateName: 'IDLE', + clusterType: 'REPLICASET', + }; +} + +async function addApiKeyCredential(publicKey: string): Promise { + const { record } = await upsertAtlasCredential({ + authMethod: 'apikey', + publicKey, + privateKey: `${publicKey}-private`, + }); + return record.id; +} + +function newService(): AtlasDiscoveryService { + return new AtlasDiscoveryService(new AtlasCredentialSessionRegistry()); +} + +beforeEach(() => { + globalStateBacking.clear(); + secretStorageBacking.clear(); + StorageService._resetForTests(); + resetAtlasCredentialStoreCache(); + mockListOrganizations.mockReset(); + mockListProjects.mockReset(); + mockListClusters.mockReset(); +}); + +describe('AtlasDiscoveryService.listAll', () => { + it('returns an empty snapshot when no credentials are stored', async () => { + const snapshot = await newService().listAll(); + + expect(snapshot.credentialsQueried).toBe(0); + expect(snapshot.organizations).toEqual([]); + expect(snapshot.credentialErrors).toEqual([]); + }); + + it('aggregates organizations and projects across credentials', async () => { + const first = await addApiKeyCredential('aaaaaaaa'); + const second = await addApiKeyCredential('bbbbbbbb'); + + mockListOrganizations.mockImplementation((session: { publicKey: string }) => + session.publicKey === 'aaaaaaaa' + ? Promise.resolve([{ id: 'org-1', name: 'Acme Corp' }]) + : Promise.resolve([{ id: 'org-2', name: 'Beta Ltd' }]), + ); + mockListProjects.mockImplementation((session: { publicKey: string }) => + session.publicKey === 'aaaaaaaa' + ? Promise.resolve([project('p1', 'Payments', 'org-1')]) + : Promise.resolve([project('p2', 'Web', 'org-2')]), + ); + + const snapshot = await newService().listAll(); + + expect(snapshot.credentialsQueried).toBe(2); + expect(snapshot.organizations.map((o) => o.organization.name)).toEqual(['Acme Corp', 'Beta Ltd']); + expect(snapshot.projects.map((p) => p.project.name)).toEqual(['Payments', 'Web']); + expect(snapshot.organizations[0].ownerCredentialId).toBe(first); + expect(snapshot.organizations[1].ownerCredentialId).toBe(second); + }); + + it('merges a project two credentials can both see, retaining both owners', async () => { + const first = await addApiKeyCredential('aaaaaaaa'); + const second = await addApiKeyCredential('bbbbbbbb'); + + mockListOrganizations.mockResolvedValue([{ id: 'org-1', name: 'Acme Corp' }]); + mockListProjects.mockImplementation((session: { publicKey: string }) => + session.publicKey === 'aaaaaaaa' + ? Promise.resolve([project('shared', 'Shared'), project('only-a', 'Only A')]) + : Promise.resolve([project('shared', 'Shared'), project('only-b', 'Only B')]), + ); + + const snapshot = await newService().listAll(); + + expect(snapshot.organizations).toHaveLength(1); + expect(snapshot.organizations[0].credentialIds).toEqual([first, second]); + expect(snapshot.projects.map((p) => p.project.name)).toEqual(['Only A', 'Only B', 'Shared']); + + const shared = snapshot.projects.find((p) => p.project.id === 'shared'); + expect(shared?.credentialIds).toEqual([first, second]); + expect(shared?.ownerCredentialId).toBe(first); + }); + + it('keeps healthy data when one credential fails (allSettled, not all)', async () => { + const healthy = await addApiKeyCredential('aaaaaaaa'); + const broken = await addApiKeyCredential('bbbbbbbb'); + + mockListOrganizations.mockImplementation((session: { publicKey: string }) => + session.publicKey === 'aaaaaaaa' + ? Promise.resolve([{ id: 'org-1', name: 'Acme Corp' }]) + : Promise.reject(new AtlasApiErrorMock('Credentials rejected', 401)), + ); + mockListProjects.mockImplementation((session: { publicKey: string }) => + session.publicKey === 'aaaaaaaa' + ? Promise.resolve([project('p1', 'Payments')]) + : Promise.reject(new AtlasApiErrorMock('Credentials rejected', 401)), + ); + + const snapshot = await newService().listAll(); + + expect(snapshot.projects.map((p) => p.project.name)).toEqual(['Payments']); + expect(snapshot.organizations[0].ownerCredentialId).toBe(healthy); + expect(snapshot.credentialErrors).toHaveLength(1); + expect(snapshot.credentialErrors[0]).toMatchObject({ + credentialId: broken, + kind: 'auth', + status: 401, + retryable: true, + }); + }); + + it('reports a healthy empty result as emptiness, not failure', async () => { + await addApiKeyCredential('aaaaaaaa'); + mockListOrganizations.mockResolvedValue([{ id: 'org-1', name: 'Acme Corp' }]); + mockListProjects.mockResolvedValue([]); + + const snapshot = await newService().listAll(); + + expect(snapshot.projects).toEqual([]); + expect(snapshot.credentialErrors).toEqual([]); + expect(snapshot.organizations).toHaveLength(1); + }); + + it('scopes a failing cluster list to its project and keeps the credential healthy', async () => { + await addApiKeyCredential('aaaaaaaa'); + mockListOrganizations.mockResolvedValue([{ id: 'org-1', name: 'Acme Corp' }]); + mockListProjects.mockResolvedValue([project('p1', 'Payments'), project('p2', 'Web')]); + mockListClusters.mockImplementation((_session: unknown, projectId: string) => + projectId === 'p1' + ? Promise.resolve([cluster('payments-prod', 'p1')]) + : Promise.reject(new AtlasApiErrorMock('Access denied', 403)), + ); + + const snapshot = await newService().listAll({ includeClusters: true }); + + expect(snapshot.clusters.map((c) => c.cluster.name)).toEqual(['payments-prod']); + expect(snapshot.credentialErrors).toEqual([]); + expect(snapshot.projectErrors).toHaveLength(1); + expect(snapshot.projectErrors[0]).toMatchObject({ projectId: 'p2', kind: 'forbidden', status: 403 }); + }); + + it('deduplicates clusters visible through two credentials', async () => { + const first = await addApiKeyCredential('aaaaaaaa'); + const second = await addApiKeyCredential('bbbbbbbb'); + + mockListOrganizations.mockResolvedValue([{ id: 'org-1', name: 'Acme Corp' }]); + mockListProjects.mockResolvedValue([project('p1', 'Payments')]); + mockListClusters.mockResolvedValue([cluster('payments-prod', 'p1')]); + + const snapshot = await newService().listAll({ includeClusters: true }); + + expect(snapshot.clusters).toHaveLength(1); + expect(snapshot.clusters[0].credentialIds).toEqual([first, second]); + expect(snapshot.clusters[0].ownerCredentialId).toBe(first); + expect(snapshot.clusters[0].projectName).toBe('Payments'); + }); + + it('caches the snapshot so a burst of passive reads never re-queries a failing credential', async () => { + await addApiKeyCredential('aaaaaaaa'); + mockListOrganizations.mockRejectedValue(new AtlasApiErrorMock('Credentials rejected', 401)); + mockListProjects.mockRejectedValue(new AtlasApiErrorMock('Credentials rejected', 401)); + + const service = newService(); + await service.listAll(); + await service.listAll(); + + expect(mockListProjects).toHaveBeenCalledTimes(1); + + await service.listAll({ forceRefresh: true }); + expect(mockListProjects).toHaveBeenCalledTimes(2); + }); + + it('expires the cached snapshot so passive reads cannot serve an indefinitely stale answer', async () => { + // Regression guard for the invalidate-only cache: every node type had to remember to + // invalidate, and the one that forgot showed an outdated tree forever. + await addApiKeyCredential('aaaaaaaa'); + mockListOrganizations.mockResolvedValue([{ id: 'org-1', name: 'Acme Corp' }]); + mockListProjects.mockResolvedValue([]); + + const service = newService(); + await service.listAll(); + await service.listAll(); + expect(mockListProjects).toHaveBeenCalledTimes(1); + + // The TTL runs on the monotonic clock, not the wall clock, so that an NTP correction or a + // resume from sleep cannot make a stale snapshot look fresh. + const realNow = performance.now.bind(performance); + const advanced = jest.spyOn(performance, 'now').mockImplementation(() => realNow() + 60_000); + try { + await service.listAll(); + } finally { + advanced.mockRestore(); + } + + expect(mockListProjects).toHaveBeenCalledTimes(2); + }); + + it('re-derives every session on refreshAll so an Atlas role change is picked up', async () => { + // Regression: a Service Account access token carries the roles it was minted with and is + // cached for its lifetime. Reusing it after the user widened the account's roles kept + // reporting the old, empty scope until the token expired. + await addApiKeyCredential('aaaaaaaa'); + mockListOrganizations.mockResolvedValue([{ id: 'org-1', name: 'Acme Corp' }]); + mockListProjects.mockResolvedValueOnce([]).mockResolvedValueOnce([project('p1', 'Payments')]); + + const registry = new AtlasCredentialSessionRegistry(); + const refreshSession = jest.spyOn(registry, 'refreshSession'); + const service = new AtlasDiscoveryService(registry); + + const before = await service.listAll(); + expect(before.projects).toEqual([]); + expect(refreshSession).not.toHaveBeenCalled(); + + const after = await service.refreshAll(); + + expect(refreshSession).toHaveBeenCalledTimes(1); + expect(after.projects.map((p) => p.project.name)).toEqual(['Payments']); + }); + + it('re-derives the session on a single-credential retry and leaves peers untouched', async () => { + // The real flow is: open the credential manager, fix something in the Atlas web UI, come + // back and press Retry. Reusing the cached token would report the pre-change answer. + const failingId = await addApiKeyCredential('aaaaaaaa'); + await addApiKeyCredential('bbbbbbbb'); + mockListOrganizations.mockResolvedValue([{ id: 'org-1', name: 'Acme Corp' }]); + mockListProjects.mockResolvedValue([]); + + const registry = new AtlasCredentialSessionRegistry(); + const refreshSession = jest.spyOn(registry, 'refreshSession'); + const service = new AtlasDiscoveryService(registry); + + await service.listAll(); + const callsAfterFirstPass = mockListProjects.mock.calls.length; + + await service.retryCredential(failingId); + + expect(refreshSession).toHaveBeenCalledTimes(1); + expect(refreshSession).toHaveBeenCalledWith(failingId); + // Only the retried credential issued another request; its peer reused its last result. + expect(mockListProjects.mock.calls.length).toBe(callsAfterFirstPass + 1); + }); + + it('re-queries when clusters are requested but the cached snapshot has none', async () => { + await addApiKeyCredential('aaaaaaaa'); + mockListOrganizations.mockResolvedValue([{ id: 'org-1', name: 'Acme Corp' }]); + mockListProjects.mockResolvedValue([project('p1', 'Payments')]); + mockListClusters.mockResolvedValue([cluster('payments-prod', 'p1')]); + + const service = newService(); + const withoutClusters = await service.listAll(); + expect(withoutClusters.clusters).toEqual([]); + expect(mockListClusters).not.toHaveBeenCalled(); + + const withClusters = await service.listAll({ includeClusters: true }); + expect(withClusters.clusters).toHaveLength(1); + }); + + it('forwards the abort signal to the API client', async () => { + await addApiKeyCredential('aaaaaaaa'); + mockListOrganizations.mockResolvedValue([]); + mockListProjects.mockResolvedValue([]); + + const controller = new AbortController(); + await newService().listAll({ signal: controller.signal }); + + // The signal handed to the client is a composite of the caller's signal and the per-pass + // timeout, so it is not the same object - but it aborts when the caller aborts. + const forwardedSignal = mockListProjects.mock.calls[0][1] as AbortSignal; + expect(forwardedSignal).toBeInstanceOf(AbortSignal); + expect(forwardedSignal.aborted).toBe(false); + controller.abort(); + expect(forwardedSignal.aborted).toBe(true); + }); + + it('caches the organization name on the credential for later attribution', async () => { + const credentialId = await addApiKeyCredential('aaaaaaaa'); + mockListOrganizations.mockResolvedValue([{ id: 'org-1', name: 'Acme Corp' }]); + mockListProjects.mockResolvedValue([]); + + await newService().listAll(); + + const record = await getAtlasCredential(credentialId); + expect(record?.orgId).toBe('org-1'); + expect(record?.orgName).toBe('Acme Corp'); + }); + + it('surfaces a rejected credential as an auth error when no session can be built', async () => { + const { record } = await upsertAtlasCredential({ + authMethod: 'serviceaccount', + clientId: 'client-1', + clientSecret: 'secret-1', + }); + + const registry = new AtlasCredentialSessionRegistry(); + jest.spyOn(registry, 'getSession').mockResolvedValue(undefined); + + const snapshot = await new AtlasDiscoveryService(registry).listAll(); + + expect(snapshot.credentialErrors).toHaveLength(1); + expect(snapshot.credentialErrors[0]).toMatchObject({ credentialId: record.id, kind: 'auth', retryable: true }); + expect(mockListProjects).not.toHaveBeenCalled(); + }); +}); + +describe('resolveCredentialLabel', () => { + const base = { id: 'record-1', authMethod: 'apikey' as const, order: 0 }; + + it('prefers the user-supplied label', () => { + expect(resolveCredentialLabel({ ...base, label: 'Work key', orgName: 'Acme' })).toBe('Work key'); + }); + + it('falls back to the cached organization name', () => { + expect(resolveCredentialLabel({ ...base, orgName: 'Acme Corp' })).toBe('Acme Corp'); + }); + + it('falls back to the non-secret identity hint', () => { + expect(resolveCredentialLabel({ ...base, identityHint: 'abcdefgh' })).toBe('abcdefgh…'); + }); + + it('falls back to the record id as a last resort', () => { + expect(resolveCredentialLabel(base)).toBe('record-1'); + }); +}); + +describe('classifyAtlasError', () => { + it.each([ + [401, 'auth'], + [403, 'forbidden'], + [429, 'rateLimited'], + [500, 'other'], + ])('maps status %s to kind %s', (status, kind) => { + expect(classifyAtlasError(new AtlasApiErrorMock('boom', status)).kind).toBe(kind); + }); + + it('maps a fetch failure to a network error', () => { + expect(classifyAtlasError(new TypeError('fetch failed')).kind).toBe('network'); + }); + + it('maps an abort or timeout to a network error, never a credential problem', () => { + // A disposed webview / collapsed tree node rejects with AbortError, and the per-pass + // deadline rejects with TimeoutError. Neither is an Atlas response, so neither may be + // reported as `other` (which would render a credential-blaming recovery row). + expect(classifyAtlasError(new DOMException('aborted', 'AbortError')).kind).toBe('network'); + expect(classifyAtlasError(new DOMException('timed out', 'TimeoutError')).kind).toBe('network'); + }); +}); + +describe('AtlasDiscoveryService.listAll serialization (MEDIUM-2)', () => { + it('does not answer a clusters-inclusive caller with a projects-only pass', async () => { + await addApiKeyCredential('aaaaaaaa'); + mockListOrganizations.mockResolvedValue([{ id: 'org-1', name: 'Acme' }]); + mockListProjects.mockResolvedValue([project('p1', 'Payments', 'org-1')]); + mockListClusters.mockResolvedValue([cluster('prod', 'p1')]); + + const service = newService(); + // Two overlapping calls: the second must not join the projects-only pass and render empty. + const [projectsOnly, withClusters] = await Promise.all([ + service.listAll({ includeClusters: false }), + service.listAll({ includeClusters: true }), + ]); + + expect(projectsOnly.clustersIncluded).toBe(false); + expect(withClusters.clustersIncluded).toBe(true); + expect(withClusters.clusters).toHaveLength(1); + expect(mockListClusters).toHaveBeenCalledTimes(1); + }); + + it('lets a forced refresh queued behind a running pass commit last', async () => { + await addApiKeyCredential('aaaaaaaa'); + mockListOrganizations.mockResolvedValue([{ id: 'org-1', name: 'Acme' }]); + + let call = 0; + mockListProjects.mockImplementation(() => { + call += 1; + return Promise.resolve([project(`p${String(call)}`, `Project ${String(call)}`, 'org-1')]); + }); + + const service = newService(); + const [, forced] = await Promise.all([service.listAll(), service.listAll({ forceRefresh: true })]); + + // The forced pass runs second and is the last writer, regardless of completion order. + expect(forced.projects[0].project.name).toBe('Project 2'); + const current = await service.listAll(); + expect(current.projects[0].project.name).toBe('Project 2'); + }); + + it('serializes overlapping passes so only one runs at a time', async () => { + await addApiKeyCredential('aaaaaaaa'); + mockListOrganizations.mockResolvedValue([{ id: 'org-1', name: 'Acme' }]); + + let active = 0; + let maxActive = 0; + mockListProjects.mockImplementation(async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 5)); + active -= 1; + return [project('p1', 'Payments', 'org-1')]; + }); + + const service = newService(); + await Promise.all([service.listAll(), service.listAll({ forceRefresh: true })]); + + expect(maxActive).toBe(1); + }); + + it('reports a timed-out pass as a network failure for every credential, not "other"', async () => { + await addApiKeyCredential('aaaaaaaa'); + mockListOrganizations.mockRejectedValue(new DOMException('The operation timed out', 'TimeoutError')); + mockListProjects.mockRejectedValue(new DOMException('The operation timed out', 'TimeoutError')); + + const snapshot = await newService().listAll(); + + expect(snapshot.credentialErrors).toHaveLength(1); + expect(snapshot.credentialErrors[0].kind).toBe('network'); + }); + + it('does not cache a snapshot produced by a caller-cancelled pass', async () => { + await addApiKeyCredential('aaaaaaaa'); + mockListOrganizations.mockResolvedValue([{ id: 'org-1', name: 'Acme' }]); + mockListProjects.mockResolvedValue([project('p1', 'Payments', 'org-1')]); + + const controller = new AbortController(); + const service = newService(); + // Abort before the pass commits; its result must not become the cached snapshot. + controller.abort(); + await service.listAll({ signal: controller.signal }); + + // A follow-up pass must re-query rather than serve the cancelled result from cache. + mockListProjects.mockClear(); + await service.listAll(); + expect(mockListProjects).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/service-atlas-mongodb/discovery/AtlasDiscoveryService.ts b/src/plugins/service-atlas-mongodb/discovery/AtlasDiscoveryService.ts new file mode 100644 index 000000000..0617c5f03 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/discovery/AtlasDiscoveryService.ts @@ -0,0 +1,763 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Single aggregation surface for MongoDB Atlas discovery. + * + * `listAll()` fans out across every stored credential and returns healthy data **and** typed + * per-credential errors together. It never throws for an individual credential: one dead + * credential degrades that credential's branch only, instead of blanking the whole view. This is + * the concrete difference from the Azure prior art in this repository, which uses `Promise.all` + * and collapses to an empty list when any account fails. + * + * Resources are merged by their Atlas IDs (`orgId` / `projectId` / cluster id) so two credentials + * that can see the same organization or project produce one node, and each merged node remembers + * every credential that can reach it plus a healthy owner for follow-up requests. + */ + +import * as l10n from '@vscode/l10n'; +import { createConcurrencyLimiter } from '../../../utils/concurrencyLimiter'; +import { AtlasApiClient, AtlasApiError } from '../api/AtlasApiClient'; +import { atlasTrace, describeCredential, formatMs, monotonicNow } from '../atlasTrace'; +import { AtlasCredentialSessionRegistry } from '../auth/AtlasCredentialSessionRegistry'; +import { AtlasTokenError } from '../auth/AtlasServiceAccountClient'; +import { + getAtlasCredential, + readAtlasCredentials, + updateAtlasCredentialMetadata, + type AtlasCredentialRecord, +} from '../credentials/atlasCredentialStore'; +import { type AtlasCluster, type AtlasOrganization, type AtlasProject } from '../models/AtlasProjectModel'; + +/** + * Why a credential (or one project below it) could not be read. Drives the wording and the + * recovery affordance the tree offers. + */ +export type AtlasErrorKind = 'auth' | 'forbidden' | 'rateLimited' | 'network' | 'other'; + +/** A whole credential failed. Its healthy peers are unaffected. */ +export interface AtlasCredentialError { + readonly credentialId: string; + readonly label: string; + readonly kind: AtlasErrorKind; + readonly status?: number; + readonly message: string; + /** `false` only when retrying cannot possibly help (for example the credential was removed). */ + readonly retryable: boolean; +} + +/** A single project's cluster list failed while the owning credential stayed healthy. */ +export interface AtlasProjectError extends AtlasCredentialError { + readonly projectId: string; + readonly projectName: string; +} + +/** Common shape for every merged resource: who can see it, and who acts on it. */ +interface MergedResource { + /** Every credential that can currently reach this resource. */ + readonly credentialIds: string[]; + /** The credential used for follow-up requests. Always one of {@link credentialIds}. */ + readonly ownerCredentialId: string; +} + +export interface AtlasOrganizationEntry extends MergedResource { + readonly organization: AtlasOrganization; +} + +export interface AtlasProjectEntry extends MergedResource { + readonly project: AtlasProject; +} + +export interface AtlasClusterEntry extends MergedResource { + readonly cluster: AtlasCluster; + readonly projectId: string; + readonly projectName: string; + readonly orgId: string; +} + +/** + * Everything the tree, the list view, and the add-connection wizard need, in one value. + */ +export interface AtlasDiscoverySnapshot { + readonly organizations: AtlasOrganizationEntry[]; + readonly projects: AtlasProjectEntry[]; + readonly clusters: AtlasClusterEntry[]; + readonly credentialErrors: AtlasCredentialError[]; + readonly projectErrors: AtlasProjectError[]; + /** How many credentials were queried for this snapshot. */ + readonly credentialsQueried: number; + /** Whether cluster data was requested; `false` snapshots carry an empty `clusters` array. */ + readonly clustersIncluded: boolean; +} + +export interface ListAllOptions { + /** + * Fetch clusters for every visible project. Off by default: Tree mode only needs + * organizations and projects up front and loads clusters when a project is expanded, so the + * default keeps the first paint to two requests per credential. + */ + readonly includeClusters?: boolean; + /** Ignore the cached snapshot and re-query every credential. */ + readonly forceRefresh?: boolean; + /** + * Re-derive every credential's session before querying, discarding cached Service Account + * access tokens. Needed after the user changes roles in Atlas, because a token carries the + * scope it was minted with. Prefer {@link AtlasDiscoveryService.refreshAll}. + */ + readonly forceFreshSessions?: boolean; + readonly signal?: AbortSignal; +} + +/** Bounded fan-out across credentials. Independent credentials use independent rate buckets. */ +const CREDENTIAL_CONCURRENCY = 4; + +/** Bounded fan-out across a single credential's projects when cluster data is requested. */ +const PROJECT_CONCURRENCY = 5; + +/** + * How long a snapshot may be served to passive tree expansion before it is re-fetched. + * + * The cache exists to stop a single interaction burst (expand the root, then expand three + * organizations) from re-running the fleet query four times, and to keep `ownerCredentialId` + * coherent between an organization node and its project children. Neither of those needs the + * cache to survive longer than the burst itself. + * + * Making it an invalidate-only cache is what produced stale-tree bugs: every node type had to + * remember to invalidate, and the one that forgot showed a permanently outdated answer. A short + * window removes that whole class of bug at the cost of a couple of fast requests, which is the + * right trade for an API with no timeouts and a 1-to-2 credential happy path. + * + * It cannot replace explicit invalidation for permissions changes: a Service Account access token + * carries the scope it was minted with and lives for about an hour, so an explicit refresh still + * has to re-derive sessions. See {@link AtlasDiscoveryService.refreshAll}. + */ +const SNAPSHOT_TTL_MS = 30_000; + +/** + * A single discovery pass may not outlive this. Passes are serialized (queued behind each other), + * so a request with no deadline would stall every later expansion for the rest of the session - + * `fetch` has no default timeout and `AtlasServiceRootItem.getChildren()` calls `listAll()` with no + * signal. Uses `AbortSignal.timeout`, which already has in-repo precedent in + * `SelectAtlasDatabaseUserStep`. + */ +const DISCOVERY_TIMEOUT_MS = 30_000; + +/** + * Resolves the user-facing label for a credential: an explicit user label wins, then the cached + * organization name, then a non-secret identity hint, then the record ID. + */ +export function resolveCredentialLabel(record: AtlasCredentialRecord): string { + if (record.label && record.label.trim().length > 0) { + return record.label.trim(); + } + if (record.orgName && record.orgName.trim().length > 0) { + return record.orgName.trim(); + } + if (record.identityHint && record.identityHint.length > 0) { + return `${record.identityHint}…`; + } + return record.id; +} + +/** + * Classifies a thrown error into the taxonomy the UX reacts to. + * + * `errorCode` is carried through for the log. The HTTP status alone is ambiguous: several very + * different problems share `403`, and only Atlas's own code separates them. + * + * This is the **tree / discovery** classifier. It stays deliberately coarse - every 403 becomes + * `forbidden` - because the tree's only recovery is to hand the user to the credential manager, + * which then offers the same deep link regardless of the exact 403. The **webview credential flow** + * needs a finer split (IP access list vs missing role) and classifies separately in + * `describeAtlasError` (see `atlasCredentialsRouter.ts`). The two classifiers are intentionally + * duplicated rather than merged, but if this one ever needs to tell an IP access-list 403 apart it + * must reuse the shared `isAtlasIpAccessListError` predicate so both paths agree on which codes count. + */ +export function classifyAtlasError(error: unknown): { + kind: AtlasErrorKind; + status?: number; + message: string; + errorCode?: string; +} { + // `AbortSignal.timeout()` rejects with a TimeoutError and a disposed webview / collapsed tree + // node with an AbortError. Neither is an Atlas response, so neither may be reported as a + // credential problem (which would render a "revisit credentials" recovery row for work the + // extension itself cancelled or timed out). + if (error instanceof DOMException && (error.name === 'TimeoutError' || error.name === 'AbortError')) { + return { kind: 'network', message: error.message }; + } + + // A Service Account token failure that was rethrown as transient (429 / 5xx / unrecognized). + // A rejected client/secret (400/401) never reaches here - the session registry returns + // `undefined` for those, which the caller maps to the credential-rejected path. + if (error instanceof AtlasTokenError) { + if (error.statusCode === 429) { + return { kind: 'rateLimited', status: 429, message: error.message, errorCode: error.code }; + } + if (error.statusCode === 401 || error.statusCode === 400) { + return { kind: 'auth', status: error.statusCode, message: error.message, errorCode: error.code }; + } + return { kind: 'other', status: error.statusCode, message: error.message, errorCode: error.code }; + } + + if (error instanceof AtlasApiError) { + const errorCode = error.errorCode; + switch (error.statusCode) { + case 401: + return { kind: 'auth', status: 401, message: error.message, errorCode }; + case 403: + return { kind: 'forbidden', status: 403, message: error.message, errorCode }; + case 429: + return { kind: 'rateLimited', status: 429, message: error.message, errorCode }; + default: + return { kind: 'other', status: error.statusCode, message: error.message, errorCode }; + } + } + + const message = error instanceof Error ? error.message : String(error); + // `fetch` surfaces connectivity problems as a TypeError with a generic message; treat any + // non-API failure that mentions the network as a connectivity problem so the UX can say so. + if (error instanceof TypeError || /network|fetch failed|ENOTFOUND|ECONNREFUSED|ETIMEDOUT/i.test(message)) { + return { kind: 'network', message }; + } + + return { kind: 'other', message }; +} + +/** Result of querying one credential, before merging. */ +export interface CredentialResult { + readonly record: AtlasCredentialRecord; + readonly organizations: AtlasOrganization[]; + readonly projects: AtlasProject[]; + readonly clusters: Array<{ project: AtlasProject; cluster: AtlasCluster }>; + readonly credentialError?: AtlasCredentialError; + readonly projectErrors: AtlasProjectError[]; +} + +/** + * Aggregates discovery data across the whole credential fleet. + */ +export class AtlasDiscoveryService { + private snapshot: AtlasDiscoverySnapshot | undefined; + /** Monotonic reading, so a wall-clock step cannot make a stale snapshot look fresh forever. */ + private snapshotTakenAt = 0; + private lastResults: CredentialResult[] | undefined; + private inflight: Promise | undefined; + + constructor(private readonly sessions: AtlasCredentialSessionRegistry = new AtlasCredentialSessionRegistry()) {} + + /** The session registry backing this service, so callers can build their own scoped clients. */ + public get sessionRegistry(): AtlasCredentialSessionRegistry { + return this.sessions; + } + + /** + * Returns the cached snapshot when it is still fresh and rich enough for this caller. + * + * Split out of {@link listAll} because the serialized path has to ask twice: once before + * queuing, and again after the pass ahead of it committed, which may have already produced the + * answer this caller needs. + */ + private readUsableSnapshot(needsClusters: boolean, forceRefresh: boolean): AtlasDiscoverySnapshot | undefined { + if (forceRefresh || !this.snapshot) { + return undefined; + } + if (needsClusters && !this.snapshot.clustersIncluded) { + return undefined; + } + return monotonicNow() - this.snapshotTakenAt < SNAPSHOT_TTL_MS ? this.snapshot : undefined; + } + + /** + * Returns everything visible across every stored credential. Never rejects because of a + * single credential; per-credential failures come back in `credentialErrors`. + * + * The cached snapshot is reused on passive tree expansion, but only for {@link SNAPSHOT_TTL_MS}, + * so navigating around cannot keep serving an answer the user has since fixed in Atlas. An + * explicit refresh passes `forceRefresh` and does not wait for the window to close. + */ + public async listAll(options: ListAllOptions = {}): Promise { + const needsClusters = options.includeClusters === true; + const forceRefresh = options.forceRefresh === true; + + const cached = this.readUsableSnapshot(needsClusters, forceRefresh); + if (cached) { + atlasTrace( + `listAll: serving the cached snapshot, ${String(monotonicNow() - this.snapshotTakenAt)}ms old (${String(cached.organizations.length)} org(s), ${String(cached.projects.length)} project(s), ${String(cached.credentialErrors.length)} credential error(s))`, + ); + return cached; + } + + if (!forceRefresh && this.snapshot && (!needsClusters || this.snapshot.clustersIncluded)) { + atlasTrace( + `listAll: the cached snapshot is ${String(monotonicNow() - this.snapshotTakenAt)}ms old and has expired, re-querying`, + ); + } + + // Discovery passes are queued, never raced. Joining an arbitrary in-flight pass was wrong in + // both directions: a projects-only pass would answer a clusters-inclusive caller (List mode + // rendered empty when the view was toggled mid-fetch), and two overlapping passes both wrote + // `this.snapshot`, so a slow old pass could replace a newer forced refresh for the whole TTL. + // Waiting and then re-checking the cache gives the fast path back for free: a caller whose + // needs the predecessor already satisfied returns that snapshot without a second API pass. + const previous = this.inflight; + if (previous) { + atlasTrace('listAll: waiting for the discovery pass ahead of this one'); + } + const work = (previous ?? Promise.resolve()) + // The predecessor's failure is its own caller's problem; it must not fail this pass. + .catch(() => undefined) + .then(() => { + const fresh = this.readUsableSnapshot(needsClusters, forceRefresh); + if (fresh) { + atlasTrace('listAll: the pass ahead of this one already produced a usable snapshot'); + return fresh; + } + return this.buildSnapshot(needsClusters, options.signal, options.forceFreshSessions === true); + }) + .finally(() => { + // Only the tail of the queue may clear the slot; an earlier pass finishing late must + // not detach a successor that other callers are already chained behind. + if (this.inflight === work) { + this.inflight = undefined; + } + }); + + this.inflight = work; + return work; + } + + /** + * Re-attempts the whole fleet from scratch: drops the cached snapshot **and** re-derives every + * credential's session before querying. + * + * Re-deriving the session is the part that matters after a permissions change in Atlas. A + * Service Account access token is minted with the roles the account had at that moment and is + * cached for its lifetime, so reusing it would keep reporting the old scope for up to an hour + * after the user grants a new role. An explicit refresh is a deliberate user action, so paying + * for one token mint per credential is the right trade. + */ + public async refreshAll( + options: { includeClusters?: boolean; signal?: AbortSignal } = {}, + ): Promise { + atlasTrace('refreshAll: dropping the cached snapshot and every cached session'); + this.invalidate(); + return this.listAll({ + ...options, + forceRefresh: true, + forceFreshSessions: true, + }); + } + + /** + * Drops the cached snapshot so the next {@link listAll} re-queries every credential. + */ + public invalidate(): void { + this.snapshot = undefined; + this.snapshotTakenAt = 0; + this.lastResults = undefined; + // `inflight` is deliberately NOT cleared: it is the tail of the serialized pass queue, not + // cached data. `refreshAll()` calls `invalidate()` and then `listAll({ forceRefresh: true })`, + // so clearing it would detach the forced pass from the running one and reintroduce exactly + // the two-writer race the serialized queue removes. + } + + /** + * Re-attempts a single credential and folds the result back into the cached snapshot. + * + * Retrying one credential must not hammer its healthy peers, which is why the + * credential-management "Retry" action calls this instead of a full refresh: only the selected + * credential issues requests, and every other credential's last known result is reused. + * + * Like {@link refreshAll}, it re-derives the session rather than reusing the cached one. The + * user's actual flow is to open the credential manager, fix something in the Atlas web UI, and + * come back to press Retry, and a Service Account access token carries the roles it was minted + * with, so reusing it would report the pre-change answer. + * + * Falls back to a full refresh when there is no cached snapshot to fold into. + */ + public async retryCredential(credentialId: string, signal?: AbortSignal): Promise { + const previous = this.lastResults; + const snapshot = this.snapshot; + if (!previous || !snapshot) { + this.sessions.invalidate(credentialId); + this.invalidate(); + return this.listAll({ forceRefresh: true, forceFreshSessions: true, signal }); + } + + const record = await getAtlasCredential(credentialId); + if (!record) { + // The credential is gone; simply drop its contribution. + this.sessions.invalidate(credentialId); + const remaining = previous.filter((result) => result.record.id !== credentialId); + return this.commit(remaining, snapshot.clustersIncluded); + } + + const refreshed = await this.queryCredential(record, snapshot.clustersIncluded, signal, true); + const merged = previous.some((result) => result.record.id === credentialId) + ? previous.map((result) => (result.record.id === credentialId ? refreshed : result)) + : [...previous, refreshed]; + + return this.commit(merged, snapshot.clustersIncluded); + } + + /** Forgets every cached session and snapshot. Used after "sign out of all". */ + public reset(): void { + this.sessions.invalidateAll(); + this.invalidate(); + } + + private async buildSnapshot( + includeClusters: boolean, + signal?: AbortSignal, + forceFreshSessions = false, + ): Promise { + const credentials = await readAtlasCredentials(); + const limit = createConcurrencyLimiter({ concurrency: CREDENTIAL_CONCURRENCY }); + const startedAt = monotonicNow(); + + // Serialized passes queue behind each other, so a request with no deadline would stall every + // later expansion for the rest of the session. Give each pass its own timeout, combined with + // the caller's signal so either can abort the fan-out. A timeout rejects the credential + // requests with a TimeoutError, which `classifyAtlasError` maps to `network`. + const deadline = AbortSignal.timeout(DISCOVERY_TIMEOUT_MS); + const effectiveSignal = signal ? AbortSignal.any([signal, deadline]) : deadline; + + atlasTrace( + `listAll: querying ${String(credentials.length)} credential(s), clusters ${includeClusters ? 'included' : 'deferred to project expand'}${forceFreshSessions ? ', forcing fresh sessions' : ''}`, + ); + + // `allSettled`, not `all`: a rejected credential must not discard its healthy peers. + const settled = await Promise.allSettled( + credentials.map((record) => + limit(() => this.queryCredential(record, includeClusters, effectiveSignal, forceFreshSessions)), + ), + ); + + const results: CredentialResult[] = []; + for (let index = 0; index < settled.length; index++) { + const outcome = settled[index]; + if (outcome.status === 'fulfilled') { + results.push(outcome.value); + continue; + } + + // Defensive: queryCredential already converts failures into descriptors. A rejection + // here means an unexpected bug, and it still must not take the fleet down. + const record = credentials[index]; + const classified = classifyAtlasError(outcome.reason); + results.push({ + record, + organizations: [], + projects: [], + clusters: [], + projectErrors: [], + credentialError: { + credentialId: record.id, + label: resolveCredentialLabel(record), + kind: classified.kind, + status: classified.status, + message: classified.message, + retryable: true, + }, + }); + } + + const snapshot = mergeResults(results, includeClusters); + + // A caller-cancelled pass (webview disposed, tree node collapsed) must not become the cached + // snapshot: it would replace fresher data - or a healthy snapshot - with cancellation errors + // for the whole TTL. A *timeout* is different: `deadline` aborts `effectiveSignal` but not + // the caller's `signal`, so a timed-out pass still commits as network errors like any other + // fleet-wide failure. + if (signal?.aborted) { + atlasTrace('listAll: pass was cancelled by the caller; returning without committing to the cache'); + return snapshot; + } + + this.snapshot = snapshot; + this.snapshotTakenAt = monotonicNow(); + this.lastResults = results; + + atlasTrace( + `listAll: done in ${formatMs(startedAt)} - ${String(snapshot.organizations.length)} org(s), ${String(snapshot.projects.length)} project(s), ${String(snapshot.clusters.length)} cluster(s), ${String(snapshot.credentialErrors.length)} credential error(s), ${String(snapshot.projectErrors.length)} project error(s)`, + ); + + return snapshot; + } + + /** Stores a set of per-credential results as the new cached snapshot. */ + private commit(results: CredentialResult[], clustersIncluded: boolean): AtlasDiscoverySnapshot { + const snapshot = mergeResults(results, clustersIncluded); + this.snapshot = snapshot; + this.snapshotTakenAt = monotonicNow(); + this.lastResults = results; + return snapshot; + } + + private async queryCredential( + record: AtlasCredentialRecord, + includeClusters: boolean, + signal?: AbortSignal, + forceFreshSession = false, + ): Promise { + const label = resolveCredentialLabel(record); + const owner = describeCredential(label, record.id); + const session = forceFreshSession + ? await this.sessions.refreshSession(record.id) + : await this.sessions.getSession(record.id); + + if (!session) { + atlasTrace(`${owner}: no usable session, reporting an auth error for this credential only`); + return { + record, + organizations: [], + projects: [], + clusters: [], + projectErrors: [], + credentialError: { + credentialId: record.id, + label, + kind: 'auth', + message: l10n.t('Stored credentials were rejected. Update them to continue.'), + retryable: true, + }, + }; + } + + const client = new AtlasApiClient(session, this.sessions.refresherFor(record.id), owner); + + let organizations: AtlasOrganization[] = []; + let projects: AtlasProject[] = []; + + // Different endpoints with independent scopes, so they are safe to run together. The + // Azure "sequential or you get wrong data" caveat applies to tenants vs subscriptions + // inside one provider, not to these two Atlas calls. + // + // `allSettled`, not `all`: `all` rejects as soon as the first request fails and leaves the + // other one running, so its failure lands in the log after the credential has already been + // recorded as failed and reads like a second, racing pass. Waiting for both also makes the + // reported error deterministic instead of whichever request happened to lose the race. + const [orgOutcome, projectOutcome] = await Promise.allSettled([ + client.listOrganizations(signal), + client.listProjects(signal), + ]); + + const failure = [orgOutcome, projectOutcome].find((outcome) => outcome.status === 'rejected'); + if (failure) { + const classified = classifyAtlasError(failure.reason); + atlasTrace( + `${owner}: discovery failed (${classified.kind}${classified.status ? ` ${String(classified.status)}` : ''}${classified.errorCode ? ` ${classified.errorCode}` : ''}) - ${classified.message}`, + ); + if (classified.errorCode === 'IP_ADDRESS_NOT_ON_ACCESS_LIST') { + // Worth spelling out, because the surrounding log looks self-contradictory: the + // token was minted seconds earlier from the same machine. Atlas applies the access + // list when a token is *used*, not when it is created, and the list is configured + // per credential, so a sibling credential on the same IP can be working fine. + atlasTrace( + `${owner}: Atlas rejected the caller's IP. The API access list is configured per credential, and it is enforced when a token is used rather than when it is minted, so another credential may still work from this same machine.`, + ); + } + return { + record, + organizations: [], + projects: [], + clusters: [], + projectErrors: [], + credentialError: { + credentialId: record.id, + label, + kind: classified.kind, + status: classified.status, + message: classified.message, + retryable: true, + }, + }; + } + + organizations = orgOutcome.status === 'fulfilled' ? orgOutcome.value : []; + projects = projectOutcome.status === 'fulfilled' ? projectOutcome.value : []; + + atlasTrace( + `${owner}: sees ${String(organizations.length)} organization(s) and ${String(projects.length)} project(s)`, + ); + if (projects.length === 0) { + // A healthy 200 with an empty list is an authoritative answer, not a failure. Saying so + // explicitly makes the difference from a 401/403 obvious in the log. + atlasTrace( + `${owner}: Atlas answered with an empty project list; this is a permissions/scope result, not an error`, + ); + } + + await this.cacheOrganizationMetadata(record, organizations); + + if (!includeClusters || projects.length === 0) { + return { record, organizations, projects, clusters: [], projectErrors: [] }; + } + + const projectLimit = createConcurrencyLimiter({ concurrency: PROJECT_CONCURRENCY }); + const clusters: Array<{ project: AtlasProject; cluster: AtlasCluster }> = []; + const projectErrors: AtlasProjectError[] = []; + + const clusterOutcomes = await Promise.allSettled( + projects.map((project) => + projectLimit(async () => ({ project, clusters: await client.listClusters(project.id, signal) })), + ), + ); + + for (let index = 0; index < clusterOutcomes.length; index++) { + const outcome = clusterOutcomes[index]; + const project = projects[index]; + if (outcome.status === 'fulfilled') { + for (const cluster of outcome.value.clusters) { + clusters.push({ project, cluster }); + } + continue; + } + + const classified = classifyAtlasError(outcome.reason); + atlasTrace( + `${owner}: cluster list for project "${project.name}" failed (${classified.kind}) - ${classified.message}`, + ); + projectErrors.push({ + credentialId: record.id, + label, + projectId: project.id, + projectName: project.name, + kind: classified.kind, + status: classified.status, + message: classified.message, + retryable: true, + }); + } + + atlasTrace( + `${owner}: found ${String(clusters.length)} cluster(s) across ${String(projects.length)} project(s)`, + ); + + return { record, organizations, projects, clusters, projectErrors }; + } + + /** + * Caches the organization name for a credential that resolves to exactly one organization, so + * a failed credential can still be attributed to a readable organization name later. + */ + private async cacheOrganizationMetadata( + record: AtlasCredentialRecord, + organizations: AtlasOrganization[], + ): Promise { + if (organizations.length !== 1) { + return; + } + const [organization] = organizations; + if (record.orgId === organization.id && record.orgName === organization.name) { + return; + } + try { + await updateAtlasCredentialMetadata(record.id, { orgId: organization.id, orgName: organization.name }); + } catch { + // Caching the display name is best-effort; discovery must not fail because of it. + } + } +} + +/** + * Merges per-credential results into one deduplicated snapshot. + * + * Exported for focused testing of the merge contract without needing the network. + */ +export function mergeResults(results: readonly CredentialResult[], clustersIncluded: boolean): AtlasDiscoverySnapshot { + const organizations = new Map(); + const projects = new Map(); + const clusters = new Map< + string, + { cluster: AtlasCluster; project: AtlasProject; credentialIds: string[]; orgId: string } + >(); + const credentialErrors: AtlasCredentialError[] = []; + const projectErrors: AtlasProjectError[] = []; + + for (const result of results) { + if (result.credentialError) { + credentialErrors.push(result.credentialError); + } + projectErrors.push(...result.projectErrors); + + for (const organization of result.organizations) { + const entry = organizations.get(organization.id); + if (entry) { + if (!entry.credentialIds.includes(result.record.id)) { + entry.credentialIds.push(result.record.id); + } + } else { + organizations.set(organization.id, { organization, credentialIds: [result.record.id] }); + } + } + + for (const project of result.projects) { + const entry = projects.get(project.id); + if (entry) { + if (!entry.credentialIds.includes(result.record.id)) { + entry.credentialIds.push(result.record.id); + } + } else { + projects.set(project.id, { project, credentialIds: [result.record.id] }); + } + } + + for (const { project, cluster } of result.clusters) { + const key = clusterKey(project.id, cluster); + const entry = clusters.get(key); + if (entry) { + if (!entry.credentialIds.includes(result.record.id)) { + entry.credentialIds.push(result.record.id); + } + } else { + clusters.set(key, { cluster, project, credentialIds: [result.record.id], orgId: project.orgId }); + } + } + } + + return { + organizations: [...organizations.values()] + .map(({ organization, credentialIds }) => ({ + organization, + credentialIds, + ownerCredentialId: credentialIds[0], + })) + .sort((a, b) => + (a.organization.name ?? '').localeCompare(b.organization.name ?? '', undefined, { numeric: true }), + ), + projects: [...projects.values()] + .map(({ project, credentialIds }) => ({ project, credentialIds, ownerCredentialId: credentialIds[0] })) + .sort((a, b) => (a.project.name ?? '').localeCompare(b.project.name ?? '', undefined, { numeric: true })), + clusters: [...clusters.values()] + .map(({ cluster, project, credentialIds, orgId }) => ({ + cluster, + projectId: project.id, + projectName: project.name, + orgId, + credentialIds, + ownerCredentialId: credentialIds[0], + })) + .sort((a, b) => (a.cluster.name ?? '').localeCompare(b.cluster.name ?? '', undefined, { numeric: true })), + credentialErrors, + projectErrors, + credentialsQueried: results.length, + clustersIncluded, + }; +} + +/** + * Clusters are keyed by project id + cluster name. Atlas cluster names are unique inside a + * project, and the `id` field is absent on some cluster shapes, so the name is the reliable key. + */ +function clusterKey(projectId: string, cluster: AtlasCluster): string { + return `${projectId}/${cluster.name}`; +} + +/** Convenience predicate for the UX: does this snapshot need a recovery action? */ +export function snapshotHasFailures(snapshot: AtlasDiscoverySnapshot): boolean { + return snapshot.credentialErrors.length > 0 || snapshot.projectErrors.length > 0; +} diff --git a/src/plugins/service-atlas-mongodb/models/AtlasClusterModel.test.ts b/src/plugins/service-atlas-mongodb/models/AtlasClusterModel.test.ts new file mode 100644 index 000000000..4557a1769 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/models/AtlasClusterModel.test.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DocumentDBExperience } from '../../../DocumentDBExperiences'; +import { createAtlasClusterModel, createAtlasClusterStableSuffix } from './AtlasClusterModel'; +import { type AtlasCluster } from './AtlasProjectModel'; + +function baseCluster(overrides: Partial = {}): AtlasCluster { + return { + id: 'c1', + name: 'Cluster0', + groupId: 'g1', + mongoDBVersion: '7.0', + connectionStrings: { standardSrv: 'mongodb+srv://cluster0.example.invalid' }, + stateName: 'IDLE', + clusterType: 'REPLICASET', + providerSettings: { providerName: 'AWS', regionName: 'US_EAST_1', instanceSizeName: 'M10' }, + ...overrides, + }; +} + +describe('createAtlasClusterModel (NEW-7 boundary guards)', () => { + it('uses the unprefixed stable suffix in the provider-prefixed cluster ID', () => { + const model = createAtlasClusterModel('p1', 'Project 0', baseCluster(), DocumentDBExperience); + + expect(createAtlasClusterStableSuffix('p1', 'Cluster0')).toBe('p1_Cluster0'); + expect(model.clusterId).toBe('atlas-mongodb-discovery_p1_Cluster0'); + }); + + it('does not throw when Atlas omits connectionStrings, leaving the connection string undefined', () => { + const cluster = baseCluster({ connectionStrings: undefined }); + + const model = createAtlasClusterModel('p1', 'Project 0', cluster, DocumentDBExperience); + + expect(model.connectionString).toBeUndefined(); + }); + + it('prefers standardSrv, then standard', () => { + const model = createAtlasClusterModel( + 'p1', + 'Project 0', + baseCluster({ connectionStrings: { standard: 'mongodb://cluster0.example.invalid' } }), + DocumentDBExperience, + ); + + expect(model.connectionString).toBe('mongodb://cluster0.example.invalid'); + }); + + it('normalizes an unrecognized cluster state to UNKNOWN', () => { + const cluster = baseCluster({ stateName: 'PAUSED' as AtlasCluster['stateName'] }); + + const model = createAtlasClusterModel('p1', 'Project 0', cluster, DocumentDBExperience); + + expect(model.stateName).toBe('UNKNOWN'); + }); + + it('keeps a recognized cluster state', () => { + const model = createAtlasClusterModel('p1', 'Project 0', baseCluster(), DocumentDBExperience); + + expect(model.stateName).toBe('IDLE'); + }); + + it('preserves paused independently of the control-plane state', () => { + const model = createAtlasClusterModel( + 'p1', + 'Project 0', + baseCluster({ paused: true, stateName: 'IDLE' }), + DocumentDBExperience, + ); + + expect(model.paused).toBe(true); + expect(model.stateName).toBe('IDLE'); + }); +}); diff --git a/src/plugins/service-atlas-mongodb/models/AtlasClusterModel.ts b/src/plugins/service-atlas-mongodb/models/AtlasClusterModel.ts new file mode 100644 index 000000000..05affde46 --- /dev/null +++ b/src/plugins/service-atlas-mongodb/models/AtlasClusterModel.ts @@ -0,0 +1,111 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Experience } from '../../../DocumentDBExperiences'; +import { type BaseClusterModel } from '../../../tree/models/BaseClusterModel'; +import { ATLAS_CLUSTER_STATES, type AtlasClusterState, type AtlasClusterType } from './AtlasProjectModel'; + +/** + * Cluster model for MongoDB Atlas clusters discovered via the Atlas Admin API. + * Extends BaseClusterModel with Atlas-specific metadata. + */ +export interface AtlasClusterModel extends BaseClusterModel { + /** Atlas project (group) ID this cluster belongs to */ + readonly projectId: string; + + /** Atlas project name */ + readonly projectName: string; + + /** Whether Atlas has paused the cluster, including automatic inactivity pauses. */ + readonly paused: boolean; + + /** Cluster state (IDLE, CREATING, UPDATING, etc.) */ + readonly stateName: AtlasClusterState; + + /** Cluster type (REPLICASET, SHARDED, GEOSHARDED) */ + readonly clusterType: AtlasClusterType; + + /** Cloud provider name (AWS, GCP, AZURE) */ + readonly providerName: string; + + /** Cloud region (e.g., US_EAST_1) */ + readonly regionName: string; + + /** Instance size (e.g., M10, M30) */ + readonly instanceSizeName: string; + + /** MongoDB version running on the cluster */ + readonly mongoDBVersion: string; +} + +/** + * Builds the unprefixed stable suffix shared by Atlas cluster and tree identifiers. + */ +export function createAtlasClusterStableSuffix(projectId: string, clusterName: string): string { + const safeProjectId = projectId.replaceAll('/', '_'); + const safeClusterName = clusterName.replaceAll('/', '_'); + return `${safeProjectId}_${safeClusterName}`; +} + +/** + * Creates an AtlasClusterModel from Atlas API response data. + */ +export function createAtlasClusterModel( + projectId: string, + projectName: string, + cluster: { + id: string; + name: string; + mongoDBVersion: string; + paused?: boolean; + connectionStrings?: { standardSrv?: string; standard?: string }; + stateName: AtlasClusterState; + clusterType: AtlasClusterType; + providerSettings?: { providerName: string; regionName: string; instanceSizeName: string }; + replicationSpecs?: { + regionConfigs?: { + providerName?: string; + regionName?: string; + electableSpecs?: { instanceSize?: string }; + }[]; + }[]; + }, + dbExperience: Experience, +): AtlasClusterModel { + // clusterId must not contain '/' — use provider prefix + project + cluster name + const clusterId = `atlas-mongodb-discovery_${createAtlasClusterStableSuffix(projectId, cluster.name)}`; + // Resolve provider info from top-level providerSettings or replicationSpecs + const provider = + cluster.providerSettings ?? + (() => { + const rc = cluster.replicationSpecs?.[0]?.regionConfigs?.[0]; + return rc + ? { + providerName: rc.providerName ?? '', + regionName: rc.regionName ?? '', + instanceSizeName: rc.electableSpecs?.instanceSize ?? '', + } + : { providerName: '', regionName: '', instanceSizeName: '' }; + })(); + + return { + name: cluster.name, + // Atlas is a live API and this model is built from a cast, not a validated payload. These + // guards cover the fields a missing value would actually throw on; see the tracked issue + // (NEW-7 Proposal A) for validating the whole boundary. + connectionString: cluster.connectionStrings?.standardSrv ?? cluster.connectionStrings?.standard, + dbExperience, + clusterId, + projectId, + projectName, + paused: cluster.paused === true, + stateName: ATLAS_CLUSTER_STATES.includes(cluster.stateName) ? cluster.stateName : 'UNKNOWN', + clusterType: cluster.clusterType, + providerName: provider.providerName, + regionName: provider.regionName, + instanceSizeName: provider.instanceSizeName, + mongoDBVersion: cluster.mongoDBVersion, + }; +} diff --git a/src/plugins/service-atlas-mongodb/models/AtlasProjectModel.ts b/src/plugins/service-atlas-mongodb/models/AtlasProjectModel.ts new file mode 100644 index 000000000..c67d63aeb --- /dev/null +++ b/src/plugins/service-atlas-mongodb/models/AtlasProjectModel.ts @@ -0,0 +1,135 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Represents a MongoDB Atlas organization. + */ +export interface AtlasOrganization { + readonly id: string; + readonly name: string; +} + +/** + * Represents the authenticated Atlas user. + */ +export interface AtlasUserInfo { + readonly id: string; + readonly emailAddress: string; + readonly firstName: string; + readonly lastName: string; + readonly username: string; +} + +/** + * Represents a MongoDB Atlas project (also called "group" in the API). + */ +export interface AtlasProject { + readonly id: string; + readonly name: string; + readonly orgId: string; + readonly clusterCount: number; + readonly created: string; +} + +/** + * Represents a MongoDB Atlas cluster. + * Atlas API v2 may return providerSettings at the top level (legacy) + * or embed provider info inside replicationSpecs[].regionConfigs[]. + */ +export interface AtlasCluster { + readonly id: string; + readonly name: string; + readonly groupId: string; + readonly mongoDBVersion: string; + /** Independent of stateName, which only describes current control-plane activity. */ + readonly paused?: boolean; + // Optional on purpose: this is a cast from a live API payload, and Atlas omits connection + // strings for a cluster that is still being created. Making it optional forces every + // dereference to be guarded (see NEW-7). Full boundary validation is tracked as a follow-up. + readonly connectionStrings?: AtlasConnectionStrings; + readonly stateName: AtlasClusterState; + readonly clusterType: AtlasClusterType; + readonly providerSettings?: AtlasProviderSettings; + readonly replicationSpecs?: AtlasReplicationSpec[]; +} + +export interface AtlasConnectionStrings { + readonly standardSrv?: string; + readonly standard?: string; +} + +export interface AtlasProviderSettings { + readonly providerName: string; + readonly regionName: string; + readonly instanceSizeName: string; +} + +export interface AtlasReplicationSpec { + readonly regionConfigs?: AtlasRegionConfig[]; +} + +export interface AtlasRegionConfig { + readonly providerName?: string; + readonly regionName?: string; + readonly electableSpecs?: AtlasElectableSpecs; +} + +export interface AtlasElectableSpecs { + readonly instanceSize?: string; +} + +export type AtlasClusterState = 'IDLE' | 'CREATING' | 'UPDATING' | 'DELETING' | 'REPAIRING' | 'UNKNOWN'; + +/** Every recognized {@link AtlasClusterState}, for normalizing an unrecognized value to `UNKNOWN`. */ +export const ATLAS_CLUSTER_STATES: readonly AtlasClusterState[] = [ + 'IDLE', + 'CREATING', + 'UPDATING', + 'DELETING', + 'REPAIRING', + 'UNKNOWN', +]; + +export type AtlasClusterType = 'REPLICASET' | 'SHARDED' | 'GEOSHARDED'; + +/** + * A database user defined in an Atlas project. + * + * Database users are project-scoped, not cluster-scoped: `scopes` is what ties a user to + * particular clusters, and an empty `scopes` array means the user applies to every cluster in + * the project. Atlas never returns the password. + */ +export interface AtlasDatabaseUser { + readonly username: string; + /** + * Authentication database. `admin` is a SCRAM (username plus password) user; `$external` + * means the user authenticates through X.509, AWS IAM, LDAP or OIDC and therefore cannot be + * used with the username and password prompt. + */ + readonly databaseName: string; + readonly description?: string; + readonly scopes?: AtlasDatabaseUserScope[]; + readonly roles?: AtlasDatabaseUserRole[]; + + /** + * Which non-SCRAM method a `$external` user signs in with. Atlas sets exactly one of these to + * something other than `NONE`, so together they name the method precisely. + */ + readonly x509Type?: string; + readonly awsIAMType?: string; + readonly ldapAuthType?: string; + readonly oidcAuthType?: string; +} + +export interface AtlasDatabaseUserScope { + readonly name: string; + readonly type: 'CLUSTER' | 'DATA_LAKE' | 'STREAM'; +} + +export interface AtlasDatabaseUserRole { + readonly roleName: string; + readonly databaseName?: string; + readonly collectionName?: string; +} diff --git a/src/plugins/service-kubernetes/discovery-tree/KubernetesContextItem.test.ts b/src/plugins/service-kubernetes/discovery-tree/KubernetesContextItem.test.ts index 675c790cf..a672c261c 100644 --- a/src/plugins/service-kubernetes/discovery-tree/KubernetesContextItem.test.ts +++ b/src/plugins/service-kubernetes/discovery-tree/KubernetesContextItem.test.ts @@ -395,45 +395,6 @@ describe('KubernetesContextItem', () => { expect(maxActiveScans).toBeLessThanOrEqual(5); }); - it('should continue scanning remaining namespaces after one namespace times out', async () => { - const namespaces = [ - 'namespace-1', - 'namespace-2', - 'namespace-timeout', - 'namespace-4', - 'namespace-5', - 'namespace-6', - 'namespace-working', - ]; - mockListNamespaces.mockResolvedValue(namespaces); - mockListDocumentDBServices.mockImplementation(async (_coreApi: unknown, namespace: string) => { - if (namespace === 'namespace-timeout') { - const timeoutError = new Error('Operation timed out after 30 seconds.'); - timeoutError.name = 'KubernetesApiTimeoutError'; - throw timeoutError; - } - - return namespace === 'namespace-working' - ? [{ name: 'svc-a', namespace, type: 'ClusterIP', port: 10260 }] - : []; - }); - - const item = new KubernetesContextItem('parent', 'default', baseContextInfo, 'corr-1'); - const children = await item.getChildren(); - - expect(mockListDocumentDBServices).toHaveBeenCalledTimes(namespaces.length); - const timedOutNamespace = children?.find((child) => getNamespaceName(child) === 'namespace-timeout'); - expect(timedOutNamespace).toBeDefined(); - expect(getCollapsibleState(timedOutNamespace)).toBe(1); - - const retryChildren = await timedOutNamespace!.getChildren!(); - expect(retryChildren).toHaveLength(1); - expect((retryChildren![0] as { contextValue?: string }).contextValue).toBe('error'); - - const workingNamespace = children?.find((child) => getNamespaceName(child) === 'namespace-working'); - expect(workingNamespace).toBeDefined(); - }); - it('should show informational child when no namespaces exist', async () => { mockListNamespaces.mockResolvedValue([]); @@ -468,32 +429,6 @@ describe('KubernetesContextItem', () => { ); }); - it('should identify a namespace-list timeout and retain the retry action', async () => { - (vscode.window.showErrorMessage as jest.Mock).mockClear(); - const timeoutError = new Error('localized deadline message'); - timeoutError.name = 'KubernetesApiTimeoutError'; - mockListNamespaces.mockRejectedValue(timeoutError); - - const item = new KubernetesContextItem('parent', 'default', baseContextInfo, 'corr-1'); - const children = await item.getChildren(); - - expect(children).toHaveLength(1); - expect((children![0] as { contextValue?: string }).contextValue).toBe('error'); - expect(item.hasRetryNode(children)).toBe(true); - expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( - expect.stringContaining('Failed to connect'), - expect.objectContaining({ - modal: true, - detail: expect.stringContaining('Connection timed out'), - }), - ); - expect(mockOutputChannelError).toHaveBeenCalledWith(expect.stringContaining('localized deadline message')); - expect(telemetryContextMock.telemetry.properties).toHaveProperty( - 'namespaceFetchErrorType', - 'KubernetesApiTimeoutError', - ); - }); - it('should keep namespaces expandable when service pre-scan throws', async () => { mockListNamespaces.mockResolvedValue(['default', 'broken-ns', 'working-ns']); mockListDocumentDBServices.mockImplementation(async (_coreApi: unknown, namespace: string) => { diff --git a/src/plugins/service-kubernetes/discovery-tree/KubernetesContextItem.ts b/src/plugins/service-kubernetes/discovery-tree/KubernetesContextItem.ts index 6bd015ff2..6ea29d71d 100644 --- a/src/plugins/service-kubernetes/discovery-tree/KubernetesContextItem.ts +++ b/src/plugins/service-kubernetes/discovery-tree/KubernetesContextItem.ts @@ -28,7 +28,6 @@ import { import { KubernetesNamespaceItem } from './KubernetesNamespaceItem'; import { KubernetesOtherNamespacesItem } from './KubernetesOtherNamespacesItem'; import { KubernetesResourceItem } from './documentdb/KubernetesResourceItem'; -import { classifyKubernetesConnectionError } from './kubernetesConnectionError'; interface NamespaceDiscoveryResult { readonly namespace: string; @@ -107,7 +106,12 @@ export class KubernetesContextItem implements TreeElement, TreeElementWithContex context.telemetry.properties.namespaceFetchErrorType = error instanceof Error ? error.name : 'UnknownError'; - return createConnectionErrorChildren(this.id, error, this, this.alias ?? this.contextInfo.name); + return createConnectionErrorChildren( + this.id, + errorMessage, + this, + this.alias ?? this.contextInfo.name, + ); } context.telemetry.measurements.clusterConnectMs = Date.now() - clusterConnectStart; @@ -337,31 +341,74 @@ async function mapWithBoundedConcurrency( * Classifies a Kubernetes API error message into a user-friendly summary * and an actionable hint so tree error nodes are immediately useful. */ -function classifyConnectionError(error: unknown): { summary: string; hint: string } { - const errorMessage = error instanceof Error ? error.message : String(error); - // Truncate long generic messages - const truncated = errorMessage.length > 120 ? errorMessage.slice(0, 117) + '...' : errorMessage; +function classifyConnectionError(errorMessage: string): { summary: string; hint: string } { + const lower = errorMessage.toLowerCase(); - return classifyKubernetesConnectionError(error, { - unauthorized: { + if (lower.includes('401') || lower.includes('unauthorized')) { + return { summary: vscode.l10n.t('Authentication failed (401 Unauthorized)'), hint: vscode.l10n.t( 'Credentials may have expired. Re-authenticate with your cluster or update the kubeconfig.', ), - }, - forbidden: { + }; + } + if (lower.includes('403') || lower.includes('forbidden')) { + return { summary: vscode.l10n.t('Access denied (403 Forbidden)'), hint: vscode.l10n.t( 'Your account lacks the required RBAC permissions. Contact your cluster administrator.', ), - }, - unknown: { - summary: vscode.l10n.t('Connection failed: {0}', truncated), + }; + } + if (lower.includes('econnrefused') || lower.includes('connection refused')) { + return { + summary: vscode.l10n.t('Connection refused'), + hint: vscode.l10n.t( + 'The cluster may be stopped or unreachable. Verify the cluster is running and the server URL is correct.', + ), + }; + } + if (lower.includes('enotfound') || lower.includes('getaddrinfo')) { + return { + summary: vscode.l10n.t('Cluster not found (DNS resolution failed)'), hint: vscode.l10n.t( - 'Check the output channel for details. The cluster may be unreachable or your credentials may need updating.', + 'The server hostname could not be resolved. The cluster may have been deleted or the URL may be incorrect.', ), - }, - }); + }; + } + if (lower.includes('etimedout') || lower.includes('timeout') || lower.includes('timed out')) { + return { + summary: vscode.l10n.t('Connection timed out'), + hint: vscode.l10n.t( + 'The cluster did not respond in time. Check your network connection and firewall settings.', + ), + }; + } + if (lower.includes('certificate') || lower.includes('cert') || lower.includes('ssl') || lower.includes('tls')) { + return { + summary: vscode.l10n.t('Certificate error'), + hint: vscode.l10n.t( + 'The cluster certificate may have changed or expired. Update your kubeconfig with fresh credentials.', + ), + }; + } + if (lower.includes('not found') || lower.includes('404')) { + return { + summary: vscode.l10n.t('Resource not found'), + hint: vscode.l10n.t( + 'The cluster or API endpoint may have been deleted. Verify your kubeconfig is up to date.', + ), + }; + } + + // Truncate long generic messages + const truncated = errorMessage.length > 120 ? errorMessage.slice(0, 117) + '...' : errorMessage; + return { + summary: vscode.l10n.t('Connection failed: {0}', truncated), + hint: vscode.l10n.t( + 'Check the output channel for details. The cluster may be unreachable or your credentials may need updating.', + ), + }; } /** @@ -380,12 +427,11 @@ function classifyConnectionError(error: unknown): { summary: string; hint: strin */ function createConnectionErrorChildren( parentId: string, - error: unknown, + errorMessage: string, retryTarget: TreeElement, connectionLabel: string, ): TreeElement[] { - const errorMessage = error instanceof Error ? error.message : String(error); - const { summary, hint } = classifyConnectionError(error); + const { summary, hint } = classifyConnectionError(errorMessage); void vscode.window.showErrorMessage(vscode.l10n.t('Failed to connect to "{0}"', connectionLabel), { modal: true, diff --git a/src/plugins/service-kubernetes/discovery-tree/KubernetesNamespaceItem.test.ts b/src/plugins/service-kubernetes/discovery-tree/KubernetesNamespaceItem.test.ts index 1b8a0353e..e3cd4dc92 100644 --- a/src/plugins/service-kubernetes/discovery-tree/KubernetesNamespaceItem.test.ts +++ b/src/plugins/service-kubernetes/discovery-tree/KubernetesNamespaceItem.test.ts @@ -227,32 +227,6 @@ describe('KubernetesNamespaceItem', () => { expect(telemetryContextMock.telemetry.properties).toHaveProperty('serviceFetchError', 'true'); }); - it('should identify a timed-out service request and retain the retry action', async () => { - (vscode.window.showErrorMessage as jest.Mock).mockClear(); - const timeoutError = new Error('localized deadline message'); - timeoutError.name = 'KubernetesApiTimeoutError'; - mockListDocumentDBServices.mockRejectedValue(timeoutError); - - const item = new KubernetesNamespaceItem('parent/ctx', 'default', baseContextInfo, 'my-ns', 'corr-1'); - const children = await item.getChildren(); - - expect(children).toHaveLength(1); - expect((children![0] as { contextValue?: string }).contextValue).toBe('error'); - expect(item.hasRetryNode(children)).toBe(true); - expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( - expect.stringContaining('Failed to list services in "my-context/my-ns"'), - expect.objectContaining({ - modal: true, - detail: expect.stringContaining('Connection timed out'), - }), - ); - expect(mockOutputChannelError).toHaveBeenCalledWith(expect.stringContaining('localized deadline message')); - expect(telemetryContextMock.telemetry.properties).toHaveProperty( - 'serviceFetchErrorType', - 'KubernetesApiTimeoutError', - ); - }); - it('should show retry node and modal when kubeconfig fails to load', async () => { (vscode.window.showErrorMessage as jest.Mock).mockClear(); mockLoadConfiguredKubeConfig.mockRejectedValue(new Error('ENOENT: config not found')); diff --git a/src/plugins/service-kubernetes/discovery-tree/KubernetesNamespaceItem.ts b/src/plugins/service-kubernetes/discovery-tree/KubernetesNamespaceItem.ts index a2577990b..049ad2319 100644 --- a/src/plugins/service-kubernetes/discovery-tree/KubernetesNamespaceItem.ts +++ b/src/plugins/service-kubernetes/discovery-tree/KubernetesNamespaceItem.ts @@ -21,7 +21,6 @@ import { type KubeServiceInfo, } from '../kubernetesClient'; import { KubernetesResourceItem } from './documentdb/KubernetesResourceItem'; -import { classifyKubernetesConnectionError } from './kubernetesConnectionError'; export class KubernetesNamespaceItem implements TreeElement, TreeElementWithContextValue { public readonly id: string; @@ -70,7 +69,7 @@ export class KubernetesNamespaceItem implements TreeElement, TreeElementWithCont error instanceof Error ? error.name : 'UnknownError'; return createServiceErrorChildren( this.id, - error, + errorMessage, this, `${this.contextInfo.name}/${this.namespace}`, ); @@ -157,26 +156,25 @@ export class KubernetesNamespaceItem implements TreeElement, TreeElementWithCont */ function createServiceErrorChildren( parentId: string, - error: unknown, + errorMessage: string, retryTarget: TreeElement, namespaceLabel: string, ): TreeElement[] { - const errorMessage = error instanceof Error ? error.message : String(error); - - const { summary, hint } = classifyKubernetesConnectionError(error, { - unauthorized: { - summary: vscode.l10n.t('Authentication failed listing services (401)'), - hint: vscode.l10n.t('Credentials may have expired. Re-authenticate with your cluster.'), - }, - forbidden: { - summary: vscode.l10n.t('Access denied listing services (403 Forbidden)'), - hint: vscode.l10n.t('Your account lacks permission to list services in this namespace.'), - }, - unknown: { - summary: vscode.l10n.t('Failed to list services'), - hint: vscode.l10n.t('Check the output channel for details.'), - }, - }); + const lower = errorMessage.toLowerCase(); + + let summary: string; + let hint: string; + + if (lower.includes('403') || lower.includes('forbidden')) { + summary = vscode.l10n.t('Access denied listing services (403 Forbidden)'); + hint = vscode.l10n.t('Your account lacks permission to list services in this namespace.'); + } else if (lower.includes('401') || lower.includes('unauthorized')) { + summary = vscode.l10n.t('Authentication failed listing services (401)'); + hint = vscode.l10n.t('Credentials may have expired. Re-authenticate with your cluster.'); + } else { + summary = vscode.l10n.t('Failed to list services'); + hint = vscode.l10n.t('Check the output channel for details.'); + } void vscode.window.showErrorMessage(vscode.l10n.t('Failed to list services in "{0}"', namespaceLabel), { modal: true, diff --git a/src/plugins/service-kubernetes/discovery-tree/documentdb/KubernetesResourceItem.test.ts b/src/plugins/service-kubernetes/discovery-tree/documentdb/KubernetesResourceItem.test.ts index 976aed4fe..2d3f2b80b 100644 --- a/src/plugins/service-kubernetes/discovery-tree/documentdb/KubernetesResourceItem.test.ts +++ b/src/plugins/service-kubernetes/discovery-tree/documentdb/KubernetesResourceItem.test.ts @@ -7,7 +7,6 @@ import { KUBERNETES_PORT_FORWARD_METADATA_PROPERTY } from '../../portForwardMeta import { KubernetesResourceItem } from './KubernetesResourceItem'; const mockHasCredentials = jest.fn(); -const mockGetCachedCredentials = jest.fn(); const mockGetClient = jest.fn(); jest.mock('@vscode/l10n', () => ({ @@ -104,7 +103,6 @@ jest.mock('../../../../extensionVariables', () => ({ jest.mock('../../../../documentdb/CredentialCache', () => ({ CredentialCache: { hasCredentials: (...args: unknown[]) => mockHasCredentials(...args), - getCredentials: (...args: unknown[]) => mockGetCachedCredentials(...args), deleteCredentials: jest.fn(), setAuthCredentials: jest.fn(), }, @@ -160,11 +158,6 @@ jest.mock('../../sources/sourceStore', () => ({ getSource: (...args: unknown[]) => mockGetSource(...(args as [string])), })); -const mockEnsureKubernetesPortForward = jest.fn(); -jest.mock('../../ensureKubernetesPortForward', () => ({ - ensureKubernetesPortForward: (...args: unknown[]) => mockEnsureKubernetesPortForward(...args), -})); - // Mock the icons util so the cluster icon path resolves without an extension context. jest.mock('../../../../utils/icons', () => ({ getResourcesPath: () => '/resources', @@ -183,13 +176,9 @@ describe('KubernetesResourceItem', () => { beforeEach(() => { jest.clearAllMocks(); mockHasCredentials.mockReturnValue(true); - mockGetCachedCredentials.mockReturnValue({ - connectionString: 'mongodb://127.0.0.1:10260/', - }); mockGetClient.mockResolvedValue({ listDatabases: jest.fn().mockResolvedValue([{ name: 'appdb' }]), }); - mockEnsureKubernetesPortForward.mockResolvedValue({ outcome: 'started' }); }); it('expands to database nodes instead of metadata detail rows', async () => { @@ -460,43 +449,6 @@ describe('KubernetesResourceItem', () => { }); }); - it('should restore the ClusterIP tunnel before a cluster-level command uses cached credentials', async () => { - const item = new KubernetesResourceItem( - 'corr-shell', - 'default', - { - name: 'my-ctx', - cluster: 'my-cluster', - user: 'my-user', - server: 'https://api.example.com:6443', - }, - { - sourceKind: 'dko', - name: 'my-svc', - displayName: 'My Service', - serviceName: 'my-svc', - namespace: 'default', - type: 'ClusterIP', - port: 10260, - clusterIP: '10.0.0.1', - }, - 'discoveryView/kubernetes-discovery/default', - ); - - await expect(item.ensureConnectionReady()).resolves.toBe(true); - expect(mockEnsureKubernetesPortForward).toHaveBeenCalledWith({ - kind: 'kubernetesClusterIpPortForward', - sourceId: 'default', - sourceLabel: 'Label for default', - contextName: 'my-ctx', - namespace: 'default', - serviceName: 'my-svc', - servicePort: 10260, - servicePortName: undefined, - localPort: 10260, - }); - }); - it('should return copy credentials for ClusterIP services without prompting or starting a tunnel', async () => { mockResolveServiceEndpoint.mockResolvedValue({ kind: 'needsPortForward', diff --git a/src/plugins/service-kubernetes/discovery-tree/kubernetesConnectionError.test.ts b/src/plugins/service-kubernetes/discovery-tree/kubernetesConnectionError.test.ts deleted file mode 100644 index f9e746f49..000000000 --- a/src/plugins/service-kubernetes/discovery-tree/kubernetesConnectionError.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { categorizeKubernetesConnectionError, classifyKubernetesConnectionError } from './kubernetesConnectionError'; - -jest.mock('vscode', () => ({ - l10n: { - t: jest.fn((template: string, ...args: unknown[]) => - template.replace(/\{(\d+)\}/g, (_match: string, index: string) => String(args[Number(index)])), - ), - }, -})); - -function createNamedError(name: string, message: string): Error { - const error = new Error(message); - error.name = name; - return error; -} - -const operationCopy = { - unauthorized: { summary: 'op-unauthorized', hint: 'op-unauthorized-hint' }, - forbidden: { summary: 'op-forbidden', hint: 'op-forbidden-hint' }, - unknown: { summary: 'op-unknown', hint: 'op-unknown-hint' }, -}; - -describe('categorizeKubernetesConnectionError', () => { - it('classifies a branded API timeout as a timeout regardless of message', () => { - const timeout = createNamedError('KubernetesApiTimeoutError', 'localized deadline message'); - expect(categorizeKubernetesConnectionError(timeout)).toBe('timeout'); - }); - - it.each([ - ['401 message', 'request failed with 401', 'unauthorized'], - ['unauthorized message', 'Unauthorized', 'unauthorized'], - ['403 message', 'HTTP 403 returned', 'forbidden'], - ['forbidden message', 'services is forbidden', 'forbidden'], - ['connection refused', 'connect ECONNREFUSED 127.0.0.1:6443', 'connectionRefused'], - ['dns failure', 'getaddrinfo ENOTFOUND cluster.example.com', 'dnsFailure'], - ['socket timeout', 'connect ETIMEDOUT', 'timeout'], - ['certificate error', 'unable to verify the first certificate', 'certificate'], - ['not found', 'the server could not find the requested resource (404)', 'notFound'], - ['generic', 'something unexpected happened', 'unknown'], - ])('classifies %s', (_label, message, expected) => { - expect(categorizeKubernetesConnectionError(new Error(message))).toBe(expected); - }); -}); - -describe('classifyKubernetesConnectionError', () => { - it('uses the shared transport copy for a branded timeout', () => { - const timeout = createNamedError('KubernetesApiTimeoutError', 'localized deadline message'); - expect(classifyKubernetesConnectionError(timeout, operationCopy).summary).toBe('Connection timed out'); - }); - - it('uses caller-supplied copy for authentication failures and the generic fallback', () => { - expect(classifyKubernetesConnectionError(new Error('401 Unauthorized'), operationCopy)).toEqual( - operationCopy.unauthorized, - ); - expect(classifyKubernetesConnectionError(new Error('403 Forbidden'), operationCopy)).toEqual( - operationCopy.forbidden, - ); - expect(classifyKubernetesConnectionError(new Error('mystery failure'), operationCopy)).toEqual( - operationCopy.unknown, - ); - }); - - it('recognizes certificate and DNS failures even when the caller only overrides auth copy', () => { - // Regression guard: these transport categories used to fall through to the - // generic "unknown" copy in the namespace view. - expect(classifyKubernetesConnectionError(new Error('SSL certificate problem'), operationCopy).summary).toBe( - 'Certificate error', - ); - expect(classifyKubernetesConnectionError(new Error('getaddrinfo ENOTFOUND host'), operationCopy).summary).toBe( - 'Cluster not found (DNS resolution failed)', - ); - }); -}); diff --git a/src/plugins/service-kubernetes/discovery-tree/kubernetesConnectionError.ts b/src/plugins/service-kubernetes/discovery-tree/kubernetesConnectionError.ts deleted file mode 100644 index dd44a3b9b..000000000 --- a/src/plugins/service-kubernetes/discovery-tree/kubernetesConnectionError.ts +++ /dev/null @@ -1,131 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { isKubernetesApiTimeoutError } from '../kubernetesApiTimeout'; - -/** - * A coarse category for a Kubernetes API/connection failure. - * - * Derived once from the raw error so every discovery-tree view classifies - * failures the same way; callers map the category to view-specific wording. - */ -export type KubernetesConnectionErrorCategory = - | 'timeout' - | 'unauthorized' - | 'forbidden' - | 'connectionRefused' - | 'dnsFailure' - | 'certificate' - | 'notFound' - | 'unknown'; - -/** User-facing summary and actionable hint for a connection error node. */ -export interface ConnectionErrorCopy { - summary: string; - hint: string; -} - -/** - * Classifies a Kubernetes error into a single category. This is the one place - * that inspects error names and messages, so views stay consistent instead of - * each maintaining its own drifting chain of string checks. - */ -export function categorizeKubernetesConnectionError(error: unknown): KubernetesConnectionErrorCategory { - if (isKubernetesApiTimeoutError(error)) { - return 'timeout'; - } - - const errorMessage = error instanceof Error ? error.message : String(error); - const lower = errorMessage.toLowerCase(); - - if (lower.includes('401') || lower.includes('unauthorized')) { - return 'unauthorized'; - } - if (lower.includes('403') || lower.includes('forbidden')) { - return 'forbidden'; - } - if (lower.includes('econnrefused') || lower.includes('connection refused')) { - return 'connectionRefused'; - } - if (lower.includes('enotfound') || lower.includes('getaddrinfo')) { - return 'dnsFailure'; - } - if (lower.includes('etimedout') || lower.includes('timeout') || lower.includes('timed out')) { - return 'timeout'; - } - if (lower.includes('certificate') || lower.includes('cert') || lower.includes('ssl') || lower.includes('tls')) { - return 'certificate'; - } - if (lower.includes('not found') || lower.includes('404')) { - return 'notFound'; - } - return 'unknown'; -} - -/** - * Maps a classified error to user-facing copy. - * - * Transport-level failures (timeout, refused, DNS, certificate, not-found) - * describe cluster reachability and read the same in every view, so their copy - * lives here. The operation-specific cases — authentication failures and the - * generic fallback — are supplied by the caller so a namespace view can say - * "listing services" where a context view says "connecting". - */ -export function classifyKubernetesConnectionError( - error: unknown, - operationCopy: { - unauthorized: ConnectionErrorCopy; - forbidden: ConnectionErrorCopy; - unknown: ConnectionErrorCopy; - }, -): ConnectionErrorCopy { - const timedOut: ConnectionErrorCopy = { - summary: vscode.l10n.t('Connection timed out'), - hint: vscode.l10n.t( - 'The cluster did not respond in time. Check your network connection and firewall settings.', - ), - }; - - switch (categorizeKubernetesConnectionError(error)) { - case 'timeout': - return timedOut; - case 'unauthorized': - return operationCopy.unauthorized; - case 'forbidden': - return operationCopy.forbidden; - case 'connectionRefused': - return { - summary: vscode.l10n.t('Connection refused'), - hint: vscode.l10n.t( - 'The cluster may be stopped or unreachable. Verify the cluster is running and the server URL is correct.', - ), - }; - case 'dnsFailure': - return { - summary: vscode.l10n.t('Cluster not found (DNS resolution failed)'), - hint: vscode.l10n.t( - 'The server hostname could not be resolved. The cluster may have been deleted or the URL may be incorrect.', - ), - }; - case 'certificate': - return { - summary: vscode.l10n.t('Certificate error'), - hint: vscode.l10n.t( - 'The cluster certificate may have changed or expired. Update your kubeconfig with fresh credentials.', - ), - }; - case 'notFound': - return { - summary: vscode.l10n.t('Resource not found'), - hint: vscode.l10n.t( - 'The cluster or API endpoint may have been deleted. Verify your kubeconfig is up to date.', - ), - }; - case 'unknown': - default: - return operationCopy.unknown; - } -} diff --git a/src/plugins/service-kubernetes/kubernetesApiTimeout.test.ts b/src/plugins/service-kubernetes/kubernetesApiTimeout.test.ts deleted file mode 100644 index ca6c0f7c9..000000000 --- a/src/plugins/service-kubernetes/kubernetesApiTimeout.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { - createKubernetesApiOperationError, - getKubernetesApiErrorMessage, - isKubernetesApiTimeoutError, - normalizeKubernetesApiError, -} from './kubernetesApiTimeout'; - -jest.mock('vscode', () => ({ - l10n: { - t: jest.fn((template: string, ...args: unknown[]) => - template.replace(/\{(\d+)\}/g, (_match: string, index: string) => String(args[Number(index)])), - ), - }, -})); - -function createNamedError(name: string, message: string): Error { - const error = new Error(message); - error.name = name; - return error; -} - -describe('kubernetesApiTimeout', () => { - it.each(['AbortError', 'TimeoutError'])('normalizes generated-client %s failures as timeouts', (name) => { - const rawError = createNamedError(name, 'transport abort'); - - expect(isKubernetesApiTimeoutError(rawError)).toBe(false); - - const normalized = normalizeKubernetesApiError(rawError); - expect(isKubernetesApiTimeoutError(normalized)).toBe(true); - expect(normalized.message).toBe('Operation timed out after 30 seconds.'); - expect(normalized.cause).toBe(rawError); - }); - - it('does not classify an arbitrary AbortError until it crosses a known API boundary', () => { - const abortError = createNamedError('AbortError', 'request cancelled during teardown'); - - expect(isKubernetesApiTimeoutError(abortError)).toBe(false); - expect(getKubernetesApiErrorMessage(abortError)).toBe('request cancelled during teardown'); - }); - - it('preserves contextual messages on an already-wrapped timeout error', () => { - const contextualError = createNamedError( - 'KubernetesApiTimeoutError', - 'Failed to list services in namespace "app": Operation timed out after 30 seconds.', - ); - - expect(normalizeKubernetesApiError(contextualError)).toBe(contextualError); - expect(getKubernetesApiErrorMessage(contextualError)).toBe(contextualError.message); - }); - - it('brands a contextual operation error when its cause is a generated-client timeout', () => { - const normalizedCause = normalizeKubernetesApiError(createNamedError('AbortError', 'transport abort')); - const operationError = createKubernetesApiOperationError( - 'Failed to list namespaces: Operation timed out after 30 seconds.', - normalizedCause, - ); - - expect(isKubernetesApiTimeoutError(operationError)).toBe(true); - expect(operationError.message).toBe('Failed to list namespaces: Operation timed out after 30 seconds.'); - expect(operationError.cause).toBe(normalizedCause); - }); - - it('does not brand a contextual operation error from an unnormalized AbortError', () => { - const abortError = createNamedError('AbortError', 'request cancelled during teardown'); - const operationError = createKubernetesApiOperationError('Port-forward setup was cancelled.', abortError); - - expect(isKubernetesApiTimeoutError(operationError)).toBe(false); - expect(operationError.cause).toBe(abortError); - }); - - it('preserves non-Error values as causes when normalizing them', () => { - const rawError = 'Forbidden'; - - const normalized = normalizeKubernetesApiError(rawError); - - expect(normalized.message).toBe(rawError); - expect(normalized.cause).toBe(rawError); - }); - - it('preserves an extractable message when normalizing a record-shaped API error', () => { - // The generated client can reject with a plain object rather than an Error; - // normalizing it must not collapse the message to "[object Object]". - const apiException = { body: { message: 'services is forbidden' } }; - - const normalized = normalizeKubernetesApiError(apiException); - - expect(normalized.message).toBe('services is forbidden'); - expect(normalized.cause).toBe(apiException); - expect(getKubernetesApiErrorMessage(normalized)).toBe('services is forbidden'); - }); -}); diff --git a/src/plugins/service-kubernetes/kubernetesApiTimeout.ts b/src/plugins/service-kubernetes/kubernetesApiTimeout.ts deleted file mode 100644 index c3a1e664b..000000000 --- a/src/plugins/service-kubernetes/kubernetesApiTimeout.ts +++ /dev/null @@ -1,99 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { type Middleware } from '@kubernetes/client-node'; -import * as vscode from 'vscode'; - -export const KUBERNETES_API_TIMEOUT_MS = 30_000; - -const KUBERNETES_API_TIMEOUT_SECONDS = KUBERNETES_API_TIMEOUT_MS / 1000; -const KUBERNETES_API_TIMEOUT_ERROR_NAME = 'KubernetesApiTimeoutError'; - -export const kubernetesApiTimeoutMiddleware: Middleware = { - async pre(context) { - // A signal cannot be reused after it aborts. Create a fresh deadline for - // every generated-client request when the middleware runs. - context.setSignal(AbortSignal.timeout(KUBERNETES_API_TIMEOUT_MS)); - return context; - }, - async post(context) { - return context; - }, -}; - -export function isKubernetesApiTimeoutError(error: unknown): error is Error { - return error instanceof Error && error.name === KUBERNETES_API_TIMEOUT_ERROR_NAME; -} - -export function getKubernetesApiErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - - if (typeof error === 'string') { - return error; - } - - if (isRecord(error)) { - const message = error.message; - if (typeof message === 'string') { - return message; - } - - const body = error.body; - if (typeof body === 'string') { - return body; - } - if (isRecord(body) && typeof body.message === 'string') { - return body.message; - } - } - - return String(error); -} - -export function createKubernetesApiOperationError(message: string, cause: unknown): Error { - const error = new Error(message, { cause }); - if (isKubernetesApiTimeoutError(cause)) { - error.name = KUBERNETES_API_TIMEOUT_ERROR_NAME; - } - return error; -} - -export function normalizeKubernetesApiError(error: unknown): Error { - if (isKubernetesApiTimeoutError(error)) { - return error; - } - - if (!isAbortSignalTimeoutError(error)) { - // Wrap non-Error values (e.g. the generated client's record-shaped API - // exceptions) without discarding the message getKubernetesApiErrorMessage - // can extract from them — String(error) would collapse to "[object Object]". - return error instanceof Error ? error : new Error(getKubernetesApiErrorMessage(error), { cause: error }); - } - - const timeoutError = new Error( - vscode.l10n.t('Operation timed out after {0} seconds.', String(KUBERNETES_API_TIMEOUT_SECONDS)), - { cause: error }, - ); - timeoutError.name = KUBERNETES_API_TIMEOUT_ERROR_NAME; - return timeoutError; -} - -function isAbortSignalTimeoutError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false; - } - - // The generated client uses node-fetch v2, which reports our timed signal - // as AbortError. TimeoutError covers runtimes that preserve - // AbortSignal.timeout()'s native reason. Only normalize these names at - // known generated-client request boundaries. - return error.name === 'AbortError' || error.name === 'TimeoutError'; -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} diff --git a/src/plugins/service-kubernetes/kubernetesClient.test.ts b/src/plugins/service-kubernetes/kubernetesClient.test.ts index cb1ffd98f..8154f52c6 100644 --- a/src/plugins/service-kubernetes/kubernetesClient.test.ts +++ b/src/plugins/service-kubernetes/kubernetesClient.test.ts @@ -29,18 +29,7 @@ const mockGetClusters = jest.fn(); const mockGetUsers = jest.fn(); const mockGetCurrentContext = jest.fn(); const mockSetCurrentContext = jest.fn(); -const mockGetCurrentCluster = jest.fn(); -const mockCreateConfiguration = jest.fn((configuration: unknown) => configuration); -const mockServerConfiguration = jest.fn().mockImplementation((server: string, variables: Record) => ({ - server, - variables, -})); -const mockCoreV1ApiClient = {}; -const mockCoreV1ApiConstructor = jest.fn().mockImplementation(() => mockCoreV1ApiClient); -let mockCustomObjectsApiClient: Record = { - listNamespacedCustomObject: jest.fn().mockResolvedValue({ items: [] }), -}; -const mockCustomObjectsApiConstructor = jest.fn().mockImplementation(() => mockCustomObjectsApiClient); +const mockMakeApiClient = jest.fn(); const mockLoadFromDefault = jest.fn(); const mockGetSource = jest.fn(); @@ -62,12 +51,10 @@ jest.mock('@kubernetes/client-node', () => ({ getUsers: mockGetUsers, getCurrentContext: mockGetCurrentContext, setCurrentContext: mockSetCurrentContext, - getCurrentCluster: mockGetCurrentCluster, + makeApiClient: mockMakeApiClient, })), - CoreV1Api: mockCoreV1ApiConstructor, - CustomObjectsApi: mockCustomObjectsApiConstructor, - createConfiguration: mockCreateConfiguration, - ServerConfiguration: mockServerConfiguration, + CoreV1Api: jest.fn(), + CustomObjectsApi: jest.fn(), ActionOnInvalid: { THROW: 'throw', FILTER: 'filter' }, })); @@ -91,19 +78,6 @@ function createApiExceptionLike(statusCode: number, message: string): Error & { return error; } -function createAbortError(): Error { - const error = new Error('The user aborted a request.'); - error.name = 'AbortError'; - return error; -} - -function createMockKubeConfig(customApiMock: Record): { getCurrentCluster: jest.Mock } { - mockCustomObjectsApiClient = customApiMock; - return { - getCurrentCluster: jest.fn().mockReturnValue({ server: 'https://cluster.example.com' }), - }; -} - describe('kubernetesClient', () => { beforeEach(() => { jest.clearAllMocks(); @@ -113,10 +87,6 @@ describe('kubernetesClient', () => { mockGetClusters.mockReturnValue([{ name: 'cluster', server: 'https://cluster.example.com' }]); mockGetUsers.mockReturnValue([{ name: 'user' }]); mockGetCurrentContext.mockReturnValue('ctx'); - mockGetCurrentCluster.mockReturnValue({ server: 'https://cluster.example.com' }); - mockCustomObjectsApiClient = { - listNamespacedCustomObject: jest.fn().mockResolvedValue({ items: [] }), - }; }); describe('config', () => { @@ -366,70 +336,6 @@ describe('kubernetesClient', () => { }); }); - describe('createCoreApi', () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { createCoreApi } = require('./kubernetesClient'); - - it('throws an actionable error when the selected context has no active cluster', async () => { - const kubeConfig = { - setCurrentContext: jest.fn(), - getCurrentCluster: jest.fn().mockReturnValue(undefined), - }; - - await expect(createCoreApi(kubeConfig, 'context-a')).rejects.toThrow( - 'No active Kubernetes cluster was found. Check your kubeconfig and try again.', - ); - }); - - it('constructs the generated client with a fresh 30-second timeout signal for every request', async () => { - const kubeConfig = { - setCurrentContext: jest.fn(), - getCurrentCluster: jest.fn().mockReturnValue({ server: 'https://cluster.example.com' }), - }; - const firstSignal = new AbortController().signal; - const secondSignal = new AbortController().signal; - const timeoutSpy = jest - .spyOn(AbortSignal, 'timeout') - .mockReturnValueOnce(firstSignal) - .mockReturnValueOnce(secondSignal); - - try { - const client = await createCoreApi(kubeConfig, 'context-a'); - - expect(client).toBe(mockCoreV1ApiClient); - expect(kubeConfig.setCurrentContext).toHaveBeenCalledWith('context-a'); - expect(mockServerConfiguration).toHaveBeenCalledWith('https://cluster.example.com', {}); - - const configurationParameters = mockCreateConfiguration.mock.calls[0][0] as { - readonly authMethods?: { readonly default?: unknown }; - readonly promiseMiddleware?: Array<{ - pre(context: { setSignal(signal: AbortSignal): void }): Promise; - post(context: unknown): Promise; - }>; - }; - expect(configurationParameters.authMethods?.default).toBe(kubeConfig); - - const middleware = configurationParameters.promiseMiddleware?.[0]; - expect(middleware).toBeDefined(); - - const firstRequest = { setSignal: jest.fn() }; - const secondRequest = { setSignal: jest.fn() }; - await middleware!.pre(firstRequest); - await middleware!.pre(secondRequest); - - expect(timeoutSpy).toHaveBeenNthCalledWith(1, 30_000); - expect(timeoutSpy).toHaveBeenNthCalledWith(2, 30_000); - expect(firstRequest.setSignal).toHaveBeenCalledWith(firstSignal); - expect(secondRequest.setSignal).toHaveBeenCalledWith(secondSignal); - - const response = {}; - await expect(middleware!.post(response)).resolves.toBe(response); - } finally { - timeoutSpy.mockRestore(); - } - }); - }); - describe('listDocumentDBServices', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const { listDocumentDBServices } = require('./kubernetesClient'); @@ -466,20 +372,22 @@ describe('kubernetesClient', () => { ], }), }; - const mockKubeConfig = createMockKubeConfig({ - listNamespacedCustomObject: jest.fn().mockResolvedValue({ - items: [ - { - metadata: { name: 'mydb' }, - spec: {}, - status: { - status: 'Cluster in healthy state', - tls: { ready: true }, + const mockKubeConfig = { + makeApiClient: jest.fn().mockReturnValue({ + listNamespacedCustomObject: jest.fn().mockResolvedValue({ + items: [ + { + metadata: { name: 'mydb' }, + spec: {}, + status: { + status: 'Cluster in healthy state', + tls: { ready: true }, + }, }, - }, - ], + ], + }), }), - }); + }; const services: KubeServiceInfo[] = await listDocumentDBServices(mockCoreApi, 'default', mockKubeConfig); @@ -494,20 +402,12 @@ describe('kubernetesClient', () => { externalAddress: 'mydb.example.com', connectionParams: expect.stringContaining('tlsAllowInvalidCertificates=true'), }); - expect(services[0].connectionParams).toContain('directConnection=true'); - expect(services[0].connectionParams).not.toContain('replicaSet'); expect(services[1]).toMatchObject({ sourceKind: 'generic', name: 'manual-documentdb', serviceName: 'manual-documentdb', clusterIP: '10.0.0.2', }); - expect(services[1].connectionParams).toContain('directConnection=true'); - expect(services[1].connectionParams).not.toContain('replicaSet'); - expect(mockCustomObjectsApiConstructor).toHaveBeenCalledTimes(1); - expect(mockCreateConfiguration).toHaveBeenCalledWith( - expect.objectContaining({ promiseMiddleware: expect.any(Array) }), - ); }); it('should fall back to generic DocumentDB discovery when the DKO CRD is unavailable', async () => { @@ -529,9 +429,11 @@ describe('kubernetesClient', () => { ], }), }; - const mockKubeConfig = createMockKubeConfig({ - listNamespacedCustomObject: jest.fn().mockRejectedValue(createApiExceptionLike(404, 'Not Found')), - }); + const mockKubeConfig = { + makeApiClient: jest.fn().mockReturnValue({ + listNamespacedCustomObject: jest.fn().mockRejectedValue(createApiExceptionLike(404, 'Not Found')), + }), + }; const services: KubeServiceInfo[] = await listDocumentDBServices(mockCoreApi, 'default', mockKubeConfig); expect(services).toHaveLength(1); @@ -554,9 +456,11 @@ describe('kubernetesClient', () => { ], }), }; - const mockKubeConfig = createMockKubeConfig({ - listNamespacedCustomObject: jest.fn().mockRejectedValue(createApiExceptionLike(403, 'Forbidden')), - }); + const mockKubeConfig = { + makeApiClient: jest.fn().mockReturnValue({ + listNamespacedCustomObject: jest.fn().mockRejectedValue(createApiExceptionLike(403, 'Forbidden')), + }), + }; await expect(listDocumentDBServices(mockCoreApi, 'default', mockKubeConfig)).rejects.toThrow( /Failed to list DKO resources.*Forbidden/, @@ -577,31 +481,17 @@ describe('kubernetesClient', () => { ], }), }; - const mockKubeConfig = createMockKubeConfig({ - listNamespacedCustomObject: jest.fn().mockRejectedValue(new Error('ECONNRESET')), - }); + const mockKubeConfig = { + makeApiClient: jest.fn().mockReturnValue({ + listNamespacedCustomObject: jest.fn().mockRejectedValue(new Error('ECONNRESET')), + }), + }; await expect(listDocumentDBServices(mockCoreApi, 'default', mockKubeConfig)).rejects.toThrow( /Failed to list DKO resources.*ECONNRESET/, ); }); - it('should surface DKO request timeouts with a distinct timeout error type', async () => { - const mockCoreApi = { - listNamespacedService: jest.fn().mockResolvedValue({ items: [] }), - }; - const mockKubeConfig = createMockKubeConfig({ - listNamespacedCustomObject: jest.fn().mockRejectedValue(createAbortError()), - }); - - await expect(listDocumentDBServices(mockCoreApi, 'default', mockKubeConfig)).rejects.toMatchObject({ - name: 'KubernetesApiTimeoutError', - message: expect.stringMatching( - /Failed to list services.*Failed to list DKO resources.*Operation timed out after 30 seconds\./, - ), - }); - }); - it('should throw on RBAC error', async () => { const mockCoreApi = { listNamespacedService: jest.fn().mockRejectedValue(new Error('Forbidden')), @@ -808,26 +698,6 @@ describe('kubernetesClient', () => { expect(endpoint.reason).toContain('node address'); } }); - - it('should propagate a NodePort node-list timeout instead of reporting the target as unreachable', async () => { - const service = createServiceInfo({ - name: 'mongo-np-timeout', - displayName: 'mongo-np-timeout', - serviceName: 'mongo-np-timeout', - namespace: 'default', - type: 'NodePort', - port: 27017, - nodePort: 30017, - }); - const mockCoreApi = { - listNode: jest.fn().mockRejectedValue(createAbortError()), - }; - - await expect(resolveServiceEndpoint(service, mockCoreApi)).rejects.toMatchObject({ - name: 'KubernetesApiTimeoutError', - message: 'Operation timed out after 30 seconds.', - }); - }); }); describe('listNamespaces', () => { @@ -856,17 +726,6 @@ describe('kubernetesClient', () => { await expect(listNamespaces(mockCoreApi)).rejects.toThrow(/Failed to list namespaces/); }); - - it('should classify an aborted request as a 30-second Kubernetes API timeout', async () => { - const mockCoreApi = { - listNamespace: jest.fn().mockRejectedValue(createAbortError()), - }; - - await expect(listNamespaces(mockCoreApi)).rejects.toMatchObject({ - name: 'KubernetesApiTimeoutError', - message: expect.stringMatching(/Failed to list namespaces: Operation timed out after 30 seconds\./), - }); - }); }); describe('buildConnectionString (via resolveServiceEndpoint)', () => { @@ -1044,6 +903,10 @@ describe('kubernetesClient', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const { resolveDocumentDBCredentials } = require('./kubernetesClient'); + const createMockKubeConfig = (customApiMock: Record) => ({ + makeApiClient: jest.fn().mockReturnValue(customApiMock), + }); + it('should return credentials when matching CR and secret are found', async () => { const mockCustomApi = { listNamespacedCustomObject: jest.fn().mockResolvedValue({ @@ -1076,7 +939,6 @@ describe('kubernetesClient', () => { expect(result!.username).toBe('admin'); expect(result!.password).toBe('s3cret!'); expect(result!.connectionParams).toContain('directConnection=true'); - expect(result!.connectionParams).not.toContain('replicaSet'); expect(mockCoreApi.readNamespacedSecret).toHaveBeenCalledWith({ name: 'my-secret', namespace: 'default', @@ -1619,11 +1481,13 @@ describe('kubernetesClient', () => { ], }), }; - const mockKubeConfig = createMockKubeConfig({ - listNamespacedCustomObject: jest.fn().mockResolvedValue({ - items: [{ metadata: { name: 'mydb' }, spec: {}, status: {} }], + const mockKubeConfig = { + makeApiClient: jest.fn().mockReturnValue({ + listNamespacedCustomObject: jest.fn().mockResolvedValue({ + items: [{ metadata: { name: 'mydb' }, spec: {}, status: {} }], + }), }), - }); + }; const services: KubeServiceInfo[] = await listDocumentDBServices(mockCoreApi, 'default', mockKubeConfig); expect(services).toHaveLength(1); expect(services[0].sourceKind).toBe('dko'); diff --git a/src/plugins/service-kubernetes/kubernetesClient.ts b/src/plugins/service-kubernetes/kubernetesClient.ts index 25ed689a4..a3d3e797d 100644 --- a/src/plugins/service-kubernetes/kubernetesClient.ts +++ b/src/plugins/service-kubernetes/kubernetesClient.ts @@ -11,7 +11,6 @@ import * as vscode from 'vscode'; // Lazy-load @kubernetes/client-node to avoid impacting extension startup. // Only type imports are used at the top level — they disappear at runtime. import { - type Configuration, type CoreV1Api, type KubeConfig, type V1Namespace, @@ -20,13 +19,6 @@ import { } from '@kubernetes/client-node'; import { ext } from '../../extensionVariables'; import { CREDENTIAL_SECRET_ANNOTATION, DISCOVERY_ANNOTATION, DOCUMENTDB_PORTS } from './config'; -import { - createKubernetesApiOperationError, - getKubernetesApiErrorMessage, - isKubernetesApiTimeoutError, - kubernetesApiTimeoutMiddleware, - normalizeKubernetesApiError, -} from './kubernetesApiTimeout'; import { getSource, readInlineYaml } from './sources/sourceStore'; /** @@ -39,8 +31,6 @@ import { getSource, readInlineYaml } from './sources/sourceStore'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports type KubernetesClientModule = typeof import('@kubernetes/client-node'); -type KubernetesApiClientConstructor = new (configuration: Configuration) => T; - /** * Lazily imports `@kubernetes/client-node` with defensive interop and diagnostics. * @@ -105,33 +95,6 @@ async function importKubernetesClient(): Promise { return resolved as KubernetesClientModule; } -/** - * Recreates {@link KubeConfig.makeApiClient} with a request timeout middleware. - * - * `@kubernetes/client-node` 1.4.0 does not expose a way to attach middleware to - * the client returned by `makeApiClient`, so this mirrors its implementation - * (`baseServer` + kubeconfig authentication) and adds only the 30-second - * request deadline agreed in #741. - */ -function createKubernetesApiClient( - k8s: KubernetesClientModule, - kubeConfig: KubeConfig, - apiClientType: KubernetesApiClientConstructor, -): T { - const cluster = kubeConfig.getCurrentCluster(); - if (!cluster) { - throw new Error(vscode.l10n.t('No active Kubernetes cluster was found. Check your kubeconfig and try again.')); - } - - const configuration = k8s.createConfiguration({ - baseServer: new k8s.ServerConfiguration(cluster.server, {}), - authMethods: { default: kubeConfig }, - promiseMiddleware: [kubernetesApiTimeoutMiddleware], - }); - - return new apiClientType(configuration); -} - /** * Information about a Kubernetes context extracted from kubeconfig. */ @@ -623,7 +586,7 @@ function getUrlHostname(server: string): string { export async function createCoreApi(kubeConfig: KubeConfig, contextName: string): Promise { const k8s = await importKubernetesClient(); kubeConfig.setCurrentContext(contextName); - return createKubernetesApiClient(k8s, kubeConfig, k8s.CoreV1Api); + return kubeConfig.makeApiClient(k8s.CoreV1Api); } /** @@ -642,14 +605,12 @@ export async function listNamespaces(coreApi: CoreV1Api): Promise { .filter((name): name is string => !!name) .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); } catch (error) { - const apiError = normalizeKubernetesApiError(error); - const errorMessage = getKubernetesApiErrorMessage(apiError); - throw createKubernetesApiOperationError( + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error( vscode.l10n.t( 'Failed to list namespaces: {0}. Check that your credentials are valid and you have the required RBAC permissions.', errorMessage, ), - apiError, ); } } @@ -661,14 +622,11 @@ function getDkoServiceName(documentDbName: string): string { function buildDocumentDbConnectionParams(): string { const params = new URLSearchParams(); - // Discovery resolves one reachable gateway endpoint (including localhost - // port-forwards), so pin the driver to that endpoint. DocumentDB gateways - // identify as isdbgrid and do not advertise a replica-set name or members; - // a hardcoded replicaSet would describe a topology discovery cannot verify. params.set('directConnection', 'true'); params.set('authMechanism', 'SCRAM-SHA-256'); params.set('tls', 'true'); params.set('tlsAllowInvalidCertificates', 'true'); + params.set('replicaSet', 'rs0'); return params.toString(); } @@ -727,7 +685,7 @@ async function listDkoDocumentDbResources( ): Promise { try { const k8s = await importKubernetesClient(); - const customApi = createKubernetesApiClient(k8s, kubeConfig, k8s.CustomObjectsApi); + const customApi = kubeConfig.makeApiClient(k8s.CustomObjectsApi); const response: unknown = await customApi.listNamespacedCustomObject({ group: 'documentdb.io', version: 'preview', @@ -793,32 +751,18 @@ async function listDkoDocumentDbResources( return result.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); } catch (error) { - if (isDkoCrdUnavailableError(error)) { + if (isDkoCrdUnavailableError(error) || options.suppressUnexpectedErrors) { + // Treat unavailable DKO metadata as no DKO resources when callers explicitly allow fallback. return []; } - const apiError = normalizeKubernetesApiError(error); - - if (options.suppressUnexpectedErrors) { - // Credential lookup is best-effort, but a timeout should still be - // visible in diagnostics rather than looking like a missing Secret. - if (isKubernetesApiTimeoutError(apiError)) { - ext.outputChannel.warn( - `[KubernetesDiscovery] ${getKubernetesApiErrorMessage(apiError)} ` + - `while listing DKO resources in namespace "${namespace}".`, - ); - } - return []; - } - - const errorMessage = getKubernetesApiErrorMessage(apiError); - throw createKubernetesApiOperationError( + const errorMessage = getKubernetesApiErrorMessage(error); + throw new Error( vscode.l10n.t( 'Failed to list DKO resources in namespace "{0}": {1}. Check that the DocumentDB Kubernetes Operator CRD is installed and that your Kubernetes credentials can list documentdb.io dbs resources.', namespace, errorMessage, ), - apiError, ); } } @@ -848,6 +792,33 @@ function getKubernetesApiStatusCode(error: unknown): number | undefined { return undefined; } +function getKubernetesApiErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === 'string') { + return error; + } + + if (isRecord(error)) { + const message = error.message; + if (typeof message === 'string') { + return message; + } + + const body = error.body; + if (typeof body === 'string') { + return body; + } + if (isRecord(body) && typeof body.message === 'string') { + return body.message; + } + } + + return String(error); +} + function getNumberProperty(record: Record, propertyName: string): number | undefined { const value = record[propertyName]; return typeof value === 'number' ? value : undefined; @@ -978,15 +949,13 @@ export async function listDocumentDBServices( return a.displayName.localeCompare(b.displayName, undefined, { numeric: true }); }); } catch (error) { - const apiError = normalizeKubernetesApiError(error); - const errorMessage = getKubernetesApiErrorMessage(apiError); - throw createKubernetesApiOperationError( + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error( vscode.l10n.t( 'Failed to list services in namespace "{0}": {1}. Check your RBAC permissions.', namespace, errorMessage, ), - apiError, ); } } @@ -1216,11 +1185,7 @@ async function getFirstNodeAddress(coreApi: CoreV1Api): Promise<{ address: strin if (firstInternalAddress) { return { address: firstInternalAddress, isExternal: false }; } - } catch (error) { - const apiError = normalizeKubernetesApiError(error); - if (isKubernetesApiTimeoutError(apiError)) { - throw apiError; - } + } catch { // If we can't list nodes, we can't resolve NodePort addresses. } @@ -1263,14 +1228,7 @@ export async function resolveDocumentDBCredentials( connectionParams: buildDocumentDbConnectionParams(), }; } - } catch (error) { - const apiError = normalizeKubernetesApiError(error); - if (isKubernetesApiTimeoutError(apiError)) { - ext.outputChannel.warn( - `[KubernetesDiscovery] ${getKubernetesApiErrorMessage(apiError)} ` + - `while reading credential Secret "${matchingResource.secretName}" in namespace "${namespace}".`, - ); - } + } catch { // Secret not found or not readable } @@ -1311,14 +1269,7 @@ export async function resolveGenericServiceCredentials( const password = Buffer.from(data.password, 'base64').toString('utf-8'); return { username, password }; } - } catch (error) { - const apiError = normalizeKubernetesApiError(error); - if (isKubernetesApiTimeoutError(apiError)) { - ext.outputChannel.warn( - `[KubernetesDiscovery] ${getKubernetesApiErrorMessage(apiError)} ` + - `while reading credential Secret "${secretName}" in namespace "${namespace}".`, - ); - } + } catch { // Secret not found or not readable — return undefined so UI can prompt later. } diff --git a/src/plugins/service-kubernetes/portForwardTunnel.test.ts b/src/plugins/service-kubernetes/portForwardTunnel.test.ts index 862890a8d..2ea9f13af 100644 --- a/src/plugins/service-kubernetes/portForwardTunnel.test.ts +++ b/src/plugins/service-kubernetes/portForwardTunnel.test.ts @@ -235,19 +235,6 @@ describe('resolveServiceBackend', () => { await expect(resolveServiceBackend(coreApi, 'ns', 'svc', 27017)).rejects.toThrow('Forbidden'); }); - it('should normalize an Endpoints API abort as a Kubernetes timeout', async () => { - const abortError = new Error('The user aborted a request.'); - abortError.name = 'AbortError'; - const coreApi = { - readNamespacedEndpoints: jest.fn().mockRejectedValue(abortError), - } as never; - - await expect(resolveServiceBackend(coreApi, 'ns', 'svc', 27017)).rejects.toMatchObject({ - name: 'KubernetesApiTimeoutError', - message: 'Operation timed out after 30 seconds.', - }); - }); - it('should pass correct arguments to readNamespacedEndpoints', async () => { const mockRead = jest.fn().mockResolvedValue({ subsets: [ @@ -1051,33 +1038,4 @@ describe('PortForwardTunnelManager', () => { expect(ext.outputChannel.appendLine).toHaveBeenCalledWith(expect.stringContaining('error-svc')); expect(ext.outputChannel.appendLine).toHaveBeenCalledWith(expect.stringContaining('Forbidden')); }); - - it('should not relabel a non-API AbortError from port-forward setup as a timeout', async () => { - const abortError = new Error('request cancelled during teardown'); - abortError.name = 'AbortError'; - mockPortForward.mockRejectedValue(abortError); - - const freePort = await new Promise((resolve) => { - const tmp = net.createServer(); - tmp.listen(0, '127.0.0.1', () => { - const port = (tmp.address() as net.AddressInfo).port; - tmp.close(() => resolve(port)); - }); - }); - - await manager.startTunnel(createMockParams({ localPort: freePort })); - - const client = new net.Socket(); - const closed = new Promise((resolve) => client.on('close', () => resolve())); - client.connect(freePort, '127.0.0.1'); - await closed; - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(ext.outputChannel.appendLine).toHaveBeenCalledWith( - expect.stringContaining('request cancelled during teardown'), - ); - expect(ext.outputChannel.appendLine).not.toHaveBeenCalledWith( - expect.stringContaining('Operation timed out after 30 seconds.'), - ); - }); }); diff --git a/src/plugins/service-kubernetes/portForwardTunnel.ts b/src/plugins/service-kubernetes/portForwardTunnel.ts index 4a5a509d2..7af928e1f 100644 --- a/src/plugins/service-kubernetes/portForwardTunnel.ts +++ b/src/plugins/service-kubernetes/portForwardTunnel.ts @@ -8,7 +8,6 @@ import * as net from 'net'; import { PassThrough } from 'stream'; import * as vscode from 'vscode'; import { ext } from '../../extensionVariables'; -import { getKubernetesApiErrorMessage, normalizeKubernetesApiError } from './kubernetesApiTimeout'; interface TunnelParams { /** @@ -74,12 +73,7 @@ export async function resolveServiceBackend( servicePort: number, servicePortName?: string, ): Promise<{ podName: string; targetPort: number }> { - let endpoints: Awaited>; - try { - endpoints = await coreApi.readNamespacedEndpoints({ name: serviceName, namespace }); - } catch (error) { - throw normalizeKubernetesApiError(error); - } + const endpoints = await coreApi.readNamespacedEndpoints({ name: serviceName, namespace }); const subsets = endpoints.subsets ?? []; for (const subset of subsets) { @@ -627,7 +621,7 @@ export class PortForwardTunnelManager implements vscode.Disposable { }); } } catch (err) { - const errMsg = getKubernetesApiErrorMessage(err); + const errMsg = err instanceof Error ? err.message : String(err); ext.outputChannel.appendLine( vscode.l10n.t( 'Port-forward backend resolution failed for {0}/{1}: {2}', diff --git a/src/services/discoveryProviderVisibility.ts b/src/services/discoveryProviderVisibility.ts index d20d99fb3..688a18864 100644 --- a/src/services/discoveryProviderVisibility.ts +++ b/src/services/discoveryProviderVisibility.ts @@ -8,60 +8,19 @@ import { DiscoveryService, type ProviderDescription } from './discoveryServices' const HIDDEN_DISCOVERY_PROVIDER_IDS_KEY = 'hiddenDiscoveryProviderIds'; -/** - * Legacy pre-0.9.0 opt-in allow-list key (see the module doc below). No longer read; - * removed once by {@link removeLegacyActiveDiscoveryProviderIds}. - */ -const LEGACY_ACTIVE_DISCOVERY_PROVIDER_IDS_KEY = 'activeDiscoveryProviderIds'; - let hiddenProviderIdsCache: string[] | undefined; /** - * Discovery-provider visibility — storage model and cross-version upgrade behavior. - * - * Current model (>= 0.9.0): OPT-OUT deny-list. Every registered provider is visible by - * default; `hiddenDiscoveryProviderIds` records only the ones the user chose to hide. - * Default (empty list) => all providers visible. - * - * Legacy model (<= 0.8.x): OPT-IN allow-list stored under `activeDiscoveryProviderIds`. - * Default (empty list) => nothing visible; users explicitly activated providers. - * - * The 0.8.x -> 0.9.x upgrade is intentionally NOT migrated. The semantics are inverted - * (opt-in allow-list -> opt-out deny-list), so the legacy `activeDiscoveryProviderIds` - * value is ignored and left as-is in globalState. Upgraders therefore start with ALL - * providers visible. This is by design and upgrade-safe: it only ever reveals more - * providers, never hides one that was previously visible, so it is not a downgrade. - * - * A one-time active->hidden migration existed briefly during 0.9.0 development but was - * dropped before release (never shipped), so no half-migrated state exists in the wild. - * Do NOT reintroduce a migration without revisiting that product decision. + * Provider visibility uses a single, simple model: every registered discovery + * provider is visible by default, and the persisted `hiddenDiscoveryProviderIds` + * list tracks only the providers the user has chosen to hide. There is no + * migration path — older state keys are simply ignored, so everyone starts with + * all providers visible and can hide the ones they don't want. */ export async function getHiddenDiscoveryProviderIds(): Promise { return readHiddenDiscoveryProviderIds(); } -/** - * One-time, best-effort cleanup of the stale pre-0.9.0 `activeDiscoveryProviderIds` - * globalState key. Safe to run on every activation: it writes only when the key is - * still present. The value is intentionally not migrated (see the module doc). - * - * TODO(#831): remove this function and {@link LEGACY_ACTIVE_DISCOVERY_PROVIDER_IDS_KEY} - * ~6 months after 0.9.2, once users have upgraded past 0.8.x. - */ -export async function removeLegacyActiveDiscoveryProviderIds(): Promise { - // Best-effort: swallow storage errors so this cleanup can never disrupt activation. - try { - if (ext.context.globalState.get(LEGACY_ACTIVE_DISCOVERY_PROVIDER_IDS_KEY) !== undefined) { - await ext.context.globalState.update(LEGACY_ACTIVE_DISCOVERY_PROVIDER_IDS_KEY, undefined); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - ext.outputChannel.error( - `Failed to remove legacy '${LEGACY_ACTIVE_DISCOVERY_PROVIDER_IDS_KEY}' discovery state: ${message}`, - ); - } -} - export async function getVisibleDiscoveryProviders(): Promise { const hiddenProviderIds = new Set(await getHiddenDiscoveryProviderIds()); return DiscoveryService.listProviders().filter((provider) => !hiddenProviderIds.has(provider.id)); diff --git a/src/services/discoveryServices.ts b/src/services/discoveryServices.ts index 66a455f7a..4fd2c9d41 100644 --- a/src/services/discoveryServices.ts +++ b/src/services/discoveryServices.ts @@ -48,9 +48,13 @@ export interface DiscoveryProvider extends ProviderDescription { * Retrieves wizard options for discovering new connections. * * @param context - The wizard context used during the discovery process. - * @returns Wizard options configured for the discovery process. + * @returns Wizard options configured for the discovery process. Providers may return the + * options synchronously or asynchronously (e.g. when they need to resolve a session + * or prompt for authentication before the wizard steps run). */ - getDiscoveryWizard(context: NewConnectionWizardContext): IWizardOptions; + getDiscoveryWizard( + context: NewConnectionWizardContext, + ): IWizardOptions | Promise>; /** * Retrieves the root tree item for the discovery tree view. diff --git a/src/services/legacyEmulatorMigration.ts b/src/services/legacyEmulatorMigration.ts new file mode 100644 index 000000000..7d5251149 --- /dev/null +++ b/src/services/legacyEmulatorMigration.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * One-time legacy emulator migration (Local Quick Start design §4). + * + * The dedicated "DocumentDB Local" emulator storage zone and its tree node are being + * retired in favour of regular connections + the Quick Start managed instance. On the + * first activation after the update we **copy** every connection from the `Emulators` + * storage zone into a new "Local Connections (Legacy)" folder in the regular `Clusters` + * zone, preserving credentials, auth config and (normalized) `emulatorConfiguration`. + * + * This relies on the storage-zone decoupling: a connection's operations are routed by the + * tree model's `storageZone` (set to `Clusters` by `FolderItem` for the migrated copies), + * NOT by `emulatorConfiguration.isEmulator`. The copies therefore keep `isEmulator: true` + * so local TLS-allow-invalid still works, while connect/rename/delete/move correctly target + * the `Clusters` zone. + * + * Safety properties (so a migration bug can never orphan a user's local connections): + * - The original `Emulators` zone is **kept** as a deprecated, read-only rollback path + * (§4.5) — we never delete it here. + * - Folder + copied-connection ids are **deterministic**, and a retry **creates only the + * missing** copies (it never overwrites an existing one), so a partial run that retries on + * the next launch is idempotent (no duplicates) and never reverts a user's later edits. + * - The completion flag is only set after **all** connections copy successfully; until + * then the legacy emulator tree node stays visible, so nothing is ever hidden while + * still un-migrated. + * + * Scope note: per §4 ("into that folder"), Emulators-zone **sub-folders are flattened** — + * every connection is copied directly under the single legacy folder. No data is lost (the + * originals remain in the Emulators zone); only the folder organization is not reproduced. + */ + +import { callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { ext } from '../extensionVariables'; +import { + ConnectionStorageService, + isConnection, + isFolder, + ItemType, + StorageZone, + type StoredItem, +} from './connectionStorageService'; + +/** globalState flag recording that the one-time migration has fully completed. */ +const MIGRATION_COMPLETED_KEY = 'documentdb.localQuickStart.legacyEmulatorMigration.completed'; + +/** Stable id of the destination folder, so retries reuse it instead of duplicating. */ +const LEGACY_FOLDER_ID = 'vscode-documentdb.legacyLocalConnectionsFolder'; + +/** Base name of the destination folder (a numeric suffix is added on a name clash). */ +const LEGACY_FOLDER_BASE_NAME = 'Local Connections (Legacy)'; + +/** Whether the one-time legacy emulator migration has completed (gates the tree node). */ +export function isLegacyEmulatorMigrationComplete(): boolean { + return ext.context.globalState.get(MIGRATION_COMPLETED_KEY, false); +} + +/** Deterministic id for a copied connection, so retries overwrite rather than duplicate. */ +function legacyConnectionId(originalId: string): string { + return `legacy_${originalId}`; +} + +/** + * Pick a root-level folder name that does not clash with an existing user folder in the + * Clusters zone (excluding our own deterministic folder so retries don't keep re-suffixing). + */ +async function uniqueLegacyFolderName(): Promise { + const rootFolders = await ConnectionStorageService.getChildren(undefined, StorageZone.Clusters, ItemType.Folder); + const takenNames = new Set(rootFolders.filter((f) => f.id !== LEGACY_FOLDER_ID).map((f) => f.name)); + if (!takenNames.has(LEGACY_FOLDER_BASE_NAME)) { + return LEGACY_FOLDER_BASE_NAME; + } + for (let i = 2; ; i++) { + const candidate = `${LEGACY_FOLDER_BASE_NAME} (${i})`; + if (!takenNames.has(candidate)) { + return candidate; + } + } +} + +/** + * Run the one-time migration. Best-effort and non-blocking: wrapped in + * `callWithTelemetryAndErrorHandling` so it never throws into activation; on any failure + * the completion flag is left unset so the next launch retries (idempotently) and the + * legacy node stays visible meanwhile. + */ +export async function migrateLegacyEmulatorConnections(): Promise { + if (isLegacyEmulatorMigrationComplete()) { + return; + } + + await callWithTelemetryAndErrorHandling('documentDB.quickstart.legacyMigration', async (context) => { + context.errorHandling.suppressDisplay = true; + context.telemetry.properties.outcome = 'started'; + + const allItems = await ConnectionStorageService.getAllItems(StorageZone.Emulators); + const emulatorConnections = allItems.filter(isConnection); + context.telemetry.measurements.itemsFound = allItems.length; + context.telemetry.measurements.connectionsFound = emulatorConnections.length; + + if (emulatorConnections.length === 0) { + // Nothing to migrate — mark done so we never run again, and refresh so the + // (now retired) legacy node disappears. + await ext.context.globalState.update(MIGRATION_COMPLETED_KEY, true); + ext.connectionsBranchDataProvider?.refresh(); + context.telemetry.properties.outcome = 'nothingToMigrate'; + return; + } + + // Reuse an existing legacy folder from a prior (partial) run so a retry never + // overwrites a user rename; only create it the first time. Guard that the id still + // refers to a folder — if corruption/a bug ever made it a connection, fall back to a + // fresh folder rather than parenting copies under a non-folder (which the storage + // cleanup would later treat as orphaned and delete). + const existing = await ConnectionStorageService.get(LEGACY_FOLDER_ID, StorageZone.Clusters); + const existingFolder = existing && isFolder(existing) ? existing : undefined; + const folderName = existingFolder?.name ?? (await uniqueLegacyFolderName()); + context.telemetry.properties.folderReused = String(!!existingFolder); + if (!existingFolder) { + context.telemetry.properties.folderSuffixed = String(folderName !== LEGACY_FOLDER_BASE_NAME); + await ConnectionStorageService.saveFolder(StorageZone.Clusters, { id: LEGACY_FOLDER_ID, name: folderName }); + } + + let failed = 0; + const copyMissing = async (connections: ReadonlyArray): Promise => { + for (const connection of connections) { + if (!isConnection(connection)) { + continue; + } + const destId = legacyConnectionId(connection.id); + // Create-if-missing: never overwrite an already-copied legacy connection + // (preserves a user's later edits) and never duplicate it. + if (await ConnectionStorageService.get(destId, StorageZone.Clusters)) { + continue; + } + try { + await ConnectionStorageService.saveConnection( + StorageZone.Clusters, + { + id: destId, + name: connection.name, + properties: { + ...connection.properties, + // Re-home under the legacy folder (replaces any Emulators-zone parent). + parentId: LEGACY_FOLDER_ID, + // Normalize the emulator flag exactly like LocalEmulatorsItem renders it, + // so local TLS-allow-invalid keeps working in the Clusters zone. Zone + // routing is handled separately by the tree model's storageZone. + emulatorConfiguration: { + isEmulator: true, + disableEmulatorSecurity: + !!connection.properties.emulatorConfiguration?.disableEmulatorSecurity, + }, + }, + secrets: { + connectionString: connection.secrets.connectionString, + nativeAuthConfig: connection.secrets.nativeAuthConfig, + entraIdAuthConfig: connection.secrets.entraIdAuthConfig, + }, + }, + false /* never overwrite — the get() above guarantees this is a new copy */, + ); + } catch (error) { + failed++; + ext.outputChannel.warn( + `[LegacyMigration] Failed to migrate emulator connection "${connection.name}": ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + }; + + await copyMissing(emulatorConnections); + // Reconciliation pass: re-read the Emulators zone and copy anything added after the + // initial snapshot (e.g. a localhost deep-link handled during this run), so we never + // set the completion flag — and hide the legacy node — while a source connection is + // still un-copied (closes the activation-window race). + const finalConnections = (await ConnectionStorageService.getAllItems(StorageZone.Emulators)).filter( + isConnection, + ); + await copyMissing(finalConnections); + + // Converged only when every current emulator connection has a Clusters copy. + let stillMissing = 0; + for (const connection of finalConnections) { + if (!(await ConnectionStorageService.get(legacyConnectionId(connection.id), StorageZone.Clusters))) { + stillMissing++; + } + } + context.telemetry.measurements.connectionsMigrated = finalConnections.length - stillMissing; + context.telemetry.measurements.connectionsFailed = failed; + + // Refresh so the copies appear immediately. + ext.connectionsBranchDataProvider?.refresh(); + + if (failed > 0 || stillMissing > 0) { + // Leave the flag unset: retry next launch (idempotent), keep the legacy node visible. + context.telemetry.properties.outcome = 'partial'; + return; + } + + // The Emulators zone is intentionally KEPT as a read-only rollback path (§4.5). + await ext.context.globalState.update(MIGRATION_COMPLETED_KEY, true); + ext.connectionsBranchDataProvider?.refresh(); + context.telemetry.properties.outcome = 'completed'; + + void vscode.window.showInformationMessage( + l10n.t("Your local connections have been moved to '{0}' in the Connections view.", folderName), + ); + }); +} diff --git a/src/services/localQuickStart/ContainerRuntime.ts b/src/services/localQuickStart/ContainerRuntime.ts new file mode 100644 index 000000000..318f1e830 --- /dev/null +++ b/src/services/localQuickStart/ContainerRuntime.ts @@ -0,0 +1,385 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Thin wrapper over `@microsoft/vscode-container-client` (Docker) for the Local + * Quick Start POC (WI-0). + * + * - All runtime stdout/stderr/command lines are routed through a single + * {@link MaskedChannelWritable} that **line-buffers** and **redacts secrets** + * before writing to the "DocumentDB Local Quick Start" OutputChannel (D14): + * the generated password must never reach the channel, even when a stream + * chunk splits it across a buffer boundary. + * - `docker run` is detached (D4); because a detached run streams nothing back, + * {@link ContainerRuntime.followLogs} streams `docker logs -f` so the channel + * isn't silent during the readiness wait. + * - The image takes credentials as **post-image args** (`--username/--password`), + * which the client supports via `runContainer({ command: [...] })` — validated + * in WI-0, so no raw-CLI fallback is needed. + */ + +import { + DockerClient, + type InspectContainersItem, + type ListContainersItem, + ShellStreamCommandRunnerFactory, +} from '@microsoft/vscode-container-client'; +import { Bash, Cmd, type Shell, type ShellQuotedString, ShellQuoting } from '@microsoft/vscode-processutils'; +import * as net from 'net'; +import { Writable } from 'stream'; +import * as vscode from 'vscode'; +import { startDockerProvider as launchDockerProvider } from './DockerProviderLauncher'; +import { DockerReadinessService } from './DockerReadinessService'; +import { MaskingLineBuffer, maskSecrets } from './outputMasking'; +import { + type DockerLaunchResult, + type DockerReadiness, + type DockerReadinessRequest, + QUICK_START_PORT, +} from './quickStartTypes'; + +/** + * Shell used to run docker commands. A shell provider is REQUIRED so the runner + * applies each argument's quoting metadata: without one it sets + * `windowsVerbatimArguments` on Windows and drops quoting, which splits Go-template + * `--format {{json .}}` arguments on the space and breaks info/inspect/list. + */ +const SHELL_PROVIDER: Shell = process.platform === 'win32' ? new Cmd() : new Bash(); + +let outputChannel: vscode.OutputChannel | undefined; + +/** Lazily create the shared OutputChannel. */ +export function getQuickStartOutputChannel(): vscode.OutputChannel { + if (!outputChannel) { + outputChannel = vscode.window.createOutputChannel('DocumentDB Local Quick Start'); + } + return outputChannel; +} + +export function disposeQuickStartOutputChannel(): void { + outputChannel?.dispose(); + outputChannel = undefined; +} + +/** + * Writable that line-buffers incoming chunks and masks each complete line + * before appending it to the OutputChannel (delegates to {@link MaskingLineBuffer}). + */ +class MaskedChannelWritable extends Writable { + private readonly lineBuffer: MaskingLineBuffer; + + constructor(channel: vscode.OutputChannel, secrets: ReadonlyArray) { + super(); + this.lineBuffer = new MaskingLineBuffer((line) => channel.appendLine(line), secrets); + } + + public override _write(chunk: unknown, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void { + this.lineBuffer.push(String(chunk)); + callback(); + } + + public override _final(callback: (error?: Error | null) => void): void { + this.lineBuffer.flush(); + callback(); + } +} + +export interface CreateContainerOptions { + readonly imageRef: string; + readonly name: string; + readonly labels: Record; + readonly hostPort: number; + readonly containerPort: number; + /** Named volume mounted at {@link dataPath} so data survives recreation (§8/§11). */ + readonly volumeName?: string; + readonly dataPath?: string; + /** Paths to `--env-file`s carrying credentials, so they stay off the CLI (§8.2). */ + readonly environmentFiles?: ReadonlyArray; + /** Post-image args appended after the image ref (optional; creds now go via env-file). */ + readonly command?: ReadonlyArray; +} + +/** + * IO surface of the Docker-backed runtime (WI-0). Extracted so {@link QuickStartService} + * can be unit-tested against a mock runtime with no real Docker daemon. The pure inspectors + * ({@link getBoundHostPort}, {@link isRunning}) are standalone functions — deliberately NOT part + * of this contract, which stays an IO-only surface. + */ +export interface IContainerRuntime { + isDockerReady(request?: DockerReadinessRequest): Promise; + isPortFree(port?: number): Promise; + pullImage(imageRef: string, token?: vscode.CancellationToken): Promise; + createAndRunContainer( + options: CreateContainerOptions, + secrets: ReadonlyArray, + token?: vscode.CancellationToken, + ): Promise; + inspectContainer(nameOrId: string): Promise; + startContainer(id: string): Promise; + stopContainer(id: string): Promise; + removeContainer(id: string, force?: boolean): Promise; + removeVolume(name: string, force?: boolean): Promise; + execShellInContainer( + id: string, + script: string, + secrets: ReadonlyArray, + token?: vscode.CancellationToken, + ): Promise; + listByLabel(labels: Record): Promise; + followLogs(id: string, secrets: ReadonlyArray, token?: vscode.CancellationToken): Promise; +} + +/** + * Stateless wrapper around a single Docker {@link DockerClient}. Each call + * builds a fresh runner so its line-buffered stdout/stderr writables don't leak state + * between commands. + */ +class ContainerRuntimeImpl implements IContainerRuntime { + private readonly client = new DockerClient(); + private readonly readinessService = new DockerReadinessService({ + client: this.client, + shellProvider: SHELL_PROVIDER, + createProbeOutput: () => { + const channel = getQuickStartOutputChannel(); + return { + onCommand: (command: string) => channel.appendLine('$ ' + maskSecrets(command, [])), + stdOutPipe: new MaskedChannelWritable(channel, []), + stdErrPipe: new MaskedChannelWritable(channel, []), + appendDiagnostic: (line: string) => channel.appendLine(line), + }; + }, + }); + + private makeRunner(secrets: ReadonlyArray, token?: vscode.CancellationToken) { + const channel = getQuickStartOutputChannel(); + const factory = new ShellStreamCommandRunnerFactory({ + // Non-strict: a non-zero exit still rejects, but harmless stderr warnings + // (e.g. `docker info`) do not. A shellProvider is required for arg quoting. + strict: false, + shellProvider: SHELL_PROVIDER, + onCommand: (command: string) => channel.appendLine('$ ' + maskSecrets(command, secrets)), + stdOutPipe: new MaskedChannelWritable(channel, secrets), + stdErrPipe: new MaskedChannelWritable(channel, secrets), + cancellationToken: token, + }); + return factory.getCommandRunner(); + } + + /** CLI-on-PATH + daemon-reachable check (design §9 prereq cards). */ + public isDockerReady(request?: DockerReadinessRequest): Promise { + return this.readinessService.getReadiness(request); + } + + public async startAvailableDockerProvider(): Promise { + const readiness = await this.readinessService.getReadiness({ forceRefresh: true }); + if (!readiness.startAction) { + return 'notAvailable'; + } + const result = await launchDockerProvider(readiness.startAction); + await this.readinessService.recordLaunchResult(result); + return result; + } + + /** True if the TCP port can be bound on loopback right now (pre-check, design §8.3). */ + public isPortFree(port: number = QUICK_START_PORT): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.once('error', () => resolve(false)); + server.once('listening', () => server.close(() => resolve(true))); + server.listen(port, '127.0.0.1'); + }); + } + + public async pullImage(imageRef: string, token?: vscode.CancellationToken): Promise { + const runner = this.makeRunner([], token); + await runner(this.client.pullImage({ imageRef })); + } + + /** `docker run` detached, returning the new container id. */ + public async createAndRunContainer( + options: CreateContainerOptions, + secrets: ReadonlyArray, + token?: vscode.CancellationToken, + ): Promise { + const runner = this.makeRunner(secrets, token); + const mounts = + options.volumeName && options.dataPath + ? [ + { + type: 'volume' as const, + source: options.volumeName, + destination: options.dataPath, + readOnly: false, + }, + ] + : undefined; + return runner( + this.client.runContainer({ + imageRef: options.imageRef, + name: options.name, + // `detached: true` already emits `-d --tty` (the client adds --tty + // whenever detached/interactive), matching the image README's `-dt`. + detached: true, + labels: { ...options.labels }, + // Publish on loopback only. The local instance ships with auto-generated + // credentials and TLS-allow-invalid, and the UX promises "Runs on: This + // machine" / localhost — binding 0.0.0.0 would expose it to the LAN. + // `127.0.0.1` also matches the loopback `isPortFree` pre-check above. + ports: [{ containerPort: options.containerPort, hostPort: options.hostPort, hostIp: '127.0.0.1' }], + mounts, + environmentFiles: options.environmentFiles ? [...options.environmentFiles] : undefined, + command: options.command ? [...options.command] : undefined, + }), + ); + } + + public async inspectContainer(nameOrId: string): Promise { + try { + const runner = this.makeRunner([]); + const items = await runner(this.client.inspectContainers({ containers: [nameOrId] })); + return items?.[0]; + } catch { + return undefined; + } + } + + public async startContainer(id: string): Promise { + const runner = this.makeRunner([]); + await runner(this.client.startContainers({ container: [id] })); + } + + public async stopContainer(id: string): Promise { + const runner = this.makeRunner([]); + await runner(this.client.stopContainers({ container: [id] })); + } + + public async removeContainer(id: string, force = true): Promise { + const runner = this.makeRunner([]); + await runner(this.client.removeContainers({ containers: [id], force })); + } + + /** Remove a named volume (best-effort; used for a clean fresh provision and on Delete). */ + public async removeVolume(name: string, force = true): Promise { + const runner = this.makeRunner([]); + await runner(this.client.removeVolumes({ volumes: [name], force })); + } + + /** + * Run a `/bin/sh -c