Skip to main content

hyperdb_mcp/
ingest.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Ingest inline data (JSON strings, CSV strings) and CSV files into Hyper tables.
5//!
6//! JSON is inserted row-by-row via SQL `INSERT` statements. This is simple and
7//! correct but not the fastest path — for bulk data, prefer file-based ingest
8//! where Hyper's native `COPY FROM` is used.
9//!
10//! CSV ingest always uses `COPY FROM`: inline CSV is spilled to a temp file first,
11//! while file-based CSV is read directly by `hyperd`.
12//!
13//! # Atomicity
14//!
15//! Every ingest function wraps its `INSERT` / `COPY` work inside a single
16//! transaction via [`Engine::execute_in_transaction`]. If any row fails to
17//! insert, all prior inserts from the same call are rolled back, so a failed
18//! ingest leaves zero additional rows behind.
19//!
20//! Note that Hyper auto-commits DDL (`DROP TABLE`, `CREATE TABLE`) regardless
21//! of the surrounding transaction. In `replace` mode, this means the original
22//! table is already gone by the time inserts start — a mid-ingest failure
23//! leaves the new table empty, not the original intact. In `append` mode, no
24//! DDL runs (assuming the table already exists), so rollback is fully atomic.
25
26use crate::engine::Engine;
27use crate::error::{ErrorCode, McpError};
28use crate::schema::{
29    apply_schema_override, infer_csv_schema, infer_json_schema, json_type_name,
30    widen_csv_numeric_columns, ColumnSchema,
31};
32use crate::stats::{IngestStats, StatsTimer};
33use hyperdb_api::AsyncConnection;
34use std::path::{Path, PathBuf};
35
36/// Resolve a path to a form that's safe to embed in a SQL
37/// `COPY FROM` literal.
38///
39/// On macOS the temp dir lives under `/var`, which is a symlink to
40/// `/private/var`; Hyper's `COPY FROM` resolves the symlink and then
41/// opens the file by the resolved path, so passing the unresolved one
42/// would break inline-CSV ingest. `canonicalize()` fixes that.
43///
44/// On Windows two extra steps are needed:
45///
46/// 1. `canonicalize()` returns an extended-length prefix (`\\?\C:\...`)
47///    that Hyper's `COPY FROM` cannot parse — it returns SQLSTATE 55006
48///    "unable to read from external source". Strip the prefix so the
49///    path looks like a plain `C:\...`.
50///
51/// 2. Convert backslashes to forward slashes. `escape_string_literal`
52///    only escapes single quotes, leaving `\U`, `\t`, and other
53///    backslash sequences exposed to `PostgreSQL`'s legacy string-literal
54///    escape rules, which Hyper inherits. Forward slashes are accepted
55///    by the Win32 file APIs and sidestep the escape question entirely.
56///
57/// Any canonicalize failure falls back to the original path — the
58/// common case (absolute, existing file on any platform) still works.
59fn canonicalize_for_copy(path: &Path) -> PathBuf {
60    let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
61    #[cfg(windows)]
62    {
63        if let Some(s) = canonical.to_str() {
64            // `\\?\C:\foo\bar.csv` → `C:\foo\bar.csv`. Leave UNC-style
65            // `\\?\UNC\server\share\...` alone — those are only produced
66            // by canonicalize on UNC sources, which Hyper would reject
67            // either way, and stripping blindly would corrupt them.
68            let stripped = match s.strip_prefix(r"\\?\") {
69                Some(rest) if !rest.starts_with("UNC\\") => rest,
70                _ => s,
71            };
72            return PathBuf::from(stripped.replace('\\', "/"));
73        }
74    }
75    canonical
76}
77use serde_json::Value;
78
79/// Maximum bytes read from a CSV/text file for schema inference (64 MB).
80/// The full file is still loaded by `COPY FROM` — this limit only bounds the
81/// in-process memory used for type detection.
82const SCHEMA_INFERENCE_MAX_BYTES: u64 = 64 * 1024 * 1024;
83
84/// Reads at most `max_bytes` from a text file, returning a valid UTF-8 string.
85/// Truncates at the last newline within the byte budget to avoid splitting a row
86/// or multi-byte UTF-8 characters.
87fn read_text_sample(path: impl AsRef<Path>, max_bytes: u64) -> std::io::Result<String> {
88    use std::io::Read;
89    let file = std::fs::File::open(path.as_ref())?;
90    let file_len = file.metadata()?.len();
91    if file_len <= max_bytes {
92        return std::fs::read_to_string(path.as_ref());
93    }
94    let mut reader = std::io::BufReader::new(file);
95    let cap = usize::try_from(max_bytes).unwrap_or(usize::MAX);
96    let mut buf = vec![0u8; cap];
97    reader.read_exact(&mut buf)?;
98    // Truncate at last newline to avoid partial rows
99    if let Some(pos) = buf.iter().rposition(|&b| b == b'\n') {
100        buf.truncate(pos + 1);
101    }
102    // Handle UTF-8 boundary: if truncation split a multi-byte character,
103    // trim trailing incomplete bytes until we have valid UTF-8
104    match String::from_utf8(buf) {
105        Ok(s) => Ok(s),
106        Err(e) => {
107            let valid_up_to = e.utf8_error().valid_up_to();
108            let mut bytes = e.into_bytes();
109            bytes.truncate(valid_up_to);
110            // Truncate at last newline again to ensure we have complete rows
111            if let Some(pos) = bytes.iter().rposition(|&b| b == b'\n') {
112                bytes.truncate(pos + 1);
113            }
114            String::from_utf8(bytes)
115                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
116        }
117    }
118}
119
120/// Controls how data is loaded into a target table.
121#[derive(Debug)]
122pub struct IngestOptions {
123    pub table: String,
124    /// `"replace"` drops the existing table first; `"append"` adds rows to
125    /// it; `"merge"` upserts rows by [`Self::merge_key`].
126    pub mode: String,
127    /// When set, bypasses schema inference and uses these exact column types.
128    pub schema_override: Option<serde_json::Map<String, Value>>,
129    /// When `mode == "merge"`, the column(s) to match on for upsert. Required
130    /// in merge mode; rejected in any other mode (the per-call site validates
131    /// up-front so the lower ingest paths can stay format-agnostic).
132    pub merge_key: Option<Vec<String>>,
133    /// Resolved database alias for fully-qualified SQL. `None` means the
134    /// primary (ephemeral); `Some("persistent")` or `Some("user_alias")`
135    /// qualifies table references as `"<db>"."public"."<table>"`.
136    /// Must be pre-resolved via `Engine::resolve_target_db` before setting.
137    pub target_db: Option<String>,
138}
139
140/// Build a SQL table identifier from `IngestOptions`. When `target_db` is
141/// set, returns `"db"."public"."table"`; otherwise `"table"` (unqualified).
142pub fn qualified_table(opts: &IngestOptions) -> String {
143    match &opts.target_db {
144        Some(db) => {
145            let esc_db = db.replace('"', "\"\"");
146            let esc_tbl = opts.table.replace('"', "\"\"");
147            format!("\"{esc_db}\".\"public\".\"{esc_tbl}\"")
148        }
149        None => format!("\"{}\"", opts.table.replace('"', "\"\"")),
150    }
151}
152
153/// Returned by every ingest function with the row count, resolved schema,
154/// and performance telemetry.
155#[derive(Debug)]
156pub struct IngestResult {
157    pub rows: u64,
158    pub schema: Vec<ColumnSchema>,
159    pub stats: IngestStats,
160}
161
162/// RAII guard that ensures a temp table is dropped on **every** scope
163/// exit — `Ok`, `Err`, *and* panic-unwind. Used by
164/// [`merge_via_temp_table`] so an orphan `__hyperdb_merge_*` table
165/// can't leak into the workspace, even if a per-format ingest path
166/// panics mid-load.
167///
168/// Disarm by calling [`TempTableGuard::disarm`] when the temp has
169/// been renamed away (so the guard isn't dropping a real
170/// user-facing table by name).
171struct TempTableGuard<'a> {
172    engine: &'a Engine,
173    name: String,
174    /// Target database the temp lives in. `None` → primary (unqualified
175    /// drop). `Some(alias)` → emit `"alias"."public"."<name>"` so the
176    /// drop lands in the same DB the temp was created in.
177    target_db: Option<String>,
178    armed: bool,
179}
180
181impl<'a> TempTableGuard<'a> {
182    fn new(engine: &'a Engine, name: String, target_db: Option<String>) -> Self {
183        Self {
184            engine,
185            name,
186            target_db,
187            armed: true,
188        }
189    }
190    fn disarm(&mut self) {
191        self.armed = false;
192    }
193}
194
195impl Drop for TempTableGuard<'_> {
196    fn drop(&mut self) {
197        if !self.armed {
198            return;
199        }
200        // Detect whether the guard is being dropped during normal
201        // unwinding vs. as part of a panic. Including this in the log
202        // lets ops distinguish "expected cleanup" from "we wouldn't
203        // have learned about the temp leak otherwise" — the latter is
204        // the load-bearing case for this guard's existence.
205        let panicking = std::thread::panicking();
206        let quoted = match &self.target_db {
207            Some(db) => format!(
208                "\"{}\".\"public\".\"{}\"",
209                db.replace('"', "\"\""),
210                self.name.replace('"', "\"\""),
211            ),
212            None => format!("\"{}\"", self.name.replace('"', "\"\"")),
213        };
214        let drop_sql = format!("DROP TABLE IF EXISTS {quoted}");
215        match self.engine.execute_command(&drop_sql) {
216            Ok(_) => {
217                if panicking {
218                    // Successful cleanup during a panic-unwind is
219                    // exactly what the guard exists for. Note it at
220                    // info so it shows up in normal log review without
221                    // requiring debug-level capture.
222                    tracing::info!(
223                        tmp = %self.name,
224                        "merge_via_temp_table: dropped temp table during panic unwind"
225                    );
226                }
227            }
228            Err(e) => {
229                // Don't mask the original outcome's error with a
230                // cleanup error — log and continue. The temp table is
231                // benign; the user can drop it manually if it ever
232                // surfaces. `panicking` is included so ops can tell
233                // whether this was a normal-path cleanup failure or
234                // happened while another error was already
235                // propagating.
236                tracing::warn!(
237                    tmp = %self.name,
238                    panicking,
239                    error = %e,
240                    "merge_via_temp_table: failed to drop temp table on guard exit"
241                );
242            }
243        }
244    }
245}
246
247/// Implement `mode = "merge"` for any format by reusing that format's
248/// `replace`-mode load to populate a temp table, then upserting the
249/// target by `merge_key` columns.
250///
251/// `replace_load` is the format-specific entry point (e.g. `ingest_json`,
252/// `ingest_parquet_file`) called with a `replace`-mode [`IngestOptions`]
253/// pointing at a temp table. It must return the [`IngestResult`] for
254/// the temp-table load — caller stitches that into the final result.
255///
256/// Algorithm:
257///
258/// 1. Validate `merge_key` is set and non-empty.
259/// 2. Load incoming data into a unique temp table via `replace_load`.
260///    A `TempTableGuard` arms here and unwind-safely drops the temp
261///    on every exit (`Ok` / `Err` / panic). Verify the temp actually
262///    materialized — a no-op load would otherwise produce confusing
263///    downstream errors.
264/// 3. If target table doesn't exist, rename the temp table → target,
265///    disarm the guard (the temp is now the target, do **not** drop
266///    it), and return (degenerates to "create").
267/// 4. Read target + temp column metadata. Validate every merge key
268///    exists in both with `types_compatible` type. Reject any
269///    non-key shared column whose type differs.
270/// 5. Auto-`ALTER TABLE ADD COLUMN` for any column present in temp but
271///    not target (added as nullable). When this fires, set
272///    [`IngestStats::schema_changed`] so the caller can issue a
273///    resource-list-changed notification.
274/// 6. `DELETE FROM target USING temp WHERE <key match>` then
275///    `INSERT INTO target (<temp cols>) SELECT <temp cols> FROM temp`.
276///    Columns the target has but temp doesn't are deliberately omitted
277///    from the projection so they fall through to NULL — standard
278///    PostgreSQL semantics for partial inserts.
279/// 7. The `TempTableGuard` drops the temp on scope exit.
280///
281/// Atomicity: the DELETE+INSERT pair is **not** wrapped in a transaction.
282/// Hyper auto-commits DDL inside transactions (see module-level note),
283/// and the existing `replace` mode already accepts the same race window,
284/// so this matches precedent rather than introducing a new exposure.
285///
286/// # Errors
287///
288/// - [`ErrorCode::InvalidArgument`] when `merge_key` is missing/empty,
289///   or when a key column is missing from target or temp, or when a
290///   column type differs between target and temp on a shared column.
291/// - [`ErrorCode::InternalError`] if `replace_load` returns `Ok` but
292///   doesn't actually create the temp table (a contract violation).
293/// - Propagates errors from `replace_load`, [`Engine::column_metadata`],
294///   [`Engine::alter_table_add_columns`], or the underlying DELETE /
295///   INSERT statements. The temp table is dropped before the error
296///   propagates.
297pub fn merge_via_temp_table<F>(
298    engine: &Engine,
299    opts: &IngestOptions,
300    replace_load: F,
301) -> Result<IngestResult, McpError>
302where
303    F: FnOnce(&IngestOptions) -> Result<IngestResult, McpError>,
304{
305    // Belt-and-suspenders contract check. Every per-format ingest only
306    // calls this helper when `opts.mode == "merge"`; if a future
307    // refactor adds a third mode that also recurses, this debug-only
308    // assertion catches the mistake before it manifests as silent
309    // misbehavior. The closure also passes `mode = "replace"` to the
310    // recursed call, breaking infinite recursion.
311    debug_assert_eq!(
312        opts.mode, "merge",
313        "merge_via_temp_table called with non-merge mode `{}`",
314        opts.mode
315    );
316
317    // Step 1 — validate merge_key.
318    let keys = opts.merge_key.as_ref().ok_or_else(|| {
319        McpError::new(
320            ErrorCode::InvalidArgument,
321            "merge mode requires merge_key (a column name or list of column names)",
322        )
323    })?;
324    if keys.is_empty() || keys.iter().any(String::is_empty) {
325        return Err(McpError::new(
326            ErrorCode::InvalidArgument,
327            "merge_key must be a non-empty list of non-empty column names",
328        ));
329    }
330
331    // Step 2 — load incoming data into a per-call temp table. The name
332    // mixes PID + nanosecond timestamp + a process-local atomic counter.
333    // The counter is what makes this race-free: two parallel merges of
334    // the same target inside one process can land in the same nanosecond
335    // (sub-µs OS timer, or platform-error fallback to `0`), but the
336    // counter is monotonically unique per call. That's enough to keep
337    // each merge's temp table distinct so the `TempTableGuard`s don't
338    // cross-drop one another.
339    use std::sync::atomic::{AtomicU64, Ordering};
340    static MERGE_COUNTER: AtomicU64 = AtomicU64::new(0);
341    let counter = MERGE_COUNTER.fetch_add(1, Ordering::Relaxed);
342    let nanos = std::time::SystemTime::now()
343        .duration_since(std::time::UNIX_EPOCH)
344        .map_or(0, |d| d.as_nanos());
345    // Squash anything that isn't [A-Za-z0-9_] in the target name to `_`
346    // so the auto-generated identifier is always SQL-safe. The temp
347    // name is never user-visible after a successful merge.
348    let safe_target: String = opts
349        .table
350        .chars()
351        .map(|c| {
352            if c.is_ascii_alphanumeric() || c == '_' {
353                c
354            } else {
355                '_'
356            }
357        })
358        .collect();
359    let tmp = format!(
360        "__hyperdb_merge_{safe_target}_{}_{nanos}_{counter}",
361        std::process::id(),
362    );
363
364    // The temp table lives in the same database as the target. That
365    // way every DML statement below (CREATE TABLE AS, DELETE-USING,
366    // INSERT-SELECT) stays within a single DB — no cross-DB DML — and
367    // the merge works the same regardless of whether the target is
368    // primary, persistent, or any user-attached writable database.
369    let tmp_opts = IngestOptions {
370        table: tmp.clone(),
371        mode: "replace".into(),
372        schema_override: opts.schema_override.clone(),
373        merge_key: None,
374        target_db: opts.target_db.clone(),
375    };
376    let tmp_result = replace_load(&tmp_opts)?;
377
378    // Arm the cleanup guard immediately after the load so any later
379    // failure (or panic) drops the temp table on unwind. The guard
380    // tracks `target_db` so the DROP lands in the same DB the temp
381    // was created in.
382    let mut guard = TempTableGuard::new(engine, tmp.clone(), opts.target_db.clone());
383
384    // Belt-and-suspenders check: a per-format `replace_load` that returns
385    // `Ok` without actually creating the table would surface as opaque
386    // "table not found" errors from the catalog read in step 4. Catch
387    // it here with a clear message so the contract violation is named
388    // outright.
389    if !engine.table_exists_in(opts.target_db.as_deref(), &tmp)? {
390        return Err(McpError::new(
391            ErrorCode::InternalError,
392            format!(
393                "merge: temp table '{tmp}' was not produced by the format-specific \
394                 replace load — this is a contract violation in the per-format ingest path"
395            ),
396        ));
397    }
398
399    // Step 3 — if target doesn't exist, rename temp → target.
400    if !engine.table_exists_in(opts.target_db.as_deref(), &opts.table)? {
401        let qualified_tmp_opts = IngestOptions {
402            table: tmp.clone(),
403            mode: "replace".into(),
404            schema_override: None,
405            merge_key: None,
406            target_db: opts.target_db.clone(),
407        };
408        let quoted_tmp = qualified_table(&qualified_tmp_opts);
409        // RENAME TO accepts an unqualified new name (Hyper / PostgreSQL
410        // semantics — the schema/database stays the same as the source).
411        let escaped_new = opts.table.replace('"', "\"\"");
412        engine.execute_command(&format!(
413            "ALTER TABLE {quoted_tmp} RENAME TO \"{escaped_new}\""
414        ))?;
415        // The temp is now the target; the guard must NOT try to drop it.
416        guard.disarm();
417        return Ok(IngestResult {
418            rows: tmp_result.rows,
419            schema: tmp_result.schema,
420            stats: IngestStats {
421                operation: tmp_result.stats.operation,
422                rows: tmp_result.stats.rows,
423                elapsed_ms: tmp_result.stats.elapsed_ms,
424                bytes_read: tmp_result.stats.bytes_read,
425                bytes_stored: tmp_result.stats.bytes_stored,
426                schema_inference_ms: tmp_result.stats.schema_inference_ms,
427                table: opts.table.clone(),
428                file_format: tmp_result.stats.file_format,
429                warning: tmp_result.stats.warning,
430                // Target was just created from scratch — by definition
431                // the resource list "changed" relative to its prior
432                // absence, so signal a notify.
433                schema_changed: true,
434            },
435        });
436    }
437
438    // Step 4 — schema reconciliation. Read both schemas from the
439    // target DB; in cross-DB merges the connection-bound `Catalog`
440    // wouldn't see the attached database, so route via
441    // `column_metadata_in` which falls back to the qualified
442    // `pg_catalog.pg_attribute` probe.
443    let target_cols = engine.column_metadata_in(opts.target_db.as_deref(), &opts.table)?;
444    let tmp_cols = engine.column_metadata_in(opts.target_db.as_deref(), &tmp)?;
445    // Every key must exist in both, with matching type.
446    for k in keys {
447        let in_target = target_cols.iter().find(|c| c.name == *k);
448        let in_tmp = tmp_cols.iter().find(|c| c.name == *k);
449        match (in_target, in_tmp) {
450            (None, _) => {
451                return Err(McpError::new(
452                    ErrorCode::InvalidArgument,
453                    format!(
454                        "merge_key column '{k}' is not in target table '{}'",
455                        opts.table
456                    ),
457                ));
458            }
459            (_, None) => {
460                return Err(McpError::new(
461                    ErrorCode::InvalidArgument,
462                    format!(
463                        "merge_key column '{k}' is not in incoming data \
464                         (column missing from the file)"
465                    ),
466                ));
467            }
468            (Some(t), Some(s)) if !types_compatible(&t.hyper_type, &s.hyper_type) => {
469                return Err(McpError::new(
470                    ErrorCode::InvalidArgument,
471                    format!(
472                        "merge_key column '{k}' type mismatch: target is {} but \
473                         incoming is {}. Use mode=replace or apply a schema override.",
474                        t.hyper_type, s.hyper_type
475                    ),
476                ));
477            }
478            _ => {}
479        }
480    }
481    // Reject type mismatches on any shared non-key column.
482    for tc in &tmp_cols {
483        if let Some(target_c) = target_cols.iter().find(|c| c.name == tc.name) {
484            if !types_compatible(&target_c.hyper_type, &tc.hyper_type) {
485                return Err(McpError::new(
486                    ErrorCode::InvalidArgument,
487                    format!(
488                        "Column '{}' type mismatch: target is {} but incoming is {}. \
489                         Use mode=replace or apply a schema override.",
490                        tc.name, target_c.hyper_type, tc.hyper_type
491                    ),
492                ));
493            }
494        }
495    }
496
497    // Step 5 — auto-ALTER for new columns (in temp, not in target).
498    // `alter_table_add_columns` handles the empty-input case by
499    // returning early without issuing SQL.
500    let new_cols: Vec<ColumnSchema> = tmp_cols
501        .iter()
502        .filter(|c| !target_cols.iter().any(|t| t.name == c.name))
503        .cloned()
504        // ALTER TABLE ADD COLUMN must be nullable; existing rows have no value.
505        .map(|mut c| {
506            c.nullable = true;
507            c
508        })
509        .collect();
510    let schema_changed = !new_cols.is_empty();
511    engine.alter_table_add_columns_in(opts.target_db.as_deref(), &opts.table, &new_cols)?;
512
513    // Step 6 — DELETE matching rows by key, then INSERT all temp rows.
514    // Both target and temp share `opts.target_db`, so qualifying via
515    // `qualified_table` keeps the DML scoped to one DB.
516    let quoted_tgt = qualified_table(opts);
517    let qualified_tmp_opts = IngestOptions {
518        table: tmp.clone(),
519        mode: "replace".into(),
520        schema_override: None,
521        merge_key: None,
522        target_db: opts.target_db.clone(),
523    };
524    let quoted_tmp = qualified_table(&qualified_tmp_opts);
525    let key_eq = keys
526        .iter()
527        .map(|k| {
528            let qk = k.replace('"', "\"\"");
529            format!("t.\"{qk}\" = s.\"{qk}\"")
530        })
531        .collect::<Vec<_>>()
532        .join(" AND ");
533    let delete_sql = format!("DELETE FROM {quoted_tgt} t USING {quoted_tmp} s WHERE {key_eq}");
534    engine.execute_command(&delete_sql)?;
535
536    // The INSERT projection is `tmp_cols` (not `target_cols`). Columns
537    // the target has but the incoming temp lacks are deliberately
538    // omitted — PostgreSQL fills them with NULL, which is the right
539    // widening semantic for an upsert that adds new rows for
540    // unmatched keys.
541    let cols_csv = tmp_cols
542        .iter()
543        .map(|c| format!("\"{}\"", c.name.replace('"', "\"\"")))
544        .collect::<Vec<_>>()
545        .join(", ");
546    let insert_sql =
547        format!("INSERT INTO {quoted_tgt} ({cols_csv}) SELECT {cols_csv} FROM {quoted_tmp}");
548    let inserted = engine.execute_command(&insert_sql)?;
549
550    // Re-read target schema for the result so callers see the post-merge shape.
551    let final_schema = engine.column_metadata_in(opts.target_db.as_deref(), &opts.table)?;
552
553    // The guard drops the temp table when it falls out of scope here.
554    Ok(IngestResult {
555        rows: inserted,
556        schema: final_schema,
557        stats: IngestStats {
558            operation: tmp_result.stats.operation,
559            rows: inserted,
560            elapsed_ms: tmp_result.stats.elapsed_ms,
561            bytes_read: tmp_result.stats.bytes_read,
562            bytes_stored: tmp_result.stats.bytes_stored,
563            schema_inference_ms: tmp_result.stats.schema_inference_ms,
564            table: opts.table.clone(),
565            file_format: tmp_result.stats.file_format,
566            warning: tmp_result.stats.warning,
567            schema_changed,
568        },
569    })
570}
571
572/// Compare two Hyper type strings for merge compatibility.
573///
574/// Raw string equality is wrong: the catalog canonicalizes types
575/// (`INT` → `INTEGER`, `BYTES` → `BYTEA`, `NUMERIC(15,2)` → may
576/// differ in spacing) while the inferred-incoming side carries
577/// whatever the Rust inferrer emitted (`"INT"`, `"DOUBLE
578/// PRECISION"`, `"NUMERIC(15, 2)"`). Comparing through
579/// [`crate::schema::map_hyper_type`] collapses all known aliases
580/// into a single [`SqlType`].
581///
582/// If *either* side fails to parse (returns `None`), we err on the
583/// side of permissive: treat the pair as compatible and let Hyper
584/// itself enforce at INSERT time. This avoids false-rejecting
585/// types the Rust mapper doesn't know about (future Hyper types,
586/// custom Hyper extensions, anything `map_hyper_type` hasn't been
587/// taught about). Mismatches will still surface — just from
588/// `INSERT` rather than from our pre-flight check.
589fn types_compatible(a: &str, b: &str) -> bool {
590    if a == b {
591        return true;
592    }
593    match (
594        crate::schema::map_hyper_type(a),
595        crate::schema::map_hyper_type(b),
596    ) {
597        (Some(sa), Some(sb)) => sa == sb,
598        _ => true,
599    }
600}
601
602/// Ingest an inline JSON array of objects into a Hyper table.
603///
604/// Each object becomes one row. Missing keys are inserted as NULL.
605/// Emits a warning when the input exceeds 50 MB — the caller should
606/// prefer `load_file` with a Parquet or Arrow file for large datasets.
607///
608/// # Errors
609///
610/// - Returns [`ErrorCode::SchemaMismatch`] if `json_str` cannot be
611///   parsed as a JSON array of objects (via [`serde_json::from_str`]).
612/// - Returns [`ErrorCode::EmptyData`] if the parsed array is empty.
613/// - Propagates any schema-inference error from [`infer_json_schema`]
614///   or [`apply_schema_override`].
615/// - Propagates any [`McpError`] from the wrapping transaction —
616///   [`Engine::create_table`], the per-row `INSERT` statements, or
617///   transaction commit/rollback failures.
618pub fn ingest_json(
619    engine: &Engine,
620    json_str: &str,
621    opts: &IngestOptions,
622) -> Result<IngestResult, McpError> {
623    if opts.mode == "merge" {
624        return merge_via_temp_table(engine, opts, |tmp_opts| {
625            ingest_json(engine, json_str, tmp_opts)
626        });
627    }
628    let timer = StatsTimer::start();
629    let bytes_read = json_str.len() as u64;
630
631    // Schema
632    let schema_timer = StatsTimer::start();
633    let inferred = infer_json_schema(json_str)?;
634    let columns = match &opts.schema_override {
635        Some(s) => apply_schema_override(inferred, s)?,
636        None => inferred,
637    };
638    let schema_ms = schema_timer.elapsed_ms();
639
640    // Parse JSON
641    let array: Vec<serde_json::Map<String, Value>> = serde_json::from_str(json_str)
642        .map_err(|e| McpError::new(ErrorCode::SchemaMismatch, format!("Invalid JSON: {e}")))?;
643
644    if array.is_empty() {
645        return Err(McpError::new(ErrorCode::EmptyData, "JSON array is empty"));
646    }
647
648    // All mutations run inside a transaction so a partial failure leaves
649    // zero side effects.
650    let is_replace = opts.mode != "append";
651    let qualified = qualified_table(opts);
652    let row_count = engine.execute_in_transaction(|engine| {
653        engine.create_table_in(&opts.table, &columns, is_replace, opts.target_db.as_deref())?;
654        let mut row_count = 0u64;
655        let col_names: Vec<String> = columns.iter().map(|c| format!("\"{}\"", c.name)).collect();
656        for obj in &array {
657            let values: Vec<String> = columns
658                .iter()
659                .map(|col| match obj.get(&col.name) {
660                    None | Some(Value::Null) => "NULL".to_string(),
661                    Some(Value::Bool(b)) => b.to_string(),
662                    Some(Value::Number(n)) => n.to_string(),
663                    Some(Value::String(s)) => format!("'{}'", s.replace('\'', "''")),
664                    Some(other) => format!("'{}'", other.to_string().replace('\'', "''")),
665                })
666                .collect();
667
668            let sql = format!(
669                "INSERT INTO {} ({}) VALUES ({})",
670                qualified,
671                col_names.join(", "),
672                values.join(", ")
673            );
674            engine.execute_command(&sql)?;
675            row_count += 1;
676        }
677        Ok(row_count)
678    })?;
679
680    let elapsed = timer.elapsed_ms();
681    let stats = IngestStats {
682        operation: "load_data".into(),
683        rows: row_count,
684        elapsed_ms: elapsed,
685        bytes_read,
686        bytes_stored: 0,
687        schema_inference_ms: Some(schema_ms),
688        table: opts.table.clone(),
689        file_format: Some("json".into()),
690        warning: if bytes_read > 50_000_000 {
691            Some("Large inline data. Consider using load_file for better performance.".into())
692        } else {
693            None
694        },
695        schema_changed: false,
696    };
697
698    Ok(IngestResult {
699        rows: row_count,
700        schema: columns,
701        stats,
702    })
703}
704
705/// Ingest inline CSV text into a Hyper table.
706///
707/// The CSV is written to a temp file and loaded via `COPY FROM` so that Hyper's
708/// native CSV parser handles escaping, quoting, and bulk loading. The temp file
709/// is cleaned up after the COPY completes.
710///
711/// # Errors
712///
713/// - Returns [`ErrorCode::InternalError`] if the temp CSV file cannot
714///   be written to the system temp directory.
715/// - Propagates any error from [`infer_csv_schema`],
716///   [`widen_csv_numeric_columns`], or [`apply_schema_override`].
717/// - Propagates any transaction error from [`Engine::create_table`]
718///   or the `COPY FROM` statement (SQL errors, schema mismatches,
719///   connection loss).
720pub fn ingest_csv(
721    engine: &Engine,
722    csv_text: &str,
723    opts: &IngestOptions,
724) -> Result<IngestResult, McpError> {
725    if opts.mode == "merge" {
726        return merge_via_temp_table(engine, opts, |tmp_opts| {
727            ingest_csv(engine, csv_text, tmp_opts)
728        });
729    }
730    let timer = StatsTimer::start();
731    let bytes_read = csv_text.len() as u64;
732
733    // Schema: infer from the 1 000-row sample, widen numeric columns against
734    // the full CSV body to catch values hidden after the sample window, then
735    // overlay any user-provided partial override.
736    let schema_timer = StatsTimer::start();
737    let mut inferred = infer_csv_schema(csv_text, true)?;
738    widen_csv_numeric_columns(csv_text.as_bytes(), true, &mut inferred)?;
739    let columns = match &opts.schema_override {
740        Some(s) => apply_schema_override(inferred, s)?,
741        None => inferred,
742    };
743    let schema_ms = schema_timer.elapsed_ms();
744
745    // Write CSV to a temp file before starting the transaction (it's a pure
746    // filesystem operation and doesn't need to be atomic with the DB work).
747    //
748    // Uses `tempfile::NamedTempFile` for OS-guaranteed unique paths — the
749    // previous PID+nanosecond scheme could collide on macOS where timer
750    // resolution is coarser, causing parallel tests to race on the same file.
751    // `into_temp_path()` closes the file handle immediately while retaining
752    // the auto-delete-on-drop guarantee. This is required on Windows where
753    // `hyperd` cannot open a file held by another process.
754    let temp_path = tempfile::Builder::new()
755        .prefix("hyperdb_mcp_csv_")
756        .suffix(".csv")
757        .tempfile()
758        .map_err(|e| {
759            McpError::new(
760                ErrorCode::InternalError,
761                format!("Failed to create temp CSV file: {e}"),
762            )
763        })?
764        .into_temp_path();
765    std::fs::write(&temp_path, csv_text).map_err(|e| {
766        McpError::new(
767            ErrorCode::InternalError,
768            format!("Failed to write temp CSV: {e}"),
769        )
770    })?;
771
772    // `NULL ''` makes unquoted empty CSV cells load as SQL NULL, which is
773    // what users expect from the `,,` convention (and what `inspect_file`
774    // already reports in its `null_count` diagnostics). Without this,
775    // Hyper's CSV COPY treats empty cells as literal empty strings —
776    // breaking downstream `WHERE col IS NULL` and failing outright on
777    // numeric columns.
778    let canonical_temp = canonicalize_for_copy(&temp_path);
779    let qualified = qualified_table(opts);
780    let copy_sql = format!(
781        "COPY {} FROM {} WITH (FORMAT csv, NULL '', DELIMITER ',', HEADER)",
782        qualified,
783        hyperdb_api::escape_string_literal(canonical_temp.to_str().unwrap_or(""))
784    );
785
786    // Create table + COPY inside one transaction so that a COPY failure also
787    // unwinds the table creation.
788    let is_replace = opts.mode != "append";
789    let row_count = engine.execute_in_transaction(|engine| {
790        engine.create_table_in(&opts.table, &columns, is_replace, opts.target_db.as_deref())?;
791        engine.execute_command(&copy_sql)
792    });
793
794    // `temp_path` (TempPath) auto-deletes the file when dropped at end of scope.
795    drop(temp_path);
796    let row_count = row_count?;
797
798    let elapsed = timer.elapsed_ms();
799    let stats = IngestStats {
800        operation: "load_data".into(),
801        rows: row_count,
802        elapsed_ms: elapsed,
803        bytes_read,
804        bytes_stored: 0,
805        schema_inference_ms: Some(schema_ms),
806        table: opts.table.clone(),
807        file_format: Some("csv".into()),
808        warning: if bytes_read > 50_000_000 {
809            Some("Large inline data. Consider using load_file for better performance.".into())
810        } else {
811            None
812        },
813        schema_changed: false,
814    };
815
816    Ok(IngestResult {
817        rows: row_count,
818        schema: columns,
819        stats,
820    })
821}
822
823/// Ingest a CSV file from disk into a Hyper table.
824///
825/// The file is read once for schema inference (up to 1 000 rows sampled),
826/// then loaded in bulk via `COPY FROM` using the canonicalized path so
827/// `hyperd` can read it directly.
828///
829/// # Errors
830///
831/// - Returns [`ErrorCode::FileNotFound`] if `path` does not exist or
832///   cannot be read.
833/// - Propagates any error from [`infer_csv_schema`],
834///   [`widen_csv_numeric_columns`], or [`apply_schema_override`].
835/// - Propagates any transaction error from [`Engine::create_table`]
836///   or the `COPY FROM` statement.
837pub fn ingest_csv_file(
838    engine: &Engine,
839    path: &str,
840    opts: &IngestOptions,
841) -> Result<IngestResult, McpError> {
842    if opts.mode == "merge" {
843        return merge_via_temp_table(engine, opts, |tmp_opts| {
844            ingest_csv_file(engine, path, tmp_opts)
845        });
846    }
847    let timer = StatsTimer::start();
848
849    let abs_path = std::path::Path::new(path);
850    if !abs_path.exists() {
851        return Err(McpError::new(
852            ErrorCode::FileNotFound,
853            format!("File not found: {path}"),
854        ));
855    }
856
857    let file_size = std::fs::metadata(abs_path).map_or(0, |m| m.len());
858
859    // Read file for schema inference (capped at 64 MB to prevent OOM on huge files)
860    let schema_timer = StatsTimer::start();
861    let sample = read_text_sample(abs_path, SCHEMA_INFERENCE_MAX_BYTES)
862        .map_err(|e| McpError::new(ErrorCode::FileNotFound, format!("Cannot read file: {e}")))?;
863    let mut inferred = infer_csv_schema(&sample, true)?;
864    widen_csv_numeric_columns(sample.as_bytes(), true, &mut inferred)?;
865    let columns = match &opts.schema_override {
866        Some(s) => apply_schema_override(inferred, s)?,
867        None => inferred,
868    };
869    let schema_ms = schema_timer.elapsed_ms();
870
871    // COPY FROM the file directly, inside a transaction with CREATE TABLE.
872    let canonical = canonicalize_for_copy(abs_path);
873    let qualified = qualified_table(opts);
874    // See `ingest_csv` above for the NULL-handling rationale: `NULL ''`
875    // makes unquoted empty cells load as SQL NULL.
876    let copy_sql = format!(
877        "COPY {} FROM {} WITH (FORMAT csv, NULL '', DELIMITER ',', HEADER)",
878        qualified,
879        hyperdb_api::escape_string_literal(canonical.to_str().unwrap_or(""))
880    );
881
882    let is_replace = opts.mode != "append";
883    let row_count = engine.execute_in_transaction(|engine| {
884        engine.create_table_in(&opts.table, &columns, is_replace, opts.target_db.as_deref())?;
885        engine.execute_command(&copy_sql)
886    })?;
887
888    let elapsed = timer.elapsed_ms();
889    let stats = IngestStats {
890        operation: "load_file".into(),
891        rows: row_count,
892        elapsed_ms: elapsed,
893        bytes_read: file_size,
894        bytes_stored: 0,
895        schema_inference_ms: Some(schema_ms),
896        table: opts.table.clone(),
897        file_format: Some("csv".into()),
898        warning: None,
899        schema_changed: false,
900    };
901
902    Ok(IngestResult {
903        rows: row_count,
904        schema: columns,
905        stats,
906    })
907}
908
909/// Async twin of [`ingest_csv_file`]. Runs schema inference on the
910/// blocking pool, then issues `CREATE TABLE` + `COPY FROM` on the given
911/// async connection inside a single transaction.
912///
913/// # Errors
914///
915/// - Returns [`ErrorCode::FileNotFound`] if `path` does not exist or
916///   cannot be read.
917/// - Returns [`ErrorCode::InternalError`] if the schema-inference task
918///   panics on the blocking pool (surfaced as a join error).
919/// - Propagates any error from schema inference or override
920///   application.
921/// - Propagates any transaction, `CREATE TABLE`, or `COPY FROM` error
922///   from the async connection. A rollback failure after an inner
923///   error is logged but does not override the original error.
924pub async fn ingest_csv_file_async(
925    conn: &AsyncConnection,
926    path: &str,
927    opts: &IngestOptions,
928) -> Result<IngestResult, McpError> {
929    let timer = StatsTimer::start();
930
931    let abs_path = std::path::Path::new(path);
932    if !abs_path.exists() {
933        return Err(McpError::new(
934            ErrorCode::FileNotFound,
935            format!("File not found: {path}"),
936        ));
937    }
938    let file_size = std::fs::metadata(abs_path).map_or(0, |m| m.len());
939
940    // Inference is CPU-bound (parses the whole file to widen numerics)
941    // so it runs on the blocking pool rather than stalling a worker.
942    let schema_timer = StatsTimer::start();
943    let path_owned = path.to_string();
944    let override_owned = opts.schema_override.clone();
945    let columns: Vec<ColumnSchema> = tokio::task::spawn_blocking(move || -> Result<_, McpError> {
946        let sample = read_text_sample(&path_owned, SCHEMA_INFERENCE_MAX_BYTES).map_err(|e| {
947            McpError::new(ErrorCode::FileNotFound, format!("Cannot read file: {e}"))
948        })?;
949        let mut inferred = infer_csv_schema(&sample, true)?;
950        widen_csv_numeric_columns(sample.as_bytes(), true, &mut inferred)?;
951        let columns = match &override_owned {
952            Some(s) => apply_schema_override(inferred, s)?,
953            None => inferred,
954        };
955        Ok(columns)
956    })
957    .await
958    .map_err(|e| McpError::new(ErrorCode::InternalError, format!("Task join error: {e}")))??;
959    let schema_ms = schema_timer.elapsed_ms();
960
961    let canonical = canonicalize_for_copy(abs_path);
962    let qualified = qualified_table(opts);
963    // `NULL ''` — unquoted empty cells load as SQL NULL (see sync twin).
964    let copy_sql = format!(
965        "COPY {} FROM {} WITH (FORMAT csv, NULL '', DELIMITER ',', HEADER)",
966        qualified,
967        hyperdb_api::escape_string_literal(canonical.to_str().unwrap_or(""))
968    );
969
970    let is_replace = opts.mode != "append";
971
972    conn.begin_transaction().await.map_err(McpError::from)?;
973    let inner: Result<u64, McpError> = async {
974        create_table_async(
975            conn,
976            &opts.table,
977            &columns,
978            is_replace,
979            opts.target_db.as_deref(),
980        )
981        .await?;
982        conn.execute_command(&copy_sql)
983            .await
984            .map_err(McpError::from)
985    }
986    .await;
987    let row_count = match inner {
988        Ok(n) => {
989            conn.commit().await.map_err(McpError::from)?;
990            n
991        }
992        Err(e) => {
993            if let Err(rb) = conn.rollback().await {
994                tracing::warn!("rollback after error failed: {}", rb);
995            }
996            return Err(e);
997        }
998    };
999
1000    let elapsed = timer.elapsed_ms();
1001    let stats = IngestStats {
1002        operation: "load_file".into(),
1003        rows: row_count,
1004        elapsed_ms: elapsed,
1005        bytes_read: file_size,
1006        bytes_stored: 0,
1007        schema_inference_ms: Some(schema_ms),
1008        table: opts.table.clone(),
1009        file_format: Some("csv".into()),
1010        warning: None,
1011        schema_changed: false,
1012    };
1013
1014    Ok(IngestResult {
1015        rows: row_count,
1016        schema: columns,
1017        stats,
1018    })
1019}
1020
1021/// Ingest a JSON or JSON-Lines file from disk into a Hyper table.
1022///
1023/// Auto-detects the payload shape from the first non-whitespace byte:
1024///
1025/// * `[` → a single JSON array of objects, loaded verbatim via
1026///   [`ingest_json`].
1027/// * anything else → newline-delimited JSON (`.jsonl` convention): each
1028///   non-empty line is parsed as one JSON object, collected into an
1029///   array, then passed to [`ingest_json`].
1030///
1031/// The dispatch is content-based rather than extension-based so `.json`
1032/// files that happen to contain JSONL (or vice-versa) still load; the
1033/// extension `.jsonl` is accepted but not required. Blank lines and
1034/// lines containing only whitespace are skipped.
1035///
1036/// # Errors
1037///
1038/// - Returns [`ErrorCode::FileNotFound`] if `path` does not exist or
1039///   cannot be read.
1040/// - Propagates errors from [`normalize_json_or_jsonl`] (malformed
1041///   JSON / JSONL) and from [`ingest_json`] (schema inference,
1042///   transaction failures, etc.).
1043pub fn ingest_json_file(
1044    engine: &Engine,
1045    path: &str,
1046    opts: &IngestOptions,
1047) -> Result<IngestResult, McpError> {
1048    let abs_path = std::path::Path::new(path);
1049    if !abs_path.exists() {
1050        return Err(McpError::new(
1051            ErrorCode::FileNotFound,
1052            format!("File not found: {path}"),
1053        ));
1054    }
1055
1056    let text = std::fs::read_to_string(abs_path)
1057        .map_err(|e| McpError::new(ErrorCode::FileNotFound, format!("Cannot read file: {e}")))?;
1058
1059    let json_array_text = normalize_json_or_jsonl(&text)?;
1060    let mut result = ingest_json(engine, &json_array_text, opts)?;
1061
1062    // Re-label the operation + file_format so telemetry reflects the
1063    // file-based path; `ingest_json` defaults to inline-mode metadata.
1064    result.stats.operation = "load_file".into();
1065    // Preserve the actual on-disk size rather than the serialized array,
1066    // which may differ slightly after normalization.
1067    result.stats.bytes_read = std::fs::metadata(abs_path).map_or(0, |m| m.len());
1068    // Report the effective format we dispatched on so LLMs can
1069    // distinguish the JSONL path from the array path when debugging.
1070    result.stats.file_format = Some(
1071        if text.trim_start().starts_with('[') {
1072            "json"
1073        } else {
1074            "jsonl"
1075        }
1076        .into(),
1077    );
1078
1079    Ok(result)
1080}
1081
1082/// Async twin of [`ingest_json`]: insert a JSON array of objects on the
1083/// given async connection. See [`ingest_json`] for the JSON-shape
1084/// contract (expects a top-level array; call [`normalize_json_or_jsonl`]
1085/// first if you have JSONL).
1086///
1087/// # Errors
1088///
1089/// - Returns [`ErrorCode::SchemaMismatch`] if `json_str` cannot be
1090///   parsed as a JSON array of objects.
1091/// - Returns [`ErrorCode::EmptyData`] if the parsed array is empty.
1092/// - Propagates any error from [`infer_json_schema`] /
1093///   [`apply_schema_override`].
1094/// - Propagates any transaction or `INSERT`-loop error from the async
1095///   connection. Rollback failures after an inner error are logged
1096///   but do not shadow the original error.
1097pub async fn ingest_json_async(
1098    conn: &AsyncConnection,
1099    json_str: &str,
1100    opts: &IngestOptions,
1101) -> Result<IngestResult, McpError> {
1102    let timer = StatsTimer::start();
1103    let bytes_read = json_str.len() as u64;
1104
1105    let schema_timer = StatsTimer::start();
1106    let inferred = infer_json_schema(json_str)?;
1107    let columns = match &opts.schema_override {
1108        Some(s) => apply_schema_override(inferred, s)?,
1109        None => inferred,
1110    };
1111    let schema_ms = schema_timer.elapsed_ms();
1112
1113    let array: Vec<serde_json::Map<String, Value>> = serde_json::from_str(json_str)
1114        .map_err(|e| McpError::new(ErrorCode::SchemaMismatch, format!("Invalid JSON: {e}")))?;
1115    if array.is_empty() {
1116        return Err(McpError::new(ErrorCode::EmptyData, "JSON array is empty"));
1117    }
1118
1119    let is_replace = opts.mode != "append";
1120    let qualified = qualified_table(opts);
1121
1122    conn.begin_transaction().await.map_err(McpError::from)?;
1123    let inner: Result<u64, McpError> = async {
1124        create_table_async(
1125            conn,
1126            &opts.table,
1127            &columns,
1128            is_replace,
1129            opts.target_db.as_deref(),
1130        )
1131        .await?;
1132        let mut row_count = 0u64;
1133        let col_names: Vec<String> = columns.iter().map(|c| format!("\"{}\"", c.name)).collect();
1134        for obj in &array {
1135            let values: Vec<String> = columns
1136                .iter()
1137                .map(|col| match obj.get(&col.name) {
1138                    None | Some(Value::Null) => "NULL".to_string(),
1139                    Some(Value::Bool(b)) => b.to_string(),
1140                    Some(Value::Number(n)) => n.to_string(),
1141                    Some(Value::String(s)) => format!("'{}'", s.replace('\'', "''")),
1142                    Some(other) => format!("'{}'", other.to_string().replace('\'', "''")),
1143                })
1144                .collect();
1145
1146            let sql = format!(
1147                "INSERT INTO {} ({}) VALUES ({})",
1148                qualified,
1149                col_names.join(", "),
1150                values.join(", ")
1151            );
1152            conn.execute_command(&sql).await.map_err(McpError::from)?;
1153            row_count += 1;
1154        }
1155        Ok(row_count)
1156    }
1157    .await;
1158
1159    let row_count = match inner {
1160        Ok(n) => {
1161            conn.commit().await.map_err(McpError::from)?;
1162            n
1163        }
1164        Err(e) => {
1165            if let Err(rb) = conn.rollback().await {
1166                tracing::warn!("rollback after error failed: {}", rb);
1167            }
1168            return Err(e);
1169        }
1170    };
1171
1172    let elapsed = timer.elapsed_ms();
1173    let stats = IngestStats {
1174        operation: "load_data".into(),
1175        rows: row_count,
1176        elapsed_ms: elapsed,
1177        bytes_read,
1178        bytes_stored: 0,
1179        schema_inference_ms: Some(schema_ms),
1180        table: opts.table.clone(),
1181        file_format: Some("json".into()),
1182        warning: if bytes_read > 50_000_000 {
1183            Some("Large inline data. Consider using load_file for better performance.".into())
1184        } else {
1185            None
1186        },
1187        schema_changed: false,
1188    };
1189
1190    Ok(IngestResult {
1191        rows: row_count,
1192        schema: columns,
1193        stats,
1194    })
1195}
1196
1197/// Async twin of [`ingest_json_file`].
1198///
1199/// # Errors
1200///
1201/// - Returns [`ErrorCode::FileNotFound`] if `path` does not exist or
1202///   cannot be read.
1203/// - Propagates errors from [`normalize_json_or_jsonl`] and from
1204///   [`ingest_json_async`].
1205pub async fn ingest_json_file_async(
1206    conn: &AsyncConnection,
1207    path: &str,
1208    opts: &IngestOptions,
1209) -> Result<IngestResult, McpError> {
1210    let abs_path = std::path::Path::new(path);
1211    if !abs_path.exists() {
1212        return Err(McpError::new(
1213            ErrorCode::FileNotFound,
1214            format!("File not found: {path}"),
1215        ));
1216    }
1217
1218    let text = std::fs::read_to_string(abs_path)
1219        .map_err(|e| McpError::new(ErrorCode::FileNotFound, format!("Cannot read file: {e}")))?;
1220
1221    let json_array_text = normalize_json_or_jsonl(&text)?;
1222    let mut result = ingest_json_async(conn, &json_array_text, opts).await?;
1223
1224    result.stats.operation = "load_file".into();
1225    result.stats.bytes_read = std::fs::metadata(abs_path).map_or(0, |m| m.len());
1226    result.stats.file_format = Some(
1227        if text.trim_start().starts_with('[') {
1228            "json"
1229        } else {
1230            "jsonl"
1231        }
1232        .into(),
1233    );
1234
1235    Ok(result)
1236}
1237
1238/// Shared helper: `CREATE TABLE` (optionally dropping first) on an async
1239/// connection. Mirrors [`Engine::create_table`] exactly so the async
1240/// ingest paths produce identical tables to the sync ones. Callers that
1241/// need atomicity should wrap this in `begin_transaction` / `commit`.
1242pub(crate) async fn create_table_async(
1243    conn: &AsyncConnection,
1244    table_name: &str,
1245    columns: &[ColumnSchema],
1246    replace: bool,
1247    target_db: Option<&str>,
1248) -> Result<(), McpError> {
1249    if columns.is_empty() {
1250        return Err(McpError::new(
1251            ErrorCode::EmptyData,
1252            "No columns to create table from",
1253        ));
1254    }
1255    for col in columns {
1256        if crate::schema::map_hyper_type(&col.hyper_type).is_none() {
1257            return Err(McpError::new(
1258                ErrorCode::SchemaMismatch,
1259                format!(
1260                    "Unknown type '{}' for column '{}'",
1261                    col.hyper_type, col.name
1262                ),
1263            ));
1264        }
1265    }
1266
1267    let quoted_table = match target_db {
1268        Some(db) => {
1269            let esc_db = db.replace('"', "\"\"");
1270            let esc_tbl = table_name.replace('"', "\"\"");
1271            format!("\"{esc_db}\".\"public\".\"{esc_tbl}\"")
1272        }
1273        None => format!("\"{}\"", table_name.replace('"', "\"\"")),
1274    };
1275    if replace {
1276        conn.execute_command(&format!("DROP TABLE IF EXISTS {quoted_table}"))
1277            .await
1278            .map_err(McpError::from)?;
1279    }
1280
1281    let col_defs: Vec<String> = columns
1282        .iter()
1283        .map(|c| {
1284            let nullable = if c.nullable { "" } else { " NOT NULL" };
1285            format!(
1286                "\"{}\" {}{}",
1287                c.name.replace('"', "\"\""),
1288                c.hyper_type,
1289                nullable
1290            )
1291        })
1292        .collect();
1293    let create_sql = format!(
1294        "CREATE TABLE IF NOT EXISTS {} ({})",
1295        quoted_table,
1296        col_defs.join(", ")
1297    );
1298    conn.execute_command(&create_sql)
1299        .await
1300        .map_err(McpError::from)?;
1301    Ok(())
1302}
1303
1304/// Normalize a raw JSON / JSONL string to the "JSON array of objects"
1305/// representation [`ingest_json`] expects. Returns the original string
1306/// when it already parses as a top-level array; otherwise treats the
1307/// input as JSONL and re-serializes into a JSON array.
1308///
1309/// Public so [`crate::inspect`] can reuse the same JSON/JSONL
1310/// auto-detection for its dry-run inspector.
1311///
1312/// # Errors
1313///
1314/// - Returns [`ErrorCode::SchemaMismatch`] with the offending line
1315///   number when a JSONL line fails to parse as valid JSON.
1316/// - Returns [`ErrorCode::EmptyData`] if the input contains no
1317///   non-blank records.
1318/// - Returns [`ErrorCode::InternalError`] if the aggregated array
1319///   cannot be serialized back to a string (should not happen in
1320///   practice).
1321pub fn normalize_json_or_jsonl(text: &str) -> Result<String, McpError> {
1322    let trimmed = text.trim_start();
1323    if trimmed.starts_with('[') {
1324        return Ok(text.to_string());
1325    }
1326
1327    // JSONL path: one JSON object per non-empty line. We parse each line
1328    // eagerly rather than concatenating then parsing, so malformed lines
1329    // produce a useful per-line error pointing at the exact offender.
1330    let mut objects: Vec<Value> = Vec::new();
1331    for (idx, line) in text.lines().enumerate() {
1332        let line = line.trim();
1333        if line.is_empty() {
1334            continue;
1335        }
1336        let value: Value = serde_json::from_str(line).map_err(|e| {
1337            McpError::new(
1338                ErrorCode::SchemaMismatch,
1339                format!("Invalid JSON on line {}: {e}", idx + 1),
1340            )
1341        })?;
1342        objects.push(value);
1343    }
1344    if objects.is_empty() {
1345        return Err(McpError::new(
1346            ErrorCode::EmptyData,
1347            "JSON/JSONL file contained no records",
1348        ));
1349    }
1350    serde_json::to_string(&Value::Array(objects)).map_err(|e| {
1351        McpError::new(
1352            ErrorCode::InternalError,
1353            format!("Failed to serialize JSONL as array: {e}"),
1354        )
1355    })
1356}
1357
1358/// Navigate a dot-separated path into a JSON value, transparently parsing
1359/// stringified JSON at each step.
1360///
1361/// Path segments are dot-separated (e.g., `content.0.text.query_result.results`).
1362/// Numeric segments index into JSON arrays (`content.0` means `content[0]`).
1363/// When a segment resolves to a JSON string, the function automatically tries
1364/// to parse that string as JSON and continues navigating into the parsed
1365/// result. This handles the common MCP wrapper pattern where response
1366/// payloads are double-encoded as stringified JSON.
1367///
1368/// After all segments are consumed, if the final value is still a string,
1369/// one more parse attempt is made so that a terminal stringified array
1370/// (e.g., `"[{\"a\":1}]"`) is returned as the parsed array.
1371///
1372/// Returns the serialized JSON string of the value at the terminal path
1373/// segment.
1374///
1375/// # Errors
1376///
1377/// Returns [`ErrorCode::SchemaMismatch`] when:
1378/// - `raw_json` is not valid JSON.
1379/// - A stringified JSON value at a traversal point cannot be re-parsed.
1380/// - A numeric segment is out of range for the current array, or the
1381///   current value is not an array.
1382/// - A named segment is missing from the current object, or the current
1383///   value is not an object.
1384/// - The final result cannot be serialized back to a JSON string
1385///   (surfaces as [`ErrorCode::InternalError`]).
1386pub fn extract_json_path(raw_json: &str, path: &str) -> Result<String, McpError> {
1387    let mut current: Value = serde_json::from_str(raw_json).map_err(|e| {
1388        McpError::new(
1389            ErrorCode::SchemaMismatch,
1390            format!("json_extract_path: file is not valid JSON: {e}"),
1391        )
1392    })?;
1393
1394    let segments: Vec<&str> = path.split('.').collect();
1395    let mut traversed: Vec<&str> = Vec::new();
1396
1397    for segment in &segments {
1398        // If the current value is a string, try to parse it as JSON before
1399        // applying this segment. This handles stringified JSON wrappers.
1400        if let Value::String(s) = &current {
1401            current = serde_json::from_str(s).map_err(|_| {
1402                McpError::new(
1403                    ErrorCode::SchemaMismatch,
1404                    format!(
1405                        "json_extract_path '{}': at segment '{}' (after '{}'): \
1406                         value is a string but not valid JSON",
1407                        path,
1408                        segment,
1409                        traversed.join(".")
1410                    ),
1411                )
1412            })?;
1413        }
1414
1415        current = if let Ok(idx) = segment.parse::<usize>() {
1416            // Numeric segment: index into array.
1417            match current {
1418                Value::Array(mut arr) => {
1419                    if idx >= arr.len() {
1420                        return Err(McpError::new(
1421                            ErrorCode::SchemaMismatch,
1422                            format!(
1423                                "json_extract_path '{}': at segment '{}' (after '{}'): \
1424                                 array index {} out of bounds (length {})",
1425                                path,
1426                                segment,
1427                                traversed.join("."),
1428                                idx,
1429                                arr.len()
1430                            ),
1431                        ));
1432                    }
1433                    arr.swap_remove(idx)
1434                }
1435                other => {
1436                    return Err(McpError::new(
1437                        ErrorCode::SchemaMismatch,
1438                        format!(
1439                            "json_extract_path '{}': at segment '{}' (after '{}'): \
1440                             expected array, found {}",
1441                            path,
1442                            segment,
1443                            traversed.join("."),
1444                            json_type_name(&other)
1445                        ),
1446                    ));
1447                }
1448            }
1449        } else {
1450            // String segment: index into object by key.
1451            match current {
1452                Value::Object(mut map) => match map.remove(*segment) {
1453                    Some(v) => v,
1454                    None => {
1455                        return Err(McpError::new(
1456                            ErrorCode::SchemaMismatch,
1457                            format!(
1458                                "json_extract_path '{}': at segment '{}' (after '{}'): \
1459                                 key not found in object",
1460                                path,
1461                                segment,
1462                                traversed.join(".")
1463                            ),
1464                        ));
1465                    }
1466                },
1467                other => {
1468                    return Err(McpError::new(
1469                        ErrorCode::SchemaMismatch,
1470                        format!(
1471                            "json_extract_path '{}': at segment '{}' (after '{}'): \
1472                             expected object, found {}",
1473                            path,
1474                            segment,
1475                            traversed.join("."),
1476                            json_type_name(&other)
1477                        ),
1478                    ));
1479                }
1480            }
1481        };
1482
1483        traversed.push(segment);
1484    }
1485
1486    // Terminal auto-parse: if the final value is a string, try to parse it
1487    // as JSON so that e.g. a stringified array becomes the actual array.
1488    if let Value::String(s) = &current {
1489        if let Ok(parsed) = serde_json::from_str::<Value>(s) {
1490            current = parsed;
1491        }
1492    }
1493
1494    serde_json::to_string(&current).map_err(|e| {
1495        McpError::new(
1496            ErrorCode::InternalError,
1497            format!("json_extract_path: failed to serialize extracted value: {e}"),
1498        )
1499    })
1500}
1501
1502/// High-level file-format categories the ingest and inspect layers
1503/// dispatch on. Text-based formats (JSON, CSV) are distinguished by
1504/// peeking at the first non-whitespace byte when the extension is
1505/// unfamiliar, so log-like files with `.log`/`.txt`/no extension still
1506/// reach the right decoder without the caller having to rename them.
1507#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1508pub enum InferredFileFormat {
1509    /// Columnar, binary. Matched by `.parquet` / `.pq` extensions.
1510    Parquet,
1511    /// Arrow IPC stream or file. Matched by `.arrow` / `.ipc` /
1512    /// `.feather` extensions.
1513    ArrowIpc,
1514    /// JSON — either a top-level array of objects or newline-delimited
1515    /// JSON. [`ingest_json_file`] auto-detects between the two shapes
1516    /// from the first non-whitespace byte.
1517    Json,
1518    /// Everything else: CSV / TSV / log text that the CSV COPY path
1519    /// can still make sense of.
1520    Csv,
1521}
1522
1523/// Decide how to dispatch an ingest / inspect call for `path`.
1524///
1525/// Extension first (zero-IO, cheap): binary formats (`.parquet`, `.pq`,
1526/// `.arrow`, `.ipc`, `.feather`) always win by extension because the
1527/// file is a binary container whose magic bytes we'd have to unpack
1528/// anyway. Known text extensions (`.json`, `.jsonl`, `.ndjson`) map
1529/// straight to JSON without needing to read the file.
1530///
1531/// Otherwise — for unknown, ambiguous, or missing extensions (`.log`,
1532/// `.txt`, no extension at all) — peek at the first 4 KiB and return
1533/// [`InferredFileFormat::Json`] if the first non-whitespace byte is
1534/// `[` or `{`, else [`InferredFileFormat::Csv`]. This is the path that
1535/// lets hyperd's raw `.log` files load without renaming, since JSONL
1536/// lines always begin with `{`.
1537///
1538/// Returns [`InferredFileFormat::Csv`] if the file can't be opened;
1539/// the subsequent CSV ingest will surface a clearer error than a
1540/// format-detection failure would.
1541#[must_use]
1542pub fn detect_file_format(path: &std::path::Path) -> InferredFileFormat {
1543    let ext = path
1544        .extension()
1545        .and_then(|e| e.to_str())
1546        .unwrap_or("")
1547        .to_lowercase();
1548    match ext.as_str() {
1549        "parquet" | "pq" => return InferredFileFormat::Parquet,
1550        "arrow" | "ipc" | "feather" => return InferredFileFormat::ArrowIpc,
1551        "json" | "jsonl" | "ndjson" => return InferredFileFormat::Json,
1552        _ => {}
1553    }
1554    // Content-sniff fallback for anything else. We only need the first
1555    // non-whitespace byte; 4 KiB comfortably covers any realistic
1556    // leading-whitespace padding or BOM noise.
1557    use std::io::Read;
1558    if let Ok(mut f) = std::fs::File::open(path) {
1559        let mut buf = [0u8; 4096];
1560        if let Ok(n) = f.read(&mut buf) {
1561            for b in &buf[..n] {
1562                match b {
1563                    b' ' | b'\t' | b'\n' | b'\r' | 0xEF | 0xBB | 0xBF => {}
1564                    b'[' | b'{' => return InferredFileFormat::Json,
1565                    _ => return InferredFileFormat::Csv,
1566                }
1567            }
1568        }
1569    }
1570    InferredFileFormat::Csv
1571}
1572
1573#[cfg(test)]
1574mod read_text_sample_tests {
1575    use super::read_text_sample;
1576    use std::io::Write;
1577
1578    fn write_temp(name: &str, contents: &[u8]) -> tempfile::TempPath {
1579        let mut tmp = tempfile::NamedTempFile::new().expect("temp file");
1580        tmp.write_all(contents).unwrap();
1581        tmp.flush().unwrap();
1582        // Use the path directly so the file persists for the read.
1583        let _ = name;
1584        tmp.into_temp_path()
1585    }
1586
1587    #[test]
1588    fn small_file_returns_full_contents() {
1589        let path = write_temp("small.csv", b"a,b,c\n1,2,3\n");
1590        let s = read_text_sample(&path, 1024 * 1024).unwrap();
1591        assert_eq!(s, "a,b,c\n1,2,3\n");
1592    }
1593
1594    #[test]
1595    fn truncates_at_last_newline_within_budget() {
1596        // 100 bytes total, cap at 30 — should truncate at last newline before byte 30.
1597        use std::fmt::Write;
1598        let mut data = String::new();
1599        for i in 0..20 {
1600            writeln!(&mut data, "row-{i}").unwrap();
1601        }
1602        let path = write_temp("rows.csv", data.as_bytes());
1603        let s = read_text_sample(&path, 30).unwrap();
1604        // Result should end with newline (no partial row).
1605        assert!(s.ends_with('\n'));
1606        // Should be at most 30 bytes.
1607        assert!(s.len() <= 30);
1608    }
1609
1610    #[test]
1611    fn handles_utf8_boundary_split() {
1612        // Construct a file where the byte budget falls in the middle of a
1613        // multi-byte UTF-8 character (4-byte emoji).
1614        let prefix = b"row1\n".to_vec();
1615        let mut data = prefix.clone();
1616        // 4-byte emoji: 🔥 = 0xF0 0x9F 0x94 0xA5
1617        data.extend_from_slice("🔥".as_bytes());
1618        // Total: 5 + 4 = 9 bytes
1619        let path = write_temp("emoji.csv", &data);
1620        // Cap at 8 bytes — splits the emoji at byte 8 (after F0 9F 94)
1621        let s = read_text_sample(&path, 8).unwrap();
1622        // Result must be valid UTF-8 (the emoji is dropped).
1623        // Should end with the prefix's newline, not partial bytes.
1624        assert!(s.ends_with('\n'));
1625        assert_eq!(s, "row1\n");
1626    }
1627
1628    #[test]
1629    fn returns_full_buffer_when_no_newline_under_budget() {
1630        // Single CSV row, no terminator, fits under cap.
1631        let path = write_temp("noeol.csv", b"a,b,c");
1632        let s = read_text_sample(&path, 1024).unwrap();
1633        assert_eq!(s, "a,b,c");
1634    }
1635
1636    #[test]
1637    fn handles_no_newline_in_first_max_bytes() {
1638        // File larger than cap, no newlines anywhere — degenerate case.
1639        // We accept this (returns the full max_bytes prefix, possibly truncated
1640        // at last UTF-8 boundary). Schema inference will likely fail downstream,
1641        // but read_text_sample itself must not panic or return garbage.
1642        let data = vec![b'a'; 2048];
1643        let path = write_temp("noeol-big.csv", &data);
1644        let s = read_text_sample(&path, 100).unwrap();
1645        // No newline → keeps all 100 bytes of ASCII 'a'.
1646        assert_eq!(s.len(), 100);
1647        assert!(s.chars().all(|c| c == 'a'));
1648    }
1649}