Skip to content

feat(databases): load csv/json, keyed load modes, and table declaration - #293

Merged
anoop-narang merged 6 commits into
mainfrom
feat/load-modes-declare-and-search-projection
Sep 8, 2026
Merged

feat(databases): load csv/json, keyed load modes, and table declaration#293
anoop-narang merged 6 commits into
mainfrom
feat/load-modes-declare-and-search-projection

Conversation

@anoop-narang

@anoop-narang anoop-narang commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

The CLI offered less than the load API accepts. Four capabilities the API documents were unreachable from the command line, and a vector search returned a column nobody asked for.

Loads are no longer parquet-only

--file and --url rejected anything not ending in .parquet. The load API takes csv, newline-delimited json, and parquet, so the gate is gone:

hotdata databases load --catalog demo --table listings --file ./listings.csv

The format comes from the extension (.json/.jsonl/.ndjson all mean newline-delimited json), and --format csv|json|parquet overrides it when the extension is missing or misleading. --format is rejected with --result-id, which is always parquet. An unrecognised extension is no longer a client-side failure — the upload announces application/octet-stream and the server resolves the format from the bytes.

Dropping the gate alone would not have worked. The upload's recorded content type was pinned to the parquet MIME type, and the load treats a confidently wrong content type as authoritative rather than sniffing past it, so a csv announced as parquet fails with failed to parse parquet metadata: Corrupt footer. The content type now follows the file. For --url it is derived from the URL rather than from the staged temp file, whose generated name says nothing about the bytes inside.

All five load modes

--mode replace|append|delete|update|upsert. Only replace and append were reachable before, through --append.

The keyed modes match existing rows by key, so they need one: declare it on the table (below), or name it per load with --key, repeatable for a composite key.

hotdata databases load --catalog demo --table listings --file changed.csv --mode upsert
hotdata databases load --catalog demo --table listings --file removed.csv --mode delete --key listing_id

--append still works as the shorthand for --mode append; the two cannot be combined. Keyed modes are refused with --result-id, which carries whole rows rather than a key-shaped upload.

databases tables add

Declares a table with the key and storage layout a load cannot infer:

hotdata databases tables add events \
  --key event_id \
  --sorted-by created_at=desc \
  --partition-by created_at=year --partition-by created_at=month

Declaring --key is what enables the keyed load modes on that table. --sorted-by and --partition-by are fixed once the table exists. --partition-by takes a calendar transform after = (year, month, day, hour) and defaults to the value itself — one partition per calendar month needs both year and month, or every March shares a partition. --key-determines asserts a column's value is fixed by the key; it prunes keyed loads harder and is correctness-affecting, so it is documented as such.

Declare the key before the table's first load. tables add on a table that already exists returns 409, and tables remove does not clear the declaration — the table leaves tables list but the name stays declared and still conflicts. So a key cannot be retrofitted onto a table declared without one; the CLI's no-key hint and the docs both say so rather than suggesting a recovery that fails. This is a server-side constraint, not something the CLI can work around.

This also removes a delete-and-recreate path from the load. A load into a table that was never declared used to delete the entire database and recreate it with the table declared, warning that loaded data would be lost and returning a new database id. The load endpoint now declares a missing table (and a missing schema) itself, so that branch could not be reached; it is removed rather than rewritten.

Vector search no longer dumps the embedding column

An auto-embed vector index materialises a {column}_embedding column on the table, and hotdata search issued SELECT * — so every result row carried a 1536-float list, tens of kilobytes per search, unrequested.

Before and after on the same index:

columns: [id, title, body, body_embedding, dist]     # ~6 KB of floats per row
columns: [id, title, body, dist]

It is excluded with a wildcard EXCLUDE, taken from the index's own metadata rather than guessed from the column name. This changes hotdata search output for anyone reading the embedding out of it: --select '*' asks for it back, and naming the column (--select 'id,body_embedding') still reaches it. Pair either with --output json, since --output table abbreviates long lists for display.

