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

  1. Use upsert instead of update when the row may not yet exist — upsert inserts on conflict.
  2. Ensure the corresponding insert mutation ran (and was acknowledged) before issuing updates against the same primary key.
  3. 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

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.