tldraw/tldraw · error · ZMutationError
bad_request
bad_request
Error message
update failed, no matching rows
What it means
Thrown as ZMutationError(ZErrorCode.bad_request) inside ServerCRUD.update when a Kysely UPDATE affected a rowCount other than 1 and a follow-up _doesExist check confirms the row does not exist. Zero's mutate pipeline calls update with the assumption the row is present; a missing row is reported as a bad_request rather than a silent no-op, so the client learns the entity it is patching is gone.
Source
Thrown at apps/dotcom/sync-worker/src/zero/ServerCrud.ts:178
}
async update(data: any) {
assert(!this.signal.aborted, 'CRUD usage outside of mutator scope')
assertRowIsValid(data, this.table)
const vals = omit(data, this.table.primaryKey)
if (Object.keys(vals).length === 0) {
console.error('update is a noop', data)
return
}
const res = await this._exec(
this._wherePrimaryKey(db.updateTable(this.table.name).set(vals), data)
)
if (res.rowCount !== 1) {
// might have been a noop
const doesExist = await this._doesExist(data)
if (!doesExist) {
throw new ZMutationError(ZErrorCode.bad_request, `update failed, no matching rows`)
}
}
await this._trackUpdated(data)
}
}
function assertRowIsValid(row: any, table: TlaSchema['tables'][keyof TlaSchema['tables']]): void {
const entries = Object.entries(row)
for (const [key, value] of entries) {
const column = table.columns[key as keyof typeof table.columns] as SchemaValue
if (!column) {
throw new Error(`Unknown column ${key} in table ${table.name}`)
}
if (value == null && column.optional) {
continue
}
switch (column.type) {
case 'string':View on GitHub (pinned to b31086b447)
Solutions
- Use upsert instead of update when the row may not yet exist — upsert inserts on conflict.
- Ensure the corresponding insert mutation ran (and was acknowledged) before issuing updates against the same primary key.
- Handle bad_request on the client by re-syncing from the server and re-applying the mutation against the current state.
Example fix
// before
mutators.updateFileState({ id, ...patch }) // throws if 'id' never inserted
// after
mutators.upsertFileState({ id, ...patch }) // inserts or updates Defensive patterns
Strategy: try-catch
Validate before calling
// In a Zero mutator, prefer upsert when the row may not exist:
m Table.upsert({ ... }) // instead of Table.update({ ... }) Try / catch
try {
await mutators.updateRow(...)
} catch (e) {
if (e instanceof ZMutationError && e.errorCode === ZErrorCode.bad_request) {
// row is gone: re-sync and re-apply, or switch to upsert
} else throw e
} Prevention
- Use upsert when the row's existence is not guaranteed.
- Ensure the insert mutation is acknowledged before issuing updates.
- On bad_request from update, re-sync from the server to reconcile.
When it happens
Trigger: A zero-cache client pushing a mutate with mutators that call table.update() for a primary key that was never inserted or was concurrently deleted. The check fires only after the UPDATE returns rowCount !== 1 AND _doesExist returns false (rowCount 1 from a real no-op or a successful update does not throw).
Common situations: A client optimistically updating a row that the server never received an insert for; a race where another client deleted the row between the client's last sync and its mutate; an off-by-one or stale id in the mutator payload.
Related errors
AI-assisted analysis of tldraw/tldraw@b31086b447 (2026-08-12).
Data as JSON: /api/errors/3be3b35ae4ff6622.
Report an issue: GitHub.