JDBC
Read tables from a live PostgreSQL database into hub tables — the whole table once, then only what changed, on a schedule.
| Type | jdbc-generic |
| Dialect | postgres |
| Target | connector.t_<table> |
| Schedulable | cron |
| Edition | Open |
The connection is read-only: every query this connector issues is a SELECT, and nothing in
it inserts, updates, deletes or alters anything on the source.
Quickstart — one table, three commands, a look at the ledger
# 10-full-dump.yaml — the whole table, once, then only what is inserted after it.
#
# `mode: full` pages through the table by primary key and remembers the last key it read.
# The next run continues past that key: new rows arrive, rows changed in place do not.
# Switch this same connector to `20-watermark.yaml` to pick up changes too.
#
# The source tables are created by data/demo-source.sql in the hub's own PostgreSQL, so the
# page can be replayed with nothing but the self-host stack. LUMNIK_READONLY_DB_PASSWORD is
# already exported where the hub runs; a real base names its own variable.
apiVersion: connectors.lumnik.io/v1
kind: Connector
metadata:
name: demo-invoices
connector_type: jdbc-generic
scopes: [billing]
spec:
connection:
url: jdbc:postgresql://postgres:5432/lumnik
user: lumnik_readonly
password_env: LUMNIK_READONLY_DB_PASSWORD
defaults:
schema: demo
source:
tables:
- name: invoices
mode: full
pk_columns: [invoice_id]
Every screen on this page reads a source that lives in the hub's own PostgreSQL — four tables and a view — so the page can be replayed with nothing but the self-host stack. Create them first:
docker exec -i lumnik-postgres-1 psql -U lumnik -d lumnik < docs/connectors/jdbc/data/demo-source.sql
What that script creates
-- demo-source.sql — the source tables the JDBC page's screens read.
--
-- They live in the hub's OWN PostgreSQL so the page can be replayed with nothing but the
-- self-host stack (`deploy/selfhost/up.sh`). A real integration points at the ERP instead;
-- nothing else in the manifests changes.
--
-- docker exec -i lumnik-postgres-1 psql -U lumnik -d lumnik < docs/connectors/jdbc/data/demo-source.sql
--
-- The two GRANTs are the only ones a lumnik connection user ever needs: USAGE on the schema,
-- SELECT on the tables. Nothing in the jdbc connector writes to a source.
CREATE SCHEMA IF NOT EXISTS demo;
-- The view goes first: it reads demo.invoices, and Postgres refuses to drop a table a view
-- depends on — so a second run of this script would stop here.
DROP VIEW IF EXISTS demo.v_open_invoices;
DROP TABLE IF EXISTS demo.invoices;
CREATE TABLE demo.invoices (
invoice_id bigint PRIMARY KEY,
customer text NOT NULL,
total numeric(12,2) NOT NULL,
status text NOT NULL,
updated_at timestamp NOT NULL
);
INSERT INTO demo.invoices VALUES
(1001, 'Northwind Ltd', 1250.00, 'paid', '2026-09-01 09:15:00'),
(1002, 'Contoso SA', 480.50, 'open', '2026-09-01 11:40:00'),
(1003, 'Fabrikam Oy', 2310.75, 'open', '2026-09-02 08:05:00'),
(1004, 'Adventure BV', 199.90, 'draft', '2026-09-02 16:20:00');
-- A table whose primary key is called `id` — one of the seven names the hub reserves on its
-- own tables. See "Reserved column names" on the page.
DROP TABLE IF EXISTS demo.price_list;
CREATE TABLE demo.price_list (
id bigint PRIMARY KEY,
sku text NOT NULL,
price numeric(10,2) NOT NULL
);
INSERT INTO demo.price_list VALUES
(1, 'PUMP-075', 349.00),
(2, 'HOSE-32M', 59.90),
(3, 'FILT-A1', 129.50);
-- Two more tables for the guided flow: `warehouses` is the one it adds, `audit_log` has no
-- primary key, which is what the guided add refuses.
DROP TABLE IF EXISTS demo.warehouses;
CREATE TABLE demo.warehouses (
warehouse_id integer PRIMARY KEY,
code text NOT NULL,
city text NOT NULL,
updated_at timestamp NOT NULL
);
INSERT INTO demo.warehouses VALUES
(1, 'W-LYS', 'Lyon', '2026-09-01 08:00:00'),
(2, 'W-MRS', 'Marseille', '2026-09-03 08:00:00');
DROP TABLE IF EXISTS demo.audit_log;
CREATE TABLE demo.audit_log (
at timestamp NOT NULL,
actor text,
action text
);
INSERT INTO demo.audit_log VALUES ('2026-09-01 06:00:00', 'system', 'boot');
-- A COMPOSITE primary key: 100 orders of seven lines each, 700 rows. `pk_columns` is read
-- whole, so the pair (order_id, line_no) is what the reader orders and resumes on and no
-- order is split by a chunk boundary. See "Per-table keys" on the page.
DROP TABLE IF EXISTS demo.order_lines;
CREATE TABLE demo.order_lines (
order_id integer NOT NULL,
line_no integer NOT NULL,
sku text,
PRIMARY KEY (order_id, line_no)
);
INSERT INTO demo.order_lines
SELECT o, l, 'SKU-' || o || '-' || l
FROM generate_series(1, 100) AS o, generate_series(1, 7) AS l;
-- The view the page reads in "Reading a view": the invoices that are not paid.
CREATE VIEW demo.v_open_invoices AS
SELECT invoice_id, customer, total, updated_at FROM demo.invoices WHERE status <> 'paid';
GRANT USAGE ON SCHEMA demo TO lumnik_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA demo TO lumnik_readonly;
ANALYZE demo.invoices;
ANALYZE demo.price_list;
ANALYZE demo.warehouses;
ANALYZE demo.audit_log;
ANALYZE demo.order_lines;
Then, from the repository root:
$ lm validate -f docs/connectors/jdbc/10-full-dump.yaml
✓ docs/connectors/jdbc/10-full-dump.yaml — valid
$ lm apply -f docs/connectors/jdbc/10-full-dump.yaml
{"name":"demo-invoices","connectorId":"f0e7d07b-289b-47c1-874d-ecb167dc2e90","endpointsApplied":1}
$ lm connector run demo-invoices
run scheduled: 16b2bee8-c96a-4c27-8d74-39b3697f5a72
"Scheduled" is not "done" — the run ledger is where the result lands:
$ lm run list --connector demo-invoices
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
16b2bee8 demo-invoices invoices Completed 4 4 0 0 2026-09-08 01:18 2026-09-08 01:18
STARTED / ENDED print in your terminal's time zone (these screens: Paris, UTC+2). The
hub stores UTC, and schedule is evaluated in UTC.
What landed in connector.t_invoices — every source column, no column filter:
invoice_id | customer | total | status | updated_at
------------+---------------+-----------+--------+------------------------
1001 | Northwind Ltd | 1250.0000 | paid | 2026-09-01 09:15:00+00
1002 | Contoso SA | 480.5000 | open | 2026-09-01 11:40:00+00
1003 | Fabrikam Oy | 2310.7500 | open | 2026-09-02 08:05:00+00
1004 | Adventure BV | 199.9000 | draft | 2026-09-02 16:20:00+00
jdbc-genericis a 1:1 dump.SELECT *on the declared table; there is no column filter in the manifest.rename:is the only per-column knob.invoice_idreads1001,totalreads1250.0000. A whole-number source type lands inBIGINT, a source type that carries a scale inNUMERIC(18,4)— see Column types.- The password never sits in the manifest.
password_envnames a variable in the environment where the hub runs; the self-host stack already exports the one used here.
Two modes — full and watermark
Both modes page through the table 500 rows at a time and remember where they stopped. What they remember is the whole difference:
mode: fullremembers the last primary key it read. The next run continues past that key.mode: watermarkremembers the last value of a column that moves on every change, plus that row's primary key. The next run reads everything past that value.
mode: full |
mode: watermark |
|
|---|---|---|
| ordered by | primary key | watermark column, then primary key |
| cursor holds | the last primary key | the last watermark value and its row's key |
| a row inserted with a higher key | read | read |
| a row inserted with a lower key (a backfill) | never read | read — the insert stamps the column |
| a row updated in place | never read again | read again |
| a row deleted | never noticed | never noticed |
| a run with nothing changed | 0 rows | 0 rows |
| declares | pk_columns |
pk_columns and watermark_column |
The watermark manifest
The quickstart's connector, read incrementally instead. mode and watermark_column sit
under defaults: — the place for what every table of a connector shares:
# 20-watermark.yaml — the same table, read incrementally on a column that moves on every change.
#
# `updated_at` only ever grows, so a run reads the rows past the persisted cursor and nothing
# else: an INSERT and an UPDATE both arrive, a run with nothing changed reads 0 rows.
#
# Same connector name as 10-full-dump.yaml on purpose. Applying this over a table that already
# ran in `full` meets the cursor the full read persisted; the hub recognises it as foreign,
# logs a WARN naming both, and restarts the table from the beginning. Rows already in the hub
# table are deduplicated on their content hash — the table does not grow.
apiVersion: connectors.lumnik.io/v1
kind: Connector
metadata:
name: demo-invoices
connector_type: jdbc-generic
scopes: [billing]
spec:
connection:
url: jdbc:postgresql://postgres:5432/lumnik
user: lumnik_readonly
password_env: LUMNIK_READONLY_DB_PASSWORD
defaults:
schema: demo
mode: watermark
watermark_column: updated_at
source:
tables:
- name: invoices
pk_columns: [invoice_id]
indexes:
- { columns: [customer], unique: false }
# Uncomment to let the hub poll the source itself. Incremental runs cost one keyset query
# per table when nothing has changed. Cron times are UTC.
# schedule: "*/10 * * * *"
# window: "22:00-06:00"
The same table, five runs
Runs 1 and 2 use the quickstart manifest; run 3 applies the one above over the same connector, without touching the hub table.
-- between run 1 and run 2: one insert, one update
INSERT INTO demo.invoices VALUES (1005, 'Tailspin AG', 640.00, 'open', '2026-09-08 07:30:00');
UPDATE demo.invoices SET status = 'paid', updated_at = '2026-09-08 07:45:00' WHERE invoice_id = 1002;
-- between run 3 and run 4: one update
UPDATE demo.invoices SET status = 'paid', updated_at = '2026-09-08 09:10:00' WHERE invoice_id = 1003;
$ lm run list --connector demo-invoices
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
41823606 demo-invoices invoices Completed 0 0 0 0 2026-09-08 01:19 2026-09-08 01:19
8440fe3e demo-invoices invoices Completed 1 1 0 0 2026-09-08 01:19 2026-09-08 01:19
69f31e57 demo-invoices invoices Completed 5 5 0 0 2026-09-08 01:18 2026-09-08 01:18
bdfb4d96 demo-invoices invoices Completed 1 1 0 0 2026-09-08 01:18 2026-09-08 01:18
16b2bee8 demo-invoices invoices Completed 4 4 0 0 2026-09-08 01:18 2026-09-08 01:18
| run | mode | source change before it | IN |
why |
|---|---|---|---|---|
| 16b2bee8 | full | — | 4 | first read, no cursor |
| bdfb4d96 | full | 1 insert, 1 update | 1 | only the insert — its key sorts past the cursor. The update is invisible to a key cursor |
| 69f31e57 | watermark | the manifest changed mode | 5 | the full-mode cursor belongs to another configuration, so the table restarts |
| 8440fe3e | watermark | 1 update | 1 | the changed row's updated_at is past the cursor |
| 41823606 | watermark | — | 0 | nothing past the cursor |
After run 2 the hub still said invoice 1002 was open while the source said paid. That is
mode: full doing exactly what it declares.
Which one to declare
watermark, whenever the source stamps a column that moves on every write. ERPs usually have one:updated_at,updated,modified_at,last_modified.full, for a table that only grows — an append-only ledger, a reference table nobody edits in place.fullon a table that is edited in place will not see the edits. Either expose a timestamp column on the source, or re-read the table on purpose withlm endpoint reset-cursorbefore each run.
A watermark column that does not move is worse than full
The mode is a promise about the column, and the hub cannot check it. Two ways to get it wrong, both silent:
- A column the application forgets to touch on some writes — those changes never reach the hub.
- A numeric primary key as the watermark.
lm source jdbc discoversuggests one when it finds no timestamp (see theprice_listline below) — accepting it gives youmode: fullbehaviour under another name.
Connection
connection:
url: jdbc:postgresql://db.acme.io:5432/billing # required
user: ro_user # required
password_env: ACME_DB_PASSWORD # required — a variable where the HUB runs
dialect: postgres # optional, inferred from the url
- Dialect:
postgresonly in v1 (postgresqlis accepted as an alias). A declareddialectthat disagrees with the URL scheme fails validation. - Grants:
USAGEon the schema andSELECTon the tables. Nothing more — ingestion issuesSELECT * FROM <schema>.<table>, andtest/discoveruse the driver's read-only metadata calls. - Reachability is the hub's, not yours. The URL is resolved where the hub runs: a
database on your Mac is
host.docker.internalfor a hub in Docker, notlocalhost.
Where the password comes from
Resolved at run time, first match wins:
| Source | Notes | |
|---|---|---|
| 1 | Attached credential | A Basic credential on the connector, stored encrypted. Created by lm source jdbc add, named <source-name>--credential. A manifest applied with lm apply never sets one. |
| 2 | password in the manifest |
Plain text. Dev and test only — it wins over password_env. |
| 3 | password_env |
The name of a variable in the hub server's environment (OS variable or JVM system property). Unset at run time is a failed run with the variable named. |
password_envis required by validation even whenpasswordis present.- jdbc does not read the
lm secretregistry — that is for the CSV transports. The value must exist where the hub process runs. - "Stored encrypted" is pgcrypto AES-256 under
LUMNIK_CRYPTO_SECRET_KEY— see Security → Secrets at rest.
Never commit a real password
Line 2 exists for a local demo. Anything else uses password_env or the guided flow's
credential.
Source — one entry per table
# 40-two-tables.yaml — two tables under one connector, each with its own mode.
#
# Every entry in `source.tables[]` becomes an endpoint, and every endpoint gets its own run
# row in the ledger: one `lm connector run` on this connector produces two rows. `defaults:`
# carries what both tables share; each table overrides what it needs.
apiVersion: connectors.lumnik.io/v1
kind: Connector
metadata:
name: demo-erp
connector_type: jdbc-generic
scopes: [billing]
spec:
connection:
url: jdbc:postgresql://postgres:5432/lumnik
user: lumnik_readonly
password_env: LUMNIK_READONLY_DB_PASSWORD
defaults:
schema: demo
source:
tables:
- name: warehouses
mode: watermark
watermark_column: updated_at
pk_columns: [warehouse_id]
- name: price_list
mode: full
pk_columns: [id]
rename:
id: price_list_id
Each entry in source.tables[] is an endpoint of the connector:
- the endpoint name is the table name, its path is
/<table-name>; - one
lm connector runfans out over all of them; - each endpoint writes its own row in the run ledger — a two-table connector logs two rows per run.
$ lm run list --connector demo-erp
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
41b06fa9 demo-erp price_list Completed 0 0 0 0 2026-09-08 01:22 2026-09-08 01:22
18c3897f demo-erp warehouses Completed 0 0 0 0 2026-09-08 01:22 2026-09-08 01:22
103bb6c9 demo-erp price_list Completed 3 3 0 0 2026-09-08 01:20 2026-09-08 01:20
8953b0c8 demo-erp warehouses Completed 2 2 0 0 2026-09-08 01:20 2026-09-08 01:20
The first run read warehouses (2 rows) and price_list (3); the second found nothing new
in either. ENDPOINT is the table each row read — and the second half of the address the
endpoint commands take, so a row that needs one reads straight into
lm endpoint reset-cursor demo-erp/warehouses.
Per-table keys
| Key | Required | What it does |
|---|---|---|
name |
yes | The source table. Also names the hub table, connector.t_<name> |
schema |
yes (here or in defaults) |
The source schema |
mode |
yes | full or watermark |
watermark_column |
when mode: watermark |
The column that moves on every change |
pk_columns |
yes | The paging key, read whole and in order. It must be unique together |
indexes |
no | Postgres indexes to create on the hub table |
rename |
no | Source column name → hub column name |
- Any other key is refused,
chunk_sizefirst among them — see Validation rules.
pk_columns is the paging key, read whole
The reader orders by every entry and resumes on all of them at once —
WHERE (order_id, line_no) > (72, 3), one lexicographic comparison. A composite key pages
exactly like a single-column one: no group is split by a chunk boundary.
demo.order_lines is 700 lines over 100 orders, seven each:
# 60-composite-key.yaml — a table whose primary key is two columns.
#
# `pk_columns` is the paging key and it is read WHOLE: the reader orders by (order_id, line_no)
# and resumes on the pair, so no order is split by a chunk boundary. The order of the entries is
# the paging order — reverse it and the stored cursor becomes foreign, and the table restarts.
#
# What has to hold is that the key is unique TOGETHER. `order_id` alone is not: 700 lines over
# 100 orders, seven lines each. demo.order_lines is created by data/demo-source.sql.
apiVersion: connectors.lumnik.io/v1
kind: Connector
metadata:
name: demo-lines
connector_type: jdbc-generic
scopes: [billing]
spec:
connection:
url: jdbc:postgresql://postgres:5432/lumnik
user: lumnik_readonly
password_env: LUMNIK_READONLY_DB_PASSWORD
source:
tables:
- name: order_lines
schema: demo
mode: full
pk_columns: [order_id, line_no]
The full read, then a second run that finds nothing — and 700 rows in the hub table:
$ lm run list --connector demo-lines --limit 2
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
b8b26cac demo-lines order_lines Completed 0 0 0 0 2026-09-08 23:38 2026-09-08 23:38
65dee4bd demo-lines order_lines Completed 700 700 0 0 2026-09-08 23:37 2026-09-08 23:37
note: result hit the limit of 2 — there may be more
The order of the entries matters — it is the paging order, and it is part of what the cursor belongs to. Reversing it restarts the table (see What restarts a table).
What still has to hold: the key must be unique together. A real primary key is — that is
what makes it one, and lm source jdbc add writes the discovered primary key, so the guided
flow is safe by construction. A hand-written manifest is not: declaring part of a key, or a
plain column that repeats, still drops the group a chunk boundary falls inside, with no skip,
no dead letter and no failed run.
defaults
defaults: is a flat overlay: every key it holds is a fallback for every table, and a table
that declares the same key wins.
- The merge is per key, not deep. A table's
rename:replaces the one indefaults:entirely rather than merging into it. mode,watermark_column,schemaindefaults:are the common case — see the watermark manifest, where both live there.
Chunking is fixed
Reads page through the source in 500-row chunks, keyset-paginated on the primary key, so
a table of millions of rows stays linear and memory-bounded. There is no chunk_size setting:
a manifest that declares one is refused rather than silently ignored.
The hub table
Rows land in the connector schema as connector.t_<table-name>, one table per source
table, created on the first run from the columns the source returned.
The hub table is named after the source table, not the connector
Two connectors reading a table of the same name — the same table in two schemas, or the
same table on two servers — write into the same connector.t_<name>. Give one of
them a view under another name.
Column types
The hub types each column from the source's declared JDBC type:
| Source type | Hub column |
|---|---|
smallint, integer, bigint |
BIGINT |
real, double precision, numeric, decimal |
NUMERIC(18,4) |
timestamp, timestamptz |
TIMESTAMPTZ |
date |
DATE |
boolean, bit |
BOOLEAN |
| everything else | TEXT |
NUMERIC(18,4)holds at most 14 digits before the point. A source column wider than that — a 19-digit snowflake-style id declarednumeric(20,0)— fails the run withnumeric field overflow. Expose it as text through a view:SELECT event_id::text AS event_id, .... An id declaredbigintis not concerned: it lands inBIGINT, which holds everything the source column can.- A table the hub already created keeps the types it was created with. There is no backfill:
columns typed
numeric(18,4)before this stay that way and keep printing four decimals. To retype them, drop the connector (which drops its tables) and re-ingest, orALTERthe columns yourself. - A column can still widen, once, in one direction. Types are decided from the chunk in hand,
so a column of whole numbers lands
bigint; the first later chunk that carries a fractional value for it is widened tonumeric(18,4)before the insert, and the run continues. The hub log names the columns.ALTER COLUMN … TYPErewrites the table under an exclusive lock, so on a large table that chunk takes noticeably longer — once.
Seven reserved column names
id, _row_hash, _ingested_at, _source, _run_id, _mapping_version, tenant_id —
the hub adds these to every table it creates. A source column of one of those names is
refused at write time, on a new table and an existing one alike, so no run ever drops a
column's values in silence.
$ lm run get 9165490f
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
9165490f demo-price-list price_list Failed 0 0 0 0 2026-09-08 01:19 2026-09-08 01:19
✖ run failed: source column 'id' collides with a hub system column (_ingested_at, _mapping_version, _row_hash, _run_id, _source, id, tenant_id); it must reach the hub under another name — jdbc: source.tables[].rename; csv: a map-columns row hook under spec.mapping; kafka: no rename exists, the field must be renamed in the topic payload
rename: is the fix — id is the one you will meet, but the seven collide alike:
# 30-rename-reserved-column.yaml — a source table whose primary key is called `id`.
#
# The hub adds seven columns of its own to every table it creates, `id` among them, so a
# source column of that name is refused at write time rather than silently dropped. `rename:`
# is the fix: the source column lands under another name in the hub table. Nothing is renamed
# on the source — the connection stays read-only.
apiVersion: connectors.lumnik.io/v1
kind: Connector
metadata:
name: demo-price-list
connector_type: jdbc-generic
scopes: [billing]
spec:
connection:
url: jdbc:postgresql://postgres:5432/lumnik
user: lumnik_readonly
password_env: LUMNIK_READONLY_DB_PASSWORD
source:
tables:
- name: price_list
schema: demo
mode: full
pk_columns: [id]
rename:
id: price_list_id
$ lm connector run demo-price-list
run scheduled: 484aea15-ac55-44e6-ab9d-0e7e26189a8a
$ lm run list --connector demo-price-list
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
484aea15 demo-price-list price_list Completed 3 3 0 0 2026-09-08 01:19 2026-09-08 01:19
9165490f demo-price-list price_list Failed 0 0 0 0 2026-09-08 01:19 2026-09-08 01:19
price_list_id | sku | price
---------------+----------+----------
1 | PUMP-075 | 349.0000
2 | HOSE-32M | 59.9000
3 | FILT-A1 | 129.5000
- The rename applies in the hub table only. Nothing is renamed on the source.
- Renaming a column into one of the seven is refused at
lm validate— it only moves the collision. - Two source columns renamed onto the same hub column are refused for the same reason.
One row per version, never an update
The hub table has a unique constraint on _row_hash, a hash of the row's content:
- A row read again unchanged is skipped, so re-reading costs nothing but the read.
- A row changed at the source lands as a new row, with its own
_ingested_at. Both versions stay. That is the time axis — the hub table is a tape, not a mirror.
After the five runs above, t_invoices holds seven rows for five invoices:
invoice_id | status | updated_at | _ingested_at
------------+--------+------------------------+-------------------------------
1001 | paid | 2026-09-01 09:15:00+00 | 2026-09-07 23:18:34.339879+00
1002 | open | 2026-09-01 11:40:00+00 | 2026-09-07 23:18:34.339879+00
1002 | paid | 2026-09-08 07:45:00+00 | 2026-09-07 23:18:56.001046+00
1003 | open | 2026-09-02 08:05:00+00 | 2026-09-07 23:18:34.339879+00
1003 | paid | 2026-09-08 09:10:00+00 | 2026-09-07 23:19:00.384925+00
1004 | draft | 2026-09-02 16:20:00+00 | 2026-09-07 23:18:34.339879+00
1005 | open | 2026-09-08 07:30:00+00 | 2026-09-07 23:18:44.432353+00
_ingested_at is stored in UTC; the source's own updated_at is whatever the source wrote.
OUT counts rows delivered, not rows inserted
records_out is the number of source rows handed to the target. Rows the hash skipped
are counted there too, which is why a re-read that grows the table by nothing still
reports a non-zero OUT.
Deletes are never propagated
A row deleted at the source stays in the hub table, in both modes, forever: neither cursor
ever sees an absence. To resync from nothing, lm connector delete drops the connector's
connector.t_* tables and its RAG chunks, and the next apply plus run starts clean.
Indexes on the hub table
indexes: is per table for jdbc — that is where the runtime reads it. (spec.target.indexes
at connector level is what CSV & files reads. A jdbc manifest carrying a
spec.target block at all is answered with an unknown key warning: nothing on this kind reads
one, so an index declared there would be accepted and then silently dropped.)
indexes:
- { columns: [customer], unique: false } # unique defaults to false
Each entry is { columns: [non-empty strings], unique?: bool }. A malformed entry fails
validation with its own error rather than being dropped.
Cursors — resume, restart, reset
A cursor is stored per endpoint, after every chunk. An interrupted run resumes exactly where it stopped: no duplicates, no gaps.
mode: watermarkstores{col, value, pk}and resumes withWHERE (col, pk...) > (value, last_pk...)— one lexicographic comparison over the watermark followed by every key column. Rows sharing one watermark value cannot straddle a chunk boundary and be lost, or read twice — the key breaks the tie.mode: fullstores the key alone and resumes withWHERE (pk...) > (last_pk...).
A run reads what the table held when it started
Both modes also carry a ceiling: the greatest key row the table held at the moment the run
started, measured once and added to the same comparison —
AND (col, pk...) <= (ceiling...). So a row inserted while the run is in flight belongs to the
next run, not this one.
That is what makes a run end on a table that is still being written to. Without the ceiling the
query paged forward for as long as the inserts continued: on a busy table the run never reached a
quiet chunk, so it never completed, the run row stayed Running, and every scheduled slot behind
it was skipped by the per-endpoint lock. Nothing was lost — but nothing ever finished either, and
a window fence that only gates a run's start was walked straight through by a run that began
inside it.
Rows whose watermark is NULL are read by a first pass and never by a resume — the resume
comparison is unknown for them. That is not the ceiling's doing and the ceiling does not change it:
it carries OR <watermark> IS NULL for exactly that reason. If such rows must be kept current,
give the column a value or read the table in mode: full.
The ceiling costs one query per run, ORDER BY <keys> DESC LIMIT 1 — a backward scan of the
same index the chunk loop already needs. On a table with no index over (watermark, pk…) it is a
full sort, but so is every chunk the run then reads: the index is the condition for ingesting that
table at all, not a new requirement this adds.
What restarts a table
A cursor belongs to the configuration that wrote it. Changing mode, watermark_column or
pk_columns — its columns or their order, which is the paging order — makes the stored
cursor foreign: the hub ignores it, logs a WARN naming both, and the table restarts from the
beginning.
WARN demo.invoices: ignoring cursor persisted by another configuration (col='', pk=[invoice_id];
this reader writes col='updated_at', pk=[invoice_id]) — restarting from the beginning
A restart re-reads the table from the beginning: every row already in the hub table meets its
own hash and is skipped, so the table does not grow. IN and OUT count the re-read rows all
the same — run 69f31e57 above is exactly this.
Restarting a table on purpose
lm endpoint reset-cursor <connector>/<endpoint> drops the stored cursor. For jdbc the
endpoint name is the table name. It prints nothing; the next run's IN is the proof:
$ lm endpoint reset-cursor demo-invoices/invoices
$ lm connector run demo-invoices
run scheduled: 0935e410-5216-4b0e-8d02-2f0adb1a6ac2
$ lm run list --connector demo-invoices
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
0935e410 demo-invoices invoices Completed 5 5 0 0 2026-09-08 01:20 2026-09-08 01:20
41823606 demo-invoices invoices Completed 0 0 0 0 2026-09-08 01:19 2026-09-08 01:19
Scheduling
spec:
schedule: "*/30 * * * *" # cron, UTC
window: "22:00-06:00" # optional fence, UTC
scheduleapplies to the connector as a whole: all its tables ingest on the same trigger.- Both clocks are UTC, not the server's local zone.
windowfences the schedule: a slot due outside it is skipped, never queued. A window may cross midnight.- The end is exclusive. A nightly
0 6 * * *needs a window ending later than06:00, for instance22:00-07:00. - A manual
lm connector runignores the window. It is an operator's act. - Re-applying without the key lifts it. An absent
scheduleunschedules; an absentwindowremoves the fence.
If the source is a production database, a window is usually the DBA's condition for granting
the SELECT account — see Sizing & load tests.
Guided flow — lm source jdbc
Four commands that reach the same connectors without writing YAML. They need the lm_admin
role, and the password is never a flag — there is no --password, so it stays out of ps
and out of your shell history.
Where it comes from instead:
| You are | What happens |
|---|---|
| at a keyboard | Password for <user>: is prompted, and what you type is not shown |
piping, with --password-stdin |
the first line of stdin is the password |
| piping, without the flag | same, plus a warning on stderr naming the flag |
--password-stdin is spelled the way docker login and helm registry login spell it. Declaring
the pipe matters: lm source jdbc add … < list.txt otherwise sends that file's first line to the
hub as a password, and nothing would say so.
Use it to explore a base you do not know, and to stand up a table quickly. Reach for a
manifest when you need what it cannot express: rename, indexes, a view, a composite or
undiscoverable key, or config you keep in version control.
test
$ printf '%s\n' "$DB_PASSWORD" | lm source jdbc test --password-stdin --url jdbc:postgresql://postgres:5432/lumnik --user lumnik_readonly
OK : true
Dialect : postgres
Version : 16.15 (Debian 16.15-1.pgdg12+2)
Latency : 1 ms
Schemas : connector, core, demo, ext, identity, information_schema, pg_catalog, platform, public, rag
Schemas lists what this user can see — a schema absent from that line is a missing USAGE
grant, before any manifest is written.
discover
$ printf '%s\n' "$DB_PASSWORD" | lm source jdbc discover --password-stdin --url jdbc:postgresql://postgres:5432/lumnik --user lumnik_readonly --schema demo
SCHEMA NAME ROWS COLS PK WATERMARK
demo audit_log 1 3 -
demo invoices 4 5 invoice_id updated_at
demo price_list 3 3 id id
demo warehouses 2 4 warehouse_id updated_at
--schemadefaults topublic. An ERP in its own schema names it here.ROWSis the planner's estimate,-1when the table was never analyzed.PKempty means no primary key — that table needs a manifest or a view.WATERMARKis a suggestion, in this order: a column namedupdated_at,updated,modified_atorlast_modified; else the first timestamp or date column; else a single numeric primary key. The last case is the trap named above —price_listsuggestsid.--include-viewsadds views to the listing;--skip-patternreplaces the defaultpg_*,information_schema*,t_*;--output jsonprints the whole descriptor.
add
$ printf '%s\n' "$DB_PASSWORD" | lm source jdbc add --password-stdin demo-src --url jdbc:postgresql://postgres:5432/lumnik --user lumnik_readonly --schema demo --tables warehouses
Created 1 connectors:
• demo-src--warehouses
$ lm source jdbc list-tables demo-src
TABLE CONNECTOR STATUS
warehouses demo-src--warehouses active
$ lm connector run demo-src--warehouses
run scheduled: 110866d1-9926-4c0b-a200-25fb9037f8f2
$ lm run list --connector demo-src--warehouses
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
110866d1 demo-src--warehouses warehouses Completed 2 2 0 0 2026-09-08 01:20 2026-09-08 01:20
What it creates, per requested table:
- one connector named
<source-name>--<table>; - one Basic credential per source,
<source-name>--credential, stored encrypted — the password never lands in a file; mode: watermarkwhen a watermark column was found or given,mode: fullotherwise. The hub decides; there is no--mode.--interactiveopens a TUI table picker instead of--tables, pre-filled with the discovered suggestions.--schedule-cronsets the schedule.
What it refuses, by name and reason, before anything is persisted:
$ printf '%s\n' "$DB_PASSWORD" | lm source jdbc add --password-stdin demo-src ... --tables audit_log
Error: API error 400: table demo.audit_log has no primary key — jdbc-generic pages by primary key; declare pk_columns in a manifest instead
$ printf '%s\n' "$DB_PASSWORD" | lm source jdbc add --password-stdin demo-src ... --tables nope
Error: API error 400: table demo.nope not found among the base tables of that schema — check the schema and the connection user's SELECT grant; a view is declared in a manifest, not through the guided add
- a table it cannot find in that schema, and a view — a view has no key to discover;
- a table without a primary key;
- a table whose name is not a plain identifier — the hub names its table after it;
mode: watermarkwith no column to watermark on.
A table whose connector already exists is reported as skipped, not re-created.
Reading a view
A view is how a table reaches the hub when the table itself cannot: no primary key, a reserved column name, a value too wide for the hub's numeric type, or more rows than the métier needs.
# 50-view.yaml — read a VIEW instead of a table.
#
# A view is how a source-side table reaches the hub when the table itself cannot: no primary
# key to page on, a column name the hub reserves, a column too wide for the hub's numeric
# type, or simply more rows than the métier needs. jdbc-generic issues `SELECT *` — a view is
# a table as far as the read is concerned.
#
# The guided `lm source jdbc add` refuses views on purpose: a view has no primary key to
# discover, so `pk_columns` has to be declared, and that is what a manifest is for. The column
# named here must be unique and stable — it is what the reader pages on.
apiVersion: connectors.lumnik.io/v1
kind: Connector
metadata:
name: demo-open-invoices
connector_type: jdbc-generic
scopes: [billing]
spec:
connection:
url: jdbc:postgresql://postgres:5432/lumnik
user: lumnik_readonly
password_env: LUMNIK_READONLY_DB_PASSWORD
source:
tables:
# demo.v_open_invoices is created by data/demo-source.sql, with the tables.
- name: v_open_invoices
schema: demo
mode: full
pk_columns: [invoice_id]
$ lm run list --connector demo-open-invoices
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
eaee8eaa demo-open-invoices v_open_invoices Completed 2 2 0 0 2026-09-08 01:22 2026-09-08 01:22
The columns named in pk_columns must be unique together and stable — they are what the
reader pages on.
Validation rules
lm validate refuses before lm apply does, and both name the path:
$ lm validate -f bad-chunk.yaml
✗ bad-chunk.yaml — 1 error(s), 0 warning(s)
ERROR spec.source.tables[0].chunk_size
chunk_size is not a knob — jdbc chunking is fixed at 500 rows per read; remove it
→ The engine reads its chunk size from the connector type, never from the manifest
| Rule | Severity | Path |
|---|---|---|
connection.url matches jdbc:<dialect>:... |
ERROR | spec.connection.url |
| Dialect is supported (postgres) | ERROR | spec.connection.url |
A declared dialect matches the URL scheme |
ERROR | spec.connection.dialect |
connection.user and connection.password_env present |
ERROR | spec.connection.* |
source.tables[] non-empty |
ERROR | spec.source.tables |
mode present and in |
ERROR | spec.source.tables[i].mode |
mode: watermark declares watermark_column |
ERROR | spec.source.tables[i].watermark_column |
pk_columns is a non-empty list |
ERROR | spec.source.tables[i].pk_columns |
chunk_size is refused — chunking is fixed at 500 |
ERROR | spec.source.tables[i].chunk_size |
| No unknown per-table key | ERROR | spec.source.tables[i] |
indexes entries are { columns: [...], unique?: bool } |
ERROR | spec.source.tables[i].indexes[j] |
| No duplicate table names | ERROR | spec.source.tables[i].name |
rename targets are plain identifiers |
ERROR | spec.source.tables[i].rename |
rename targets none of the seven reserved names |
ERROR | spec.source.tables[i].rename |
No two rename sources hit the same target |
ERROR | spec.source.tables[i].rename |
schedule is a valid cron |
ERROR | spec.schedule |
window is HH:MM-HH:MM and not empty |
ERROR | spec.window |
No unknown key at the root of spec |
WARNING | spec.<key> |
metadata.scopes declared |
WARNING | metadata.scopes |
Troubleshooting
| Symptom | Cause | Gesture |
|---|---|---|
password_env '...' is not set in the server environment |
the variable must exist where the hub runs, not on your laptop; jdbc does not read lm secret |
export it where the hub runs, or attach a credential through the guided flow |
source column 'id' collides with a hub system column |
the source has a column named like one of the seven | add rename: { id: source_id } on that table |
numeric field overflow |
a numeric value wider than 14 digits before the point | expose the column as text through a view |
| the run reads 0 rows and nothing is wrong | the cursor is already past everything | expected — change a row at the source, or lm endpoint reset-cursor |
| updated rows never arrive | mode: full, or a watermark column the application does not move |
switch to watermark on a column that moves |
| a WARN says the cursor was persisted by another configuration | mode, watermark_column or pk_columns changed (its columns or their order) |
expected once; the table restarts and the hashes deduplicate |
| a deleted source row is still in the hub table | by design, in both modes | lm connector delete then re-apply, or filter on _ingested_at |
a table is in discover but add refuses it |
no primary key, or it is a view | declare pk_columns in a manifest |
| the hub table holds fewer rows than the source, no error anywhere | the declared pk_columns is not unique — part of a key, or a plain column that repeats — so a chunk boundary dropped a group |
declare the whole primary key — see Per-table keys |
See also
- Connectors overview
- CSV & files — the same hub table, fed by files
- Scopes
- Diagnosing a failed run