--output csv no longer abbreviates lists

The csv writer shared the terminal's abbreviator, so a long list value came out as [1, 2, 3, ..., 9] (1536 items) in a csv cell — lossy in a format read by programs. Csv now renders list values whole. The table renderer still abbreviates, where a human is reading and terminal width is the constraint.

Testing

Unit and CLI-surface tests cover the request bodies, the extension-to-format mapping, the sort/partition parsing and its rejections, the flag conflicts, and the search projection including the quoting of user-named index columns.

Every path was also exercised against the API by hand, asserting destination row counts rather than trusting status codes:

  • csv, .jsonl, and extension-free (.dat) uploads via --file; csv via --url; csv misnamed .parquet reproducing Corrupt footer, then succeeding with --format csv
  • replace, append, --append, upsert (rows updated and inserted), delete (rows removed), update (matched row changed, unmatched ignored)
  • tables add with key, key_determines, two calendar partitions on one column, and a sort direction, followed by loads into the resulting table
  • a load into a never-declared table and a never-declared schema, confirming the database id and a sibling table's rows both survive
  • a real auto-embed index, diffed against the released CLI, plus both --select escape hatches

Later commits added: the two hand-rolled rejections clap cannot express (a keyed --mode with --result-id, and --key-determines without --key), the layout parse rejections, the extension parser's URL cases, and the sort/partition renderers.

Both worked examples in the docs were run verbatim against the API — declare with key and sort, load, upsert, delete, read back — which is how the ordering bug in them was found.

The integration job needs HOTDATA_SDK_TEST_* credentials and skips without them, so none of the live checks re-run automatically; the tests added here are unit and parse-time.

The CLI offered less than the load API accepts. Four capabilities the
API documents were unreachable, and one search projection returned data
nobody asked for.

Loads are no longer parquet-only. `--file`/`--url` accept csv and
newline-delimited json (`.json`/`.jsonl`/`.ndjson`) as well as parquet;
the format comes from the extension and `--format csv|json|parquet`
overrides it. An unrecognised extension is no longer rejected locally —
the upload announces `application/octet-stream` and the server resolves
the format from the bytes.

This needed the upload's recorded content type to follow the file. It
was pinned to the parquet MIME type, and the load treats a confidently
wrong content type as authoritative rather than sniffing past it, so a
csv announced as parquet fails with "failed to parse parquet metadata:
Corrupt footer". `--url` derives it from the URL, not from the staged
temp file, whose generated name says nothing about the bytes.

`--mode replace|append|delete|update|upsert` exposes all five load
modes; only replace and append were reachable before. The keyed modes
match rows by key, so `--key` (repeatable) names one per load for a
table declared without one. `--append` remains as the shorthand for
`--mode append`, and clap refuses the two together. Keyed modes are
refused with `--result-id`, which carries whole rows.

`databases tables add` declares a table with the key and layout a load
cannot infer: `--key`, `--key-determines`, `--sorted-by <col>[=desc]`,
and `--partition-by <col>[=year|month|day|hour]`. Declaring a key is
what enables the keyed modes on that table.

That replaces a delete-and-recreate path in the load: a load into an
undeclared table used to delete the whole database and recreate it with
the table declared, warning that loaded data would be lost and minting
a new database id. The load endpoint now declares a missing table and a
missing schema itself, so the branch could not be reached and is gone
rather than rewritten.

Behaviour change: a vector search's default projection now leaves out
the embedding column an auto-embed index materialises on the table.
`hotdata search` issued `SELECT *`, so every result row carried a
1536-float list — tens of kilobytes per search, unrequested. It is
excluded via a wildcard EXCLUDE; `--select '*'` asks for it back and
naming the column still reaches it.

