A multi-row insertInto().values([...]) takes its column list from the first row, so any column absent from rows[0] is silently dropped from every row that has it. No error, no warning, and the insert reports the full row count.
Reproduction
timeline_entries has a nullable previous_text. Two rows, where only the second sets it:
await db.insertInto('timeline_entries').values([
{ subject_type: 'issue', subject_id: 1, kind: 'labeled', subject_text: 'first-row-only', uuid: crypto.randomUUID() },
{ subject_type: 'issue', subject_id: 1, kind: 'renamed', subject_text: 'second', previous_text: 'SHOULD PERSIST', uuid: crypto.randomUUID() },
]).execute()Read back:
[{"kind":"labeled","subject_text":"first-row-only","previous_text":null},
{"kind":"renamed","subject_text":"second","previous_text":null}]SHOULD PERSIST is gone. Splitting the same two rows into two single-row inserts stores it.
Observed on bun-query-builder 0.3.13 against Postgres 17, via @stacksjs/database.
Where it comes from
In dist/chunk-bhm321nb.js the column list is derived from the first element and then every row is read through it:
let U = V[0], z = Object.keys(U), Y = z.length
...
for (let ee = 0; ee < Y; ee++) y[ee] = U[z[ee]]So rows[0] decides the statement's columns, and a key that only appears later has nowhere to go.
Why this is worth more than a note in the docs
It is a silent, partial data loss on the happy path, and the shape of the bug hides itself:
- Nothing throws, so no test that asserts "the insert succeeded" can catch it.
- The row count is correct, so a seeder or importer reports success and the table is not empty.
- It only bites when rows are heterogeneous, which is exactly the realistic case: a timeline where a
renamedentry carries two titles and alabeledentry carries one, an import where some records have an optional field, an audit log where only some events carry a reference.
I hit it writing a seeder for a forge's activity timeline. Forty rows went in, the count was right, and five cross references rendered as "referenced this in a commit" instead of naming the change, because reference_number had been dropped: the first row in the batch was a labeled entry that had no such key. I only found it by querying count(reference_number) after the fact. Without that check it would have shipped as "the cross-reference feature does not work".
What I would want instead
Any of these would have saved the debugging, in rough order of preference:
- Union the keys across all rows and emit
DEFAULT(orNULL) for the rows that lack one. This is what most query builders do and it makes the obvious code correct. - Throw when the rows are not homogeneous, naming the offending key and index. Loud and cheap.
- If neither is acceptable for performance reasons, keep the fast path for homogeneous batches and fall back to the union path when
Object.keysdiffer, which is detectable in one pass.
Option 1 or 2 would both be fine. What is hard to work with is the current behaviour, because the only way to be safe is to know this rule and normalise every batch by hand:
// what a caller has to write today
rows.push({ a: x.a ?? null, b: x.b ?? null, c: x.c ?? null })and nothing in the types or the API hints that it is required.