CSV & files (SFTP/S3)
Ingest CSV files — dropped locally, on an SFTP server, or in an S3 bucket — into a hub table on a schedule, with per-row error isolation.
| Type | csv-file |
| Transports | local, sftp, s3 |
| Target | connector.t_<name> |
| Schedulable | cron |
| Edition | Open |
Quickstart — one local file, three commands and a look at the ledger
# 10-local-simple.yaml — the simplest CSV ingest: one local file, full load.
#
# Apply and run:
# lm apply -f docs/connectors/csv/10-local-simple.yaml
# lm connector run csv-local-simple
#
# A file that has not changed since its last run is skipped; one that has changed
# (replaced in place, or grown) is read again in full, and the table then holds
# exactly what the file holds: rows the file no longer has are removed — a run that
# would shrink the table by more than 30% fails instead, unless refresh.max_removed
# says otherwise (docs/connectors/csv.md, full refresh). Suitable for reference
# files fully replaced on each load cycle. after_process: none means the source is
# never moved or deleted.
apiVersion: connectors.lumnik.io/v1
kind: Connector
metadata:
name: csv-local-simple # unique connector name within the workspace
connector_type: csv-file # selects the CSV-file connector implementation
scopes: [demo] # the scope(s) this table is askable in — a scope-bound user needs scope:demo
spec:
transport:
kind: local # no remote fetch — the path below is on the HUB's filesystem,
# not on yours (mount a volume, or use sftp/s3 / lm csv ingest)
path: docs/connectors/csv/data/customers.csv # plain path = single file
parser:
has_header: true # first row holds column names; they drive target column mapping
after_process: none # leave the source file untouched after ingestion
The hub reads the manifest's path:, on its own disk. On the self-host stack, copy the sample
file into the hub's container, then set path: /tmp/customers.csv in your copy of the manifest:
docker compose -f docker-compose.selfhost.yml cp docs/connectors/csv/data/customers.csv hub:/tmp/customers.csv
A hub in dev mode reads the repository path as it is. From the repository root:
$ lm validate -f docs/connectors/csv/10-local-simple.yaml
✓ 10-local-simple.yaml — valid
$ lm apply -f docs/connectors/csv/10-local-simple.yaml
Applied connector "csv-local-simple" — 1 endpoint
$ lm connector run csv-local-simple
run scheduled: 2e37cb12-0a66-4c4c-a4c2-b796b2d40540
"Scheduled" is not "done" — the result lands in the run ledger a second later:
$ lm run get 2e37cb12
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
2e37cb12 csv-local-simple default Completed 2 2 0 0 2026-10-19 01:39 2026-10-19 01:39
STARTED / ENDED are in your terminal's time zone (these screens: Paris, UTC+2); schedule
is evaluated in UTC.
What landed in connector.t_csv_local_simple — the header row became the columns:
lumnik=> select row_id, email, signup_date from connector.t_csv_local_simple;
row_id | email | signup_date
--------+-------------+-------------
1 | alice@x.com | 02/06/2026
2 | bob@x.com | 15/01/2026
(2 rows)
Run it again on the unchanged file, then once more after a third line is appended:
$ lm run list --connector csv-local-simple
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
2565ea5a csv-local-simple default Completed 3 3 0 0 2026-10-19 01:39 2026-10-19 01:39
52d1ac0b csv-local-simple default Completed 0 0 0 0 2026-10-19 01:39 2026-10-19 01:39
2e37cb12 csv-local-simple default Completed 2 2 0 0 2026-10-19 01:39 2026-10-19 01:39
- A file already read is not read again. The second run says
IN 0, andlm run getadds• 1 file(s) unchanged since they were last read — not read again. - A file that changed is read again in full — replaced in place, or grown. The third run
says
IN 3. This holds even when the new copy kept the file's size and date. - Every file is read again after you apply a changed
mapping:. - The table deduplicates: after three runs it holds 3 rows, not 5.
SKIPPEDandDEAD-LETTERSare explained below.
The three transports
The manifest's transport: block picks one:
local |
sftp |
s3 |
|
|---|---|---|---|
| files are on | the hub's filesystem | a remote server | a bucket |
| selected by | path or glob | path or glob | prefix + suffix |
| a new run reads | one file: when it changed. A glob: new names sorting after the last one read | files modified since the last run | files modified since the last run |
after_process |
none only |
all four | all four |
| credentials | — | key_env or password_env |
access_key_env / secret_key_env |
Local — the hub's disk, not yours
transport:
kind: local
path: /imports/*.csv # glob OK
- The path is on the hub's filesystem. The self-host stack mounts nothing: mount a volume,
use
sftp/s3, or upload one file withlm csv ingest. - Glob:
*and?, in the final filename segment only.**does not reach into subfolders. - A glob in a folder name fails the run:
/imports/*/x.csvgivesFailed to resolve path pattern: /imports/*/x.csv. So does a glob whose folder does not exist. - No name is excluded implicitly — use
*.csvto keep.tmpfiles out. - A path that matches nothing is not an error: the run completes with
IN 0. This holds for a glob in an existing folder, and for one file that does not exist. - A path without
*or?names one file, and that file is the whole table — see full refresh.
A drop folder, run by run. A 1 200-row export 2026-09-01-orders.csv, then two more files:
$ lm run list --connector orders-drop
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
b159e727 orders-drop default Completed 0 0 0 0 2026-10-19 01:39 2026-10-19 01:39
393a2729 orders-drop default Completed 10 10 0 0 2026-10-19 01:39 2026-10-19 01:39
165a4c6d orders-drop default Completed 0 0 0 0 2026-10-19 01:39 2026-10-19 01:39
ff5b8f80 orders-drop default Completed 1200 1200 0 0 2026-10-19 01:39 2026-10-19 01:39
| run | the folder holds | IN |
why |
|---|---|---|---|
| 1 | 2026-09-01 (1 200 rows) |
1 200 | first read |
| 2 | the same file | 0 | already read in full |
| 3 | plus 2026-09-02 (10 rows) |
10 | only the new file — it sorts after the last file read |
| 4 | plus 2026-08-31 (5 rows) |
0 | it sorts before the last file read — never read |
Drop-folder contract: names must grow lexically
A run resumes after the last file it read, in name order. Name incoming files
monotonically (date-prefixed: 2026-07-25-orders.csv), or use sftp/s3, which track
modification time instead.
- Have the producer write under a name the glob does not match (
.tmp), then rename it. - Archive a file only after its run is
Completed.
SFTP — a remote folder, a secret, a fingerprint
transport:
kind: sftp
host: sftp.example.com
port: 22 # optional, default 22
user: lumnik
path: /erp-export/*.csv # glob OK
password_env: SFTP_ERP_PASSWORD # OR key_env (the PEM private key) — one is required
host_key_fingerprint: "SHA256:M0Is0aG8ubOr0cLpQs6QcDwVSxMkvmlfuW4K9Q6CJuc" # required unless strict_host_key: false
# strict_host_key: true # the default — refuses unless the fingerprint above matches
# spec level, next to transport:
after_process: archive
archive_path: /archive/ # default /archive/; required when after_process: move
Credentials are never inline. key_env / password_env name a secret stored with
lm secret; an OS env var of that name is the fallback:
$ lm secret set SFTP_ERP_PASSWORD
Value for SFTP_ERP_PASSWORD:
Created secret "SFTP_ERP_PASSWORD"
A manifest with neither credential is refused:
$ lm validate -f sftp-nocred.yaml
✗ sftp-nocred.yaml — 1 error(s), 0 warning(s)
ERROR spec.transport
at least one of key_env, password_env is required when kind is sftp
→ Use `lm secret set <NAME> ...` then reference it via key_env: <NAME>
Fix errors above before running 'lm apply -f sftp-nocred.yaml'.
Error: validation failed: 1 error(s)
One session per run. Your server sees one SSH connection per running endpoint, for the whole run. Mind its idle timeout and its limit on concurrent sessions.
The host key is verified. With strict_host_key: true (the default), host_key_fingerprint
must match what the server presents. Here a wrong fingerprint is declared:
$ lm run get b31b1efe
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
b31b1efe sftp-badkey default Failed 0 0 0 0 2026-09-06 22:13 2026-09-06 22:13
✖ run failed: SFTP host key refused for sftp.example.com:22 — the server presented SHA256:M0Is0aG8ubOr0cLpQs6QcDwVSxMkvmlfuW4K9Q6CJuc, and it does not match the declared SHA256:0000000000000000000000000000000000000000000. Confirm that fingerprint out of band (ssh-keyscan -p 22 sftp.example.com | ssh-keygen -lf -), then set host_key_fingerprint: "SHA256:M0Is0aG8ubOr0cLpQs6QcDwVSxMkvmlfuW4K9Q6CJuc" in the transport block. Setting strict_host_key: false connects without checking at all — which accepts an impostor as readily as the real server.
- Confirm the presented fingerprint out of band (the
ssh-keyscan | ssh-keygen -lf -line, from a machine you trust), then paste it. strict_host_key: falseskips the check: an impostor would receive the password and feed rows. Use it only for a throwaway container whose key changes at every restart — as the demo manifest30-sftp-key.yamldoes.
A wrong password, or a user who cannot read the folder:
$ lm run get 22957eeb
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
22957eeb sftp-c-bpartner-credentials default Failed 0 0 0 0 2026-09-04 14:23 2026-09-04 14:23
✖ run failed: SFTP error (SSH_FX_PERMISSION_DENIED): Permission denied
Which files a run reads:
- A file is read once while it is unchanged.
- A file rewritten later (newer modification time) is read again, even under the same name and at the same size.
- Files are read oldest first. A run that finds nothing new reads nothing.
- Zero-byte files are never listed — never ingested, never archived.
$ lm run list --connector sftp-c-bpartner-credentials
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
a9533cb5 sftp-c-bpartner-credentials default Completed 0 0 0 0 2026-09-06 22:09 2026-09-06 22:09
80032250 sftp-c-bpartner-credentials default Completed 6 5 1 1 2026-09-04 15:27 2026-09-04 15:27
8358fb91 sftp-c-bpartner-credentials default Completed 19009 19009 0 0 2026-09-04 14:40 2026-09-04 14:41
S3 — a prefix in a bucket (AWS, MinIO, R2, GCS S3-compat)
transport:
kind: s3
bucket: acme-exports # required
prefix: billing/ # optional key prefix filter, default ""
suffix: .csv # optional filename filter, default "" (no filter)
region: eu-west-3 # optional, default eu-west-3
endpoint: http://minio:9000 # optional — only for non-AWS S3
access_key_env: AWS_ACCESS_KEY_ID # optional, default AWS_ACCESS_KEY_ID
secret_key_env: AWS_SECRET_ACCESS_KEY # optional, default AWS_SECRET_ACCESS_KEY
archive_bucket: acme-archive # optional, for after_process archive|move (default: source bucket)
archive_prefix: archive/ # optional, default archive/
billing/+.csvreadsbilling/2026-09-01.csvand ignoresbilling/2026-09-01.csv.tmp.- Same rule as SFTP: an object is read when new or rewritten, oldest first; zero-byte objects are skipped.
access_key_env/secret_key_envare secret names (lm secret set AWS_ACCESS_KEY_ID). A key rotated withlm secret setis used from the next run on. On a hub with several replicas, allow up to 5 minutes.
One export, archived after commit — the manifest of the S3 how-to:
$ lm run list --connector s3-c-bpartner
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
ee860da1 s3-c-bpartner default Completed 10 5 5 5 2026-09-06 16:30 2026-09-06 16:30
dda81fcb s3-c-bpartner default Completed 0 0 0 0 2026-09-06 16:21 2026-09-06 16:21
7329d11d s3-c-bpartner default Completed 19009 19009 0 0 2026-09-06 16:03 2026-09-06 16:03
- 16:03 — the 19 009-row export, read and archived.
- 16:21 — nothing new under
billing/(the file is in the archive bucket now):IN 0. - 16:30 — a 10-record file, half malformed on purpose: 5 rows in, 5 dead-lettered.
Parser
parser:
delimiter: "," # optional, auto-detected if absent
encoding: UTF-8 # optional, auto-detected if absent
has_header: true # default true
bom_strip: true # default true
null_values: ["", "NULL", "null", "#N/A"] # default as shown
columns: [code, name, email] # REQUIRED when has_header: false; ignored otherwise
lines: false # true: one line = one row, one column `line` (fixed width, logs)
short_rows: error # default; skip-leading skips a key;value block before the table
escape: "\\" # optional; the character that escapes a quote inside a value (default: the quote is doubled)
- The encoding and the delimiter are guessed per file. Declare
delimiterorencodingwhen the guess is wrong. - A doubtful guess is said. A DOS export (FoxPro, Clipper, dBase) read as windows-1252
turns
HuréintoHur‚. When the guess looks like that, the run endsPartial. Its error names the file and the encoding chosen. Declare the right encoding (encoding: IBM850), or the guessed one if it is right: the next runs stop beingPartial. Then runlm endpoint reset-cursor <connector>/<endpoint>, so the next run reads the file again. - A byte the encoding cannot read stops the file there. The rows before it land. Its line
goes to the dead letters (
MalformedSource):windows-1252 has no character for the byte 0x90 at line 3. The rows after it are not read. Declare the real encoding, thenlm endpoint reset-cursor <connector>/<endpoint>. -
A quote inside a quoted value may be escaped with a backslash:
"Client: \"never received\", chased". The file then stops at that line. The line goes to the dead letters withA quoted value does not close where expected, and the rest of the file is not read. Declare the character:parser: escape: "\\"The value then reads
Client: "never received", chased.escapeis one character, not the delimiter or the quote, and not allowed withlines: true. - A header name is trimmed.DATE_MAJgivesDATE_MAJ. Values keep their padding;clean: { strip: true }removes it. - A value holding a NUL byte (0x00) sends its row to the dead letters (lm dlq list). The other rows land. To keep the row, remove the byte:cell: - { clean: { columns: [packed], remove-control-chars: true } } -
Headerless files need names, in file order (
"\t"is a tab in YAML):parser: delimiter: "\t" has_header: false columns: [c_bpartner_id, name, created, isactive]Without
columns:$ lm validate -f hl.yaml ✗ hl.yaml — 1 error(s), 0 warning(s) ERROR spec.parser.columns required field missing (required when has_header is false and lines is off) → Add parser.columns: [code, name, ...] in file order Fix errors above before running 'lm apply -f hl.yaml'. Error: validation failed: 1 error(s) -
A block before the table (batch, instrument, method: an instrument export opens with a
key;valueblock) is skipped withshort_rows: skip-leading. It needshas_header: falseand thecolumnsof the table:parser: delimiter: ";" has_header: false columns: [sample, mz_theo, mz_meas] short_rows: skip-leadingAgainst this file:
BATCH;B-42 INSTRUMENT;QTOF METHOD;QC caffeine;195.08765;195.08771 MRFA;524.26496 MRFA;524.26496;524.26524- Only the lines before the table are skipped. Short lines are skipped until the first
full one. Here the three first lines count in
SKIPPED, with no dead letter. The run log says3 leading short row(s) skipped. - A short line after that is still a dead letter. Here the fifth line,
MRFAwith two values, goes tolm dlq list. The other rows land. short_rowsis refused with a header line and withlines: true.
- Only the lines before the table are skipped. Short lines are skipped until the first
full one. Here the three first lines count in
-
A fixed-width file (positions, not separators: mainframe extracts, bank or payroll files) is read one line per row with
lines: true. Each line lands in one column,line. Thenregex-extractcuts it by position. The file:E00001Martin Claire 0003250.00 E00002Dupont Jean 0002980.50 E00003Nguyen Linh 0004100.00The manifest's parser and mapping:
parser: lines: true mapping: cell: - regex-extract: # employee 6, name 30, salary 10 column: line pattern: "^(.{6})(.{30})(.{10})" groups: { 1: employee, 2: name, 3: salary } - clean: { columns: [employee, name, salary], strip: true } - parse-number: { column: salary, decimal-separator: "." }What lands:
lumnik=> select employee, name, salary from connector.t_payroll; employee | name | salary ----------+---------------+--------- E00001 | Martin Claire | 3250.00 E00002 | Dupont Jean | 2980.50 E00003 | Nguyen Linh | 4100.00 (3 rows)lines: truerefusesdelimiterandhas_header. Full example:docs/connectors/csv/73-lines-fixed-width.yaml. -
An empty value is a present column.
1,against two columns is written, and the second value isnull:""is in the defaultnull_values. To keep"", declare your own list.
Malformed rows — the per-row DLQ
A malformed row never fails the file. It goes to the dead-letter queue (DLQ), reason
MalformedSource, and the run continues. A 6-row file with two bad rows:
"c_bpartner_id","name"
1004194,"DUPONT SARL"
1019809,"MARTIN"
BADROW,"trop de colonnes","erp legacy glitch"
1030001,"LAMBERT & FILS"
BADROW2
1030002,"NAUTILUS SAS"
$ lm run get 0a4af97d
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
0a4af97d csv-malformed default Completed 6 4 2 2 2026-10-19 01:40 2026-10-19 01:40
⚠ 2 rows quarantined — see: lm dlq list --run 0a4af97d-cac0-4de0-a299-1db4599b8b31
$ lm dlq list --run 0a4af97d-cac0-4de0-a299-1db4599b8b31
ID CONNECTOR RUN REASON ATTEMPTS RESOLUTION MESSAGE
556c4da1 csv-malformed 0a4af97d MalformedSource 1 /tmp/exports/erp-export/c_bpartner_malformed.csv:6 — Record …
a2867c28 csv-malformed 0a4af97d MalformedSource 1 /tmp/exports/erp-export/c_bpartner_malformed.csv:4 — Record …
The entry keeps every raw value, with the file and line:
$ lm dlq get a2867c28
ID: a2867c28-0424-44f7-86dd-77f3a0d86872
Connector: csv-malformed (434507c6-df49-4a09-a59f-dfb5df0589e9)
Run: 0a4af97d-cac0-4de0-a299-1db4599b8b31
Reason: MalformedSource
Attempts: 1
Message: /tmp/exports/erp-export/c_bpartner_malformed.csv:4 — Record has 3 values but only 2 columns are declared
Detail: {"file":"/tmp/exports/erp-export/c_bpartner_malformed.csv","line":4,"message":"Record has 3 values but only 2 columns are declared"}
Payload: {"raw":["BADROW","trop de colonnes","erp legacy glitch"],"file":"/tmp/exports/erp-export/c_bpartner_malformed.csv","line":4}
Resolution: open
Malformed = the value count differs from the declared columns, in either direction:
| the row | against 2 declared columns | result |
|---|---|---|
BADROW,"trop de colonnes","erp legacy glitch" |
3 values | DLQ — Record has 3 values but only 2 columns are declared |
BADROW2 |
1 value | DLQ — Record has 1 value but 2 columns are declared |
1, |
2 values, one empty | written, name is null |
1,2, |
3 values — a trailing delimiter is one column too many | DLQ |
3,"open (unclosed quote) |
swallows the rest of the file | DLQ — one entry; the lines after it are never rows |
- An extra value is never dropped to make the row fit.
- A trailing delimiter on every line sends the whole file to the DLQ — strip it producer-side.
SKIPPEDandDEAD-LETTERSmove together for malformed rows.- Fix the export, run again, then close each entry: Diagnosing a failed run.
- Rows a
validate-rowhook drops land here too, reasonValidationFailed.
One file is the table — full refresh
A local path with no * or ? names one file, and that file is the table's whole state. A
run that reads it to the end and succeeds removes the rows the file no longer holds.
transport:
kind: local
path: /imports/articles.csv # one file: full refresh
after_process: none
refresh:
max_removed: 30% # the default — off, or 100%, lifts the guard
Ten articles, then A07 deleted from the file:
$ lm run get 2a1c346f
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
2a1c346f articles default Completed 9 9 0 0 2026-10-19 01:41 2026-10-19 01:41
• 1 row(s) removed — the source no longer holds them
The removed count sits outside IN = OUT + SKIPPED: the run never read those rows.
Four guards: the run fails and nothing is removed. The next run reads the whole file again.
- The table would shrink by more than
refresh.max_removed(30% unless declared). A truncated export looks exactly like a purge. Here the file is cut to 4 lines; the purge is real, so the threshold is raised:
$ lm run get d4fa0d53
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
d4fa0d53 articles default Failed 4 4 0 0 2026-10-19 01:41 2026-10-19 01:41
✖ run failed: 5 of 9 rows would be removed: the file holds 4 where the table held 9, more than refresh.max_removed (30%) allows — nothing was removed. If the file really lost them, raise refresh.max_removed (or set it to off) and run again.
$ tail -3 articles.yaml
after_process: none
refresh:
max_removed: 60%
$ lm apply -f articles.yaml
Applied connector "articles" — 1 endpoint
$ lm connector run articles
run scheduled: 54de3ded-1939-449a-bd99-73eb36813a1d
$ lm run get 54de3ded
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
54de3ded articles default Completed 4 4 0 0 2026-10-19 01:41 2026-10-19 01:41
• 5 row(s) removed — the source no longer holds them
- A row of the file went to the dead letters while rows would be removed: its last good
version stays. A row a
validate-rowhook drops counts the same way.
$ lm run get 08cb8fb5
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
08cb8fb5 articles default Failed 4 3 1 1 2026-10-19 01:41 2026-10-19 01:41
⚠ 1 rows quarantined — see: lm dlq list --run 08cb8fb5-286b-4fe0-84bd-eafec50d10f5
✖ run failed: 1 row(s) of the file went to the dead letters this run (lm dlq list --run 08cb8fb5-286b-4fe0-84bd-eafec50d10f5): a full refresh removes nothing while a row of the file is in the dead letters, whatever its reason (unreadable, dropped by validate-row…) — nothing was removed. Fix the cause each dead letter reports (lm dlq get <id>) and run again.
- An empty file (a header and no rows) never empties the table:
$ lm run get 4f61dcf3
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
4f61dcf3 articles default Failed 0 0 0 0 2026-10-19 01:41 2026-10-19 01:41
✖ run failed: the file is empty: a full refresh never empties a table from an empty file — nothing was removed. If the source really is empty, delete the connector's data instead (lm connector delete).
- The file changed while the run was reading it — the producer rewrote it mid-run. The run
fails with:
the file changed while this run was reading it, so the run met parts of two versions — nothing was removed. Have the producer write elsewhere, then rename the file in.
The guards speak only when rows would be removed. A dead letter on a new row, with nothing to
remove, completes as usual (IN 5 OUT 4 DEAD-LETTERS 1).
A changed row is not a removed row. The run writes its new version and drops the old one.
It is not counted as removed, and the guard sees no shrink. Ten articles, then the price of
A03 changed:
$ lm run get dd0a52c4
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED EVALUATED
dd0a52c4 articles default Completed 10 10 0 0 2026-10-25 11:17 2026-10-25 11:17 yes
The removed count is how much the table shrank. A row lost while another is added counts as a changed row: nothing is counted as removed.
What never removes a row:
| the run | why |
|---|---|
| finds the file unchanged | it is not read again |
reads a glob (*.csv) |
a drop folder only adds |
| reads an sftp or s3 transport | each file is read when new or rewritten; nothing is compared |
A refresh block on a drop folder is flagged:
$ lm validate -f drop.yaml
! drop.yaml — 0 error(s), 1 warning(s)
WARN spec.refresh
refresh is read only for one local file (transport kind: local, a path without * or ?): this source is a drop folder and never removes rows, so the block does nothing here
→ Remove the refresh block, or point transport.path at the one file that holds the table
A removed row also leaves the entities fused from it, lm ask, and any Search index over them.
Scheduling & lifecycle
schedule— optional cron, 5 fields, in UTC:0 6 * * *is daily at 06:00 UTC. It is checked at apply. The hub fires it — no OS cron. A re-apply withoutscheduleunschedules the connector.window—window: "22:00-06:00"fences scheduled runs. UTC, end exclusive.after_process— required. What happens to a file once its rows are committed:
none |
archive |
move |
delete |
|
|---|---|---|---|---|
| does | leaves the file in place | relocates it | relocates it — same action, archive_path mandatory |
removes it |
| sftp | ✓ | → archive_path (default /archive/) |
→ archive_path |
✓ |
| s3 | ✓ | → archive_bucket/archive_prefix (defaults: source bucket, archive/) |
same | ✓ |
| local | ✓ | refused | refused | refused |
- The archived name carries the file's modification time:
livraisons.csvread with its mtime at 16:35:44 UTC on 22 October 2026 is archived aslivraisons.20261022T163544Z.csv. An export that drops the same name every night keeps one archive per night; none replaces another. A source with sub-second times keeps them: on s3,livraisons.20261022T163544.047Z.csv. - Only the version that was read is archived. Have the producer write under a name the glob does not match, then rename it in.
- A file that cannot be archived is said on the run — an archive folder the user cannot write,
say. The file was read; it stays in the source folder, and
lm run getsays so:
• 1 file(s) read but not archived — still in the source folder (after_process failed; see the hub log)
On a local transport, only after_process: none is accepted.
A file is archived only after its rows are in the table (sftp/s3).
A misspelled key is flagged, and the manifest still applies. Inside mapping, a misspelt tier is refused.
$ lm validate -f typo.yaml
! typo.yaml — 0 error(s), 1 warning(s)
WARN spec.windo
unknown key — ignored by the runtime
→ Remove it, or check the spelling
Target table
- One table per connector,
connector.t_<name>, columns from the header (orparser.columns). - Reserved column names:
id,_row_hash,_ingested_at,_source,_run_id,_mapping_version,tenant_id, and_seen_runon a one-file table. A source column with one of these names is refused: rename it withmap-columns. - Two identical lines are one row.
OUTcounts the lines; the table holds distinct rows. -
target.indexesdeclares Postgres indexes —{ columns: [...], unique: <bool> }(uniquedefaults to false); a malformed entry fails validation:target: indexes: - { columns: [email], unique: true } - { columns: [bpartner_id, dateordered] } -
The business scope is on the connector:
metadata.scopes(see Scopes); the validator warns when none is declared. -
lm connector deleteremoves the connector's rows, its run history and its RAG chunks (the table goes with the last connector writing into it):$ lm connector delete csv-local-simple --dry-run Would delete connector csv-local-simple (csv-file) — 3 rows of connector.t_csv_local_simple, its run history and RAG chunks (dry run, nothing deleted) -
lm endpoint purgerefuses acsv-fileendpoint. Delete the connector instead.
Transform at ingestion
An optional mapping block repairs legacy data as it flows in — dataset → row → cell hooks,
in that order, on every connector run:
mapping:
cell:
- fix-mojibake: { columns: [name, city] } # "Bénédict" → "Bénédict"
Every hook, by business problem: Transformers. A value a hook cannot read is
emptied, the row is still written, and the run counts it in cell_errors.
One file, one command: lm csv ingest
To look at one export before writing a manifest, lm csv ingest loads it in one command. It
does not schedule, transform or set rows aside: One file, one command.
How to…
Ingest an SFTP folder nightly
-
Store the secret the manifest names:
$ lm secret set CSV_SFTP_PASSWORD Value for CSV_SFTP_PASSWORD: Created secret "CSV_SFTP_PASSWORD" -
Apply the manifest — this one targets the docker-compose demo server:
# 30-sftp-key.yaml — pull CSVs from an SFTP server into the hub. # lm apply -f docs/connectors/csv/30-sftp-key.yaml # lm connector run csv-sftp # # Credentials are never inline: `password_env` names a secret stored with `lm secret set`. # Use `key_env` instead for an SSH key (the value is the private key). # # Each run reads only the files that are new or rewritten since the last run. apiVersion: connectors.lumnik.io/v1 kind: Connector metadata: name: csv-sftp connector_type: csv-file scopes: [demo] spec: transport: kind: sftp host: localhost port: 2222 # the dev compose 'sftp' service; from a hub in Docker, # use host.docker.internal user: csvuser path: /upload/seed/*.csv # glob on the remote side (seed/ subdir from the read-only mount) password_env: CSV_SFTP_PASSWORD # the secret's name, never the password itself strict_host_key: false # DEMO ONLY: this test server gets a new host key at every # restart. Against a real server, drop this line and declare # host_key_fingerprint: "SHA256:…" instead. Without the check, # an impostor server would receive the password. parser: has_header: true after_process: none # archive | delete | move | none (leave the file in place) -
lm connector run csv-sftpreads every file under/upload/seed/*.csv. Once it looks right, addschedule: "0 6 * * *", andafter_process: archivewith anarchive_path.
Watch it in the TUI (CLI & TUI): :runs for the run's status, :dlq
for rejected rows.
Ingest a headerless export under an S3 prefix, and archive it
# 45-s3-headerless-archive.yaml — a headerless, tab-separated ERP export under an S3 prefix,
# archived to another bucket once its rows are committed.
# lm validate -f docs/connectors/csv/45-s3-headerless-archive.yaml
# lm apply -f docs/connectors/csv/45-s3-headerless-archive.yaml
# lm connector run s3-c-bpartner
#
# No `endpoint:` → real AWS; set `region` to the region the bucket lives in (the default is eu-west-3).
# `access_key_env` / `secret_key_env` name secrets stored with `lm secret set`, never the values.
# `has_header: false` makes `parser.columns` mandatory — the names, in file order.
# `after_process: archive` moves each file to `archive_bucket`/`archive_prefix` once its rows are
# in the table; the next run then finds nothing new and reads 0 rows.
# Walk-through: docs/connectors/csv.md.
apiVersion: connectors.lumnik.io/v1
kind: Connector
metadata:
name: s3-c-bpartner
connector_type: csv-file
scopes: [s3-scenario]
spec:
transport:
kind: s3
bucket: lumnik-exports
prefix: billing/
region: us-east-1
suffix: .csv
access_key_env: AWS_ACCESS_KEY_ID
secret_key_env: AWS_SECRET_ACCESS_KEY
archive_bucket: lumnik-archive
archive_prefix: archive/
parser:
delimiter: "\t"
has_header: false
columns: [c_bpartner_id, name, created, isactive]
after_process: archive
Result: the run ledger above — 19 009
rows on the first run, IN 0 on the next because the file is in lumnik-archive now. Column 1
lands as c_bpartner_id, column 2 as name, and so on. For MinIO or another S3-compatible store,
add endpoint: — see 40-s3-prefix.yaml in docs/connectors/csv/.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
SFTP host key refused for host:22 — the server presented SHA256:… |
host_key_fingerprint absent or wrong, with strict_host_key: true (the default) |
confirm the presented fingerprint out of band (ssh-keyscan -p 22 HOST \| ssh-keygen -lf -), paste it into host_key_fingerprint |
SFTP error (SSH_FX_PERMISSION_DENIED): Permission denied |
wrong password/key, or the user cannot read path |
lm secret list, then lm secret set <NAME> (a private key is many lines — pipe it with <NAME> -); check the folder's rights |
at least one of key_env, password_env is required when kind is sftp |
neither credential declared | lm secret set <NAME>, then password_env: <NAME> or key_env: <NAME> |
run Failed: S3 credentials unavailable (access_key_env=…, secret_key_env=…) |
no secret, and no hub variable, holds one of those names | lm secret set <NAME> for each name the message gives |
run Failed: … (Service: S3, Status Code: 403, …) |
invalid S3 credentials, or a bucket policy that refuses the user | check the secrets named by access_key_env / secret_key_env, and the bucket's policy |
dead letters Record has 2 values but 12 columns are declared, one per line of a key;value block |
an export that opens with a block before its table | has_header: false, columns, and parser.short_rows: skip-leading — see Parser |
required field missing (required when has_header is false and lines is off) |
a headerless parser without names | parser.columns: [a, b, …] in file order |
after_process: archive is not executed on local connector runs |
a relocation on a local transport | after_process: none, or an sftp/s3 transport |
… is not read by connector runs |
file_filter, pre_process, post_process or max_file_size in a connector manifest |
drop the key |
run Failed: connector.t_… is already written by connector '…' |
another connector of the tenant already writes the table named after this one | apply it under another metadata.name, then lm connector delete the refused one — see JDBC |
run completes with IN 0 |
nothing new — or the local path matches nothing on the hub's disk | lm connector get NAME shows the path (config.transport.path); check it exists on the hub |
| a file dropped in a local folder is never read | its name sorts before the last file read (drop-folder contract) | name incoming files monotonically, or switch to sftp/s3 |
run Failed: The cursor's file … is gone, but files sorting after it are waiting |
a file was archived before the run that read it completed | lm endpoint reset-cursor <connector>/<endpoint>; archive only after Completed |
DEAD-LETTERS > 0, reason MalformedSource |
value count differs from the declared columns — trailing delimiter, unclosed quote, or a quote escaped by a backslash (parser.escape) |
lm dlq get ID shows the raw values, file and line; fix the export or parser: |
| rows missing, DLQ empty | filter-where / skip-empty-rows excluded them (counted in SKIPPED) |
see Transformers § Troubleshooting |
| a mapped column is blank for some rows | a parse-* or lookup hook nulled a value it could not read |
cell_errors on lm run get <id> — see the reference |
run Failed: N of M rows would be removed: the file holds … |
the one local file lost more rows than refresh.max_removed allows |
check the file; for a real purge raise refresh.max_removed (or off), re-apply, run again — see full refresh |
run Failed: the file is empty: a full refresh never empties a table |
the one local file holds a header and no row | fix the export; to empty the table, lm connector delete |
run Failed: N row(s) of the file went to the dead letters this run |
a line of the one local file could not be read | lm dlq list --run <id>, fix the export, run again |
run Failed: the file changed while this run was reading it |
the producer rewrote the file mid-run | run again; have the producer write elsewhere and rename the file in |
| rows gone from a local table after a run | the file no longer holds them | lm run get <id> says N row(s) removed; to keep history, ingest a folder (*.csv) |
| a source column shares a reserved column name | one of the reserved columns | rename it with map-columns |
See also
- Overview — the lifecycle every connector shares: validate → apply → run.
- Transformers — every hook, with input → output.
- Scopes
- Diagnosing a failed run