Behaviour change: `--output csv` no longer abbreviates list values.
`value_to_string` shared the terminal's abbreviator, so a long list
became "[1, 2, 3, ..., 9] (1536 items)" in a csv cell — lossy in a
format read by programs. The table renderer still abbreviates, where a
human is reading and the width is the constraint.
@anoop-narang
anoop-narang requested a review from a team as a code owner September 8, 2026 14:32
@anoop-narang
anoop-narang requested review from rohan-hotdata and removed request for a team September 8, 2026 14:32
…query

`content_type_for_path` and `format_for_path` each read the extension
themselves, so the type announced on the upload and the `format` sent on
the load could disagree about what a name means. Both now call
`extension_of`.

That parser also drops a URL's query and fragment before reading the
extension. A presigned storage URL ends in
`…/listings.parquet?X-Amz-Signature=…`, which yielded the extension
`parquet?x-amz-signature=…` and matched nothing: the upload fell back to
`application/octet-stream` and the load sent no `format`, leaving the
server to sniff. Sniffing gets it right, so this is about announcing the
type we already know rather than repairing a break. A `/` after the last
`.` is likewise not an extension (`…/v1.2/export`).
Comment thread src/commands/databases.rs
Comment thread src/client/sdk.rs
Comment thread src/commands/databases.rs
Comment thread src/commands/databases.rs
claude[bot]
claude Bot previously approved these changes Sep 8, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the full diff. Four non-blocking comments inline: --format silently dropped with --result-id, URL query strings defeating the extension lookup, a missing test for the keyed-mode guard, and the omitted layout in tables add output.

CI was still queued or in progress at review time, so the test results are unknown.

Review follow-ups.

`--format` was accepted with `--result-id` and silently dropped: a stored
result is always parquet, the load rejects `format` beside `result_id`,
and the result-load request builder has no field to carry it. clap now
reports the pair.

`databases tables add` printed only the key, while `--sorted-by` and
`--partition-by` are fixed at declaration — the echo is the user's only
confirmation of what the server accepted. Both now print when set, with
`identity` shown as the bare column since the partition is the value
itself.

Tests for the two hand-rolled rejections that clap cannot express: a
keyed `--mode` with `--result-id`, and `--key-determines` without
`--key`. Both sit behind workspace resolution, so the tests pass
`HOTDATA_WORKSPACE` to reach them without credentials or a network call;
the non-keyed modes are asserted to still get past the same guard.
Comment thread src/commands/databases.rs
Comment thread src/commands/databases.rs
claude[bot]
claude Bot previously approved these changes Sep 8, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cycle 3: prior blocking items are none, and the four cycle-2 nits are addressed or answered. One new nit inline on the no-key hint. CI / test was still running at review time, so this approval rests on reading the diff, not on a passing test run.

Note: the .suffix(".parquet") thread (#3959092816) has no reply yet and stays open.

@codecov

codecov Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.10730% with 116 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/commands/databases.rs 67.51% 102 Missing ⚠️
src/commands/indexes.rs 0.00% 6 Missing ⚠️
src/main.rs 80.00% 6 Missing ⚠️
src/commands/query.rs 0.00% 1 Missing ⚠️
src/commands/search.rs 98.52% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Review follow-ups.

`download_to_temp` hardcoded `.suffix(".parquet")`, so every `--url`
load recorded a parquet file name whatever the bytes were. It now
follows the URL's own extension, and carries none when the URL has
none. The recorded name is advisory — the load resolves the format from
the content type and then the bytes, never the name, and an
extensionless csv URL loads correctly either way (verified) — but a
staged name should not claim a format it cannot know, which is the rule
`content_type_for_path`'s own doc states.

The no-key hint on `tables add` named a recovery that does not work. It
suggested re-adding with `--key`, which returns 409. `tables remove`
does not help either: 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. The hint now says that, and points at
declaring the next table with `--key`.

Also refreshes help text the earlier commits left behind: both `Load`
variants and `--file`/`--url` still described parquet as the only
accepted format.
claude[bot]
claude Bot previously approved these changes Sep 8, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The full diff was omitted from the review prompt. Reviewed the patch fetched with gh pr diff 293, plus src/commands/query.rs, src/commands/indexes.rs, and src/commands/databases.rs from the checkout.

All four prior threads are resolved in code:

  • --format now carries conflicts_with = "result_id" on both load commands, with a surface test.
  • extension_of truncates at ? and #, and both content_type_for_path and format_for_path share it.
  • databases_load_rejects_a_keyed_mode_with_result_id and databases_tables_add_rejects_key_determines_without_a_key cover the two hand-rolled guards.
  • tables add echoes sorted: and parts:.
  • download_to_temp takes the suffix from extension_of(url).

Checked two things that the removal of the delete-and-recreate branch could have broken: collect_tables, create_database_request, and CreateDatabaseResponse all still have live callers, and value_to_string in src/commands/query.rs:102 reaches only the csv arm of print_result, so the table renderer keeps its abbreviation.

No blocking issues. CI was still queued or in progress at review time, so the test results are unknown.

A deliberate sweep for parquet-only language, after finding two stale
spots by accident.

README's command table and the workflow decision guide still described
`databases load` as parquet-only and instant databases as "parquet files
you own". Both now name csv, newline-delimited json, and parquet, and
the table lists `databases tables add`.

The instant-database workflow carried a callout that was materially
wrong after this branch: it warned that loading into an undeclared table
recreates the database and changes its `id`, and advised against caching
ids across loads. The load declares a missing table and schema in place,
so the id and the other tables survive. The workflow now shows the
declare-a-key step and records that a key cannot be added later.

Two code comments justified themselves by the removed delete+recreate
path: the raw JSON create-request builder ("the delete+recreate path
still consumes the raw JSON form, so the JSON builder stays") and
`delete_raw`'s doc. Both are still used for other reasons, so only the
reasons are corrected.
Comment thread README.md Outdated
claude[bot]
claude Bot previously approved these changes Sep 8, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The full diff was omitted from the review prompt. Fetched it with gh pr diff 293 and read it in full, plus src/commands/query.rs, src/commands/indexes.rs, and README.md from the checkout.

All four prior threads are resolved in code: --format now conflicts with --result-id on both load commands, extension_of truncates at ?/#, the staged temp file takes the source URL's extension, the keyed-mode and --key-determines guards have surface tests, and tables add echoes sorted: and parts:.

Verified separately: is_parquet_path has no remaining callers, truncate_array is still used by the table renderer, and value_to_string reaches only the -o csv branch (src/commands/query.rs:650), so dropping abbreviation there does not change table output.

One non-blocking docs nit left inline.

Running the examples found an ordering bug I had just introduced. The
instant-database workflow loaded into `orders` at step 2, then ran
`tables add orders --key` at step 3 — which returns 409, because the
load had already declared the table. It also contradicted its own note
in the same step to declare before the first load. Declaration is now
step 2 and the load step 3, with the keyed sync as step 4.

SKILL.md's keyed example had the same trap across two adjacent blocks:
it declared `listings`, the table the preceding example had already
loaded into. It uses its own table and includes the initial load, so the
block stands alone and runs in the order given.

Both sequences were then run verbatim against the API: declare with key
and sort, load, upsert, delete, and read back the expected rows.

Also fixes the `databases tables load` row in README's command table,
which still described parquet and `--append` after the sweep updated the
row above it.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the full patch. The diff was omitted from the review context, so it was fetched with gh pr diff; src/commands/query.rs and src/commands/databases.rs were also read from the checkout.

All prior threads are resolved in code. No blocking issues.

CI is still queued or in progress for every check except CI / changelog. This approval does not assert that the test job passes.

@anoop-narang
anoop-narang merged commit 237ea0c into main Sep 8, 2026
14 checks passed
@anoop-narang
anoop-narang deleted the feat/load-modes-declare-and-search-projection branch September 8, 2026 14:58
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.

1 participant