From 7043be92033061cfe59db2e8211aa81ead30f66f Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:15:45 -0700 Subject: [PATCH 1/2] feat(workspaces)!: remember current workspace and database across logins Replace the "default" workspace (first entry of the cached list) with an explicit current workspace saved on this machine. Logout keeps it and the per-workspace current database, so the next login resumes where you left off. Only `workspaces use` and `databases use` switch. `--workspace-id`, `-d/--database`, `databases create` and `databases fork` apply to one command and no longer change the current selection. - `databases use` accepts a catalog alias or name, like `-d` - `workspaces list` / `databases list` column renamed DEFAULT -> CURRENT - skill and README document the rules BREAKING CHANGE: `databases create` and `databases fork` no longer make the new database current. Run `hotdata databases use ` or pass `-d `. --- README.md | 19 ++- skills/hotdata/SKILL.md | 57 +++++---- .../hotdata/references/DATA_MODEL.template.md | 2 +- skills/hotdata/references/MODEL_BUILD.md | 2 +- skills/hotdata/references/WORKFLOWS.md | 16 +-- skills/hotdata/subskills/analytics/SKILL.md | 9 +- .../analytics/references/WORKFLOWS.md | 5 +- skills/hotdata/subskills/search/SKILL.md | 14 +-- .../subskills/search/references/INDEXES.md | 2 +- src/cli.rs | 16 +-- src/client/credentials.rs | 74 ++++++------ src/commands/auth.rs | 30 +++-- src/commands/databases.rs | 84 +++++++------- src/commands/search.rs | 4 +- src/commands/support.rs | 40 +++---- src/commands/workspace.rs | 29 ++--- src/config.rs | 109 +++++++++++++++--- src/main.rs | 16 +-- 18 files changed, 309 insertions(+), 219 deletions(-) diff --git a/README.md b/README.md index 0bc45f2f..4401ae3c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Stay current with `hotdata manage upgrade`; enable tab completion with ```sh hotdata auth login # or: hotdata auth register hotdata databases create --catalog demo +hotdata databases use demo # make it current (create does not switch) hotdata databases load --catalog demo --table trips \ --url https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2024-01.parquet hotdata query "SELECT count(*) FROM demo.public.trips" @@ -122,7 +123,7 @@ hotdata search create trips_notes --type text --from demo.public.trips --column hotdata search "airport surcharge dispute" --index trips_notes ``` -Use `--type vector` for semantic search. Indexes resolve in the active database +Use `--type vector` for semantic search. Indexes resolve in the current database (`hotdata databases use `); pass `-d/--database ` to target another. Bring your own model with `hotdata search embeddings add`. @@ -159,7 +160,7 @@ The full command surface. The top level has nine groups — `auth`, `workspaces` | `auth logout` | Remove authentication for a profile | | `auth status` | Show authentication status | | `workspaces list` | List all workspaces | -| `workspaces use` | Set the default workspace | +| `workspaces use` | Switch the current workspace (remembered across logins) | | `databases list` | List instant databases in the workspace | | `databases count` | Count instant databases in the workspace | | `databases show` | Show details for an instant database | @@ -168,7 +169,7 @@ The full command surface. The top level has nine groups — `auth`, `workspaces` | `databases lineage` | Show a database's whole fork family tree | | `databases attach` | Attach another database so its tables are queryable | | `databases detach` | Detach a previously attached database | -| `databases use` | Set the current (default) database | +| `databases use` | Switch the current database (remembered across logins) | | `databases unset` | Clear the current database | | `databases remove` | Delete a database and all its tables | | `databases load` | Load a csv/json/parquet file or saved result into a table (`--mode replace\|append\|delete\|update\|upsert`) | @@ -225,6 +226,18 @@ The full command surface. The top level has nine groups — `auth`, `workspaces` | `manage skills list` | List installed skills (alias for `status`) | | `support report` | File a support ticket with the HotData team | +## Current workspace and database + +The CLI saves a current workspace and, per workspace, a current database on +this machine. Commands use them when you don't name one. They survive +`auth logout` / `auth login`, so a new session resumes where you left off. + +- `hotdata workspaces use` and `hotdata databases use` are the only commands + that switch. `databases create` and `databases fork` do not. +- `--workspace-id` / `-w` and `--database` / `-d` apply to one command only + and do not change the current selection. +- `workspaces list` and `databases list` mark the current one with `*`. + ## Configuration Config lives at `~/.hotdata/config.yml` (profile-keyed). Environment variables: diff --git a/skills/hotdata/SKILL.md b/skills/hotdata/SKILL.md index ffe336e3..84f8fe4e 100644 --- a/skills/hotdata/SKILL.md +++ b/skills/hotdata/SKILL.md @@ -39,9 +39,15 @@ API URL defaults to `https://api.hotdata.dev/v1` or overridden via `HOTDATA_API_ Optional: pass **`--debug`** on any command to print verbose HTTP request/response details. -## Workspace ID +## Current workspace and database -Commands that accept `--workspace-id` default to the active workspace from config when omitted. Use `hotdata workspaces use` to switch interactively, or `hotdata workspaces use ` for a direct choice. In `hotdata workspaces list`, the `*` marker labels the **default** workspace the CLI resolves to. +The CLI saves a **current workspace** and, for each workspace, a **current database** on this machine (`~/.hotdata/config.yml`). Commands use them when you don't name a workspace or database. They are kept across `hotdata auth logout` / `login`, so a new session resumes where you left off. There is no "default" workspace or database. + +- **Only `use` switches.** `hotdata workspaces use []` changes the current workspace (it prompts when the id is omitted). `hotdata databases use ` changes the current database. Nothing else changes them: `databases create` and `databases fork` do **not** switch to the new database. +- **Flags apply to one command only.** `--workspace-id` / `-w` and `--database` / `-d` (`--database-id` on `ingest`) target that command and leave the current workspace and database unchanged. +- `hotdata workspaces list` and `hotdata databases list` mark the current one with `*` in the `CURRENT` column. `hotdata databases unset` clears the current database. + +If **`HOTDATA_WORKSPACE`** is set in the environment, the workspace is **locked** to that value: passing a different `--workspace-id` is an error, and **`hotdata workspaces use` fails** (“workspace is locked”). If **`HOTDATA_WORKSPACE`** is set in the environment, the workspace is **locked** to that value: passing a different `--workspace-id` is an error, and **`hotdata workspaces use` fails** (“workspace is locked”). @@ -53,7 +59,7 @@ A workspace's query worker scales to zero after inactivity. The **first** comman ## Database context (API) -**`context:`** (e.g. **context:DATAMODEL**, **context:GLOSSARY**) is an authoritative Markdown document stored server-side under that stem via the context API — *not* generic English ("a data model"), and *not* a local `./DATAMODEL.md` (local files are only `push`/`pull` transport). CLI commands take the bare stem: `hotdata databases context show DATAMODEL`. Context is scoped to the **active database** (`hotdata databases use `); target another with `--database` / `-d`. Stems follow SQL identifier rules and accept a trailing `.md` (stored without it). Command reference: [Database context (named Markdown)](#database-context-named-markdown). +**`context:`** (e.g. **context:DATAMODEL**, **context:GLOSSARY**) is an authoritative Markdown document stored server-side under that stem via the context API — *not* generic English ("a data model"), and *not* a local `./DATAMODEL.md` (local files are only `push`/`pull` transport). CLI commands take the bare stem: `hotdata databases context show DATAMODEL`. Context is scoped to the **current database** (`hotdata databases use `); target another with `--database` / `-d`. Stems follow SQL identifier rules and accept a trailing `.md` (stored without it). Command reference: [Database context (named Markdown)](#database-context-named-markdown). **Agents — list before show.** Run `hotdata databases context list` (optionally `--prefix DATAMODEL`) first; run `hotdata databases context show DATAMODEL` *only if* the stem is listed. A missing stem makes `show` exit 1 — normal for a fresh database, not a failure: don't retry in a loop or run speculative `show` in parallel with other tools. Proceed without context:DATAMODEL until one exists. @@ -63,7 +69,7 @@ A workspace's query worker scales to zero after inactivity. The **first** comman These are **patterns** built from the commands below—not separate CLI subcommands: -- **Model (`context:DATAMODEL`)** — The shared semantic map of the active database (entities, keys, joins across sources). Store and read it only via database context (`hotdata databases context list`, then `show DATAMODEL` **only when listed**, `push DATAMODEL`); refresh using `databases tables list` and `databases tables show`. For a deep pass (indexes, per-table detail), see [references/MODEL_BUILD.md](references/MODEL_BUILD.md). +- **Model (`context:DATAMODEL`)** — The shared semantic map of the current database (entities, keys, joins across sources). Store and read it only via database context (`hotdata databases context list`, then `show DATAMODEL` **only when listed**, `push DATAMODEL`); refresh using `databases tables list` and `databases tables show`. For a deep pass (indexes, per-table detail), see [references/MODEL_BUILD.md](references/MODEL_BUILD.md). - **History / Chain / OLAP SQL** — See **`hotdata-analytics`** and [references/WORKFLOWS.md](references/WORKFLOWS.md). - **Search / retrieval indexes** — See **`hotdata-search`**. @@ -79,7 +85,7 @@ Global CLI options: **`--api-key`**, **`-v` / `--version`**, **`-h` / `--help`** ``` hotdata workspaces list [--output table|json|yaml] ``` -Returns workspaces with `public_id`, `name`, `active`, `favorite`, `provision_status`. Table output marks the default workspace with `*`. +Returns workspaces with `public_id`, `name`, `active`, `favorite`, `provision_status`. Table output marks the current workspace with `*`. ### Instant databases (`databases`) @@ -93,7 +99,7 @@ Returns workspaces with `public_id`, `name`, `active`, `favorite`, `provision_st - **"json in any shape" is the CLI's doing, not the API's.** `--file`/`--url` reshape an array or a pretty-printed document into newline-delimited json locally. The load API and the SDKs accept **newline-delimited json only** (`format: "json"`), so code that moves off the CLI must convert the file itself. - **Two json sources are refused before anything uploads — do not retry them unchanged.** `one object per row` means a row is a scalar or a list: reshape the file so every row is an object. `carries no json rows` means the source is `[]` or empty: there is nothing to load. -**Active database:** `hotdata databases use ` saves the active database to config. `databases tables list`/`load`/`remove`, `databases queries`/`results`, and all `databases context` commands default to the active database; pass **`--database `** to override per-command. (`databases tables show` instead takes a fully-qualified `catalog.schema.table`.) +**Current database:** `hotdata databases use ` saves the current database to config (see [Current workspace and database](#current-workspace-and-database)). `databases tables list`/`load`/`remove`, `databases queries`/`results`, and all `databases context` commands default to the current database; pass **`--database `** to target another database for that command only. (`databases tables show` instead takes a fully-qualified `catalog.schema.table`.) **Always select databases by id** (`dbid...`, from `databases list`). Display names and catalog aliases are not unique — several databases can share a name, and a fork answers to the same catalog as its source — so name-based selection is ambiguous. @@ -123,29 +129,30 @@ hotdata databases tables load [--database ] [--schema public] (--fil hotdata databases tables remove
[--database ] [--schema public] [--workspace-id ] ``` -- `list` — all instant databases in the workspace. Active database is marked with `*` under the DEFAULT column; CREATED shows when each database was made. +- `list` — all instant databases in the workspace. Current database is marked with `*` under the CURRENT column; CREATED shows when each database was made. - `count` — the total number of instant databases in the workspace, across **all** pages (`list` shows one page). Prints a bare integer by default so it drops straight into scripts (`$(hotdata databases count)`); `--output json|yaml` render `{"count": N}` / `count: N`. -- `create` — creates a new instant database. `--name` is an optional human-readable display name. `--catalog` sets the SQL alias used in queries (`SELECT … FROM .schema.table`); must be `[a-z_][a-z0-9_]*`. `--expires-at` accepts relative durations (`24h`, `7d`, `90m`) or an RFC 3339 timestamp; omitting means no expiry. Repeat `--table` to declare tables up front. -- `fork` — creates a new instant database that is an independent deep copy of an existing one (same schemas, tables, and data); the source is left unchanged and the two diverge freely afterwards. The source defaults to the active database; pass the database `` to fork another. `--name` defaults to `-fork` (so the two stay distinguishable in `list`); `--expires-at` accepts a relative duration or RFC 3339 timestamp, and when omitted a still-future source expiry is carried over. `--description` records why the fork was taken (for example `"what-if on Q3 pricing"`); it can be set only at fork time. The fork becomes the active database on success. The fork answers to the **same catalog alias** as its source inside its own scope; catalogs attached to the source are **re-attached** to the fork, but indexes are **not** carried over. Only databases created with the current (DuckLake) storage engine can be forked — older parquet-backed databases return an error. The fork's output (and its `databases ` view) records **`forked_from`** provenance: source id, the source's name at fork time, the copied snapshot, when, and the description if one was given. -- `lineage` — renders a database's **whole fork family tree**, walked from the root down (defaults to the active database; one lineage request per database, fine for the small families forks produce in practice): every reachable generation, with the queried database marked `← this database`. A fork taken with `--description` shows the first line of it on its row; `-o json`/`yaml` carry the full text. Lineage is a historical record, not a live link — the databases stay independent, and a **deleted** generation stays in the chain (marked `deleted`), though its own fork list can't be enumerated: such branches end with `⋯ forks unknown`. Forks made before the server recorded lineage carry none. `--forks-limit ` pages each database's direct-fork list (server clamps to 1–100); a truncated branch closes with `⋯ N more`. `-o json`/`yaml` return `{database_id, root_id, tree}` with a recursive `tree` node. -- `use` — saves the database **id** as the active database. Subsequent `databases tables` and `databases context` commands use it automatically. Note that a successful `fork` also updates this: the fork becomes the active database. -- `unset` — clears the active database from config. +- `create` — creates a new instant database. `--name` is an optional human-readable display name. `--catalog` sets the SQL alias used in queries (`SELECT … FROM .schema.table`); must be `[a-z_][a-z0-9_]*`. `--expires-at` accepts relative durations (`24h`, `7d`, `90m`) or an RFC 3339 timestamp; omitting means no expiry. Repeat `--table` to declare tables up front. **Does not switch the current database** — run `hotdata databases use ` with the printed id (or pass `-d `) to work in it. +- `fork` — creates a new instant database that is an independent deep copy of an existing one (same schemas, tables, and data); the source is left unchanged and the two diverge freely afterwards. The source defaults to the current database; pass the database `` to fork another. `--name` defaults to `-fork` (so the two stay distinguishable in `list`); `--expires-at` accepts a relative duration or RFC 3339 timestamp, and when omitted a still-future source expiry is carried over. `--description` records why the fork was taken (for example `"what-if on Q3 pricing"`); it can be set only at fork time. **Does not switch the current database** — run `hotdata databases use ` to work in the fork, or pass `-d ` per command. The fork answers to the **same catalog alias** as its source inside its own scope; catalogs attached to the source are **re-attached** to the fork, but indexes are **not** carried over. Only databases created with the current (DuckLake) storage engine can be forked — older parquet-backed databases return an error. The fork's output (and its `databases ` view) records **`forked_from`** provenance: source id, the source's name at fork time, the copied snapshot, when, and the description if one was given. +- `lineage` — renders a database's **whole fork family tree**, walked from the root down (defaults to the current database; one lineage request per database, fine for the small families forks produce in practice): every reachable generation, with the queried database marked `← this database`. A fork taken with `--description` shows the first line of it on its row; `-o json`/`yaml` carry the full text. Lineage is a historical record, not a live link — the databases stay independent, and a **deleted** generation stays in the chain (marked `deleted`), though its own fork list can't be enumerated: such branches end with `⋯ forks unknown`. Forks made before the server recorded lineage carry none. `--forks-limit ` pages each database's direct-fork list (server clamps to 1–100); a truncated branch closes with `⋯ N more`. `-o json`/`yaml` return `{database_id, root_id, tree}` with a recursive `tree` node. +- `use` — saves the database as the current database (accepts an id, catalog alias, or name; prefer the id). Subsequent `query`, `databases tables`, and `databases context` commands use it automatically. This is the **only** command that changes the current database. +- `unset` — clears the current database from config. - `show ` / `` — inspect one database (returns id, catalog, name, expires_at, attached databases; a fork also shows its `forked_from` record). - `remove` — removes the instant database; clears the active-database config if it matched. - `load` (top-level shorthand) — loads a file into `--catalog.--schema.--table`. Accepts `--file`, `--url`, `--upload-id`, or `--result-id` (load a saved query result by id — from `hotdata databases results` or a query's `[result-id: …]` footer — instead of a file; the result must belong to the target database). **Formats:** csv, json (`.json`/`.jsonl`/`.ndjson`), and parquet; the format comes from the file's extension, and `--format` overrides it (needed when the extension is absent or misleading). An unrecognised extension is not rejected — the server reads the bytes, and a file that plainly opens a json array is taken as json even without an extension. A json source in any shape (array, pretty-printed, one object per line) is reshaped locally to newline-delimited json before upload; an already-newline-delimited file is uploaded untouched. A table or schema that was never declared is declared by the server as part of the load, so no up-front `--table` is required. - **Load modes** (`--mode`, default `replace`) — `replace` supersedes the table's contents; `append` adds rows; `delete`, `update`, and `upsert` match existing rows **by key**. `--append` is the old shorthand for `--mode append` and still works, but the two cannot be combined. The keyed modes need a key: declare one with `databases tables add --key`, or name it per-load with `--key` (repeat for a composite key). `delete` uploads only the key columns; `update` replaces matching rows and ignores unmatched ones; `upsert` inserts the unmatched instead. Keyed modes are not available with `--result-id`. -- `tables list` — lists tables with `TABLE` (`..
`), `SYNCED`, `LAST_SYNC`. Uses active database when `--database` is omitted. +- `tables list` — lists tables with `TABLE` (`..
`), `SYNCED`, `LAST_SYNC`. Uses current database when `--database` is omitted. - `tables add` — declares a table **with its key and storage layout**, which a load cannot infer. `--key` (repeatable) is what enables the `delete`/`update`/`upsert` load modes on that table. `--sorted-by ` or `=desc` sets sort order; `--partition-by ` partitions on the value, `=month` (or `year`/`day`/`hour`) on a calendar part — one partition per calendar month needs **both** `=year` and `=month`, or every March shares a partition. Sort and partition are fixed once the table exists. `--key-determines` (repeatable) asserts a column's value is fixed by the key: it prunes keyed loads harder, and is **correctness-affecting** — declare it only where the invariant really holds, or a keyed load can leave a duplicate key behind. Re-adding an existing table is a conflict (409), and `tables remove` does not clear the declaration — the table leaves the listing but the name stays declared and still conflicts. So **a key cannot be retrofitted onto a table declared without one**: declare it with `--key` up front, or use a new table name. - `tables load` — publishes to an instant-database table from a local file (`--file`), a remote URL (`--url`), a pre-staged upload (`--upload-id`), or a saved query result (`--result-id`, must belong to the target database). Same `--mode`, `--format`, and `--key` flags as the top-level `load` above. - `tables remove` — drops a table from the instant database. -- `attach` — attaches **another instant database** to this one, so its **live** tables become visible inside this database's query scope. Name the other database by name, catalog alias, or id. Defaults to the active database; target another with `--database`. `--alias` sets the SQL name it answers to (defaults to the attached database's own catalog alias). **Required when that alias is `default`** — the stock name for a database created without `--catalog` — since `default` cannot be attached under its own name. This is how you **join across databases** — see [Querying across databases](#querying-across-databases-attach). Read-only: loads still target your own database, and attaching is not transitive — you see what you attached, not what it attached. -- `detach` — removes an attachment, withdrawing visibility without deleting any data. Accepts the attached database's name/id **or** the alias you attached it under. Defaults to the active database. +- `attach` — attaches **another instant database** to this one, so its **live** tables become visible inside this database's query scope. Name the other database by name, catalog alias, or id. Defaults to the current database; target another with `--database`. `--alias` sets the SQL name it answers to (defaults to the attached database's own catalog alias). **Required when that alias is `default`** — the stock name for a database created without `--catalog` — since `default` cannot be attached under its own name. This is how you **join across databases** — see [Querying across databases](#querying-across-databases-attach). Read-only: loads still target your own database, and attaching is not transitive — you see what you attached, not what it attached. +- `detach` — removes an attachment, withdrawing visibility without deleting any data. Accepts the attached database's name/id **or** the alias you attached it under. Defaults to the current database. - `create --attach [=]` — attach one or more databases at creation time (repeatable), e.g. `--attach reference --attach salesdb=sales`. Example: ``` -hotdata databases create --catalog airbnb +hotdata databases create --catalog airbnb # prints the new id +hotdata databases use # create does not switch hotdata databases load --catalog airbnb --table listings --url https://example.com/listings.parquet hotdata query "SELECT count(*) FROM airbnb.public.listings" ``` @@ -175,7 +182,7 @@ a key, name one per load instead: `--mode upsert --key booking_id`. #### Querying across databases (attach) -**A `hotdata query` runs inside exactly one instant database** — the active database (`hotdata databases use `) or the one named by `--database`. With none set, the query fails with *"a database is required."* That database's query scope sees **only its own catalog plus whatever is explicitly attached to it** — another database is **not** visible just because it exists. Referencing something unattached fails with *"table '\.\.\' not found."* +**A `hotdata query` runs inside exactly one instant database** — the current database (`hotdata databases use `) or the one named by `--database`. With none set, the query fails with *"a database is required."* That database's query scope sees **only its own catalog plus whatever is explicitly attached to it** — another database is **not** visible just because it exists. Referencing something unattached fails with *"table '\.\.\' not found."* To read another database's tables, or **join your own tables against them in one query**, attach it first. The data stays **live** — this is not a copy: @@ -214,21 +221,21 @@ hotdata databases tables show [--output tabl **`databases tables list`** - **Always use this command to discover available tables.** Do NOT query `information_schema` via `hotdata query`. -- With an **active database set** (`hotdata databases use `): lists tables in that database — format `..
`, columns `TABLE`, `SYNCED`, `LAST_SYNC`. -- With **no active database**: lists all tables across the workspace — format `..
`, same columns. +- With an **current database set** (`hotdata databases use `): lists tables in that database — format `..
`, columns `TABLE`, `SYNCED`, `LAST_SYNC`. +- With **no current database**: lists all tables across the workspace — format `..
`, same columns. - `--schema` and `--table` support SQL `%` wildcard patterns (e.g. `--table order%`). - Results are paginated (default 100 per page); a `--cursor` token is printed when more are available. **`databases tables show`** - Fetches column definitions (`COLUMN`, `DATA_TYPE`, `NULLABLE`) for a single table. - **`catalog.schema.table`** — three-part form; the catalog resolves to an instant database or an attached database by name. -- **`schema.table`** — two-part form; uses the active database (errors if none is set). +- **`schema.table`** — two-part form; uses the current database (errors if none is set). - Copy the name directly from `databases tables list` output — both forms match what `list` prints. - **Always use `databases tables show` to inspect columns before writing queries.** ### Database context (named Markdown) -Reads and writes **database-scoped context API** documents. Context is tied to the **active database** (set via `hotdata databases use`); pass **`--database `** (short: **`-d`**) to target a specific database. **`show`** needs no local file; **`push`** / **`pull`** use **`./.md`** in the current directory only as the CLI transport format. See [Database context (API)](#database-context-api). +Reads and writes **database-scoped context API** documents. Context is tied to the **current database** (set via `hotdata databases use`); pass **`--database `** (short: **`-d`**) to target a specific database. **`show`** needs no local file; **`push`** / **`pull`** use **`./.md`** in the current directory only as the CLI transport format. See [Database context (API)](#database-context-api). ``` hotdata databases context list [--database ] [--prefix ] [--output table|json|yaml] @@ -237,7 +244,7 @@ hotdata databases context pull [--database ] [--force] [--dry-run] hotdata databases context push [--database ] [--dry-run] ``` -- `list` — names, `updated_at`, and character counts for each stored context in the active database. Use `--prefix` to narrow names (case-sensitive). **Agents:** call **`list` before `show`** for `DATAMODEL` (or any stem) so you do not rely on `show` failing when the document does not exist yet. +- `list` — names, `updated_at`, and character counts for each stored context in the current database. Use `--prefix` to narrow names (case-sensitive). **Agents:** call **`list` before `show`** for `DATAMODEL` (or any stem) so you do not rely on `show` failing when the document does not exist yet. - `show` — print the Markdown body to **stdout** (use this when there is **no** local `./.md`; ideal for agents). **Errors** if no context with that `name` exists (exit 1)—expected for a new database; use `list` first to avoid that path. - `pull` — download context `name` to `./.md`. Refuses to overwrite an existing file unless `--force`. `--dry-run` prints target path and size only. - `push` — upload `./.md` to upsert context `name` on the server. `--dry-run` prints size only. Body size must stay within the API limit (order of 512k characters). @@ -256,8 +263,8 @@ hotdata databases query status ``` - Default output is `table` (row count and execution time). -- **A query runs inside one instant database** (active database or `--database`); with none set it fails *"a database is required."* The scope sees the database's own catalog **plus whatever is attached to it only**. To read another database's tables or join across databases, attach it first — see [Querying across databases (attach)](#querying-across-databases-attach). -- Use `hotdata databases tables list` and `hotdata databases tables show` for discovery — not `information_schema` via `query`. (Discovery lists every workspace table; queryability still requires the table's catalog to be in the active database's scope.) +- **A query runs inside one instant database** (current database or `--database`); with none set it fails *"a database is required."* The scope sees the database's own catalog **plus whatever is attached to it only**. To read another database's tables or join across databases, attach it first — see [Querying across databases (attach)](#querying-across-databases-attach). +- Use `hotdata databases tables list` and `hotdata databases tables show` for discovery — not `information_schema` via `query`. (Discovery lists every workspace table; queryability still requires the table's catalog to be in the current database's scope.) - **PostgreSQL dialect.** Quote non-lowercase columns with double quotes. To write DuckDB/Postgres/Snowflake SQL instead, pass `--dialect` (server-side transpile, read-only queries) — details in **`hotdata-analytics`**. - Async runs return `query_run_id` → poll with `query status ` (do not re-run the same heavy SQL). `query status` exit codes: `0` succeeded, `1` failed, `2` still running (poll again), `3` succeeded but the result is a truncated/incomplete preview. - **Large results: `-o csv` / `-o json` are complete, `-o table` is capped.** The server returns inline rows only up to a bounded cap and persists the full set under a `result_id`. For `csv` and `json` the CLI **streams** that full result batch by batch, so output size is unbounded and memory stays flat — pipe a big result to a file with `-o csv`/`-o json`, never `table`. For `table` the CLI fetches at most **10,000 rows** and the footer says `N of TOTAL rows — INCOMPLETE PREVIEW` (with `?` when the server sent no total); the process exits **3** so a pipeline cannot mistake it for the whole set. Same rules for `hotdata databases results get`. A result the server is still writing is waited for (up to 5 minutes, honoring `Retry-After`) rather than returned partial. If the full result can't be retrieved, the CLI prints the inline preview, a `warning:` to stderr, and exits 3. `-o json` carries `row_count`, `total_row_count`, and `truncated` — branch on `truncated`, not on the row count. @@ -496,7 +503,7 @@ hotdata auth logout # Remove saved auth for the default profile hotdata support report -m "" --subject "" [--kind bug|question|billing|feature|account|other] [--severity urgent|high|medium|low] [-w | --no-workspace] [--logs |-] [--context KEY=VALUE ...] [-o table|json|yaml] ``` -Files a support ticket via the API — no browser needed. `-m`/`--message` and `--subject` are required together for non-interactive use (agents: always pass both); omit both in an interactive terminal to compose in `$EDITOR` instead. Attaches the active workspace by default (`--no-workspace` to omit, `-w` for a specific one); `--logs` reads a file or `-` for stdin (cap 256 KiB); `--context key=value` adds extra diagnostic pairs (repeatable, max 20). Prints the ticket's `public_id` on success — replies go to the email on the HotData account, not to the CLI. +Files a support ticket via the API — no browser needed. `-m`/`--message` and `--subject` are required together for non-interactive use (agents: always pass both); omit both in an interactive terminal to compose in `$EDITOR` instead. Attaches the current workspace by default (`--no-workspace` to omit, `-w` for a specific one); `--logs` reads a file or `-` for stdin (cap 256 KiB); `--context key=value` adds extra diagnostic pairs (repeatable, max 20). Prints the ticket's `public_id` on success — replies go to the email on the HotData account, not to the CLI. ## Workflows diff --git a/skills/hotdata/references/DATA_MODEL.template.md b/skills/hotdata/references/DATA_MODEL.template.md index 4bd9141c..ba9046f4 100644 --- a/skills/hotdata/references/DATA_MODEL.template.md +++ b/skills/hotdata/references/DATA_MODEL.template.md @@ -1,6 +1,6 @@ # Data model — `` -> **Storage:** This Markdown structure is **context:DATAMODEL**—the document stored in the database-scoped **context API** (the active database; `-d`/`--database` to target another) under stem `DATAMODEL`. Use **`hotdata databases context list`** first; **only if** `DATAMODEL` appears, use `hotdata databases context show DATAMODEL` to read it (otherwise there is nothing to show yet). Maintain `./DATAMODEL.md` in your **project directory** (where you run `hotdata`) only when editing, then `hotdata databases context push DATAMODEL`. Do not use `docs/DATA_MODEL.md` or other repo paths as the source of truth. (**`context:DATAMODEL`** in skills means that API document, not generic “data model” prose.) +> **Storage:** This Markdown structure is **context:DATAMODEL**—the document stored in the database-scoped **context API** (the current database; `-d`/`--database` to target another) under stem `DATAMODEL`. Use **`hotdata databases context list`** first; **only if** `DATAMODEL` appears, use `hotdata databases context show DATAMODEL` to read it (otherwise there is nothing to show yet). Maintain `./DATAMODEL.md` in your **project directory** (where you run `hotdata`) only when editing, then `hotdata databases context push DATAMODEL`. Do not use `docs/DATA_MODEL.md` or other repo paths as the source of truth. (**`context:DATAMODEL`** in skills means that API document, not generic “data model” prose.) > Do not commit workspace-specific content into agent skill folders. > For a **full** build (per-table detail, connector enrichment, index summary), follow [MODEL_BUILD.md](MODEL_BUILD.md) from the installed skill’s `references/` (or this repo’s `skills/hotdata/references/`). Relative links to `MODEL_BUILD.md` below work only while this file lives next to those references; in your project, open that path separately if the link 404s. diff --git a/skills/hotdata/references/MODEL_BUILD.md b/skills/hotdata/references/MODEL_BUILD.md index d9f1a9ca..06f2e800 100644 --- a/skills/hotdata/references/MODEL_BUILD.md +++ b/skills/hotdata/references/MODEL_BUILD.md @@ -1,6 +1,6 @@ # Building a database data model (advanced) -Optional **deep pass** for a single authoritative markdown document stored as **`context:DATAMODEL`** (database-scoped **context API** — the active database). For a short checklist only, use the **Model** section in [WORKFLOWS.md](WORKFLOWS.md) and [DATA_MODEL.template.md](DATA_MODEL.template.md). +Optional **deep pass** for a single authoritative markdown document stored as **`context:DATAMODEL`** (database-scoped **context API** — the current database). For a short checklist only, use the **Model** section in [WORKFLOWS.md](WORKFLOWS.md) and [DATA_MODEL.template.md](DATA_MODEL.template.md). **Notation:** **`context:DATAMODEL`** is the live server document; **not** the same phrase as “building a data model” for a one-off analysis. **CLI** uses the bare stem: `hotdata databases context show DATAMODEL`. diff --git a/skills/hotdata/references/WORKFLOWS.md b/skills/hotdata/references/WORKFLOWS.md index 3e8ae4ac..22b16034 100644 --- a/skills/hotdata/references/WORKFLOWS.md +++ b/skills/hotdata/references/WORKFLOWS.md @@ -1,6 +1,6 @@ # Hotdata CLI workflows -**Notation:** **`context:`** (e.g. **`context:DATAMODEL`**) means the database-scoped document stored via the **context API** (active database; `-d`/`--database` to target another)—CLI uses bare stems: `hotdata databases context show DATAMODEL`. +**Notation:** **`context:`** (e.g. **`context:DATAMODEL`**) means the database-scoped document stored via the **context API** (current database; `-d`/`--database` to target another)—CLI uses bare stems: `hotdata databases context show DATAMODEL`. --- @@ -49,7 +49,7 @@ End-to-end checklists. Use the linked sections for command detail and guardrails **Skill:** **`hotdata-analytics`** (catalog via **`hotdata`**) 1. [ ] Run base SQL: `hotdata query "SELECT …"` — poll `hotdata query status ` if async -2. [ ] Materialize into an instant database: `hotdata databases create --catalog --table ` then `hotdata databases load --catalog --table --file ./….parquet` +2. [ ] Materialize into an instant database: `hotdata databases create --catalog --table `, then `hotdata databases use ` (create does not switch), then `hotdata databases load --catalog --table --file ./….parquet` 3. [ ] Query with the catalog-qualified name `.public.` 4. [ ] Chain: `hotdata query "SELECT … FROM .public. WHERE …"` 5. [ ] Record stable chains in **context:DATAMODEL** when they should outlive the session @@ -77,7 +77,7 @@ End-to-end checklists. Use the linked sections for command detail and guardrails A `hotdata query` runs inside **one** instant database; its scope sees that database's own catalog plus whatever is **attached** to it only. To read another database's tables — or join your own tables against them in one query — attach that database. (No instant database set → *"a database is required."*; something unattached → *"table not found."*) -1. [ ] Pick/create the instant database that will be the query context (`hotdata databases use ` or `databases create --catalog `) +1. [ ] Pick/create the instant database that will be the query context (`databases create --catalog ` if needed, then `hotdata databases use ` — create does not switch) 2. [ ] Attach the database(s) you need (live, no copy): `hotdata databases attach [--alias ]` - Or attach at creation: `hotdata databases create --catalog --attach [=]` 3. [ ] Confirm scope: `hotdata databases ` lists what is attached @@ -110,7 +110,8 @@ Attaching is read-only (loads still target your own database) and not transitive 1. Create the database with a catalog alias: ```bash - hotdata databases create --catalog sales + hotdata databases create --catalog sales # prints the new id + hotdata databases use # create does not switch ``` 2. **Before the first load**, declare any table that needs a key, a sort order, @@ -160,8 +161,9 @@ Before destructive experimentation (bulk replaces, schema rework, testing a load ```bash hotdata databases list # note the source database id (dbid...) -hotdata databases use # source to protect (`use` takes an id) -hotdata databases fork --expires-at 24h --description "test risky.parquet load" # deep copy; becomes the active database — note the fork id it prints +hotdata databases use # source to protect +hotdata databases fork --expires-at 24h --description "test risky.parquet load" # deep copy; note the fork id it prints +hotdata databases use # fork does not switch — do it explicitly hotdata databases load --catalog sales --table orders --file ./risky.parquet # hits the fork ``` @@ -202,6 +204,6 @@ Use `hotdata databases tables list` for discovery; do not query `information_sch ## Cross-cutting -- **Workspace:** Active workspace or `--workspace-id`. **`hotdata databases queries`** uses the active workspace only (no `--workspace-id`). +- **Workspace:** Current workspace or `--workspace-id`. **`hotdata databases queries`** uses the current workspace only (no `--workspace-id`). - **Jobs:** `hotdata jobs list` / `jobs ` for async refreshes and index builds. - **Discovery:** `hotdata databases tables list` — not `query` on `information_schema`. diff --git a/skills/hotdata/subskills/analytics/SKILL.md b/skills/hotdata/subskills/analytics/SKILL.md index 3d2ed528..dd27c4cc 100644 --- a/skills/hotdata/subskills/analytics/SKILL.md +++ b/skills/hotdata/subskills/analytics/SKILL.md @@ -35,7 +35,7 @@ hotdata query status Typical analytics SQL (all via `hotdata query`): - **Aggregations:** `COUNT`, `SUM`, `AVG`, `MIN`, `MAX` with `GROUP BY` -- **Joins:** `INNER` / `LEFT JOIN` across `..
` names — every referenced catalog (the instant database's own or an attached one) must be in the active database's scope; attach catalogs first (`hotdata databases attach`) +- **Joins:** `INNER` / `LEFT JOIN` across `..
` names — every referenced catalog (the instant database's own or an attached one) must be in the current database's scope; attach catalogs first (`hotdata databases attach`) - **Filtering:** `WHERE` on partition-friendly columns (consider **sorted** indexes below) - **Ordering:** `ORDER BY` on metrics or dimensions - **Bounded exploration:** always `LIMIT` while iterating; widen once validated @@ -46,7 +46,7 @@ Column names from CSV uploads may be case-sensitive — use double quotes when n ## Query run history -Scoped to the **active database** (set with `hotdata databases use`); pass `-d/--database ` to target another, `-w/--workspace-id` for another workspace. +Scoped to the **current database** (set with `hotdata databases use`); pass `-d/--database ` to target another, `-w/--workspace-id` for another workspace. ```bash hotdata databases queries list [-d ] [--workspace-id ] [--limit ] [--cursor ] [--status ] [--output table|json|yaml] @@ -66,7 +66,7 @@ hotdata databases results list [-d ] [--workspace-id ] [--l hotdata databases results get [-d ] [--workspace-id ] [--output table|json|csv] ``` -- Prefer **`databases results get `** over re-running identical heavy queries. It renders like `query`: `csv`/`json` stream the whole result, `table` is capped at 10,000 rows and exits `3` when the result is larger. Results and query runs scope to the active database; pass `-d/--database ` to target another. +- Prefer **`databases results get `** over re-running identical heavy queries. It renders like `query`: `csv`/`json` stream the whole result, `table` is capped at 10,000 rows and exits `3` when the result is larger. Results and query runs scope to the current database; pass `-d/--database ` to target another. - Query footers may include `[result-id: rslt...]`; also available from `databases queries `. - `databases results list --limit` defaults to **100** (max **1000**) — unlike `databases queries list`, which defaults to **20**. @@ -86,7 +86,8 @@ hotdata databases results get [-d ] [--workspace-id # create does not switch hotdata databases load --catalog analytics --table slice --file ./slice.parquet ``` diff --git a/skills/hotdata/subskills/analytics/references/WORKFLOWS.md b/skills/hotdata/subskills/analytics/references/WORKFLOWS.md index 76987b1c..ce7f5541 100644 --- a/skills/hotdata/subskills/analytics/references/WORKFLOWS.md +++ b/skills/hotdata/subskills/analytics/references/WORKFLOWS.md @@ -12,7 +12,7 @@ OLAP-style SQL, **History** (query runs and stored results), and **Chain** (mate ### Query runs -Uses the **active workspace only** — no `--workspace-id` on `databases queries`. Set default workspace with `hotdata workspaces use` first. +Uses the **current workspace only** — no `--workspace-id` on `databases queries`. Switch to the right workspace with `hotdata workspaces use` first. ```bash hotdata databases queries list [--limit N] [--cursor ] [--status ] @@ -67,7 +67,8 @@ hotdata query "SELECT ..." Land a smaller table in an **instant database** (parquet → `..
`): ```bash -hotdata databases create --catalog chain_db +hotdata databases create --catalog chain_db # prints the new id +hotdata databases use # create does not switch hotdata databases load --catalog chain_db --table revenue_slice --file ./revenue_slice.parquet ``` diff --git a/skills/hotdata/subskills/search/SKILL.md b/skills/hotdata/subskills/search/SKILL.md index 8eb5562b..550e40fa 100644 --- a/skills/hotdata/subskills/search/SKILL.md +++ b/skills/hotdata/subskills/search/SKILL.md @@ -8,7 +8,7 @@ version: 0.37.0 Retrieval workloads in Hotdata: **BM25 full-text**, **vector similarity**, and the **indexes** and **embedding providers** that power them. -**Prerequisites:** Authenticate, set a workspace, and set an active database (`hotdata databases use `) — see the **`hotdata`** skill. Use fully qualified table names: `..
`. +**Prerequisites:** Authenticate, set a workspace, and set an current database (`hotdata databases use `) — see the **`hotdata`** skill. Use fully qualified table names: `..
`. **Related sub-skills** (bundled alongside this one — `Read` on demand): **`hotdata-analytics`** ([`../analytics/SKILL.md`](../analytics/SKILL.md) — OLAP SQL, query history, materialized chains), **`hotdata-geospatial`** ([`../geospatial/SKILL.md`](../geospatial/SKILL.md) — PostGIS-style functions). @@ -41,14 +41,14 @@ hotdata search "" --in - **Custom embedding model, raw query vector, or no vector index?** Use `hotdata query` directly (e.g. `cosine_distance(col, [])`) — `search` only auto-embeds the query text via the index's own provider. - **Before search:** create the right index (`search create --type text` or `--type vector`). See [references/INDEXES.md](references/INDEXES.md). - Default `--limit` is 10. -- **Database:** the search commands resolve the index in the **active** database (`hotdata databases use `). Pass `-d/--database ` to target a different database explicitly — it is required when no active database is set. The same `-d/--database` works on `search show` and `search remove`. -- **Active database:** with `hotdata databases use `, an index created with a `schema.table` `--from` resolves the active database's catalog automatically. Or create it with the full `catalog.schema.table` form. Do **not** use the internal `__db_` label or raw catalog ID prefix — `bm25_search`/`vector_distance` resolve a catalog attached to the active database, so an `__db_…` or `conn…` prefix errors with *catalog … is not attached*. +- **Database:** the search commands resolve the index in the **current** database (`hotdata databases use `). Pass `-d/--database ` to target a different database explicitly — it is required when no current database is set. The same `-d/--database` works on `search show` and `search remove`. +- **Current database:** with `hotdata databases use `, an index created with a `schema.table` `--from` resolves the current database's catalog automatically. Or create it with the full `catalog.schema.table` form. Do **not** use the internal `__db_` label or raw catalog ID prefix — `bm25_search`/`vector_distance` resolve a catalog attached to the current database, so an `__db_…` or `conn…` prefix errors with *catalog … is not attached*. --- ## Indexes (text and vector) -Indexes are an **instant-database** concept, and belong to the database's own tables — an attached database is read-only, so index its tables there rather than here. Create names the index (positional) and attaches to a table via `--from` — `catalog.schema.table` (the database's own catalog), or `schema.table` with an active database set. `list` narrows to the **active database** when one is set; without one it scans the whole workspace. `show`/`remove` resolve the index by name in the active database (or `--database `). +Indexes are an **instant-database** concept, and belong to the database's own tables — an attached database is read-only, so index its tables there rather than here. Create names the index (positional) and attaches to a table via `--from` — `catalog.schema.table` (the database's own catalog), or `schema.table` with an current database set. `list` narrows to the **current database** when one is set; without one it scans the whole workspace. `show`/`remove` resolve the index by name in the current database (or `--database `). ```bash # List — active-database scope when a DB is set, else whole-workspace scan @@ -60,10 +60,10 @@ hotdata search create --type text|vector --from \ [--metric l2|cosine|dot] [--async] \ [--provider ] [--dimensions ] [--output-column ] [--description ] -# Show — by index name, in the active database (or -d/--database ) +# Show — by index name, in the current database (or -d/--database ) hotdata search show [-d ] [--output table|json|yaml] -# Remove — by index name, in the active database (or -d/--database ) +# Remove — by index name, in the current database (or -d/--database ) hotdata search remove [-d ] ``` @@ -94,7 +94,7 @@ hotdata search embeddings remove [--workspace-id ] ## Quick workflow -1. `hotdata databases use ` — set an active database, then `hotdata databases tables list` to confirm column types. +1. `hotdata databases use ` — set an current database, then `hotdata databases tables list` to confirm column types. 2. `hotdata search list` — avoid duplicate indexes (scoped to active DB automatically). 3. `hotdata search create --type text|vector --from --column ` (add `--async` if large). 4. `hotdata search "..." --index ` — address the index you created by name. diff --git a/skills/hotdata/subskills/search/references/INDEXES.md b/skills/hotdata/subskills/search/references/INDEXES.md index cc78b063..c85419d0 100644 --- a/skills/hotdata/subskills/search/references/INDEXES.md +++ b/skills/hotdata/subskills/search/references/INDEXES.md @@ -29,7 +29,7 @@ With no filters, this is a whole-workspace scan that **includes instant-database ## 3. Create indexes -For instant databases (`--from` catalog alias — auto-selects the active database catalog): +For instant databases (`--from` catalog alias — auto-selects the current database catalog): ```bash hotdata search create
_body --type text \ diff --git a/src/cli.rs b/src/cli.rs index 7d3b1872..0f27dfe9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -31,7 +31,7 @@ pub enum Commands { /// Database id or name (omit to use a subcommand) name_or_id: Option, - /// Workspace ID (defaults to first workspace from login) + /// Workspace ID for this command only (defaults to the current workspace; switch with `workspaces use`) #[arg(long, short = 'w', global = true)] workspace_id: Option, @@ -48,11 +48,11 @@ pub enum Commands { /// SQL query string (omit when using a subcommand) sql: Option, - /// Workspace ID (defaults to first workspace from login) + /// Workspace ID for this command only (defaults to the current workspace; switch with `workspaces use`) #[arg(long, short = 'w')] workspace_id: Option, - /// Run against a specific instant database (defaults to the current database set via `databases use`) + /// Run against a specific instant database, for this command only (defaults to the current database; switch with `databases use`) #[arg(long, short = 'd')] database: Option, @@ -74,7 +74,7 @@ pub enum Commands { /// Job ID (omit to use a subcommand) id: Option, - /// Workspace ID (defaults to first workspace from login) + /// Workspace ID for this command only (defaults to the current workspace; switch with `workspaces use`) #[arg(long, short = 'w', global = true)] workspace_id: Option, @@ -93,7 +93,7 @@ pub enum Commands { /// future ones; `resume` never runs anything immediately. There is no /// `run`/`run-now` verb — use `ingest schedule --next now`. Ingest { - /// Workspace ID (defaults to first workspace from login) + /// Workspace ID for this command only (defaults to the current workspace; switch with `workspaces use`) #[arg(long, short = 'w', global = true)] workspace_id: Option, @@ -115,7 +115,7 @@ pub enum Commands { #[arg(long, visible_alias = "in")] index: Option, - /// Database the index lives in (id; defaults to the active database) + /// Database the index lives in (id; this command only; defaults to the current database) #[arg(long, short = 'd')] database: Option, @@ -129,7 +129,7 @@ pub enum Commands { #[arg(long, default_value = "10")] limit: u32, - /// Workspace ID (defaults to first workspace from login) + /// Workspace ID for this command only (defaults to the current workspace; switch with `workspaces use`) #[arg(long, short = 'w', global = true)] workspace_id: Option, @@ -163,7 +163,7 @@ pub enum ManageCommands { #[arg(long)] since: Option, - /// Workspace ID (defaults to first workspace from login) + /// Workspace ID for this command only (defaults to the current workspace; switch with `workspaces use`) #[arg(long, short = 'w', global = true)] workspace_id: Option, diff --git a/src/client/credentials.rs b/src/client/credentials.rs index c6555b5a..9f14c056 100644 --- a/src/client/credentials.rs +++ b/src/client/credentials.rs @@ -61,46 +61,41 @@ pub fn check_status(profile_config: &config::ProfileConfig) -> AuthStatus { /// /// An api-key credential scoped to exactly one workspace (a database API token) /// pins that workspace. For a multi-workspace api key we honor the saved -/// default (`workspaces set` moves a workspace to the front of the config list) -/// when the key can reach it, otherwise fall back to the credential's own first -/// authorized workspace. A CLI session uses the saved default. `None` means no -/// default is known and the caller must pass `--workspace-id`. +/// current workspace (`workspaces use`) when the key can reach it, otherwise +/// fall back to the credential's own first authorized workspace. A CLI session +/// uses the saved current workspace. `None` means no workspace is known and the +/// caller must pass `--workspace-id`. /// /// The scope comes from a live `GET /workspaces`, so this is best-effort rather -/// than a guarantee: when that probe fails we fall back to the saved default -/// and let the gateway be the one to reject it. Note the consequence for an -/// unrestricted key with no saved default — previously it had no discoverable -/// scope and the caller was forced to pass `--workspace-id`; now it resolves to -/// whichever workspace the API lists first. -pub(crate) fn default_workspace_id(profile_config: &config::ProfileConfig) -> Option { - let saved_default = || { - profile_config - .workspaces - .first() - .map(|w| w.public_id.clone()) - }; +/// than a guarantee: when that probe fails we fall back to the saved current +/// workspace and let the gateway be the one to reject it. An unrestricted key +/// with no saved current workspace resolves to whichever workspace the API +/// lists first. +pub(crate) fn current_workspace_id(profile_config: &config::ProfileConfig) -> Option { + let saved = || profile_config.current_workspace_id(); if !matches!( profile_config.api_key_source, ApiKeySource::Flag | ApiKeySource::Env ) { - return saved_default(); + return saved(); } let ids = api_key_workspace_ids(profile_config); if let [only] = ids.as_slice() { return Some(only.clone()); } - // Multi-workspace key: prefer the saved default when the key authorizes it, + // Multi-workspace key: prefer the saved current workspace when the key + // authorizes it, // else the key's first. // // Empty `ids` no longer means "unrestricted". `api_key_workspace_ids` now // asks the server, so an unrestricted key comes back with the full list; // empty means only that we couldn't find out — no key, or the probe failed. - // Honoring the saved default in that case is a deliberate degradation: it's + // Honoring the saved workspace in that case is a deliberate degradation: it's // the best guess available, and the gateway still rejects it if wrong. - if let Some(first) = saved_default() - && (ids.is_empty() || ids.contains(&first)) + if let Some(id) = saved() + && (ids.is_empty() || ids.contains(&id)) { - return Some(first); + return Some(id); } ids.into_iter().next() } @@ -234,7 +229,7 @@ mod tests { assert!(api_key_workspace_ids(&profile).is_empty()); } - // --- default_workspace_id tests --- + // --- current_workspace_id tests --- fn ws(id: &str) -> config::WorkspaceEntry { config::WorkspaceEntry { @@ -257,21 +252,22 @@ mod tests { } #[test] - fn default_workspace_id_session_uses_saved_default_without_network() { - // Config source (a CLI session): the saved default, no network call. + fn current_workspace_id_session_uses_saved_current_without_network() { + // Config source (a CLI session): the saved current workspace, no network call. let (_tmp, _guard) = with_temp_config_dir(); let profile = ProfileConfig { - workspaces: vec![ws("work_saved"), ws("work_other")], + workspaces: vec![ws("work_other"), ws("work_saved")], + current_workspace: Some(ws("work_saved")), ..Default::default() // api_key_source defaults to Config }; assert_eq!( - default_workspace_id(&profile), + current_workspace_id(&profile), Some("work_saved".to_string()) ); } #[test] - fn default_workspace_id_single_workspace_token_pins_its_own() { + fn current_workspace_id_single_workspace_token_pins_its_own() { // A database token authorizes exactly one workspace — use it even when a // different workspace sits at the front of the (unrelated) config cache. let (_tmp, _guard) = with_temp_config_dir(); @@ -279,41 +275,41 @@ mod tests { let probe = mock_workspaces(&mut server, &["work_only"]); let mut profile = mock_profile(&server.url(), Some("hd_dbtoken")); profile.api_key_source = ApiKeySource::Env; - profile.workspaces = vec![ws("work_saved")]; + profile.current_workspace = Some(ws("work_saved")); assert_eq!( - default_workspace_id(&profile), + current_workspace_id(&profile), Some("work_only".to_string()) ); probe.assert(); } #[test] - fn default_workspace_id_multi_key_honors_saved_default_when_authorized() { - // Multi-workspace key + a saved default the key can reach → the saved - // default wins (so `workspaces set` keeps working). + fn current_workspace_id_multi_key_honors_saved_current_when_authorized() { + // Multi-workspace key + a saved current workspace the key can reach → + // it wins (so `workspaces use` keeps working). let (_tmp, _guard) = with_temp_config_dir(); let mut server = mockito::Server::new(); let _probe = mock_workspaces(&mut server, &["work_a", "work_saved", "work_b"]); let mut profile = mock_profile(&server.url(), Some("hd_org")); profile.api_key_source = ApiKeySource::Env; - profile.workspaces = vec![ws("work_saved")]; + profile.current_workspace = Some(ws("work_saved")); assert_eq!( - default_workspace_id(&profile), + current_workspace_id(&profile), Some("work_saved".to_string()) ); } #[test] - fn default_workspace_id_multi_key_falls_back_to_first_authorized() { - // Saved default is NOT one the key authorizes → the credential's first + fn current_workspace_id_multi_key_falls_back_to_first_authorized() { + // Saved current workspace is NOT one the key authorizes → the credential's first // authorized workspace, never a workspace the gateway would 403. let (_tmp, _guard) = with_temp_config_dir(); let mut server = mockito::Server::new(); let _probe = mock_workspaces(&mut server, &["work_a", "work_b"]); let mut profile = mock_profile(&server.url(), Some("hd_org")); profile.api_key_source = ApiKeySource::Env; - profile.workspaces = vec![ws("work_unauthorized")]; - assert_eq!(default_workspace_id(&profile), Some("work_a".to_string())); + profile.current_workspace = Some(ws("work_unauthorized")); + assert_eq!(current_workspace_id(&profile), Some("work_a".to_string())); } // --- check_status tests --- diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 027e5a6e..3eabc6ab 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -91,14 +91,14 @@ pub fn status(profile: &str) { // where work lands. Resolve a display name from the credential's // own authorized workspaces (api key) or the cached list (session), // falling back to the bare id if the name isn't known. - let default_id = crate::client::credentials::default_workspace_id(&profile_config); + let current_id = crate::client::credentials::current_workspace_id(&profile_config); let known = match profile_config.api_key_source { ApiKeySource::Flag | ApiKeySource::Env => { api_key_authorized_workspaces(&profile_config) } ApiKeySource::Config => profile_config.workspaces.clone(), }; - match default_id { + match current_id { Some(id) => { let name = known .iter() @@ -304,9 +304,9 @@ fn run_browser_auth( .execute(ResetColor) .unwrap(); - let workspaces = cache_workspaces(profile_config, &session.access_token) - .unwrap_or_else(|_| profile_config.workspaces.clone()); - match workspaces.first() { + let current = cache_workspaces(profile_config, &session.access_token) + .unwrap_or_else(|_| profile_config.current_workspace.clone()); + match current { Some(w) => { print_row( "Workspace", @@ -423,16 +423,15 @@ pub fn register(use_email: bool) { } /// Fetch workspaces with a freshly minted JWT and cache them in config. -/// Returns the freshly fetched list so callers can display it without -/// having to reload config from disk. +/// Returns the current workspace: the one saved on this machine if the +/// account can still access it, otherwise the first one. fn cache_workspaces( profile: &config::ProfileConfig, access_token: &str, -) -> Result, String> { +) -> Result, String> { let entries = crate::client::credentials::fetch_workspaces(&profile.api_url.to_string(), access_token)?; - config::save_workspaces("default", entries.clone())?; - Ok(entries) + config::save_workspaces("default", entries) } /// Workspaces the active api-key credential (`--api-key` / `HOTDATA_API_KEY`) is @@ -670,11 +669,10 @@ mod tests { .create(); let profile = mock_profile(&server.url(), None); - let entries = cache_workspaces(&profile, "jwt-xyz").unwrap(); + let current = cache_workspaces(&profile, "jwt-xyz").unwrap().unwrap(); m.assert(); - assert_eq!(entries.len(), 2); - assert_eq!(entries[0].public_id, "ws-1"); - assert_eq!(entries[0].name, "My WS"); + assert_eq!(current.public_id, "ws-1"); + assert_eq!(current.name, "My WS"); // Reload from disk and confirm the cache survived. let loaded = config::load("default").unwrap(); @@ -693,9 +691,9 @@ mod tests { .create(); let profile = mock_profile(&server.url(), None); - let entries = cache_workspaces(&profile, "jwt").unwrap(); + let current = cache_workspaces(&profile, "jwt").unwrap(); m.assert(); - assert!(entries.is_empty()); + assert!(current.is_none()); } #[test] diff --git a/src/commands/databases.rs b/src/commands/databases.rs index b32f0aad..e6a0b21e 100644 --- a/src/commands/databases.rs +++ b/src/commands/databases.rs @@ -86,7 +86,7 @@ pub enum DatabasesCommands { /// other). Catalogs attached to the source are re-attached to /// the fork; indexes are not carried over. Fork { - /// Source database id, catalog, or name (defaults to the current database) + /// Source database id, catalog, or name (this command only; defaults to the current database) database: Option, /// Display name for the fork. Defaults to "-fork" so the @@ -119,7 +119,7 @@ pub enum DatabasesCommands { /// deleted generation stays in the chain, though what it forked cannot be /// listed. Forks made before the server recorded lineage carry none. Lineage { - /// Database id, catalog, or name (defaults to the current database) + /// Database id, catalog, or name (this command only; defaults to the current database) database: Option, /// How many direct forks to list per database (server clamps to @@ -147,7 +147,7 @@ pub enum DatabasesCommands { #[arg(value_name = "DATABASE")] catalog: String, - /// Database id, catalog, or name to attach into (defaults to the current database) + /// Database id, catalog, or name to attach into (this command only; defaults to the current database) #[arg(long, short = 'd')] database: Option, @@ -166,15 +166,19 @@ pub enum DatabasesCommands { #[arg(value_name = "DATABASE")] catalog: String, - /// Database id, catalog, or name to detach from (defaults to the current database) + /// Database id, catalog, or name to detach from (this command only; defaults to the current database) #[arg(long, short = 'd')] database: Option, }, - /// Set the current database (used by default when no database is specified) + /// Switch the current database for this workspace + /// + /// This is the only command that changes the current database. + /// `databases create`, `databases fork` and `-d/--database` do not. + /// The choice is saved on this machine and kept across logout and login. #[command(name = "use")] Set { - /// Database id + /// Database id, catalog, or name id: String, }, @@ -260,7 +264,7 @@ pub enum DatabasesCommands { /// Sync database context with local Markdown Context { - /// Instant database to scope to (defaults to the current database) + /// Instant database to scope to (this command only; defaults to the current database) #[arg(long, short = 'd', global = true)] database: Option, @@ -273,7 +277,7 @@ pub enum DatabasesCommands { /// SQL query string (omit when using a subcommand) sql: Option, - /// Instant database to run against (defaults to the current database) + /// Instant database to run against (this command only; defaults to the current database) #[arg(long, short = 'd')] database: Option, @@ -295,7 +299,7 @@ pub enum DatabasesCommands { /// Query run ID to show details id: Option, - /// Instant database to scope to (defaults to the current database) + /// Instant database to scope to (this command only; defaults to the current database) #[arg(long, short = 'd', global = true)] database: Option, @@ -312,7 +316,7 @@ pub enum DatabasesCommands { /// Result ID (omit to use a subcommand) result_id: Option, - /// Instant database to scope to (defaults to the current database) + /// Instant database to scope to (this command only; defaults to the current database) #[arg(long, short = 'd', global = true)] database: Option, @@ -330,7 +334,7 @@ pub enum DatabasesCommands { pub enum DatabaseTablesCommands { /// List tables in an instant database List { - /// Database id or name (defaults to current database) + /// Database id or name (this command only; defaults to the current database) #[arg(long)] database: Option, @@ -365,7 +369,7 @@ pub enum DatabaseTablesCommands { /// Table name, or `schema.table` to target a schema other than --schema table: String, - /// Database id or name (defaults to current database) + /// Database id or name (this command only; defaults to the current database) #[arg(long)] database: Option, @@ -419,7 +423,7 @@ pub enum DatabaseTablesCommands { /// Load a csv, json, or parquet file — or a saved query result — into a /// table, replacing it, appending, or matching rows by key Load { - /// Database id or name (defaults to current database) + /// Database id or name (this command only; defaults to the current database) #[arg(long)] database: Option, @@ -479,7 +483,7 @@ pub enum DatabaseTablesCommands { /// Delete a table from an instant database #[command(name = "remove")] Delete { - /// Database id or name (defaults to current database) + /// Database id or name (this command only; defaults to the current database) #[arg(long)] database: Option, @@ -1711,7 +1715,7 @@ pub fn list(workspace_id: &str, format: &str, limit: Option, cursor: Option ] }) .collect(); - crate::output::table::print(&["DEFAULT", "ID", "NAME", "CREATED"], &rows); + crate::output::table::print(&["CURRENT", "ID", "NAME", "CREATED"], &rows); } } _ => unreachable!(), @@ -2039,14 +2043,6 @@ pub fn create( forked_from: resp.forked_from.flatten().map(|f| ForkedFrom::from(*f)), }; - if let Err(e) = crate::config::save_current_database("default", workspace_id, &result.id) { - use crossterm::style::Stylize; - eprintln!( - "{}", - format!("warning: database created but could not set as current: {e}").yellow() - ); - } - match format { "json" => println!("{}", serde_json::to_string_pretty(&result).unwrap()), "yaml" => print!("{}", serde_yaml::to_string(&result).unwrap()), @@ -2075,14 +2071,17 @@ pub fn create( "{}", format!( concat!( - "Load a table (csv, json, or parquet):\n", + "Make it the current database (create does not switch):\n", + " hotdata databases use {1}\n", + "\nLoad a table (csv, json, or parquet):\n", " hotdata databases load --catalog {0} --table
--file \n", " hotdata databases load --catalog {0} --table
--url \n", "\nQuery with:\n", - " hotdata query \"SELECT * FROM {0}.public.
LIMIT 10\"\n", + " hotdata query -d {1} \"SELECT * FROM {0}.public.
LIMIT 10\"\n", "\n Tip: column names are case-sensitive — wrap uppercase names in double quotes", ), - catalog + catalog, + result.id ) .dark_grey() ); @@ -2158,13 +2157,6 @@ pub fn fork( forked_from: resp.forked_from.flatten().map(|f| ForkedFrom::from(*f)), }; - if let Err(e) = crate::config::save_current_database("default", workspace_id, &result.id) { - eprintln!( - "{}", - format!("warning: database forked but could not set as current: {e}").yellow() - ); - } - match format { "json" => println!("{}", serde_json::to_string_pretty(&result).unwrap()), "yaml" => print!("{}", serde_yaml::to_string(&result).unwrap()), @@ -2207,13 +2199,16 @@ pub fn fork( "{}", format!( concat!( - "The fork is now the current database; the source is unchanged.\n", + "The source is unchanged, and your current database did not change.\n", "It answers to the same catalog alias as its source inside its own scope.\n", "Indexes are not carried over — recreate them on the fork if needed.\n", - "\nQuery it now:\n", - " hotdata query \"SELECT * FROM {0}.public.
LIMIT 10\"", + "\nSwitch to the fork:\n", + " hotdata databases use {1}\n", + "\nOr query it once without switching:\n", + " hotdata query -d {1} \"SELECT * FROM {0}.public.
LIMIT 10\"", ), - catalog + catalog, + result.id ) .dark_grey() ); @@ -2733,14 +2728,19 @@ fn database_exists_or_unverifiable(result: Result) -> Result } } -pub fn set(workspace_id: &str, id: &str) { +pub fn set(workspace_id: &str, id_or_name: &str) { use crossterm::style::Stylize; // `set` only writes local config; the GET is just a friendly existence-check. + // Not an id → fall back to a catalog alias or name match, like `-d`. let api = Api::new(Some(workspace_id)); - if !database_exists_or_unverifiable(get_database(&api, id)).unwrap_or_else(|e| e.exit()) { - eprintln!("{}", format!("error: no database with id '{id}'").red()); - std::process::exit(1); - } + let id = if database_exists_or_unverifiable(get_database(&api, id_or_name)) + .unwrap_or_else(|e| e.exit()) + { + id_or_name.to_string() + } else { + resolve_database(&api, id_or_name).id + }; + let id = id.as_str(); if let Err(e) = crate::config::save_current_database("default", workspace_id, id) { eprintln!("{}", format!("error saving current database: {e}").red()); std::process::exit(1); diff --git a/src/commands/search.rs b/src/commands/search.rs index d2cd4e7d..f794d3ba 100644 --- a/src/commands/search.rs +++ b/src/commands/search.rs @@ -76,7 +76,7 @@ pub enum SearchCommands { /// Index name name: String, - /// Database the index lives in (id; defaults to the active database) + /// Database the index lives in (id; this command only; defaults to the current database) #[arg(long, short = 'd')] database: Option, @@ -90,7 +90,7 @@ pub enum SearchCommands { /// Index name name: String, - /// Database the index lives in (id; defaults to the active database) + /// Database the index lives in (id; this command only; defaults to the current database) #[arg(long, short = 'd')] database: Option, }, diff --git a/src/commands/support.rs b/src/commands/support.rs index e83b1bde..e9fefb0c 100644 --- a/src/commands/support.rs +++ b/src/commands/support.rs @@ -52,7 +52,7 @@ pub enum SupportCommands { #[arg(long, default_value = "medium", value_parser = ["urgent", "high", "medium", "low"])] severity: String, - /// Workspace to attach (defaults to the active workspace from config) + /// Workspace to attach (defaults to the current workspace) #[arg(short = 'w', long = "workspace-id", conflicts_with = "no_workspace")] workspace_id: Option, @@ -277,7 +277,7 @@ fn build_request( } /// Resolve the workspace to attach. Unlike `main::resolve_workspace`, an -/// unconfigured default is not an error here — proceed with none rather than +/// unset current workspace is not an error here — proceed with none rather than /// block the one caller most likely to have a broken setup. Still honors the /// `HOTDATA_WORKSPACE` lock: an explicit `--workspace-id` that disagrees with /// it is still rejected, same as every other command. @@ -305,19 +305,15 @@ fn resolve_optional_workspace( if let Some(id) = provided { return Ok((Some(id), false)); } - // Deliberately NOT `client::credentials::default_workspace_id`: for an + // Deliberately NOT `client::credentials::current_workspace_id`: for an // api-key credential (`--api-key`/`HOTDATA_API_KEY`) that helper probes // `GET /workspaces` to discover scope. An exact workspace is optional for // filing a report, and the API being slow or down is exactly the // situation this command exists for — it must never block on a network - // round trip just to guess a default. Read only the saved default - // (`workspaces set` / a prior login moves one to the front); if there is - // none, or the current credential can't actually reach it, file with no - // workspace instead of guessing. - Ok(( - profile.workspaces.first().map(|w| w.public_id.clone()), - false, - )) + // round trip just to guess one. Read only the saved current workspace + // (`workspaces use` / login); if there is none, or the current credential + // can't actually reach it, file with no workspace instead of guessing. + Ok((profile.current_workspace_id(), false)) } /// Produce (subject, body, from_editor) from `-m`/`--subject`, or by @@ -1031,12 +1027,12 @@ Second paragraph. // --- workspace resolution ----------------------------------------------------- #[test] - fn no_workspace_flag_wins_even_with_a_saved_default() { + fn no_workspace_flag_wins_even_with_a_saved_current_workspace() { let profile = ProfileConfig { - workspaces: vec![config::WorkspaceEntry { + current_workspace: Some(config::WorkspaceEntry { public_id: "work_saved".into(), name: "Saved".into(), - }], + }), ..Default::default() }; let (id, locked) = resolve_optional_workspace(&profile, None, true).unwrap(); @@ -1054,7 +1050,7 @@ Second paragraph. } #[test] - fn no_configured_default_resolves_to_none_without_erroring() { + fn no_current_workspace_resolves_to_none_without_erroring() { // The behavior that differs from main::resolve_workspace: an // unconfigured profile must not error here. let profile = ProfileConfig::default(); @@ -1064,8 +1060,8 @@ Second paragraph. } #[test] - fn build_request_with_env_api_key_and_no_configured_default_makes_zero_http_calls() { - // `client::credentials::default_workspace_id` would probe `GET + fn build_request_with_env_api_key_and_no_current_workspace_makes_zero_http_calls() { + // `client::credentials::current_workspace_id` would probe `GET // /workspaces` for an env/flag-sourced api key with no single- // workspace answer already known -- resolve_optional_workspace must // never do that. A report is exactly what gets filed when the API @@ -1078,7 +1074,7 @@ Second paragraph. profile.api_key_source = config::ApiKeySource::Env; assert!( profile.workspaces.is_empty(), - "test setup: no saved default" + "test setup: no saved current workspace" ); let (_req, id, _from_editor) = build_request( @@ -1098,12 +1094,12 @@ Second paragraph. } #[test] - fn saved_default_is_used_when_no_flag_given() { + fn saved_current_workspace_is_used_when_no_flag_given() { let profile = ProfileConfig { - workspaces: vec![config::WorkspaceEntry { + current_workspace: Some(config::WorkspaceEntry { public_id: "work_saved".into(), name: "Saved".into(), - }], + }), ..Default::default() }; let (id, locked) = resolve_optional_workspace(&profile, None, false).unwrap(); @@ -1133,7 +1129,7 @@ Second paragraph. .unwrap_err(); assert!(err.contains("KEY=VALUE"), "got: {err}"); // build_request never talks to the network at all -- workspace - // resolution reads only the saved default, never probes -- so this + // resolution reads only the saved current workspace, never probes -- so this // always holds regardless of which validation failed; asserted // anyway as the documented guarantee. m.assert(); diff --git a/src/commands/workspace.rs b/src/commands/workspace.rs index 5a0ccec0..8e16cc91 100644 --- a/src/commands/workspace.rs +++ b/src/commands/workspace.rs @@ -12,10 +12,14 @@ pub enum WorkspaceCommands { output: String, }, - /// Set the default workspace + /// Switch the current workspace + /// + /// This is the only command that changes the current workspace. + /// `--workspace-id` applies to one command and does not switch. + /// The choice is saved on this machine and kept across logout and login. #[command(name = "use")] Set { - /// Workspace ID to set as default (omit for interactive selection) + /// Workspace ID to switch to (omit for interactive selection) workspace_id: Option, }, } @@ -79,7 +83,7 @@ pub fn set(workspace_id: Option<&str>) { .map(|w| format!("{} ({})", w.name, w.public_id)) .collect(); let selection = - match inquire::Select::new("Select default workspace:", options.clone()).prompt() { + match inquire::Select::new("Select workspace:", options.clone()).prompt() { Ok(s) => s, Err(_) => std::process::exit(1), }; @@ -92,13 +96,13 @@ pub fn set(workspace_id: Option<&str>) { } }; - if let Err(e) = config::save_default_workspace("default", chosen.clone()) { + if let Err(e) = config::save_current_workspace("default", chosen.clone()) { eprintln!("error saving config: {e}"); std::process::exit(1); } use crossterm::style::Stylize; - println!("{}", "Default workspace updated".green()); + println!("{}", "Switched workspace".green()); println!("id: {}", chosen.public_id); println!("name: {}", chosen.name); } @@ -111,13 +115,10 @@ pub fn list(format: &str) { std::process::exit(1); } }; - let default_id = std::env::var("HOTDATA_WORKSPACE").unwrap_or_else(|_| { - profile_config - .workspaces - .first() - .map(|w| w.public_id.clone()) - .unwrap_or_default() - }); + let current_id = std::env::var("HOTDATA_WORKSPACE") + .ok() + .or_else(|| profile_config.current_workspace_id()) + .unwrap_or_default(); let workspaces = fetch_workspaces(); @@ -136,7 +137,7 @@ pub fn list(format: &str) { let rows: Vec> = workspaces .iter() .map(|w| { - let marker = if w.public_id == default_id { "*" } else { "" }; + let marker = if w.public_id == current_id { "*" } else { "" }; vec![ marker.to_string(), w.public_id.clone(), @@ -146,7 +147,7 @@ pub fn list(format: &str) { }) .collect(); crate::output::table::print( - &["DEFAULT", "PUBLIC_ID", "NAME", "PROVISION_STATUS"], + &["CURRENT", "PUBLIC_ID", "NAME", "PROVISION_STATUS"], &rows, ); } diff --git a/src/config.rs b/src/config.rs index 8b2fb36b..ac1fdbf2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -102,6 +102,10 @@ pub struct ProfileConfig { pub api_key_source: ApiKeySource, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub workspaces: Vec, + /// Workspace this machine is working in (`workspaces use`). Local state, + /// kept across logout so the next login picks up where you left off. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_workspace: Option, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub current_databases: HashMap, } @@ -202,8 +206,9 @@ fn update_config(create: bool, f: impl FnOnce(&mut ConfigFile)) -> Result<(), St } /// Wipe the workspace cache for a profile. Paired with -/// `jwt::clear_session()` in `commands::auth::logout` — together they reset the -/// on-disk state that login populates. +/// `jwt::clear_session()` in `commands::auth::logout`. Leaves +/// `current_workspace` and `current_databases` alone so the next login +/// resumes in the same workspace and database. pub fn clear_workspaces(profile: &str) -> Result<(), String> { update_config(false, |config_file| { if let Some(entry) = config_file.profiles.get_mut(profile) { @@ -212,24 +217,50 @@ pub fn clear_workspaces(profile: &str) -> Result<(), String> { }) } -pub fn save_workspaces(profile: &str, workspaces: Vec) -> Result<(), String> { +/// Replace the cached workspace list (what the account can access). If there +/// is no current workspace yet, or it's no longer accessible, the first one +/// becomes current. Returns the current workspace after the update. +pub fn save_workspaces( + profile: &str, + workspaces: Vec, +) -> Result, String> { + let mut current = None; + update_config(true, |config_file| { + let entry = config_file.profiles.entry(profile.to_string()).or_default(); + let kept = entry.current_workspace.as_ref().and_then(|c| { + workspaces + .iter() + .find(|w| w.public_id == c.public_id) + .cloned() + }); + if let Some(w) = kept.or_else(|| workspaces.first().cloned()) { + entry.current_workspace = Some(w); + } + entry.workspaces = workspaces; + current = entry.current_workspace.clone(); + })?; + Ok(current) +} + +pub fn save_current_workspace(profile: &str, workspace: WorkspaceEntry) -> Result<(), String> { update_config(true, move |config_file| { config_file .profiles .entry(profile.to_string()) .or_default() - .workspaces = workspaces; + .current_workspace = Some(workspace); }) } -pub fn save_default_workspace(profile: &str, workspace: WorkspaceEntry) -> Result<(), String> { - update_config(true, move |config_file| { - let entry = config_file.profiles.entry(profile.to_string()).or_default(); - entry - .workspaces - .retain(|w| w.public_id != workspace.public_id); - entry.workspaces.insert(0, workspace); - }) +impl ProfileConfig { + /// The saved current workspace id. Configs written before + /// `current_workspace` existed fall back to the first cached workspace. + pub fn current_workspace_id(&self) -> Option { + self.current_workspace + .as_ref() + .or(self.workspaces.first()) + .map(|w| w.public_id.clone()) + } } pub fn save_current_database( @@ -394,16 +425,60 @@ mod tests { } #[test] - fn save_default_workspace_moves_to_front() { + fn save_current_workspace_does_not_reorder_cache() { let (_tmp, _guard) = with_temp_config_dir(); save_workspaces("default", vec![ws("ws-1", "First"), ws("ws-2", "Second")]).unwrap(); - // Set ws-2 as default — should move to front - save_default_workspace("default", ws("ws-2", "Second")).unwrap(); + save_current_workspace("default", ws("ws-2", "Second")).unwrap(); let profile = load("default").unwrap(); - assert_eq!(profile.workspaces[0].public_id, "ws-2"); - assert_eq!(profile.workspaces[1].public_id, "ws-1"); + assert_eq!(profile.current_workspace_id().as_deref(), Some("ws-2")); + assert_eq!(profile.workspaces[0].public_id, "ws-1"); + } + + #[test] + fn first_login_makes_first_workspace_current() { + let (_tmp, _guard) = with_temp_config_dir(); + let current = + save_workspaces("default", vec![ws("ws-1", "First"), ws("ws-2", "Second")]).unwrap(); + assert_eq!(current, Some(ws("ws-1", "First"))); + } + + #[test] + fn current_workspace_and_database_survive_logout_and_login() { + let (_tmp, _guard) = with_temp_config_dir(); + save_workspaces("default", vec![ws("ws-1", "First"), ws("ws-2", "Second")]).unwrap(); + save_current_workspace("default", ws("ws-2", "Second")).unwrap(); + save_current_database("default", "ws-2", "db-9").unwrap(); + + // logout, then login re-fetches workspaces + clear_workspaces("default").unwrap(); + let current = + save_workspaces("default", vec![ws("ws-1", "First"), ws("ws-2", "Second")]).unwrap(); + + assert_eq!(current.map(|w| w.public_id).as_deref(), Some("ws-2")); + assert_eq!( + load_current_database("default", "ws-2").as_deref(), + Some("db-9") + ); + } + + #[test] + fn inaccessible_current_workspace_is_replaced_on_login() { + let (_tmp, _guard) = with_temp_config_dir(); + save_current_workspace("default", ws("ws-old", "Old")).unwrap(); + let current = + save_workspaces("default", vec![ws("ws-1", "First"), ws("ws-2", "Second")]).unwrap(); + assert_eq!(current.map(|w| w.public_id).as_deref(), Some("ws-1")); + } + + #[test] + fn legacy_config_without_current_workspace_uses_first_cached() { + let profile = ProfileConfig { + workspaces: vec![ws("ws-1", "First"), ws("ws-2", "Second")], + ..Default::default() + }; + assert_eq!(profile.current_workspace_id().as_deref(), Some("ws-1")); } #[test] diff --git a/src/main.rs b/src/main.rs index 9b9ec6e1..b54eac9c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -74,20 +74,20 @@ fn resolve_workspace(provided: Option) -> String { let _ = ACTIVE_WORKSPACE_ID.set(id.clone()); return id; } - // Otherwise the profile's default, computed by the same helper `auth - // status` displays — so the reported workspace is the one commands hit. - // For an api-key credential that's its own authorized workspace (a database - // token's sole one, or the saved default when the key can reach it), not a - // possibly-different CLI-session cache. - match credentials::default_workspace_id(&profile) { + // Otherwise the profile's current workspace, computed by the same helper + // `auth status` displays — so the reported workspace is the one commands + // hit. For an api-key credential that's its own authorized workspace (a + // database token's sole one, or the saved current workspace when the key + // can reach it), not a possibly-different CLI-session cache. + match credentials::current_workspace_id(&profile) { Some(id) => { let _ = ACTIVE_WORKSPACE_ID.set(id.clone()); id } None => { eprintln!( - "error: no workspace-id provided and no default workspace found. \ - Run 'hotdata auth login' or specify --workspace-id." + "error: no workspace-id provided and no current workspace set. \ + Run 'hotdata auth login', 'hotdata workspaces use', or specify --workspace-id." ); std::process::exit(1); } From 9cccb041cfc124bfe716c6c23e3ebbce7e16e871 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:20:19 -0700 Subject: [PATCH 2/2] fix(config): keep a legacy workspace choice through the first login and logout Address review: configs written before current_workspace store the choice in workspaces[0]. save_workspaces now matches it via current_workspace_id, and clear_workspaces copies it into current_workspace before wiping the list. Also drop a duplicated HOTDATA_WORKSPACE paragraph and fix 'an current' typos in the skills. --- skills/hotdata/SKILL.md | 4 +-- skills/hotdata/subskills/search/SKILL.md | 6 ++-- src/config.rs | 41 ++++++++++++++++++++---- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/skills/hotdata/SKILL.md b/skills/hotdata/SKILL.md index 84f8fe4e..3d47bed1 100644 --- a/skills/hotdata/SKILL.md +++ b/skills/hotdata/SKILL.md @@ -49,8 +49,6 @@ The CLI saves a **current workspace** and, for each workspace, a **current datab If **`HOTDATA_WORKSPACE`** is set in the environment, the workspace is **locked** to that value: passing a different `--workspace-id` is an error, and **`hotdata workspaces use` fails** (“workspace is locked”). -If **`HOTDATA_WORKSPACE`** is set in the environment, the workspace is **locked** to that value: passing a different `--workspace-id` is an error, and **`hotdata workspaces use` fails** (“workspace is locked”). - **Omit `--workspace-id` unless you need to target a specific workspace** (and it is not locked by env or session). ### Cold starts (worker wake-up) @@ -221,7 +219,7 @@ hotdata databases tables show [--output tabl **`databases tables list`** - **Always use this command to discover available tables.** Do NOT query `information_schema` via `hotdata query`. -- With an **current database set** (`hotdata databases use `): lists tables in that database — format `..
`, columns `TABLE`, `SYNCED`, `LAST_SYNC`. +- With a **current database set** (`hotdata databases use `): lists tables in that database — format `..
`, columns `TABLE`, `SYNCED`, `LAST_SYNC`. - With **no current database**: lists all tables across the workspace — format `..
`, same columns. - `--schema` and `--table` support SQL `%` wildcard patterns (e.g. `--table order%`). - Results are paginated (default 100 per page); a `--cursor` token is printed when more are available. diff --git a/skills/hotdata/subskills/search/SKILL.md b/skills/hotdata/subskills/search/SKILL.md index 550e40fa..2faac983 100644 --- a/skills/hotdata/subskills/search/SKILL.md +++ b/skills/hotdata/subskills/search/SKILL.md @@ -8,7 +8,7 @@ version: 0.37.0 Retrieval workloads in Hotdata: **BM25 full-text**, **vector similarity**, and the **indexes** and **embedding providers** that power them. -**Prerequisites:** Authenticate, set a workspace, and set an current database (`hotdata databases use `) — see the **`hotdata`** skill. Use fully qualified table names: `..
`. +**Prerequisites:** Authenticate, set a workspace, and set a current database (`hotdata databases use `) — see the **`hotdata`** skill. Use fully qualified table names: `..
`. **Related sub-skills** (bundled alongside this one — `Read` on demand): **`hotdata-analytics`** ([`../analytics/SKILL.md`](../analytics/SKILL.md) — OLAP SQL, query history, materialized chains), **`hotdata-geospatial`** ([`../geospatial/SKILL.md`](../geospatial/SKILL.md) — PostGIS-style functions). @@ -48,7 +48,7 @@ hotdata search "" --in ## Indexes (text and vector) -Indexes are an **instant-database** concept, and belong to the database's own tables — an attached database is read-only, so index its tables there rather than here. Create names the index (positional) and attaches to a table via `--from` — `catalog.schema.table` (the database's own catalog), or `schema.table` with an current database set. `list` narrows to the **current database** when one is set; without one it scans the whole workspace. `show`/`remove` resolve the index by name in the current database (or `--database `). +Indexes are an **instant-database** concept, and belong to the database's own tables — an attached database is read-only, so index its tables there rather than here. Create names the index (positional) and attaches to a table via `--from` — `catalog.schema.table` (the database's own catalog), or `schema.table` with a current database set. `list` narrows to the **current database** when one is set; without one it scans the whole workspace. `show`/`remove` resolve the index by name in the current database (or `--database `). ```bash # List — active-database scope when a DB is set, else whole-workspace scan @@ -94,7 +94,7 @@ hotdata search embeddings remove [--workspace-id ] ## Quick workflow -1. `hotdata databases use ` — set an current database, then `hotdata databases tables list` to confirm column types. +1. `hotdata databases use ` — set a current database, then `hotdata databases tables list` to confirm column types. 2. `hotdata search list` — avoid duplicate indexes (scoped to active DB automatically). 3. `hotdata search create --type text|vector --from --column ` (add `--async` if large). 4. `hotdata search "..." --index ` — address the index you created by name. diff --git a/src/config.rs b/src/config.rs index ac1fdbf2..44e7c45a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -212,6 +212,11 @@ fn update_config(create: bool, f: impl FnOnce(&mut ConfigFile)) -> Result<(), St pub fn clear_workspaces(profile: &str) -> Result<(), String> { update_config(false, |config_file| { if let Some(entry) = config_file.profiles.get_mut(profile) { + // Legacy config: the choice lives in `workspaces[0]`. Keep it + // before the list is wiped. + if entry.current_workspace.is_none() { + entry.current_workspace = entry.workspaces.first().cloned(); + } entry.workspaces.clear(); } }) @@ -227,12 +232,10 @@ pub fn save_workspaces( let mut current = None; update_config(true, |config_file| { let entry = config_file.profiles.entry(profile.to_string()).or_default(); - let kept = entry.current_workspace.as_ref().and_then(|c| { - workspaces - .iter() - .find(|w| w.public_id == c.public_id) - .cloned() - }); + // `current_workspace_id` also covers legacy configs (`workspaces[0]`). + let kept = entry + .current_workspace_id() + .and_then(|id| workspaces.iter().find(|w| w.public_id == id).cloned()); if let Some(w) = kept.or_else(|| workspaces.first().cloned()) { entry.current_workspace = Some(w); } @@ -472,6 +475,32 @@ mod tests { assert_eq!(current.map(|w| w.public_id).as_deref(), Some("ws-1")); } + /// Write a pre-`current_workspace` config: the choice is `workspaces[0]`. + fn write_legacy_config() { + let legacy = "profiles:\n default:\n workspaces:\n - public_id: ws-2\n name: Second\n - public_id: ws-1\n name: First\n"; + fs::create_dir_all(config_dir().unwrap()).unwrap(); + fs::write(config_path().unwrap(), legacy).unwrap(); + } + + #[test] + fn legacy_choice_survives_first_login() { + let (_tmp, _guard) = with_temp_config_dir(); + write_legacy_config(); + let current = + save_workspaces("default", vec![ws("ws-1", "First"), ws("ws-2", "Second")]).unwrap(); + assert_eq!(current.map(|w| w.public_id).as_deref(), Some("ws-2")); + } + + #[test] + fn legacy_choice_survives_logout_and_login() { + let (_tmp, _guard) = with_temp_config_dir(); + write_legacy_config(); + clear_workspaces("default").unwrap(); + let current = + save_workspaces("default", vec![ws("ws-1", "First"), ws("ws-2", "Second")]).unwrap(); + assert_eq!(current.map(|w| w.public_id).as_deref(), Some("ws-2")); + } + #[test] fn legacy_config_without_current_workspace_uses_first_cached() { let profile = ProfileConfig {