FileSource — the full guide¶
The csv-file connector ingests over 3 transports: local, SFTP, S3 (and
S3-compatibles: MinIO/R2/GCS).
YAML structure (connector manifest)¶
What a csv-file connector manifest may legally contain. Transport hooks and
max_file_size are deliberately absent — they belong to the one-shot surface below and
are refused here (see the capability table):
spec:
transport:
kind: local | sftp | s3
# kind-specific fields below — nothing else
after_process: archive | delete | move | none # spec level (none = leave in place)
# local transport: only `none` is accepted
archive_path: /archive/ # required when after_process=move
# Timing (crash-safety, deliberate): on connector runs a file is archived/deleted only
# AFTER the next chunk confirms its rows were committed — never inside the read that
# ingests it. A crash in between leaves the file in place; the next run skips
# re-ingestion (dedup) and applies the action then. At-least-once — the file and its
# rows are never lost together.
parser: { ... }
mapping: { ... } # optional dataset/row/cell hooks (aggregator refused)
target: { tags: [...], indexes: [...] }
schedule: "0 6 * * *"
Full key-by-key reference: csv-file — Manifest reference.
Scheduled connector vs one-shot lm csv ingest¶
Two ingestion surfaces share the transports but not the features — the validator refuses what a scheduled run would silently ignore:
| Capability | Connector manifest (scheduled runs) | One-shot lm csv ingest |
|---|---|---|
| Transports | local, sftp, s3 | local upload, sftp, s3 |
schedule (cron) |
yes | no (one shot, by definition) |
mapping hooks (dataset/row/cell) |
yes (aggregator refused) |
no — never applied |
Transport hooks (file_filter/pre_process/post_process) |
refused at validate/apply | yes |
max_file_size |
refused at validate/apply | yes (default 500 MB, over-limit aborts) |
after_process on sftp/s3 |
yes (deferred until the cursor proves the commit) | yes (deferred until the ingest transaction commits) |
after_process on local |
only none — archive/delete/move refused |
yes |
| Malformed rows | isolated per-row to the DLQ, run continues | abort on the first malformed row (whole ingest rolls back) |
| Target table | connector.t_<name> (auto-schema) |
ext.<name> (hybrid JSONB + promoted columns) |
| Naming a multi-file source | that is what a manifest is — one connector over a glob | name + more than one matched file is refused (see below) |
One-shot lm csv ingest — what actually happens¶
One transaction: sample → infer → write. The service samples the first rows (default 100),
infers a schema, creates a dormant connector + endpoint (enabled: false, no schedule
— inspect, promote, or delete before enabling anything), then writes all rows to a hybrid
ext.<name> table (full row as JSONB + typed promoted columns).
- Type inference (widest type across the sample; on conflict TEXT wins): integers →
BIGINT, decimals →DECIMAL, true/false →BOOLEAN, everything elseTEXT. Date-looking values deliberately stayTEXTin v1. - Reserved columns are never promoted (they already exist in every
ext.*table):id,tenant_id,external_id,data,indexed_at,connector_id,endpoint_id. A source column with one of these names survives inside the JSONBdata, just not as a typed column. - Abort, not DLQ: the first malformed row aborts the ingest and the transaction rolls back — nothing is half-written, and no source file is archived: an aborted run leaves every file where it was, so the whole batch can simply be re-run. Per-row DLQ isolation is a connector-run behavior.
- Delimiter and encoding are auto-detected per file (same detectors as the connector path).
- Remote (sftp/s3): each matching file is checked against
max_file_size(default 500 MB, over-limit aborts), buffered to a temp file and run through the same pipeline; zero-byte files are skipped at listing.after_process(andpost_process) run after the transaction commits, for every ingested file at once — the commit is the proof the rows landed, so a crash can never leave archived files whose rows were rolled back. If the archive step itself fails once the rows are committed, it is logged as a WARNING per file and the run still succeeds: the file simply stays on the source.
--name needs a single file
Each matched file registers its own dormant connector under the name you give, so a name over a glob that matches more than one file is refused before anything is downloaded — one name cannot label two connectors:
API error 400: name 'clients' would ingest 3 files into one dataset, which the one-shot
path cannot register: each file creates its own dormant connector under that name. Point
at a single file, or drop the name to get one dataset per file. To fuse a folder into one
dataset, declare a connector manifest with a glob path.
Mind that sftp://host/dir/ globs *.csv by default (--pattern), so the plain folder
gesture is multi-file. Three ways forward:
- one dataset per file — drop
--name; each file getsext.<filename>and its own dormant connector. - one specific file — point the URL at it (or narrow
--pattern/--suffix), and--nameworks as documented. - the whole folder as one dataset — that is a connector manifest with a glob path: one connector, one table, and a schedule if you want it. See CSV file manifest.
Local transport¶
transport:
kind: local
path: /var/imports/*.csv
Glob semantics: * and ? only, matched against the filename segment — the directory
part must be literal, and there is no ** recursion. No name is excluded implicitly (a
*.csv glob is how you keep .tmp files out).
Back-compat: legacy (pre-manifest) connector configs with a top-level path: instead of a
transport block are wrapped as {kind: local, path: ...} at runtime. Manifests must
declare the transport block.
Drop-directory contract: names must grow lexically
On scheduled runs the local cursor resumes by the current file's position in the
lexically sorted glob result. A file dropped later with a lexically earlier
name sorts before the cursor and is treated as already ingested — it is never read.
Name incoming files monotonically (date-prefixed: 2026-07-25-orders.csv); if your
producer cannot guarantee that, use an sftp/s3 transport, whose cursor tracks files
by modification time instead.
SFTP transport¶
transport:
kind: sftp
host: sftp.example.com
port: 22
user: lumnik
key_env: ACME_SFTP_KEY # resolved via SecretResolver (DB → env)
# OR password_env: ACME_SFTP_PWD
path: /export/*.csv
strict_host_key: true
# spec level, next to transport:
after_process: move
archive_path: /archive/
after_process: archive on sftp relocates too (archive and move are the same runtime
action); with archive the archive_path is optional and defaults to /archive/ — only
move makes it mandatory. Zero-byte files are skipped at listing (sftp and s3 alike).
S3 transport¶
transport:
kind: s3
region: eu-west-3
endpoint: https://... # optional (MinIO/R2/GCS)
bucket: my-bucket
prefix: billing/
suffix: .csv
access_key_env: AWS_S3_ACCESS_KEY
secret_key_env: AWS_S3_SECRET_KEY
archive_bucket: my-bucket-archive # destination when after_process=archive
archive_prefix: billing-processed/
# spec level, next to transport:
after_process: archive # relocates via archive_bucket/archive_prefix
# ('move' would additionally require spec.archive_path)
Kotlin hooks (3 extension points — one-shot lm csv ingest only)¶
Honest boundary: these hooks and
max_file_sizerun only on the one-shotlm csv ingestpath. A connector manifest carrying them is refused at validate/apply — scheduled connector runs would silently ignore them. Same forafter_process: archive|delete|moveon a local connector: the local scheduled reader has no per-file completion tracking, so the manifest is refused (nonestays valid; sftp/s3 honor it).
transport:
file_filter: { kind: kotlin, script_ref: reference/skip-if-too-small.kt }
pre_process: { kind: kotlin, script_ref: reference/decompress-gzip.kt }
post_process: { kind: kotlin, script_ref: reference/notify-webhook.kt }
3 reference hooks ship under META-INF/hooks/connectors/csv-file/reference/.
Signatures:
fun shouldProcess(file: FileDescriptor, source: Any?): Boolean // file_filter
fun transform(file: FileDescriptor, input: InputStream): InputStream // pre_process
fun postProcess(file: FileDescriptor, result: Any?) // post_process
after_processaccepts onlyarchive | delete | move | none(validator). The Kotlin hooks above run in addition to the declared action —custom-kotlinis not anafter_processvalue.
CLI¶
lm csv ingest <local-file>
lm csv ingest sftp://user@host[:port]/path-or-glob --key-secret X | --password-secret X
lm csv ingest s3://bucket/prefix --region eu-west-3 [--endpoint X] [--suffix .csv]
Troubleshooting¶
| Error | Cause | Fix |
|---|---|---|
sftp connect failed: auth |
key/password missing or invalid | lm secret list then lm secret set ACME_SFTP_KEY ... |
unable to verify host key |
strict_host_key=true and no known fingerprint | strict_host_key: false for a demo |
S3 403 Forbidden |
invalid S3 credentials | check AWS_S3_ACCESS_KEY / AWS_S3_SECRET_KEY |
file exceeds max_file_size |
file > 500 MB | raise max_file_size or split producer-side |
Hook script not found |
script_ref points to a missing file | check META-INF/hooks/connectors/csv-file/<script_ref> |