Skip to content

feat!: address managed databases by id only, never by name (#39, #55) - #59

Merged
rohan-hotdata merged 6 commits into
mainfrom
fix/55-create-scoped-bootstrap
Jul 24, 2026
Merged

feat!: address managed databases by id only, never by name (#39, #55)#59
rohan-hotdata merged 6 commits into
mainfrom
fix/55-create-scoped-bootstrap

Conversation

@rohan-hotdata

@rohan-hotdata rohan-hotdata commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

What & why

Adopts @eddietejeda's review direction: stop resolving managed databases by name; address them by id. Hotdata database names are not unique — the name field is a display label (description), not an identifier — so the destination should never look one up by name.

Previously the destination listed databases and matched on the name for every operation. A name collision could silently read from, write to, or drop the wrong database. This PR removes that by-name path entirely and resolves strictly by id.

Behaviour

  • Bind an existing database by idGET /databases/{id} once per run; every op (load/add/list/query/drop) addresses the resolved ManagedDatabase record. No listing, no name scan.
  • Create on first run — with no database_id and create_database_if_missing, the database is created (labelled database_name) and its new id is logged at WARNING so it can be pinned:
    hotdata: created managed database db_abc123 (name='example_macro'). Pin it for future runs
    by setting database_id=db_abc123 (HOTDATA_DATABASE_ID / [destination.hotdata] database_id).
    
  • Create/upload/query-scoped keys bootstrap for free (Create/upload/query-scoped API keys can't bootstrap a managed database (resolve-before-create requires read access) #55) — the create path issues no read, so a key forbidden from reading /databases only makes the create it is permitted to make. No 403 probe needed.
  • Schema evolution unchanged — an existing (bound) database gets missing tables declared in place; a freshly created one skips reconcile (tables declared at create).

Config surface

New database_idhotdata(database_id=...) / HOTDATA_DATABASE_ID / [destination.hotdata] database_id. This mirrors how every other dlt destination takes its connection identity from config (Postgres DSN, MotherDuck md:, LanceDB uri): you point at a specific database, set once in config, not edited per run. database_name stays as a create-only label.

Relationship to prior work

Breaking change → 0.11.0

Managed databases are addressed by id, not name. To load into the same database across runs, pin its database_id (printed on first-run create); without it, each run creates a new database. (Also carries the earlier workspace_id-out-of-credentials and api-key-env-resolution changes already noted in the CHANGELOG.)

Testing

  • pytest: 124 passed, 1 skipped. test_client.py rewritten for id-first (bind-by-id, create-and-log, resolve-once, create-scoped-no-read); e2e models the real "create once, pin the id" flow.
  • Validated against prod: first run creates + prints the id; a pinned run binds the same database with no duplicate created (verified by count), across an isolated job-client test and a controlled create→pin cycle.

Reviewer note

A one-off transient the upload transfer to storage failed before any response appeared once during prod validation — a storage-transport hiccup (the retry loaded cleanly), unrelated to this change.

@rohan-hotdata
rohan-hotdata force-pushed the fix/39-collision-safe-resolution branch from c087c89 to 4d3f6c4 Compare July 23, 2026 19:14
When an API key may create/upload/query but is forbidden from reading
/databases (403), ensure_managed_database now creates the database (the
operation the key is permitted to make) instead of failing, caches the
returned record for the run, and hands that record to subsequent
load/add/list operations so no further read is attempted.

Builds on the resolve-once cache from #39. Requires hotdata-framework 0.9.0
(managed-table ops accept a resolved ManagedDatabase and skip the read
probe). The pin bump is intentionally deferred until 0.9.0 is published;
until then the object-passing is exercised via fakes modelling that
contract.
…hrough (#55)

0.9.0 is released on PyPI; bump the pin so create-scoped keys can pass the
resolved ManagedDatabase into load/add/list ops and skip the read probe.
Verified against real 0.9.0: _as_managed_database returns the object as-is and
the full suite is green.
@rohan-hotdata
rohan-hotdata force-pushed the fix/55-create-scoped-bootstrap branch from ed8f353 to 8865559 Compare July 23, 2026 19:48
@rohan-hotdata rohan-hotdata changed the title feat: create-scoped API keys can bootstrap a managed database (#55) [blocked on framework 0.9.0] feat: create-scoped API keys can bootstrap a managed database (#55) Jul 23, 2026
@rohan-hotdata
rohan-hotdata changed the base branch from fix/39-collision-safe-resolution to main July 23, 2026 19:48
@rohan-hotdata
rohan-hotdata marked this pull request as ready for review July 23, 2026 19:48
@rohan-hotdata
rohan-hotdata requested a review from a team as a code owner July 23, 2026 19:48
@rohan-hotdata
rohan-hotdata requested review from eddietejeda and removed request for a team July 23, 2026 19:48
@eddietejeda

Copy link
Copy Markdown
Contributor

Hey @rohan-hotdata , I am noticing that we are using database name to try and identify names, so we have this name collision logic now instead ManagedDatabase.

I don't think that's necessary. Let's not try and resolve by name, lets be explicit and require ids.

@eddietejeda

Copy link
Copy Markdown
Contributor

Also implicates this work:

0f61c5a

Hotdata database names are not unique -- the `name` field is a display label
(description), not an identifier. The destination previously listed databases
and matched on the name for every operation, so a collision could silently
read from, write to, or drop the wrong database. Resolve strictly by id
instead:

- Bind an existing database by id via `GET /databases/{id}` (no listing, no
  name scan); every op addresses it by the resolved `ManagedDatabase` record.
- With no `database_id` and `create_database_if_missing`, create the database
  (labelled `database_name`) and log its new id so it can be pinned for
  subsequent runs -- without a pinned id, each run creates a new database.
- Create/upload/query-scoped keys bootstrap for free: the create path issues
  no read, so a key forbidden from reading `/databases` only makes the create
  it is permitted to make.

Adds `database_id` (param / `HOTDATA_DATABASE_ID` / `[destination.hotdata]`),
keeping `database_name` as a create-only label. Removes the by-name resolution
and collision guard entirely. Supersedes the interim collision-safe path (#39)
and reworks the #55 create-scoped bootstrap. Requires hotdata-framework>=0.9.0.

BREAKING CHANGE: managed databases are addressed by id, not name. To load into
the same database across runs, pin its `database_id` (printed on first-run
create). Bumps to 0.11.0.
@rohan-hotdata rohan-hotdata changed the title feat: create-scoped API keys can bootstrap a managed database (#55) feat!: address managed databases by id only, never by name (#39, #55) Jul 24, 2026
if db is None:
database_id = self._configured_database_id()
if database_id:
db = self._bind_by_id(database_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: (not blocking) With a pinned database_id, the dev_mode / refresh="drop_sources" path now dead-ends. drop_storage()drop_managed_database() deletes the pinned database and clears the run cache, but config.database_id still holds the (now-deleted) id. The subsequent initialize_storage()ensure_managed_database(create_if_missing=True) takes this database_id branch, _bind_by_id gets a 404, and the resulting KeyError is not caught in initialize_storage (only HotdataTerminalError is), so it propagates as an opaque KeyError instead of recreating (the old by-name path recreated) or raising a clear "pinned database was dropped" error.

This is a narrow combination (pinning an id while also using dev_mode/refresh), and under id-first semantics recreating with the same id isn't possible anyway — but a clearer error beats a raw KeyError. Worth a follow-up.

claude[bot]
claude Bot previously approved these changes Jul 24, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the id-first redesign. Addressing-by-id, resolve-once caching, create-and-log, and the create-scoped bootstrap all look correct and are well covered by the rewritten tests. One non-blocking nit left inline about the dev_mode/refresh + pinned-id edge case.

…hangelog

- ensure_managed_database: on the create path, a configured database_id that
  404s (e.g. dropped by dev_mode/refresh) now raises a clear HotdataTerminalError
  instead of an opaque KeyError -- a server-assigned id can't be recreated, so
  the message tells the user to unset database_id or pin an existing one. The
  probe path (create_if_missing=False) still gets KeyError -> "not initialized".
  Addresses claude[bot]'s review nit on PR #59.
- CHANGELOG: promote the Unreleased notes to a dated [0.11.0] section so the
  release-metadata check matches the version bump.
claude[bot]
claude Bot previously approved these changes Jul 24, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the prior review nit is resolved: a pinned database_id that 404s on the create path now raises a clear HotdataTerminalError (caught by initialize_storage) instead of an opaque KeyError. The id-first rework is consistent across call sites, tests, docs, changelog, and version bump.

eddietejeda
eddietejeda previously approved these changes Jul 24, 2026
…irst

The read-showcasing demos did run() then read via dataset() in one process.
Under id-first the read has no id to resolve (its config differs from the
write's), so they crashed with "no managed database resolved". Provision the
managed database up front and pin its id for both write and read (use
--database-id to reuse an existing one). This also models the correct id-first
workflow. demo.py (write-only) is unchanged. Demo-only; no src behavior change.
@rohan-hotdata
rohan-hotdata dismissed stale reviews from eddietejeda and claude[bot] via 55d0c18 July 24, 2026 17:00

def _is_not_found(exc: Exception) -> bool:
"""True when ``exc`` wraps a 404 (the bound database id does not exist)."""
return getattr(getattr(exc, "__cause__", None), "status", None) == 404

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super nit: (not blocking) _is_not_found only inspects the immediate __cause__. This works today because get_database is called directly on DatabasesApi and the 404 ApiException lands one level down (confirmed by the tests and your prod validation). But the same module's _chain_messages in sql_client.py deliberately walks the whole __cause__ chain, noting the framework can bury the real status deeper. If the framework ever adds a wrapping layer around get_database errors, a dropped-id 404 would slip past this check and re-surface as an opaque HotdataTerminalError — defeating the clear "database_id was dropped" message this PR just added. Consider walking the chain here too for consistency and durability.

claude[bot]
claude Bot previously approved these changes Jul 24, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

id-first rework looks solid. The prior nit (dropped pinned database_id surfacing as an opaque KeyError) is resolved with a clear HotdataTerminalError and a covering test. Addressing is consistent across client/job_client/sql_client/ibis_backend/demos/tests. One non-blocking super nit left inline.

_is_not_found only inspected the immediate __cause__. That matches where the
framework surfaces the 404 today, but if it ever wraps get_database errors in
an extra layer the dropped-id 404 would slip past and re-surface as an opaque
HotdataTerminalError, losing the clear "database_id was dropped" message. Walk
the whole chain (mirroring sql_client._chain_messages) so it stays robust.
Addresses claude[bot]'s review nit on PR #59.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

id-first refactor is clean and internally consistent; both prior nits (clear error on a dropped pinned database_id, and walking the full __cause__ chain in _is_not_found) are addressed in code. Call sites, tests, docs, CHANGELOG, and the version/pin bump all line up. LGTM.

@rohan-hotdata
rohan-hotdata merged commit 93fb4dc into main Jul 24, 2026
3 checks passed
rohan-hotdata added a commit that referenced this pull request Jul 24, 2026
Bring the docs in line with 0.11.0's id-first addressing (#59):

README.md
- Read-back: reads resolve the managed database by id, so configure
  database_id on the reading pipeline (drops the stale "no database IDs"
  claim that now errors).
- hard_delete: marked conditional (⚠️) — deletes propagate only with a
  primary_key; keyless merge drops flagged rows without removing existing
  ones.
- Intro tagline and "Verify a load" now address the database by id, so the
  whole README tells one addressing story (avoids the ambiguous
  duplicate-name case).

docs/sql-client-spec.md
- Note that the write→read example needs a pinned database_id under id-first.

Docs-only; found via a full claim-audit of the docs rather than a string search.
rohan-hotdata added a commit that referenced this pull request Jul 24, 2026
Bring the docs in line with 0.11.0's id-first addressing (#59):

README.md
- Read-back: reads resolve the managed database by id, so configure
  database_id on the reading pipeline (drops the stale "no database IDs"
  claim that now errors).
- hard_delete: marked conditional (⚠️) — deletes propagate only on the
  default `upsert` merge strategy with a primary_key; keyless merge and
  insert-only drop flagged rows without removing existing ones.
- Intro tagline and "Verify a load" now address the database by id, so the
  whole README tells one addressing story (avoids the ambiguous
  duplicate-name case).

docs/sql-client-spec.md
- Note that the write→read example needs a pinned database_id under id-first.

Docs-only; found via a full claim-audit of the docs rather than a string search.
rohan-hotdata added a commit that referenced this pull request Jul 24, 2026
Bring the docs in line with 0.11.0's id-first addressing (#59):

README.md
- Read-back: reads resolve the managed database by id, so configure
  database_id on the reading pipeline (drops the stale "no database IDs"
  claim that now errors).
- hard_delete: marked conditional (⚠️) — deletes propagate only on the
  default `upsert` merge strategy with a primary_key; keyless merge and
  insert-only drop flagged rows without removing existing ones.
- Intro tagline and "Verify a load" now address the database by id, so the
  whole README tells one addressing story (avoids the ambiguous
  duplicate-name case).

docs/sql-client-spec.md
- Note that the write→read example needs a pinned database_id under id-first.

Docs-only; found via a full claim-audit of the docs rather than a string search.
rohan-hotdata added a commit that referenced this pull request Jul 24, 2026
Bring the docs in line with 0.11.0's id-first addressing (#59):

README.md
- Read-back: reads resolve the managed database by id, so configure
  database_id on the reading pipeline (drops the stale "no database IDs"
  claim that now errors).
- hard_delete: marked conditional (⚠️) — deletes propagate only on the
  default `upsert` merge strategy with a primary_key; keyless merge and
  insert-only drop flagged rows without removing existing ones.
- Intro tagline and "Verify a load" now address the database by id, so the
  whole README tells one addressing story (avoids the ambiguous
  duplicate-name case).

docs/sql-client-spec.md
- Note that the write→read example needs a pinned database_id under id-first.

Docs-only; found via a full claim-audit of the docs rather than a string search.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants