feat(databases): load csv/json, keyed load modes, and table declaration - #293
Conversation
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.
…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`).
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 Report❌ Patch coverage is
📢 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.
There was a problem hiding this comment.
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:
--formatnow carriesconflicts_with = "result_id"on both load commands, with a surface test.extension_oftruncates at?and#, and bothcontent_type_for_pathandformat_for_pathshare it.databases_load_rejects_a_keyed_mode_with_result_idanddatabases_tables_add_rejects_key_determines_without_a_keycover the two hand-rolled guards.tables addechoessorted:andparts:.download_to_temptakes the suffix fromextension_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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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
--fileand--urlrejected anything not ending in.parquet. The load API takes csv, newline-delimited json, and parquet, so the gate is gone:The format comes from the extension (
.json/.jsonl/.ndjsonall mean newline-delimited json), and--format csv|json|parquetoverrides it when the extension is missing or misleading.--formatis rejected with--result-id, which is always parquet. An unrecognised extension is no longer a client-side failure — the upload announcesapplication/octet-streamand 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--urlit 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.--appendstill 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 addDeclares a table with the key and storage layout a load cannot infer:
Declaring
--keyis what enables the keyed load modes on that table.--sorted-byand--partition-byare fixed once the table exists.--partition-bytakes a calendar transform after=(year,month,day,hour) and defaults to the value itself — one partition per calendar month needs bothyearandmonth, or every March shares a partition.--key-determinesasserts 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 addon a table that already exists returns 409, andtables removedoes not clear the declaration — the table leavestables listbut 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}_embeddingcolumn on the table, andhotdata searchissuedSELECT *— so every result row carried a 1536-float list, tens of kilobytes per search, unrequested.Before and after on the same index:
It is excluded with a wildcard
EXCLUDE, taken from the index's own metadata rather than guessed from the column name. This changeshotdata searchoutput 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 tableabbreviates long lists for display.--output csvno longer abbreviates listsThe 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:
.jsonl, and extension-free (.dat) uploads via--file; csv via--url; csv misnamed.parquetreproducingCorrupt footer, then succeeding with--format csvreplace,append,--append,upsert(rows updated and inserted),delete(rows removed),update(matched row changed, unmatched ignored)tables addwith key,key_determines, two calendar partitions on one column, and a sort direction, followed by loads into the resulting table--selectescape hatchesLater commits added: the two hand-rolled rejections clap cannot express (a keyed
--modewith--result-id, and--key-determineswithout--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.