Upsert
Upsert is one of only two single-shot write RPCs on dodil.tables.v1.Tables (the other is Delete). Rows are typed RowSet payloads — no SQL text, no quoting, no injection surface. This page also covers the two multi-row write surfaces: WriteStream and Commit.
See the Data hub for the full list.
Upsert vs Update: UPDATE is predicate-addressed (SET … WHERE …) and never creates rows. Upsert is row-identity-addressed and inserts when the key does not exist.
Upsert
Request
dodil data
dodil data table upsert users -b kb-prod \
-r '{"id":101,"email":"[email protected]","tier":"pro"}' \
-r '{"id":102,"email":"[email protected]","tier":"free"}'{ "wal_written": true, "wal_ulid": "01JX7QK9E4S2N..." }Flags: -r/--row (repeatable, required — each row must carry the key columns), --match-column (defaults to the table’s primary key), --merge (partial-column merge instead of full-row upsert). This is the one dodil data table write verb that uses a typed RPC rather than lowering to SQL.
Response
message WriteAck {
string key = 1;
// Durable in the WAL (always true on OK; failures are gRPC status).
bool wal_written = 2;
// The minted ordering token — the caller's next session watermark.
string wal_ulid = 3;
}
WriteAckhas norows_written, noversion, and nopending_drain. Those fields belonged to a response shape that no longer exists. What you get back is durability (wal_written) and an ordering token (wal_ulid). The number of rows you sent is the number you sent.
Key facts:
match_columnsempty resolves to the table’s declaredPRIMARY KEY. A table that declares none failsFAILED_PRECONDITION— there is nothing to match on.merge: trueis a partial-column merge: columns absent from a row stay untouched.merge: false(the default) replaces the whole row.- Read-your-writes is a client-side convention, because the plane is stateless. Carry
wal_ulidinto your next read’smin_ulid; the plane then guarantees the read observes at least that write, or fails over to the S3 WAL. Settingmin_ulidforces the STRONG read path — the eventual fast paths do not thread the watermark and would silently ignore it. - Rows dropped by schema validation are dead-lettered, not silently discarded.
GetDatabaseStats.rejected_rows(andTableStats.rejected_rows) is where they surface — see Schema → Database stats.
WriteStream
The bulk / CDC channel. The client streams op-tagged batches — upserts and deletes interleaved, order preserved — and receives one ack per batch carrying progress, backpressure and the session watermark. It is not called “UpsertStream” for a reason: a CDC feed has to carry tombstones in order.
rpc WriteStream(stream WriteStreamRequest) returns (stream WriteStreamAck);
// First message MUST be `header`; every later message is one op-tagged
// batch. Each batch is atomic (all-or-nothing) and acked individually.
message WriteStreamRequest {
oneof msg {
WriteStreamHeader header = 1;
UpsertBatch upsert = 2;
DeleteBatch delete = 3;
}
}
message WriteStreamHeader {
string db_id = 1;
string table = 2;
repeated string match_columns = 3;
}
message UpsertBatch { RowSet rows = 1; bool merge = 2; }
message DeleteBatch { RowSet keys = 1; }
// One ack per DATA batch, in order — the header is not acked.
message WriteStreamAck {
uint64 batch_seq = 1; // 1-based
uint32 rows_written = 2;
string max_ulid = 3; // the session watermark
}Commit
The transaction primitive: an atomic multi-row commit to one (db, table). It is stateless — no server session, no BEGIN, a single-shot commit.
rpc Commit(CommitRequest) returns (CommitResponse);
message CommitRequest {
string db_id = 1;
string table = 2; // the SINGLE target table (v1)
repeated Mutation mutations = 3; // order preserved, upserts + deletes mixed
optional string min_ulid = 4;
}
message Mutation {
oneof mutation {
UpsertMutation upsert = 1;
DeleteMutation delete = 2;
}
}
message CommitResponse {
string commit_ulid = 1; // one commit = one WAL blob = one S3 PUT
uint64 rows_written = 2;
string max_wal_ulid = 3;
}All-or-nothing, structurally. Every mutation encodes into one WAL segment riding one S3 PUT, and object PUT is atomic — so a validation error in any mutation fails the whole commit with nothing written. Rollback is implicit; there is nothing to roll back.
v1 is single-table (the WAL blob is per-table). Naming a second table in one commit returns INVALID_ARGUMENT pointing at the cross-table follow-up.
For an interactive transaction that spans separate round trips, use the Postgres wire — the adapter buffers the block client-side and flushes it as one atomic commit. A bare BEGIN with no COMMIT in the same call is refused by the stateless plane.
See also
- Writing rows — the
DeleteRPC and theINSERT/UPDATE/DELETE/MERGEstatements - Query — where
min_ulidis consumed - Maintenance → Compact — drain the WAL into Delta
- CLI Guide —
dodil data table upsert